This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

crdt / src / reducer.ts
6.3 kB 180 lines
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}