@openlfcp/shared-objects 0.1.1 → 0.1.3

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.
package/README.md CHANGED
@@ -20,9 +20,9 @@ reaches the replica (§95). The replica has no Markdown or editor concepts.
20
20
 
21
21
  Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
22
22
  subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
23
- at `mvp-0.1-baseline.8`, not every deferred WIRE-01 feature. It does not
23
+ at `mvp-0.1-baseline.9`, not every deferred WIRE-01 feature. It does not
24
24
  claim full LFCP-WIRE-01 conformance. This is the MVP 0.1 release
25
- (`0.1.1`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
25
+ (`0.1.3`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
26
26
 
27
27
  ## Automerge and WebAssembly
28
28
 
@@ -14,7 +14,10 @@ import { type ObjectChange, type ReplicaOptions, SharedObjectsReplica } from "./
14
14
  * every buffered change it unblocks merges with it;
15
15
  * - one object becoming profile-invalid is a diagnostic, never a refusal:
16
16
  * the other objects stay usable (§77);
17
- * - exclude (§14.1, G-EP7): rebuilds the replica without given units.
17
+ * - exclude (§14.1, G-EP7): rebuilds the replica without given units;
18
+ * - a change whose actor and sequence number another change of the
19
+ * document already has is held (§14.1, POST-001): never merged, not
20
+ * refused, and retried after every exclude.
18
21
  */
19
22
  /** An accepted unit as the applier passes it (structurally the client's ProfileUnit). */
20
23
  export interface SharedObjectsUnit {
@@ -32,6 +35,8 @@ export interface SharedObjectsApplyResult {
32
35
  readonly objects: readonly string[];
33
36
  readonly diagnostics: readonly SharedObjectsDiagnostic[];
34
37
  readonly pending?: string;
38
+ /** Why the unit is held (§14.1): its actor and sequence number belong to a different change. */
39
+ readonly held?: string;
35
40
  }
36
41
  /** The outcome of applyBatch: per unit merged, pending or rejected, and the objects once. */
37
42
  export interface SharedObjectsBatchResult {
@@ -39,7 +44,9 @@ export interface SharedObjectsBatchResult {
39
44
  readonly merged: readonly DataUnitId[];
40
45
  /** Units buffered for Automerge dependencies (the batch's and earlier ones still waiting). */
41
46
  readonly pending: readonly DataUnitId[];
42
- /** Units whose change was refused (ACTOR_EQUIVOCATION, INVALID_AUTOMERGE_BYTES, …). */
47
+ /** Units held because a different change has their actor and sequence number (§14.1), all of them. */
48
+ readonly held: readonly DataUnitId[];
49
+ /** Units whose change was refused (INVALID_AUTOMERGE_BYTES, …). */
43
50
  readonly rejected: readonly {
44
51
  readonly unitId: DataUnitId;
45
52
  readonly code: string;
@@ -51,6 +58,8 @@ export interface SharedObjectsBatchResult {
51
58
  export interface SharedObjectsExcludeResult {
52
59
  readonly objects: readonly string[];
53
60
  readonly pending: readonly DataUnitId[];
61
+ /** Held units that merged after the rebuild (§14.1): their actor and sequence number were free again. */
62
+ readonly released: readonly DataUnitId[];
54
63
  }
55
64
  /** A persisted handler state (structurally the storage ProfileCheckpoint). */
56
65
  export interface SharedObjectsCheckpoint {
@@ -128,8 +137,10 @@ export declare class SharedObjectsDataProfile {
128
137
  * only (SNAP-EP: Snapshot-derived state dropped). Notifies the changes.
129
138
  */
130
139
  reset(): void;
131
- /** Whether this handler holds the unit's change (merged, recorded or buffered). */
140
+ /** Whether this handler holds the unit's change (merged, recorded, buffered or held). */
132
141
  has(unitId: DataUnitId): boolean;
142
+ /** The units held because a different change has their actor and sequence number (§14.1). */
143
+ heldUnits(): DataUnitId[];
133
144
  /** The units waiting for Automerge dependencies. */
134
145
  pendingUnits(): DataUnitId[];
135
146
  codecFor(unit: {
@@ -10,6 +10,8 @@ export class SharedObjectsDataProfile {
10
10
  #merged = new Map();
11
11
  /** LFCP-accepted units waiting for Automerge dependencies, by unit ID hex. */
12
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();
13
15
  #listeners = new Set();
14
16
  constructor(replica) {
15
17
  this.#replica = replica;
@@ -116,12 +118,17 @@ export class SharedObjectsDataProfile {
116
118
  this.#replica = before.emptied();
117
119
  this.#merged.clear();
118
120
  this.#pending.clear();
121
+ this.#held.clear();
119
122
  this.#emit(rebuildChanges(before, this.#replica));
120
123
  }
121
- /** Whether this handler holds the unit's change (merged, recorded or buffered). */
124
+ /** Whether this handler holds the unit's change (merged, recorded, buffered or held). */
122
125
  has(unitId) {
123
126
  const key = toHex(unitId);
124
- return this.#merged.has(key) || this.#pending.has(key);
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);
125
132
  }
126
133
  /** The units waiting for Automerge dependencies. */
127
134
  pendingUnits() {
@@ -149,6 +156,13 @@ export class SharedObjectsDataProfile {
149
156
  const own = r.refused.get(key);
150
157
  if (own !== undefined)
151
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
+ });
152
166
  const self = r.result.merged.some((id) => toHex(id) === key);
153
167
  if (!self)
154
168
  return Object.freeze({
@@ -175,9 +189,15 @@ export class SharedObjectsDataProfile {
175
189
  applyBatch(units) {
176
190
  return this.#applyBatch(units).result;
177
191
  }
178
- #applyBatch(units) {
192
+ #applyBatch(units, retryHeld = false) {
179
193
  const byHash = new Map();
180
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
+ }
181
201
  for (const b of this.#pending.values())
182
202
  offer(b.unitId, b.change);
183
203
  for (const { unit, value } of units) {
@@ -197,6 +217,11 @@ export class SharedObjectsDataProfile {
197
217
  for (const { change, error } of r.refused)
198
218
  for (const unitId of byHash.get(change.hash) ?? []) {
199
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
+ }
200
225
  rejected.push({ unitId, code: error.code, message: error.message });
201
226
  refused.set(toHex(unitId), error);
202
227
  }
@@ -209,6 +234,7 @@ export class SharedObjectsDataProfile {
209
234
  result: Object.freeze({
210
235
  merged: Object.freeze(merged),
211
236
  pending: Object.freeze([...this.#pending.values()].map((b) => b.unitId)),
237
+ held: Object.freeze(this.heldUnits()),
212
238
  rejected: Object.freeze(rejected),
213
239
  objects: Object.freeze(objects),
214
240
  diagnostics: Object.freeze(this.#diagnostics(objects)),
@@ -251,6 +277,7 @@ export class SharedObjectsDataProfile {
251
277
  for (const id of unitIds) {
252
278
  const key = toHex(id);
253
279
  this.#pending.delete(key);
280
+ this.#held.delete(key);
254
281
  const m = this.#merged.get(key);
255
282
  if (m !== undefined) {
256
283
  hashes.push(m.hash);
@@ -258,7 +285,7 @@ export class SharedObjectsDataProfile {
258
285
  }
259
286
  }
260
287
  if (hashes.length === 0)
261
- return Object.freeze({ objects: [], pending: [] });
288
+ return Object.freeze({ objects: [], pending: [], released: [] });
262
289
  const before = this.#replica;
263
290
  const { replica, unapplied } = before.rebuildWithout(hashes);
264
291
  this.#replica = replica;
@@ -273,7 +300,18 @@ export class SharedObjectsDataProfile {
273
300
  }
274
301
  const changes = rebuildChanges(before, replica);
275
302
  this.#emit(changes);
276
- return Object.freeze({ objects: changes.map((c) => c.objectId), pending });
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
+ });
277
315
  }
278
316
  }
279
317
  /** §100 notifications for every object that differs between two replicas of one Resource. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlfcp/shared-objects",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "SHARED-OBJECTS-PROFILE-01 (org.openlfcp.shared-objects.v1) implementation.",
5
5
  "keywords": [
6
6
  "openlfcp",
@@ -45,8 +45,8 @@
45
45
  "dependencies": {
46
46
  "@automerge/automerge": "3.5.0",
47
47
  "fflate": "0.8.3",
48
- "@openlfcp/core": "^0.1.1",
49
- "@openlfcp/crypto": "^0.1.1"
48
+ "@openlfcp/core": "^0.1.3",
49
+ "@openlfcp/crypto": "^0.1.3"
50
50
  },
51
51
  "devDependencies": {
52
52
  "fast-check": "4.10.2"