This repository has no description
1import type { SetOperation } from "./lww.js";
2import type { IncrOperation } from "./increment.js";
3import type { AssignOperation } from "./assign.js";
4import {
5 applyCollection,
6 collectionSet,
7 collectionSeq,
8 isCollectionOp,
9 type CollectionOperation,
10 type CollectionState,
11 type SeqItem,
12} from "./collection.js";
13import { isOrSetOp } from "./orset.js";
14import { sortOperations } from "./sorter.js";
15
16/** Any operation that contributes to a register value. */
17export type CounterOperation = SetOperation | IncrOperation | AssignOperation;
18
19/** Any operation that contributes to a value object — register, OR-Set, or RGA. */
20export type AnyOperation = CounterOperation | CollectionOperation;
21
22export interface CounterResult {
23 value: unknown;
24 lastOp: CounterOperation;
25}
26
27/** Reducer state: the result so far, or `null` before any op is applied. */
28export type CounterState = CounterResult | null;
29
30/**
31 * A value object as consumed by `materialize` — one register, read back, with
32 * references left in place (resolved later, at materialization). The reducer's
33 * document body (below) is the serialized form; this is the value-space form the
34 * materializer walks.
35 */
36export interface ValueObject {
37 /** Register identity — the op `target`. */
38 id: string;
39 /** The folded value, possibly containing references. */
40 value: unknown;
41 /** opId of the last op folded into this register. */
42 lastOpId: string;
43}
44
45/** A register value object in the document body. */
46export interface DocValue {
47 $type: "local.crdt.document#value";
48 id: string;
49 value: unknown;
50 lastOpId: string;
51}
52
53/** An OR-Set value object in the document body, keyed by element identity. */
54export interface DocSet {
55 $type: "local.crdt.document#set";
56 id: string;
57 set: Record<string, unknown>;
58 lastOpId: string;
59}
60
61/** An RGA value object in the document body, a run-length encoded sequence. */
62export interface DocSequence {
63 $type: "local.crdt.document#sequence";
64 id: string;
65 seq: SeqItem[];
66 lastOpId: string;
67}
68
69/** One entry in a document body. */
70export type DocBodyItem = DocValue | DocSet | DocSequence;
71
72/** The synthetic root every document body carries, even when empty. */
73function rootItem(): DocValue {
74 return {
75 $type: "local.crdt.document#value",
76 id: "ROOT",
77 value: {},
78 lastOpId: "ROOT",
79 };
80}
81
82/**
83 * Reduce an op log into a document body — the leading `ROOT`, then one value
84 * object per `target` in the order each first appears.
85 *
86 * The log is first sorted deterministically (`sortOperations`, which also drops
87 * ops whose dependencies can never be satisfied), then grouped by `target` and
88 * folded. A register target folds with `apply` and projects to a `#value`. A
89 * collection target (OR-Set and/or RGA) folds over the shared node substrate
90 * (see `collection.ts`); its projection — `#set` or `#sequence` — is chosen by
91 * the family of its last op. References inside values are left untouched; they
92 * are resolved later, at materialization.
93 */
94export function reduceOperations(operations: AnyOperation[]): DocBodyItem[] {
95 const byTarget = new Map<string, AnyOperation[]>();
96 for (const op of sortOperations(operations)) {
97 const group = byTarget.get(op.target);
98 if (group) group.push(op);
99 else byTarget.set(op.target, [op]);
100 }
101
102 const byId = new Map<string, DocBodyItem>();
103 for (const [target, ops] of byTarget) {
104 // A target is a collection (OR-Set and/or RGA) or a register. Collection ops
105 // share one node substrate, so the two families may mix on a single target
106 // and transform across the op stream (see `collection.ts`); a register target
107 // uses the register fold. (Mixing register and collection ops on one target
108 // is undefined.)
109 if (isCollectionOp(ops[0])) {
110 const folded = (ops as CollectionOperation[]).reduce<CollectionState>(
111 applyCollection,
112 null,
113 );
114 if (!folded) continue;
115 const lastOpId = folded.lastOp.opId;
116 if (isOrSetOp(folded.lastOp)) {
117 byId.set(target, {
118 $type: "local.crdt.document#set",
119 id: target,
120 set: collectionSet(folded),
121 lastOpId,
122 });
123 } else {
124 byId.set(target, {
125 $type: "local.crdt.document#sequence",
126 id: target,
127 seq: collectionSeq(folded),
128 lastOpId,
129 });
130 }
131 continue;
132 }
133 const folded = (ops as CounterOperation[]).reduce<CounterState>(apply, null);
134 if (!folded) continue;
135 byId.set(target, {
136 $type: "local.crdt.document#value",
137 id: target,
138 value: folded.value,
139 lastOpId: folded.lastOp.opId,
140 });
141 }
142
143 // ROOT always leads the body; if no op targeted it, fall back to the synthetic
144 // empty root. Every other target follows in first-appearance order.
145 const root = byId.get("ROOT") ?? rootItem();
146 byId.delete("ROOT");
147 return [root, ...byId.values()];
148}
149
150/**
151 * Apply one register operation to the running state: `(state, op) => state`.
152 *
153 * A register is read by folding a sorted op stream (see `sortOperations`) with
154 * the standard reducer idiom — `operations.reduce(apply, null)`. An empty
155 * stream stays `null`.
156 *
157 * - `set` overwrites the value (last write wins).
158 * - `incr` adds its delta to the value when it is a number, otherwise builds
159 * up from zero (no prior set, or a set to a non-number).
160 * - `assign` shallow-merges its keys onto the value when it is a plain object,
161 * otherwise builds up from an empty object (no prior set, or a set to a
162 * non-object such as a number, string, null, or array).
163 */
164export function apply(state: CounterState, op: CounterOperation): CounterResult {
165 if (op.$type === "local.crdt.ops#set") {
166 return { value: op.value, lastOp: op };
167 }
168 const prev = state?.value;
169 if (op.$type === "local.crdt.ops#assign") {
170 const base = isPlainObject(prev) ? prev : {};
171 return { value: { ...base, ...op.value }, lastOp: op };
172 }
173 const base = typeof prev === "number" ? prev : 0;
174 return { value: base + op.delta, lastOp: op };
175}
176
177/** A non-null, non-array object — the only kind `assign` merges onto. */
178function isPlainObject(value: unknown): value is Record<string, unknown> {
179 return typeof value === "object" && value !== null && !Array.isArray(value);
180}