@openlfcp/shared-objects 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ import { LfcpError, toHex, } from "@openlfcp/core";
2
+ import { frameChange, frameSnapshot, unframeChange, unframeSnapshot, } from "./automerge-bytes.js";
3
+ import { SharedObjectsReplica } from "./replica.js";
4
+ import { ProfileInvalidError } from "./validate.js";
5
+ import { deriveActorId, PROFILE_ID } from "./values.js";
6
+ export class SharedObjectsDataProfile {
7
+ dataProfile = PROFILE_ID;
8
+ #replica;
9
+ /** Merged units: unit ID hex → change hash. */
10
+ #merged = new Map();
11
+ /** LFCP-accepted units waiting for Automerge dependencies, by unit ID hex. */
12
+ #pending = new Map();
13
+ /** LFCP-accepted units whose actor and sequence number a different change has (§14.1), by unit ID hex. */
14
+ #held = new Map();
15
+ #listeners = new Set();
16
+ constructor(replica) {
17
+ this.#replica = replica;
18
+ }
19
+ /**
20
+ * The local state to persist (structurally the storage ProfileCheckpoint,
21
+ * LFCP-034/035): the Automerge full save, this actor's sequence (the §9
22
+ * minSeq on restore) and which unit carried which merged change (for a
23
+ * G-EP7 rebuild). Units buffered for Automerge dependencies are not in it:
24
+ * they stay "profile-pending" in storage and are offered again on restore.
25
+ */
26
+ checkpoint() {
27
+ return Object.freeze({
28
+ resourceId: this.#replica.resource,
29
+ dataProfile: PROFILE_ID,
30
+ state: this.#replica.save(),
31
+ actorSeq: this.#replica.actorSeq,
32
+ units: Object.freeze([...this.#merged.values()]
33
+ .map((m) => Object.freeze({ unitId: m.unitId, ref: m.hash }))
34
+ .sort((a, b) => (toHex(a.unitId) < toHex(b.unitId) ? -1 : 1))),
35
+ });
36
+ }
37
+ /**
38
+ * The handler of a persisted checkpoint. The replica refuses local writes
39
+ * if its actor is behind the checkpoint's sequence (§9).
40
+ */
41
+ static restore(checkpoint, options) {
42
+ if (checkpoint.dataProfile !== PROFILE_ID)
43
+ throw new LfcpError("DATA_PROFILE_MISMATCH", `a ${checkpoint.dataProfile} checkpoint is not ${PROFILE_ID}`);
44
+ // This device's own persisted state: not held to the Snapshot limits (§13.1).
45
+ const replica = SharedObjectsReplica.fromSave(checkpoint.state, { ...options, minSeq: checkpoint.actorSeq }, "local-state");
46
+ const profile = new SharedObjectsDataProfile(replica);
47
+ for (const u of checkpoint.units) {
48
+ if (!replica.hasChange(u.ref))
49
+ throw new LfcpError("PROFILE_INVALID", `the checkpoint names change ${u.ref}, which its state lacks`);
50
+ profile.#merged.set(toHex(u.unitId), { unitId: u.unitId, hash: u.ref });
51
+ }
52
+ return profile;
53
+ }
54
+ /** The current replica (a G-EP7 rebuild replaces it). */
55
+ get replica() {
56
+ return this.#replica;
57
+ }
58
+ /** §98, §100: object change notifications for remote merges and rebuilds. */
59
+ onObjectChanged(listener) {
60
+ this.#listeners.add(listener);
61
+ return () => this.#listeners.delete(listener);
62
+ }
63
+ #emit(changes) {
64
+ for (const c of changes)
65
+ for (const l of this.#listeners)
66
+ l(c);
67
+ }
68
+ /**
69
+ * Records which change one of this client's OWN units carries (the unit
70
+ * was created from a local change, so it is merged already). A later
71
+ * G-EP7 exclusion of that unit then rebuilds the replica without it, and
72
+ * the checkpoint keeps the reference. Use it as createQueuedDataUnit's
73
+ * onCreated.
74
+ */
75
+ recordLocal(unitId, change) {
76
+ if (!this.#replica.hasChange(change.hash))
77
+ throw new LfcpError("PROFILE_INVALID", "the change is not in this replica; record only local changes");
78
+ this.#merged.set(toHex(unitId), { unitId, hash: change.hash });
79
+ }
80
+ /**
81
+ * The §13 Snapshot codec (structurally the wire DataProfileCodec): an
82
+ * Automerge full save framed as [1, save]; decoding requires a document
83
+ * chunk. For receiveSnapshot and createSnapshot.
84
+ */
85
+ snapshotCodec() {
86
+ return {
87
+ dataProfile: PROFILE_ID,
88
+ encode: (save) => frameSnapshot(save),
89
+ decode: (plaintext) => unframeSnapshot(plaintext),
90
+ };
91
+ }
92
+ /** The state to publish as a Snapshot: the replica's full save. */
93
+ snapshotState() {
94
+ return this.#replica.save();
95
+ }
96
+ /**
97
+ * Loads a received Snapshot's full save (§13, §66 step 3): the replica
98
+ * becomes the save merged with everything it held, and buffered units
99
+ * whose dependencies the Snapshot brings are merged. Returns the objects
100
+ * that changed and the units merged from the buffer.
101
+ */
102
+ loadSnapshot(save) {
103
+ const before = this.#replica;
104
+ const { replica } = before.mergeSave(save);
105
+ this.#replica = replica;
106
+ const merged = [...this.#applyBatch([]).result.merged];
107
+ const changes = rebuildChanges(before, replica).map((c) => Object.freeze({ ...c, origin: "remote" }));
108
+ this.#emit(changes);
109
+ return Object.freeze({ objects: changes.map((c) => c.objectId), merged });
110
+ }
111
+ /**
112
+ * Forgets the whole state (an empty replica that keeps the §9 sequence,
113
+ * no merged or buffered units), so it can be rebuilt from accepted units
114
+ * only (SNAP-EP: Snapshot-derived state dropped). Notifies the changes.
115
+ */
116
+ reset() {
117
+ const before = this.#replica;
118
+ this.#replica = before.emptied();
119
+ this.#merged.clear();
120
+ this.#pending.clear();
121
+ this.#held.clear();
122
+ this.#emit(rebuildChanges(before, this.#replica));
123
+ }
124
+ /** Whether this handler holds the unit's change (merged, recorded, buffered or held). */
125
+ has(unitId) {
126
+ const key = toHex(unitId);
127
+ return this.#merged.has(key) || this.#pending.has(key) || this.#held.has(key);
128
+ }
129
+ /** The units held because a different change has their actor and sequence number (§14.1). */
130
+ heldUnits() {
131
+ return [...this.#held.values()].map((b) => b.unitId);
132
+ }
133
+ /** The units waiting for Automerge dependencies. */
134
+ pendingUnits() {
135
+ return [...this.#pending.values()].map((b) => b.unitId);
136
+ }
137
+ codecFor(unit) {
138
+ const actor = toHex(deriveActorId(unit.resourceId, unit.actor));
139
+ // §8, §11 (SO-SEC1): a unit carries only changes of its signer's §8
140
+ // actor, so no Principal can write into another Principal's Automerge
141
+ // history. Anything else is PROFILE_INVALID / CHANGE_ACTOR_MISMATCH.
142
+ const bound = (change) => {
143
+ if (change.actor !== actor)
144
+ throw new ProfileInvalidError("CHANGE_ACTOR_MISMATCH", `the change's Automerge actor ${change.actor} is not the §8 actor ${actor} of the unit's signer (§11, SO-SEC1)`);
145
+ return change;
146
+ };
147
+ return {
148
+ dataProfile: PROFILE_ID,
149
+ encode: (change) => frameChange(bound(change).bytes),
150
+ decode: (plaintext) => bound(unframeChange(plaintext)),
151
+ };
152
+ }
153
+ apply(unit, change) {
154
+ const key = toHex(unit.unitId);
155
+ const r = this.#applyBatch([{ unit, value: change }]);
156
+ const own = r.refused.get(key);
157
+ if (own !== undefined)
158
+ throw own;
159
+ if (this.#held.has(key))
160
+ return Object.freeze({
161
+ merged: r.result.merged,
162
+ objects: r.result.objects,
163
+ diagnostics: r.result.diagnostics,
164
+ held: `held: a different change has actor ${change.actor} sequence ${change.seq} (§14.1)`,
165
+ });
166
+ const self = r.result.merged.some((id) => toHex(id) === key);
167
+ if (!self)
168
+ return Object.freeze({
169
+ merged: r.result.merged,
170
+ objects: r.result.objects,
171
+ diagnostics: r.result.diagnostics,
172
+ pending: `waiting for Automerge changes ${r.missing.join(", ")}`,
173
+ });
174
+ // The unit first, then the buffered ones it released.
175
+ return Object.freeze({
176
+ merged: [unit.unitId, ...r.result.merged.filter((id) => toHex(id) !== key)],
177
+ objects: r.result.objects,
178
+ diagnostics: r.result.diagnostics,
179
+ });
180
+ }
181
+ /**
182
+ * Applies many accepted units at once (catch-up, store replay, a
183
+ * Snapshot's remainder): their changes and every buffered one go to the
184
+ * replica in one receiveChanges call, which hands Automerge one batch
185
+ * (§14.1 admission per change; one change per call is quadratic). A
186
+ * refused change rejects only its unit; units missing a dependency are
187
+ * buffered. Diagnostics are computed once, for the objects that changed.
188
+ */
189
+ applyBatch(units) {
190
+ return this.#applyBatch(units).result;
191
+ }
192
+ #applyBatch(units, retryHeld = false) {
193
+ const byHash = new Map();
194
+ const offer = (unitId, change) => byHash.set(change.hash, [...(byHash.get(change.hash) ?? []), unitId]);
195
+ // §14.1: after a rebuild, held changes are offered again with the rest.
196
+ if (retryHeld) {
197
+ for (const [key, b] of this.#held)
198
+ this.#pending.set(key, b);
199
+ this.#held.clear();
200
+ }
201
+ for (const b of this.#pending.values())
202
+ offer(b.unitId, b.change);
203
+ for (const { unit, value } of units) {
204
+ this.#pending.set(toHex(unit.unitId), { unitId: unit.unitId, change: value });
205
+ offer(unit.unitId, value);
206
+ }
207
+ const r = this.#replica.receiveChanges([...this.#pending.values()].map((b) => b.change));
208
+ const merged = [];
209
+ for (const c of [...r.applied, ...r.duplicates])
210
+ for (const unitId of byHash.get(c.hash) ?? []) {
211
+ this.#pending.delete(toHex(unitId));
212
+ this.#merged.set(toHex(unitId), { unitId, hash: c.hash });
213
+ merged.push(unitId);
214
+ }
215
+ const rejected = [];
216
+ const refused = new Map();
217
+ for (const { change, error } of r.refused)
218
+ for (const unitId of byHash.get(change.hash) ?? []) {
219
+ this.#pending.delete(toHex(unitId));
220
+ // §14.1 (POST-001): another change has its actor and sequence number: held, not refused.
221
+ if (error.code === "ACTOR_EQUIVOCATION") {
222
+ this.#held.set(toHex(unitId), { unitId, change });
223
+ continue;
224
+ }
225
+ rejected.push({ unitId, code: error.code, message: error.message });
226
+ refused.set(toHex(unitId), error);
227
+ }
228
+ this.#emit(r.objects);
229
+ const objects = [...new Set(r.objects.map((c) => c.objectId))].sort();
230
+ const missing = [
231
+ ...new Set(r.waiting.flatMap((c) => c.deps.filter((d) => !this.#replica.hasChange(d)))),
232
+ ];
233
+ return {
234
+ result: Object.freeze({
235
+ merged: Object.freeze(merged),
236
+ pending: Object.freeze([...this.#pending.values()].map((b) => b.unitId)),
237
+ held: Object.freeze(this.heldUnits()),
238
+ rejected: Object.freeze(rejected),
239
+ objects: Object.freeze(objects),
240
+ diagnostics: Object.freeze(this.#diagnostics(objects)),
241
+ }),
242
+ refused,
243
+ missing,
244
+ };
245
+ }
246
+ /** §74.1 problems and §21 collisions of the given objects (§77: the rest are unaffected). */
247
+ #diagnostics(objects) {
248
+ if (objects.length === 0)
249
+ return [];
250
+ const validation = this.#replica.validate();
251
+ const out = [];
252
+ for (const id of objects) {
253
+ for (const p of validation.objects.get(id) ?? [])
254
+ out.push({
255
+ objectId: id,
256
+ code: p.code,
257
+ diagnostic: p.diagnostic,
258
+ pointer: p.pointer,
259
+ message: p.message,
260
+ });
261
+ if (validation.collisions.includes(id))
262
+ out.push({
263
+ objectId: id,
264
+ code: "OBJECT_ID_COLLISION",
265
+ message: `${id}: concurrent objects share this Object ID (§21)`,
266
+ });
267
+ }
268
+ return out;
269
+ }
270
+ /**
271
+ * §14.1 (G-EP7): the state without `unitIds`, rebuilt from the
272
+ * remaining changes. Merged units whose changes build on an excluded one
273
+ * go back to the buffer and are reported as pending.
274
+ */
275
+ exclude(unitIds) {
276
+ const hashes = [];
277
+ for (const id of unitIds) {
278
+ const key = toHex(id);
279
+ this.#pending.delete(key);
280
+ this.#held.delete(key);
281
+ const m = this.#merged.get(key);
282
+ if (m !== undefined) {
283
+ hashes.push(m.hash);
284
+ this.#merged.delete(key);
285
+ }
286
+ }
287
+ if (hashes.length === 0)
288
+ return Object.freeze({ objects: [], pending: [], released: [] });
289
+ const before = this.#replica;
290
+ const { replica, unapplied } = before.rebuildWithout(hashes);
291
+ this.#replica = replica;
292
+ const pending = [];
293
+ for (const change of unapplied) {
294
+ const entry = [...this.#merged].find(([, m]) => m.hash === change.hash);
295
+ if (entry === undefined)
296
+ continue; // a local change: kept out with its dependency
297
+ this.#merged.delete(entry[0]);
298
+ this.#pending.set(entry[0], { unitId: entry[1].unitId, change });
299
+ pending.push(entry[1].unitId);
300
+ }
301
+ const changes = rebuildChanges(before, replica);
302
+ this.#emit(changes);
303
+ // §14.1: the rebuild may have freed held changes' actor sequences.
304
+ const held = new Set(this.#held.keys());
305
+ const retried = held.size > 0 ? this.#applyBatch([], true) : null;
306
+ const released = (retried?.result.merged ?? []).filter((id) => held.has(toHex(id)));
307
+ const objects = [
308
+ ...new Set([...changes.map((c) => c.objectId), ...(retried?.result.objects ?? [])]),
309
+ ].sort();
310
+ return Object.freeze({
311
+ objects,
312
+ pending: pending.filter((id) => !(retried?.result.merged ?? []).some((m) => toHex(m) === toHex(id))),
313
+ released: Object.freeze(released),
314
+ });
315
+ }
316
+ }
317
+ /** §100 notifications for every object that differs between two replicas of one Resource. */
318
+ function rebuildChanges(before, after) {
319
+ const ids = [...new Set([...before.objectIds(), ...after.objectIds()])].sort();
320
+ const beforeConflicts = before.conflicts();
321
+ const afterConflicts = after.conflicts();
322
+ const out = [];
323
+ for (const id of ids) {
324
+ const was = (before.getObject(id) ?? {});
325
+ const now = (after.getObject(id) ?? {});
326
+ const fields = [...new Set([...Object.keys(was), ...Object.keys(now)])]
327
+ .filter((f) => JSON.stringify(was[f]) !== JSON.stringify(now[f]))
328
+ .sort();
329
+ const wasC = Object.keys(beforeConflicts[id] ?? {});
330
+ const nowC = Object.keys(afterConflicts[id] ?? {});
331
+ if (fields.length === 0 && JSON.stringify(wasC) === JSON.stringify(nowC))
332
+ continue;
333
+ out.push(Object.freeze({
334
+ resource: after.resource,
335
+ objectId: id,
336
+ objectType: typeof now.type === "string"
337
+ ? now.type
338
+ : typeof was.type === "string"
339
+ ? was.type
340
+ : undefined,
341
+ fields: Object.freeze(fields),
342
+ conflictsAppeared: Object.freeze(nowC.filter((f) => !wasC.includes(f)).sort()),
343
+ conflictsDisappeared: Object.freeze(wasC.filter((f) => !nowC.includes(f)).sort()),
344
+ origin: "rebuild",
345
+ }));
346
+ }
347
+ return out;
348
+ }
349
+ //# sourceMappingURL=data-profile.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * SHARED-OBJECTS-PROFILE-01 (org.openlfcp.shared-objects.v1): the Shared
3
+ * Task model and profile validation over logical state (LFCP-030), and its
4
+ * Automerge binding (LFCP-031).
5
+ */
6
+ export declare const PACKAGE = "@openlfcp/shared-objects";
7
+ export { type CheckedChange, checkChange, checkSaveHeader, frameChange, frameSnapshot, unframeChange, unframeSnapshot, } from "./automerge-bytes.js";
8
+ export { CHANGE_LIMITS, type ChunkExpansion, checkChangeExpansion, checkSnapshotExpansion, MAX_DOCUMENT_DEPTH, SNAPSHOT_LIMITS_FLOOR, type SnapshotLimits, } from "./chunk-limits.js";
9
+ export { type SharedObjectsApplyResult, type SharedObjectsBatchResult, type SharedObjectsCheckpoint, type SharedObjectsCodec, SharedObjectsDataProfile, type SharedObjectsDiagnostic, type SharedObjectsExcludeResult, type SharedObjectsSnapshotCodec, type SharedObjectsUnit, } from "./data-profile.js";
10
+ export { type BatchReceiveResult, type BuiltReplica, type LocalChange, type ObjectChange, ObjectIdCollisionError, type ObjectStatus, type ReceiveResult, type ReplicaIntent, type ReplicaOptions, type ReplicaValidation, type ResolveFieldConflict, resolveFieldConflict, SCALAR_FIELDS, type ScalarField, type ScalarView, SharedObjectsReplica, type TaskView, } from "./replica.js";
11
+ export { addTag, assign, cancel, clearDue, clearScheduled, complete, createTask, deleteTask, type NewTask, type ParsedTask, ProfileError, parseTask, removeTag, reopen, restoreTask, setDue, setPriority, setScheduled, setStatus, setTitle, type Task, type TaskChange, type TaskIntent, type TaskPriority, type TaskStatus, taskToJson, unassign, } from "./task.js";
12
+ export { DIAGNOSTIC_ORDER, firstPerField, isMap, type Json, objectProblems, type ProfileDiagnostic, ProfileInvalidError, type ProfileProblem, pointerToken, type RootValidation, validateRoot, validateTransition, } from "./validate.js";
13
+ export { deriveActorId, FRAMING_VERSION, frameProfilePayload, isLocalDate, isNamespacedValue, isPrincipalRef, isReverseDomain, isUtcTimestamp, PROFILE_ID, type PrincipalRef, parsePrincipalRef, principalRef, unframeProfilePayload, } from "./values.js";
14
+ export { initializeAutomerge, isAutomergeInitialized } from "./wasm.js";
15
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * SHARED-OBJECTS-PROFILE-01 (org.openlfcp.shared-objects.v1): the Shared
3
+ * Task model and profile validation over logical state (LFCP-030), and its
4
+ * Automerge binding (LFCP-031).
5
+ */
6
+ export const PACKAGE = "@openlfcp/shared-objects";
7
+ export { checkChange, checkSaveHeader, frameChange, frameSnapshot, unframeChange, unframeSnapshot, } from "./automerge-bytes.js";
8
+ export { CHANGE_LIMITS, checkChangeExpansion, checkSnapshotExpansion, MAX_DOCUMENT_DEPTH, SNAPSHOT_LIMITS_FLOOR, } from "./chunk-limits.js";
9
+ export { SharedObjectsDataProfile, } from "./data-profile.js";
10
+ export { ObjectIdCollisionError, resolveFieldConflict, SCALAR_FIELDS, SharedObjectsReplica, } from "./replica.js";
11
+ export { addTag, assign, cancel, clearDue, clearScheduled, complete, createTask, deleteTask, ProfileError, parseTask, removeTag, reopen, restoreTask, setDue, setPriority, setScheduled, setStatus, setTitle, taskToJson, unassign, } from "./task.js";
12
+ export { DIAGNOSTIC_ORDER, firstPerField, isMap, objectProblems, ProfileInvalidError, pointerToken, validateRoot, validateTransition, } from "./validate.js";
13
+ export { deriveActorId, FRAMING_VERSION, frameProfilePayload, isLocalDate, isNamespacedValue, isPrincipalRef, isReverseDomain, isUtcTimestamp, PROFILE_ID, parsePrincipalRef, principalRef, unframeProfilePayload, } from "./values.js";
14
+ export { initializeAutomerge, isAutomergeInitialized } from "./wasm.js";
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,12 @@
1
+ import { LfcpError } from "@openlfcp/core";
2
+ import type { ProfileDiagnostic } from "./validate.js";
3
+ /**
4
+ * PROFILE_INVALID thrown for a rejected profile plaintext or value, with
5
+ * its §74.1 diagnostic: CHANGE_ACTOR_MISMATCH (§8, §11) or
6
+ * INVALID_AUTOMERGE_BYTES (§11, §13).
7
+ */
8
+ export declare class ProfileInvalidError extends LfcpError {
9
+ readonly diagnostic: ProfileDiagnostic;
10
+ constructor(diagnostic: ProfileDiagnostic, message: string);
11
+ }
12
+ //# sourceMappingURL=profile-invalid.d.ts.map
@@ -0,0 +1,15 @@
1
+ import { LfcpError } from "@openlfcp/core";
2
+ /**
3
+ * PROFILE_INVALID thrown for a rejected profile plaintext or value, with
4
+ * its §74.1 diagnostic: CHANGE_ACTOR_MISMATCH (§8, §11) or
5
+ * INVALID_AUTOMERGE_BYTES (§11, §13).
6
+ */
7
+ export class ProfileInvalidError extends LfcpError {
8
+ diagnostic;
9
+ constructor(diagnostic, message) {
10
+ super("PROFILE_INVALID", message);
11
+ this.name = "ProfileInvalidError";
12
+ this.diagnostic = diagnostic;
13
+ }
14
+ }
15
+ //# sourceMappingURL=profile-invalid.js.map
@@ -0,0 +1,28 @@
1
+ import * as A from "@automerge/automerge";
2
+ import { type ParsedChange } from "./canonical.js";
3
+ /** A §11.4 rule a change breaks. */
4
+ export type ReferenceRule = "R1" | "R2" | "R3" | "R4" | "R5" | "R6" | "R7";
5
+ /**
6
+ * The changes of a document as §11.4 reads them. A replica keeps it current
7
+ * as changes are admitted and committed, or builds it again from its
8
+ * document.
9
+ */
10
+ export declare class ReferenceHistory {
11
+ #private;
12
+ get size(): number;
13
+ has(hash: string): boolean;
14
+ /**
15
+ * Adds a change the document now holds. Its dependencies are added
16
+ * first; one this index does not hold adds nothing to its history.
17
+ */
18
+ add(hash: string, change: ParsedChange): void;
19
+ /** The §11.4 rule `change` breaks against its causal history, or null. */
20
+ check(change: ParsedChange): ReferenceRule | null;
21
+ }
22
+ /**
23
+ * The index of every change a document holds, as the engine hands them
24
+ * out (in its encoding, so canonical), dependencies first. A change the
25
+ * walk cannot read is left out: references into it are then refused.
26
+ */
27
+ export declare function referenceHistoryOf(doc: A.Doc<unknown>): ReferenceHistory;
28
+ //# sourceMappingURL=references.d.ts.map
@@ -0,0 +1,220 @@
1
+ // Operation references (SHARED-OBJECTS-PROFILE-01 §11.4, SPEC-PATCH-10,
2
+ // ADR 0010): a change's operations refer only to its own causal history H
3
+ // (its dependencies and all their ancestors) or to operations earlier in
4
+ // the change. An engine applies a change that refers elsewhere (a
5
+ // predecessor on another key, an element of another list, an object that is
6
+ // not one), but then cannot write it back out, and some such changes abort
7
+ // the apply itself, which in Automerge JS terminates its wasm module. The
8
+ // rules are decided against H only, never anything else the receiver holds,
9
+ // so every replica decides alike.
10
+ //
11
+ // H is held as a vector clock: an admitted change's actor chain is
12
+ // contiguous in its history (R1), so the latest sequence number of each
13
+ // actor in H says exactly which changes H holds. An operation is found by
14
+ // its actor's changes and their counter ranges.
15
+ import * as A from "@automerge/automerge";
16
+ import { checkCanonicalChange } from "./canonical.js";
17
+ const MAKE = new Set([0, 2, 4, 6]);
18
+ const MAP_LIKE = new Set([0, 6]);
19
+ function slotOf(op, id) {
20
+ if (op.insert)
21
+ return `e:${id}`;
22
+ if (op.key.kind === "prop")
23
+ return `p:${op.key.name}`;
24
+ if (op.key.kind === "elem")
25
+ return `e:${op.key.id}`;
26
+ return "e:_head";
27
+ }
28
+ const targetOf = (op, id) => ({
29
+ obj: op.obj,
30
+ slot: slotOf(op, id),
31
+ insert: op.insert,
32
+ del: op.action === 3,
33
+ make: MAKE.has(op.action) ? op.action : null,
34
+ });
35
+ function splitId(id) {
36
+ const at = id.indexOf("@");
37
+ return { ctr: Number(id.slice(0, at)), actor: id.slice(at + 1) };
38
+ }
39
+ /**
40
+ * The changes of a document as §11.4 reads them. A replica keeps it current
41
+ * as changes are admitted and committed, or builds it again from its
42
+ * document.
43
+ */
44
+ export class ReferenceHistory {
45
+ #byHash = new Map();
46
+ /** Each actor's changes by sequence number (index seq - 1). */
47
+ #byActor = new Map();
48
+ get size() {
49
+ return this.#byHash.size;
50
+ }
51
+ has(hash) {
52
+ return this.#byHash.has(hash);
53
+ }
54
+ /** H of a change with these dependencies: the latest sequence of each actor. */
55
+ #history(deps) {
56
+ const clock = new Map();
57
+ for (const d of deps) {
58
+ const e = this.#byHash.get(d);
59
+ if (e === undefined)
60
+ continue;
61
+ for (const [a, s] of e.clock)
62
+ if ((clock.get(a) ?? 0) < s)
63
+ clock.set(a, s);
64
+ }
65
+ return clock;
66
+ }
67
+ /**
68
+ * Adds a change the document now holds. Its dependencies are added
69
+ * first; one this index does not hold adds nothing to its history.
70
+ */
71
+ add(hash, change) {
72
+ if (this.#byHash.has(hash))
73
+ return;
74
+ const clock = this.#history(change.deps);
75
+ if ((clock.get(change.actor) ?? 0) < change.seq)
76
+ clock.set(change.actor, change.seq);
77
+ const entry = {
78
+ actor: change.actor,
79
+ seq: change.seq,
80
+ startOp: change.startOp,
81
+ ops: change.ops.map((op, i) => targetOf(op, `${change.startOp + i}@${change.actor}`)),
82
+ clock,
83
+ };
84
+ this.#byHash.set(hash, entry);
85
+ const list = this.#byActor.get(change.actor) ?? [];
86
+ list[change.seq - 1] = entry;
87
+ this.#byActor.set(change.actor, list);
88
+ }
89
+ /** The change of `actor` holding operation counter `ctr`, among its first `upToSeq` changes. */
90
+ #find(actor, ctr, upToSeq) {
91
+ const list = this.#byActor.get(actor);
92
+ if (list === undefined)
93
+ return null;
94
+ let lo = 0;
95
+ let hi = Math.min(upToSeq, list.length) - 1;
96
+ while (lo <= hi) {
97
+ const mid = (lo + hi) >> 1;
98
+ const e = list[mid];
99
+ if (e === undefined)
100
+ return null;
101
+ if (ctr < e.startOp)
102
+ hi = mid - 1;
103
+ else if (ctr >= e.startOp + e.ops.length)
104
+ lo = mid + 1;
105
+ else
106
+ return { entry: e, index: ctr - e.startOp };
107
+ }
108
+ return null;
109
+ }
110
+ /** The §11.4 rule `change` breaks against its causal history, or null. */
111
+ check(change) {
112
+ const H = this.#history(change.deps);
113
+ // R1: the actor's previous change is in H.
114
+ if (change.seq > 1 && (H.get(change.actor) ?? 0) < change.seq - 1)
115
+ return "R1";
116
+ // R2: the start op follows H's largest counter (each actor's latest change in H has its largest).
117
+ let top = 0;
118
+ for (const [a, s] of H) {
119
+ const e = this.#byActor.get(a)?.[s - 1];
120
+ if (e !== undefined && e.startOp + e.ops.length - 1 > top)
121
+ top = e.startOp + e.ops.length - 1;
122
+ }
123
+ if (change.startOp !== top + 1)
124
+ return "R2";
125
+ const own = [];
126
+ // An operation of H or earlier in this change; deletions are not targets.
127
+ const target = (id) => {
128
+ const { ctr, actor } = splitId(id);
129
+ let t;
130
+ if (actor === change.actor && ctr >= change.startOp && ctr < change.startOp + own.length)
131
+ t = own[ctr - change.startOp];
132
+ else {
133
+ const found = this.#find(actor, ctr, H.get(actor) ?? 0);
134
+ t = found === null ? undefined : found.entry.ops[found.index];
135
+ }
136
+ return t === undefined || t.del ? undefined : t;
137
+ };
138
+ for (const [i, op] of change.ops.entries()) {
139
+ const id = `${change.startOp + i}@${change.actor}`;
140
+ // R3: the root, or an object an operation made; its key form.
141
+ let sequence;
142
+ if (op.obj === "_root")
143
+ sequence = false;
144
+ else {
145
+ const made = target(op.obj)?.make ?? null;
146
+ if (made === null)
147
+ return "R3";
148
+ sequence = !MAP_LIKE.has(made);
149
+ }
150
+ if (!sequence && (op.insert || op.key.kind !== "prop"))
151
+ return op.insert ? "R4" : "R3";
152
+ if (sequence && op.key.kind === "prop")
153
+ return "R3";
154
+ const element = (e) => {
155
+ const t = target(e);
156
+ return t !== undefined && t.insert && t.obj === op.obj;
157
+ };
158
+ // R4: an insertion goes after the head or an element of the same object, without predecessors.
159
+ if (op.insert && (op.pred.length > 0 || (op.key.kind === "elem" && !element(op.key.id))))
160
+ return "R4";
161
+ // R5: any other sequence operation names an element of the same object.
162
+ if (sequence && !op.insert && (op.key.kind !== "elem" || !element(op.key.id)))
163
+ return "R5";
164
+ // R6: predecessors on the same object and key.
165
+ const slot = slotOf(op, id);
166
+ for (const p of op.pred) {
167
+ const t = target(p);
168
+ if (t === undefined || t.obj !== op.obj || t.slot !== slot)
169
+ return "R6";
170
+ }
171
+ // R7: a deletion has a predecessor.
172
+ if (op.action === 3 && op.pred.length === 0)
173
+ return "R7";
174
+ own.push(targetOf(op, id));
175
+ }
176
+ return null;
177
+ }
178
+ }
179
+ /**
180
+ * The index of every change a document holds, as the engine hands them
181
+ * out (in its encoding, so canonical), dependencies first. A change the
182
+ * walk cannot read is left out: references into it are then refused.
183
+ */
184
+ export function referenceHistoryOf(doc) {
185
+ const parsed = new Map();
186
+ for (const bytes of A.getAllChanges(doc)) {
187
+ try {
188
+ parsed.set(A.decodeChange(bytes).hash, checkCanonicalChange(bytes));
189
+ }
190
+ catch {
191
+ // not readable: left out
192
+ }
193
+ }
194
+ const refs = new ReferenceHistory();
195
+ const placed = new Set();
196
+ for (const start of parsed.keys()) {
197
+ // Iterative depth-first: dependencies first, without recursion.
198
+ const stack = [{ hash: start, next: 0 }];
199
+ while (stack.length > 0) {
200
+ const top = stack[stack.length - 1];
201
+ const change = parsed.get(top.hash);
202
+ if (change === undefined || placed.has(top.hash)) {
203
+ stack.pop();
204
+ continue;
205
+ }
206
+ const dep = change.deps[top.next];
207
+ if (dep !== undefined) {
208
+ top.next++;
209
+ if (!placed.has(dep) && parsed.has(dep))
210
+ stack.push({ hash: dep, next: 0 });
211
+ continue;
212
+ }
213
+ placed.add(top.hash);
214
+ refs.add(top.hash, change);
215
+ stack.pop();
216
+ }
217
+ }
218
+ return refs;
219
+ }
220
+ //# sourceMappingURL=references.js.map