This repository has no description
0

Configure Feed

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

Add content-addressed operation identity

Mint deterministic opIds from canonical op content (DAG-CBOR/CID via
cidForLex), so two replicas that independently produce the same edit
collapse to one op at the sort dedup boundary. Includes the identity
module and tests, sorter integration, sort/reduce fixtures, README
notes, and LICENSE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

+591 -5
+23
LICENSE
··· 1 + This software is released into the public domain, or, where that is not 2 + possible, under the most permissive terms available. You may use, copy, 3 + modify, distribute, sell, and relicense this software and its source code 4 + for any purpose, commercial or non-commercial, with or without attribution, 5 + and without any conditions whatsoever. 6 + 7 + You may choose to use this software under the terms of ANY of the following 8 + licenses, at your option: 9 + 10 + - CC0 1.0 Universal (Public Domain Dedication) ... see LICENSE-CC0 11 + - BSD Zero Clause License (0BSD) ............... see LICENSE-0BSD 12 + - MIT No Attribution (MIT-0) .................. see LICENSE-MIT-0 13 + - The Unlicense .............................. see LICENSE-UNLICENSE 14 + 15 + SPDX-License-Identifier: CC0-1.0 OR 0BSD OR MIT-0 OR Unlicense 16 + 17 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 + SOFTWARE.
+47
README.md
··· 69 69 when it runs — the **observed-remove** property throughout: you can only remove 70 70 what you have already seen. 71 71 72 + ## Operation identity 73 + 74 + The sort boundary deduplicates by `opId`: two ops sharing an `opId` are folded as 75 + one. So `opId` is the unit of identity, and *how it is assigned decides which ops 76 + merge.* Mint it from a per-replica nonce (`replica:counter`) and only a literal 77 + re-delivery dedups. Mint it as a hash of the op's **content** and two replicas 78 + that independently produce the same edit produce the same `opId` — so two editors 79 + who both fix "a typa" → "a typo" converge on a single insert instead of "typoo". 80 + 81 + [`src/identity.ts`](src/identity.ts) mints content-addressed ids via `cidForLex` 82 + (the same canonical DAG-CBOR + CID the repo already uses for record CIDs, so 83 + clients agree on the encoding for free). Which fields are identity is per family, 84 + governed by one rule: **include `previousOpIds` exactly when the op's effect 85 + depends on its causal position** (what sorts before it). Position-independent ops 86 + exclude deps so the same act always collapses; occurrence-bearing ops are not 87 + content-addressed at all. 88 + 89 + | `$type` | Strategy | Identity fields | Incl. `previousOpIds`? | Why | 90 + | --------------------- | -------- | ------------------------------ | ---------------------- | --- | 91 + | `insert` (RGA) | content | target, anchor, side, value | **yes** | placement among concurrent peers; a re-insert after delete must stay distinct | 92 + | `set` (register) | content | target, value | **yes** | LWW recency is causal-bucket order; re-assertion must stay distinct | 93 + | `assign` (register) | content | target, value | **yes** | per-key LWW, as `set` | 94 + | `add` ref (OR-Set) | content | target, valueId | **yes** | observed-remove: a re-add after a remove must stay distinct to re-appear | 95 + | `add` inline, **set** | content | target, value | **yes** | opt-in set semantics (see below) | 96 + | `remove` (OR-Set) | content | target, valueId | **yes** | cancels what sorts before it; two removes with different views differ | 97 + | `delete` (RGA) | content | target, id, startIdx, endIdx | **no** | monotonic tombstone of elements named by stable id — position-independent | 98 + | `add` inline, default | nonce | — | — | multiset: each add is a distinct member; collapsing would lose multiplicity | 99 + | `incr` (register) | nonce | — | — | accumulation: two `+1`s must total `+2`, never merge | 100 + 101 + Inline OR-Set `add` (no `valueId`) is the one per-*collection* choice — multiset 102 + (the default, each add distinct by `opId`) or set (identical data collapses, 103 + opt-in via `mintOpId(op, { inlineAdd: "set" })`). Nonce ops are not minted here: 104 + they must carry a client-assigned unique `opId`, and `mintOpId` throws for them 105 + rather than return a colliding hash. 106 + 107 + Content ids only collide when clients produce **byte-identical** identity fields, 108 + which takes agreement on three layers — the canonical encoding (handled by 109 + `cidForLex`), the identity fields (the table above), and **canonical op 110 + construction in the editor**: a fixed anchor choice for inserts (anchor the left 111 + neighbour, side `after`), a canonical range for deletes (omit bounds for open 112 + ranges), and a minimal, canonical dependency set where deps are identity. 113 + `mintOpId` sorts `previousOpIds` so their *order* never matters, but it cannot 114 + choose *which* deps a client lists — that is the editor's contract. The 115 + conformance suite in [`test/identity.test.ts`](test/identity.test.ts) pins the 116 + "same edit → same id, distinct edit → distinct id" boundary and should run in 117 + every client. 118 + 72 119 ## Value objects 73 120 74 121 Reducing the log yields a **document body** — a list of value objects, always
+1
package-lock.json
··· 8 8 "name": "crdt", 9 9 "version": "0.0.0", 10 10 "dependencies": { 11 + "@atproto/lex-cbor": "^0.0.16", 11 12 "@atproto/lexicon": "^0.6.2", 12 13 "@atproto/xrpc-server": "^0.10.22", 13 14 "multiformats": "^14.0.0"
+1
package.json
··· 15 15 "vitest": "^2.0.0" 16 16 }, 17 17 "dependencies": { 18 + "@atproto/lex-cbor": "^0.0.16", 18 19 "@atproto/lexicon": "^0.6.2", 19 20 "@atproto/xrpc-server": "^0.10.22", 20 21 "multiformats": "^14.0.0"
+220
src/identity.ts
··· 1 + /** 2 + * Operation identity — deriving a deterministic `opId` from an op's content. 3 + * 4 + * Convergence in this system comes entirely from folding the *same* causally 5 + * sorted op stream on every replica (see `sortOperations`). The sort boundary 6 + * deduplicates by `opId`, so two ops sharing an `opId` are treated as one. That 7 + * makes `opId` the unit of identity: *how it is assigned decides which ops merge.* 8 + * 9 + * - Assign `opId` from a per-replica nonce (`replica:counter`) and only a literal 10 + * re-delivery of the byte-identical op ever dedups. 11 + * - Assign `opId` as a hash of the op's *content* and two replicas that 12 + * independently produce the same edit produce the same `opId`, so the dedup at 13 + * the sort boundary collapses them. Two editors who both fix "a typa" -> "a 14 + * typo" converge on a single insert instead of "typoo". 15 + * 16 + * This module mints the content-addressed ids. The hash is the easy part; the 17 + * contract every client must share is *what gets hashed*. That contract has three 18 + * layers, and all three must match or the ids won't collide: 19 + * 20 + * 1. **Canonical serialization.** We hash via `cidForLex` (AT Protocol canonical 21 + * DAG-CBOR + CID) — the same canonicalization the repo already uses for record 22 + * CIDs, so clients agree on it for free. It fixes map key order, number 23 + * encoding, etc. Note it inherits the AT data model constraints (e.g. no 24 + * non-integer numbers); op `value`s are lexicon records and already satisfy 25 + * this. 26 + * 2. **Identity fields (this module).** Which fields of each op are identity — 27 + * see the table below. 28 + * 3. **Canonical op construction (the client's editor).** Two clients must emit 29 + * the *same* identity fields for "the same edit": a canonical anchor choice 30 + * for inserts (anchor the left neighbour, side `after`), a canonical range for 31 + * deletes (omit bounds for open ranges), and a canonical, *minimal* dependency 32 + * set where deps are part of identity. This module sorts `previousOpIds` so 33 + * ordering is not a divergence source, but it cannot canonicalize the client's 34 + * choice of *which* deps to list. 35 + * 36 + * ## What is content-addressed, and what includes `previousOpIds` 37 + * 38 + * The rule is one criterion: include `previousOpIds` in the identity exactly when 39 + * the op's *effect depends on its causal position* — i.e. its result changes with 40 + * what sorts before it. Otherwise the op is position-independent and deps are 41 + * excluded so the same act always collapses; and occurrence-bearing ops are not 42 + * content-addressed at all. 43 + * 44 + * | Op (`$type`) | Strategy | Identity fields | Incl. `prev`? | Why | 45 + * |-------------------|-----------------|-----------------------------------------|---------------|-----| 46 + * | `insert` (RGA) | content | target, anchor, side, value | **yes** | placement among concurrent peers; re-insert after delete must stay distinct | 47 + * | `set` (LWW) | content | target, value | **yes** | last-write-wins recency is causal-bucket order; re-assertion must stay distinct | 48 + * | `assign` | content | target, value | **yes** | per-key LWW; same as `set` | 49 + * | `add` ref (OR-Set)| content | target, valueId | **yes** | observed-remove: a re-add after a remove must stay distinct to re-appear | 50 + * | `add` inline, set | content | target, value | **yes** | opt-in set semantics; otherwise see below | 51 + * | `remove` (OR-Set) | content | target, valueId | **yes** | observed-remove cancels what sorts before it; two removes with different views differ | 52 + * | `delete` (RGA) | content | target, id, startIdx, endIdx | **no** | monotonic tombstone of elements named by stable id; effect is position-independent | 53 + * | `add` inline, multiset | **nonce** | — | — | each add is a distinct member; collapsing identical data would lose multiplicity | 54 + * | `incr` | **nonce** | — | — | accumulation: two `+1`s must total `+2`; content-addressing would wrongly merge them | 55 + * 56 + * Inline OR-Set `add` (no `valueId`) is the one per-*collection* choice: a set 57 + * (collapse identical data) or a multiset (keep duplicates). It defaults to 58 + * multiset — the existing semantics, where each inline add is keyed by its own 59 + * `opId` — and set semantics are opt-in via {@link MintOptions.inlineAdd}. 60 + * 61 + * Nonce ops (`incr`, multiset inline `add`) are *not* minted here: they must carry 62 + * a client-assigned unique `opId`. {@link mintOpId} throws for them rather than 63 + * silently returning a colliding hash. 64 + */ 65 + import { cidForLex, type LexValue } from "@atproto/lex-cbor"; 66 + import type { InsertOperation, DeleteOperation } from "./rga.js"; 67 + import type { AddOperation, RemoveOperation } from "./orset.js"; 68 + import type { SetOperation } from "./lww.js"; 69 + import type { AssignOperation } from "./assign.js"; 70 + import type { IncrOperation } from "./increment.js"; 71 + 72 + /** Any op whose identity policy this module knows. */ 73 + export type IdentifiableOp = 74 + | InsertOperation 75 + | DeleteOperation 76 + | AddOperation 77 + | RemoveOperation 78 + | SetOperation 79 + | AssignOperation 80 + | IncrOperation; 81 + 82 + /** `Omit` that distributes over a union, preserving the `$type` discriminant. */ 83 + type DistributiveOmit<T, K extends PropertyKey> = T extends unknown 84 + ? Omit<T, K> 85 + : never; 86 + 87 + /** 88 + * An op as seen by minting: identical to an {@link IdentifiableOp} but with no 89 + * `opId` — the id is *derived* here, so callers don't supply it (passing a full 90 + * op with its `opId` set is also fine; the field is ignored). 91 + */ 92 + export type IdentityInput = DistributiveOmit<IdentifiableOp, "opId"> & { 93 + /** Ignored by minting; allowed so a fully-formed op can be passed unchanged. */ 94 + opId?: string; 95 + }; 96 + 97 + /** How an op's `opId` is derived. */ 98 + export interface IdentityPolicy { 99 + /** 100 + * `"content"` — `opId` is a hash of the identity fields (this module mints it). 101 + * `"nonce"` — `opId` is a client-assigned unique value (this module refuses to 102 + * mint it; minting would collapse occurrences that must stay distinct). 103 + */ 104 + strategy: "content" | "nonce"; 105 + /** For `"content"`: whether `previousOpIds` are part of the identity. */ 106 + includesDeps: boolean; 107 + } 108 + 109 + export interface MintOptions { 110 + /** 111 + * Identity policy for an inline OR-Set `add` (an `add` with no `valueId`): 112 + * - `"multiset"` (default): each add is a distinct member — `nonce`, not 113 + * content-addressed (the existing semantics). 114 + * - `"set"`: adds of identical data collapse — content-addressed (with deps). 115 + */ 116 + inlineAdd?: "multiset" | "set"; 117 + } 118 + 119 + /** Sorted copy of an op's `previousOpIds`, so dep *ordering* never changes identity. */ 120 + function sortedDeps(op: { previousOpIds?: string[] }): string[] { 121 + return [...(op.previousOpIds ?? [])].sort(); 122 + } 123 + 124 + /** The identity policy for `op` — see the table in the module doc comment. */ 125 + export function identityPolicy( 126 + op: IdentityInput, 127 + opts: MintOptions = {}, 128 + ): IdentityPolicy { 129 + switch (op.$type) { 130 + case "local.crdt.ops#delete": 131 + return { strategy: "content", includesDeps: false }; 132 + case "local.crdt.ops#insert": 133 + case "local.crdt.ops#set": 134 + case "local.crdt.ops#assign": 135 + case "local.crdt.ops#remove": 136 + return { strategy: "content", includesDeps: true }; 137 + case "local.crdt.ops#add": 138 + // A ref add is always content-addressed; an inline add follows the 139 + // per-collection set/multiset choice. 140 + if (op.valueId !== undefined) return { strategy: "content", includesDeps: true }; 141 + return (opts.inlineAdd ?? "multiset") === "set" 142 + ? { strategy: "content", includesDeps: true } 143 + : { strategy: "nonce", includesDeps: false }; 144 + case "local.crdt.ops#incr": 145 + return { strategy: "nonce", includesDeps: false }; 146 + } 147 + } 148 + 149 + /** True if `op` gets a content-addressed `opId` (vs. a client-assigned nonce). */ 150 + export function isContentAddressed(op: IdentityInput, opts: MintOptions = {}): boolean { 151 + return identityPolicy(op, opts).strategy === "content"; 152 + } 153 + 154 + /** 155 + * The canonical identity fields of `op` — the object that gets hashed into its 156 + * `opId`. Throws for nonce ops (which must carry a client-assigned `opId`). 157 + * 158 + * Optional fields are normalized to explicit sentinels (`side` defaults to 159 + * `"after"`, an open delete bound to `null`) so that two clients which differ only 160 + * in what they *omit* still produce identical fields. 161 + */ 162 + export function identityFields( 163 + op: IdentityInput, 164 + opts: MintOptions = {}, 165 + ): Record<string, unknown> { 166 + const policy = identityPolicy(op, opts); 167 + if (policy.strategy === "nonce") { 168 + throw new Error( 169 + `${op.$type} is occurrence-bearing (nonce identity); it must carry a ` + 170 + `client-assigned unique opId, not a content hash.`, 171 + ); 172 + } 173 + const withDeps = (fields: Record<string, unknown>): Record<string, unknown> => 174 + policy.includesDeps ? { ...fields, prev: sortedDeps(op) } : fields; 175 + 176 + switch (op.$type) { 177 + case "local.crdt.ops#insert": 178 + return withDeps({ 179 + $type: op.$type, 180 + target: op.target, 181 + anchor: op.anchor, 182 + side: op.side ?? "after", // lexicon default; omitted and "after" are one identity 183 + value: op.value, 184 + }); 185 + case "local.crdt.ops#delete": 186 + return withDeps({ 187 + $type: op.$type, 188 + target: op.target, 189 + id: op.id, 190 + startIdx: op.startIdx ?? null, // open bound -> null sentinel 191 + endIdx: op.endIdx ?? null, 192 + }); 193 + case "local.crdt.ops#set": 194 + case "local.crdt.ops#assign": 195 + return withDeps({ $type: op.$type, target: op.target, value: op.value }); 196 + case "local.crdt.ops#add": 197 + // Inline-multiset already threw above; here add is ref or inline-as-set. 198 + return op.valueId !== undefined 199 + ? withDeps({ $type: op.$type, target: op.target, valueId: op.valueId }) 200 + : withDeps({ $type: op.$type, target: op.target, value: op.value }); 201 + case "local.crdt.ops#remove": 202 + return withDeps({ $type: op.$type, target: op.target, valueId: op.valueId }); 203 + // `incr` is nonce and threw above; this default is unreachable for valid ops. 204 + default: 205 + throw new Error(`cannot mint a content opId for ${(op as { $type: string }).$type}`); 206 + } 207 + } 208 + 209 + /** 210 + * Mint the content-addressed `opId` for `op`: the CID of its canonical identity 211 + * fields. Deterministic across clients that share the identity contract. Throws 212 + * for nonce ops. 213 + */ 214 + export async function mintOpId( 215 + op: IdentityInput, 216 + opts: MintOptions = {}, 217 + ): Promise<string> { 218 + const cid = await cidForLex(identityFields(op, opts) as LexValue); 219 + return cid.toString(); 220 + }
+10
src/index.ts
··· 1 1 export function add(a: number, b: number): number { 2 2 return a + b; 3 3 } 4 + 5 + export { 6 + mintOpId, 7 + identityFields, 8 + identityPolicy, 9 + isContentAddressed, 10 + type IdentifiableOp, 11 + type IdentityPolicy, 12 + type MintOptions, 13 + } from "./identity.js";
+22 -5
src/sorter.ts
··· 5 5 } 6 6 7 7 /** 8 - * Sort operations into a deterministic order. 8 + * Sort operations into a deterministic order, deduplicated by opId. 9 9 * 10 10 * Algorithm: 11 + * - Deduplicate by opId — opId is identity, so duplicate copies of an op fold 12 + * once (a redelivered insert must not splice twice). A deterministic 13 + * representative is kept (see the body), so the result is independent of 14 + * input order. 11 15 * - Each operation is assigned a bucket. An op with no previousOpIds is in 12 16 * bucket 0. Otherwise its bucket is MAX(bucket of each previousOpId) + 1. 13 17 * - Operations are ordered by bucket ascending, then lexically by opId 14 - * within a bucket. 18 + * within a bucket. opIds are unique after dedup, so the order is total. 15 19 * 16 20 * An op is dropped from the result if its previousOpIds can never be satisfied 17 21 * by an applied op. That covers references to a missing opId, ops in a cycle, 18 22 * and ops that transitively depend on either. 19 23 */ 20 24 export function sortOperations<T extends Operation>(operations: T[]): T[] { 21 - const byId = new Map<string, Operation>(); 25 + // Deduplicate by opId: the opId *is* the op's identity (see `identity.ts`), so 26 + // two ops sharing one are the same op and must fold once — otherwise a redelivered 27 + // insert would splice its block twice. Same-opId copies are byte-identical except 28 + // possibly in `previousOpIds` (a content id that excludes deps, e.g. `delete`, can 29 + // be issued from different causal views). We keep a deterministic representative — 30 + // the one whose sorted `previousOpIds` is lexicographically smallest — so the pick 31 + // never depends on input order and every replica converges on it. The choice is 32 + // arbitrary but stable; it is sound because a deps-excluding id is, by definition, 33 + // one whose effect does not depend on its causal position. Deps-bearing ids carry 34 + // their deps in the id, so all their copies tie here and the first is kept. 35 + const depsKey = (op: Operation): string => 36 + JSON.stringify([...(op.previousOpIds ?? [])].sort()); 37 + const byId = new Map<string, T>(); 22 38 for (const op of operations) { 23 - byId.set(op.opId, op); 39 + const existing = byId.get(op.opId); 40 + if (!existing || depsKey(op) < depsKey(existing)) byId.set(op.opId, op); 24 41 } 25 42 26 43 // Final memoized bucket per opId. `null` means unresolvable (cyclic or ··· 66 83 return bucket; 67 84 }; 68 85 69 - return operations 86 + return [...byId.values()] 70 87 .filter((op) => bucketOf(op.opId) !== null) 71 88 .sort((a, b) => { 72 89 const bucketDiff = bucketOf(a.opId)! - bucketOf(b.opId)!;
+37
test/fixtures/reduce/rga-cases.json
··· 358 358 { "$type": "local.crdt.document#seqTombstones", "id": "c1", "startIdx": 20, "endIdx": 29 } 359 359 ] 360 360 }] 361 + }, 362 + { 363 + "name": "a redelivered insert folds once (dedup by opId, not doubled)", 364 + "input": [ 365 + { "$type": "local.crdt.ops#insert", "target": "l", "opId": "c1", "previousOpIds": [], "anchor": "BEGIN", "side": "after", "value": "hi" }, 366 + { "$type": "local.crdt.ops#insert", "target": "l", "opId": "c1", "previousOpIds": [], "anchor": "BEGIN", "side": "after", "value": "hi" } 367 + ], 368 + "output": [ 369 + { "$type": "local.crdt.document#value", "id": "ROOT", "value": {}, "lastOpId": "ROOT" }, 370 + { 371 + "$type": "local.crdt.document#sequence", 372 + "id": "l", 373 + "lastOpId": "c1", 374 + "seq": [ 375 + { "$type": "local.crdt.document#seqValues", "id": "c1", "startIdx": 0, "values": "hi" } 376 + ] 377 + } 378 + ] 379 + }, 380 + { 381 + "name": "a redelivered delete stays idempotent (one tombstone)", 382 + "input": [ 383 + { "$type": "local.crdt.ops#insert", "target": "l", "opId": "c1", "previousOpIds": [], "anchor": "BEGIN", "side": "after", "value": "a" }, 384 + { "$type": "local.crdt.ops#delete", "target": "l", "opId": "c2", "previousOpIds": ["c1"], "id": "c1" }, 385 + { "$type": "local.crdt.ops#delete", "target": "l", "opId": "c2", "previousOpIds": ["c1"], "id": "c1" } 386 + ], 387 + "output": [ 388 + { "$type": "local.crdt.document#value", "id": "ROOT", "value": {}, "lastOpId": "ROOT" }, 389 + { 390 + "$type": "local.crdt.document#sequence", 391 + "id": "l", 392 + "lastOpId": "c2", 393 + "seq": [ 394 + { "$type": "local.crdt.document#seqTombstones", "id": "c1", "startIdx": 0, "endIdx": 0 } 395 + ] 396 + } 397 + ] 361 398 } 362 399 ]
+26
test/fixtures/sort/cases.json
··· 119 119 { "opId": "e", "previousOpIds": [] } 120 120 ], 121 121 "output": [{ "opId": "e", "previousOpIds": [] }] 122 + }, 123 + { 124 + "name": "deduplicates a redelivered op by opId", 125 + "input": [ 126 + { "opId": "c", "previousOpIds": [] }, 127 + { "opId": "a", "previousOpIds": [] }, 128 + { "opId": "c", "previousOpIds": [] } 129 + ], 130 + "output": [ 131 + { "opId": "a", "previousOpIds": [] }, 132 + { "opId": "c", "previousOpIds": [] } 133 + ] 134 + }, 135 + { 136 + "name": "same opId, differing deps: keeps the lexicographically-smallest-deps copy", 137 + "input": [ 138 + { "opId": "c0", "previousOpIds": [] }, 139 + { "opId": "c1", "previousOpIds": [] }, 140 + { "opId": "d", "previousOpIds": ["c1"] }, 141 + { "opId": "d", "previousOpIds": ["c0", "c1"] } 142 + ], 143 + "output": [ 144 + { "opId": "c0", "previousOpIds": [] }, 145 + { "opId": "c1", "previousOpIds": [] }, 146 + { "opId": "d", "previousOpIds": ["c0", "c1"] } 147 + ] 122 148 } 123 149 ]
+204
test/identity.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { 3 + identityPolicy, 4 + isContentAddressed, 5 + mintOpId, 6 + } from "../src/identity.js"; 7 + 8 + /** 9 + * Conformance tests for operation identity. 10 + * 11 + * These are the contract two independent clients must agree on: the same logical 12 + * edit must mint the same `opId` (so the sort-boundary dedup collapses it), and 13 + * distinct edits must not. They double as executable documentation of the policy 14 + * table in `identity.ts`. 15 + * 16 + * The `opId` field on the inputs is deliberately junk: it is never read by minting 17 + * (the id is *derived* from content), so two clients can carry different garbage 18 + * there and still converge. 19 + */ 20 + describe("identity", () => { 21 + describe("policy", () => { 22 + it("classifies each family", () => { 23 + const p = (op: any, opts?: any) => identityPolicy(op, opts); 24 + expect(p({ $type: "local.crdt.ops#insert" })).toEqual({ 25 + strategy: "content", 26 + includesDeps: true, 27 + }); 28 + expect(p({ $type: "local.crdt.ops#delete" })).toEqual({ 29 + strategy: "content", 30 + includesDeps: false, 31 + }); 32 + expect(p({ $type: "local.crdt.ops#set" }).includesDeps).toBe(true); 33 + expect(p({ $type: "local.crdt.ops#assign" }).includesDeps).toBe(true); 34 + expect(p({ $type: "local.crdt.ops#remove" }).includesDeps).toBe(true); 35 + // ref add -> content; inline add -> nonce by default, content under "set". 36 + expect(p({ $type: "local.crdt.ops#add", valueId: "v1" }).strategy).toBe("content"); 37 + expect(p({ $type: "local.crdt.ops#add" }).strategy).toBe("nonce"); 38 + expect(p({ $type: "local.crdt.ops#add" }, { inlineAdd: "set" }).strategy).toBe("content"); 39 + expect(p({ $type: "local.crdt.ops#incr" }).strategy).toBe("nonce"); 40 + }); 41 + }); 42 + 43 + describe("inserts merge by content (typo case)", () => { 44 + it("two clients, same edit -> same opId (deps reordered, side omitted vs explicit)", async () => { 45 + // Both fix "a typ[a]" -> "a typo": insert "o" after element c2.4, deleting 46 + // the bad char is a separate (idempotent) delete. The inserts are what 47 + // would otherwise double. 48 + const clientA = { 49 + opId: "garbage-A", 50 + $type: "local.crdt.ops#insert" as const, 51 + previousOpIds: ["c2", "d-bad"], 52 + target: "seq", 53 + anchor: "c2.4", 54 + value: "o", 55 + // side omitted -> defaults to "after" 56 + }; 57 + const clientB = { 58 + opId: "garbage-B", 59 + $type: "local.crdt.ops#insert" as const, 60 + previousOpIds: ["d-bad", "c2"], // different order, same set 61 + target: "seq", 62 + anchor: "c2.4", 63 + side: "after" as const, // explicit 64 + value: "o", 65 + }; 66 + expect(await mintOpId(clientA)).toBe(await mintOpId(clientB)); 67 + }); 68 + 69 + it("different value -> different opId", async () => { 70 + const base = { 71 + $type: "local.crdt.ops#insert" as const, 72 + previousOpIds: [], 73 + target: "seq", 74 + anchor: "BEGIN", 75 + }; 76 + expect(await mintOpId({ ...base, value: "o" })).not.toBe( 77 + await mintOpId({ ...base, value: "x" }), 78 + ); 79 + }); 80 + 81 + it("different anchor -> different opId", async () => { 82 + const base = { 83 + $type: "local.crdt.ops#insert" as const, 84 + previousOpIds: [], 85 + target: "seq", 86 + value: "o", 87 + }; 88 + expect(await mintOpId({ ...base, anchor: "c2.4" })).not.toBe( 89 + await mintOpId({ ...base, anchor: "c2.5" }), 90 + ); 91 + }); 92 + 93 + it("same content but different deps -> different opId (deps are identity)", async () => { 94 + // Re-insert after a delete must stay distinct so it can re-appear. 95 + const base = { 96 + $type: "local.crdt.ops#insert" as const, 97 + target: "seq", 98 + anchor: "BEGIN", 99 + value: "o", 100 + }; 101 + expect(await mintOpId({ ...base, previousOpIds: [] })).not.toBe( 102 + await mintOpId({ ...base, previousOpIds: ["del-1"] }), 103 + ); 104 + }); 105 + }); 106 + 107 + describe("deletes are position-independent (deps excluded)", () => { 108 + it("same range, different deps -> SAME opId", async () => { 109 + const base = { 110 + $type: "local.crdt.ops#delete" as const, 111 + target: "seq", 112 + id: "c2", 113 + startIdx: 4, 114 + endIdx: 4, 115 + }; 116 + expect(await mintOpId({ ...base, previousOpIds: ["c2"] })).toBe( 117 + await mintOpId({ ...base, previousOpIds: ["c2", "x"] }), 118 + ); 119 + }); 120 + 121 + it("different range -> different opId", async () => { 122 + const base = { 123 + $type: "local.crdt.ops#delete" as const, 124 + previousOpIds: ["c2"], 125 + target: "seq", 126 + id: "c2", 127 + }; 128 + expect(await mintOpId({ ...base, startIdx: 0, endIdx: 2 })).not.toBe( 129 + await mintOpId({ ...base, startIdx: 0, endIdx: 4 }), 130 + ); 131 + }); 132 + 133 + it("open bound omitted matches open bound omitted", async () => { 134 + // NOTE: omitted (-> null) is a *different* identity from an explicit 0. 135 + // Clients must canonically represent open ranges (omit the bound). 136 + const whole = { 137 + $type: "local.crdt.ops#delete" as const, 138 + previousOpIds: ["c2"], 139 + target: "seq", 140 + id: "c2", 141 + }; 142 + expect(await mintOpId(whole)).toBe(await mintOpId({ ...whole })); 143 + expect(await mintOpId(whole)).not.toBe( 144 + await mintOpId({ ...whole, startIdx: 0 }), 145 + ); 146 + }); 147 + }); 148 + 149 + describe("registers", () => { 150 + it("set: identical value+deps merge; re-assertion (new deps) stays distinct", async () => { 151 + const five = { $type: "local.crdt.ops#set" as const, target: "reg", value: 5 }; 152 + expect(await mintOpId({ ...five, previousOpIds: [] })).toBe( 153 + await mintOpId({ ...five, previousOpIds: [] }), 154 + ); 155 + expect(await mintOpId({ ...five, previousOpIds: [] })).not.toBe( 156 + await mintOpId({ ...five, previousOpIds: ["set-7"] }), 157 + ); 158 + }); 159 + }); 160 + 161 + describe("or-set", () => { 162 + it("ref add: same valueId+deps -> same opId", async () => { 163 + const add = { 164 + $type: "local.crdt.ops#add" as const, 165 + previousOpIds: [], 166 + target: "set", 167 + valueId: "v1", 168 + value: { $ref: "v1" }, 169 + }; 170 + // opId is ignored by minting, so two clients' different garbage ids agree. 171 + expect(await mintOpId({ ...add, opId: "a" })).toBe( 172 + await mintOpId({ ...add, opId: "b" }), 173 + ); 174 + }); 175 + 176 + it("inline add: multiset (default) refuses content id; set collapses identical data", async () => { 177 + const add = { 178 + $type: "local.crdt.ops#add" as const, 179 + previousOpIds: [], 180 + target: "set", 181 + value: "hello", 182 + }; 183 + expect(isContentAddressed(add)).toBe(false); 184 + await expect(mintOpId(add)).rejects.toThrow(/occurrence-bearing/); 185 + // Opt into set semantics: two identical inline adds collapse. 186 + expect(await mintOpId(add, { inlineAdd: "set" })).toBe( 187 + await mintOpId({ ...add }, { inlineAdd: "set" }), 188 + ); 189 + }); 190 + }); 191 + 192 + describe("counters are not content-addressed", () => { 193 + it("incr refuses a content id (two +1s must stay distinct -> +2)", async () => { 194 + const incr = { 195 + $type: "local.crdt.ops#incr" as const, 196 + previousOpIds: [], 197 + target: "ctr", 198 + delta: 1, 199 + }; 200 + expect(isContentAddressed(incr)).toBe(false); 201 + await expect(mintOpId(incr)).rejects.toThrow(/occurrence-bearing/); 202 + }); 203 + }); 204 + });