doxum 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.
Files changed (48) hide show
  1. package/README.md +59 -27
  2. package/dist/{contract-DGkZcTiP.d.cts → contract-BsY1XLiG.d.ts} +2 -1
  3. package/dist/{contract-Dh3az8aN.d.ts → contract-D8kjdfqT.d.cts} +2 -1
  4. package/dist/{access-5D4KKFm7.js → dependency-C5_R1tvQ.js} +40 -262
  5. package/dist/dependency-C5_R1tvQ.js.map +1 -0
  6. package/dist/{access-z56fVqZ2.cjs → dependency-Dfzs3khk.cjs} +62 -344
  7. package/dist/dependency-Dfzs3khk.cjs.map +1 -0
  8. package/dist/driver-CNxqMVFH.cjs +69 -0
  9. package/dist/driver-CNxqMVFH.cjs.map +1 -0
  10. package/dist/driver-CbzfW5MR.js +46 -0
  11. package/dist/driver-CbzfW5MR.js.map +1 -0
  12. package/dist/index.cjs +2636 -46
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +2594 -4
  17. package/dist/index.js.map +1 -1
  18. package/dist/integration.cjs +2 -3
  19. package/dist/integration.cjs.map +1 -1
  20. package/dist/integration.d.cts +1 -1
  21. package/dist/integration.d.ts +1 -1
  22. package/dist/integration.js +1 -2
  23. package/dist/integration.js.map +1 -1
  24. package/dist/local-sync.cjs +154 -329
  25. package/dist/local-sync.cjs.map +1 -1
  26. package/dist/local-sync.d.cts +17 -22
  27. package/dist/local-sync.d.ts +17 -22
  28. package/dist/local-sync.js +149 -326
  29. package/dist/local-sync.js.map +1 -1
  30. package/dist/ownership-B-VAPcce.js +242 -0
  31. package/dist/ownership-B-VAPcce.js.map +1 -0
  32. package/dist/ownership-CU-9DKJQ.cjs +301 -0
  33. package/dist/ownership-CU-9DKJQ.cjs.map +1 -0
  34. package/package.json +1 -1
  35. package/skills/doxum-runtime/references/guide.en.md +52 -1
  36. package/skills/doxum-runtime/references/guide.zh-CN.md +46 -1
  37. package/skills/doxum-runtime/references/invariants.en.md +14 -4
  38. package/skills/doxum-runtime/references/invariants.zh-CN.md +1 -1
  39. package/dist/access-5D4KKFm7.js.map +0 -1
  40. package/dist/access-z56fVqZ2.cjs.map +0 -1
  41. package/dist/dependency-BRn1TRDi.js +0 -20
  42. package/dist/dependency-BRn1TRDi.js.map +0 -1
  43. package/dist/dependency-DmQXyj7q.cjs +0 -25
  44. package/dist/dependency-DmQXyj7q.cjs.map +0 -1
  45. package/dist/readable-B8E3FMq6.cjs +0 -2614
  46. package/dist/readable-B8E3FMq6.cjs.map +0 -1
  47. package/dist/readable-CIj02gIj.js +0 -2579
  48. package/dist/readable-CIj02gIj.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_access = require("./access-z56fVqZ2.cjs");
3
- const require_readable = require("./readable-B8E3FMq6.cjs");
2
+ const require_ownership = require("./ownership-CU-9DKJQ.cjs");
3
+ const require_driver = require("./driver-CNxqMVFH.cjs");
4
4
  //#region core/src/local-sync/contract.ts
5
5
  var LocalSyncUnavailableError = class extends Error {
6
6
  constructor(capability) {
@@ -20,9 +20,21 @@ var LocalSyncConsistencyError = class extends Error {
20
20
  this.name = "LocalSyncConsistencyError";
21
21
  }
22
22
  };
23
+ var LocalSyncReadOnlyError = class extends Error {
24
+ constructor() {
25
+ super("This tab is following the local document and cannot write until it becomes the leader.");
26
+ this.name = "LocalSyncReadOnlyError";
27
+ }
28
+ };
29
+ var LocalSyncUnsupportedOperationError = class extends Error {
30
+ constructor() {
31
+ super("Local sync persists operation commands. runtime.replace() and externally supplied remote operations are unavailable while it is attached.");
32
+ this.name = "LocalSyncUnsupportedOperationError";
33
+ }
34
+ };
23
35
  var LocalSyncDisposedError = class extends Error {
24
36
  constructor() {
25
- super("Local sync document has been disposed.");
37
+ super("Local sync has been disposed.");
26
38
  this.name = "LocalSyncDisposedError";
27
39
  }
28
40
  };
@@ -73,7 +85,7 @@ const validate = (value, path, limits, depth, ancestors) => {
73
85
  }
74
86
  return value;
75
87
  }
76
- if (require_access.isPlainObject(value)) {
88
+ if (require_ownership.isPlainObject(value)) {
77
89
  if (ancestors.has(value)) throw new LocalSyncDataError(`${describePath(path)} contains a cycle.`);
78
90
  ancestors.add(value);
79
91
  try {
@@ -97,7 +109,7 @@ const jsonArray = (value, label, input) => {
97
109
  };
98
110
  //#endregion
99
111
  //#region core/src/local-sync/timeline.ts
100
- const DATABASE_VERSION = 1;
112
+ const DATABASE_VERSION = 2;
101
113
  const DOCUMENTS = "documents";
102
114
  const COMMITS = "commits";
103
115
  const ACTORS = "actors";
@@ -122,34 +134,19 @@ const string = (value, label) => {
122
134
  if (typeof value === "string") return value;
123
135
  throw new LocalSyncConsistencyError(`${label} is malformed in IndexedDB.`);
124
136
  };
125
- const nonNegativeInteger$1 = (value, label) => {
137
+ const nonNegativeInteger = (value, label) => {
126
138
  if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return value;
127
139
  throw new LocalSyncConsistencyError(`${label} is malformed in IndexedDB.`);
128
140
  };
129
141
  const positiveInteger$1 = (value, label) => {
130
- const parsed = nonNegativeInteger$1(value, label);
142
+ const parsed = nonNegativeInteger(value, label);
131
143
  if (parsed > 0) return parsed;
132
144
  throw new LocalSyncConsistencyError(`${label} is malformed in IndexedDB.`);
133
145
  };
134
- const historyEntry = (value, label) => {
135
- if (!require_access.isRecord(value)) throw new LocalSyncConsistencyError(`${label} is malformed in IndexedDB.`);
136
- const footprint = require_readable.decodeCommandFootprint(value.footprint);
137
- if (!footprint) throw new LocalSyncConsistencyError(`${label}.footprint is malformed in IndexedDB.`);
138
- return Object.freeze({
139
- commandId: string(value.commandId, `${label}.commandId`),
140
- operations: jsonArray(value.operations, `${label}.operations`),
141
- inverse: jsonArray(value.inverse, `${label}.inverse`),
142
- footprint
143
- });
144
- };
145
- const historyEntries = (value, label) => {
146
- if (!Array.isArray(value)) throw new LocalSyncConsistencyError(`${label} is malformed in IndexedDB.`);
147
- return Object.freeze(value.map((entry, index) => historyEntry(entry, `${label}.${index}`)));
148
- };
149
146
  const documentRecord = (value) => {
150
- if (!require_access.isRecord(value)) throw new LocalSyncConsistencyError("Document record is malformed in IndexedDB.");
151
- const checkpointSeq = nonNegativeInteger$1(value.checkpointSeq, "document.checkpointSeq");
152
- const headSeq = nonNegativeInteger$1(value.headSeq, "document.headSeq");
147
+ if (!require_ownership.isRecord(value)) throw new LocalSyncConsistencyError("Document record is malformed in IndexedDB.");
148
+ const checkpointSeq = nonNegativeInteger(value.checkpointSeq, "document.checkpointSeq");
149
+ const headSeq = nonNegativeInteger(value.headSeq, "document.headSeq");
153
150
  if (checkpointSeq > headSeq) throw new LocalSyncConsistencyError("Document checkpoint exceeds its head sequence.");
154
151
  return Object.freeze({
155
152
  documentId: string(value.documentId, "document.documentId"),
@@ -160,82 +157,12 @@ const documentRecord = (value) => {
160
157
  });
161
158
  };
162
159
  const commitRecord = (value) => {
163
- if (!require_access.isRecord(value)) throw new LocalSyncConsistencyError("Commit record is malformed in IndexedDB.");
164
- const kind = value.kind;
165
- if (kind !== "update" && kind !== "undo" && kind !== "redo") throw new LocalSyncConsistencyError("Commit kind is malformed in IndexedDB.");
166
- const footprint = require_readable.decodeCommandFootprint(value.footprint);
167
- if (!footprint) throw new LocalSyncConsistencyError("Commit footprint is malformed in IndexedDB.");
160
+ if (!require_ownership.isRecord(value)) throw new LocalSyncConsistencyError("Commit record is malformed in IndexedDB.");
168
161
  return Object.freeze({
169
162
  documentId: string(value.documentId, "commit.documentId"),
170
163
  seq: positiveInteger$1(value.seq, "commit.seq"),
171
- commandId: string(value.commandId, "commit.commandId"),
172
- actorId: string(value.actorId, "commit.actorId"),
173
- kind,
174
164
  operations: jsonArray(value.operations, "commit.operations"),
175
- inverse: jsonArray(value.inverse, "commit.inverse"),
176
- footprint,
177
- createdAt: nonNegativeInteger$1(value.createdAt, "commit.createdAt")
178
- });
179
- };
180
- const actorRecord = (value, documentId, actorId) => {
181
- if (value === void 0) return Object.freeze({
182
- documentId,
183
- actorId,
184
- undo: Object.freeze([]),
185
- redo: Object.freeze([])
186
- });
187
- if (!require_access.isRecord(value)) throw new LocalSyncConsistencyError("Actor history is malformed in IndexedDB.");
188
- const storedDocumentId = string(value.documentId, "actor.documentId");
189
- const storedActorId = string(value.actorId, "actor.actorId");
190
- if (storedDocumentId !== documentId || storedActorId !== actorId) throw new LocalSyncConsistencyError("Actor history key does not match its record.");
191
- return Object.freeze({
192
- documentId,
193
- actorId,
194
- undo: historyEntries(value.undo, "actor.undo"),
195
- redo: historyEntries(value.redo, "actor.redo")
196
- });
197
- };
198
- const nextHistory = (current, change) => {
199
- if (change.kind === "record") {
200
- const undo = [...current.undo, change.entry];
201
- const start = Math.max(0, undo.length - change.capacity);
202
- return Object.freeze({
203
- documentId: current.documentId,
204
- actorId: current.actorId,
205
- undo: Object.freeze(undo.slice(start)),
206
- redo: Object.freeze([])
207
- });
208
- }
209
- const source = change.kind === "undo" ? current.undo : current.redo;
210
- const latest = source[source.length - 1];
211
- if (!latest || latest.commandId !== change.expectedCommandId) throw new LocalSyncConsistencyError("Local undo history changed before it could be committed.");
212
- if (change.kind === "undo") return Object.freeze({
213
- documentId: current.documentId,
214
- actorId: current.actorId,
215
- undo: Object.freeze(current.undo.slice(0, -1)),
216
- redo: Object.freeze([...current.redo, change.entry])
217
- });
218
- return Object.freeze({
219
- documentId: current.documentId,
220
- actorId: current.actorId,
221
- undo: Object.freeze([...current.undo, change.entry]),
222
- redo: Object.freeze(current.redo.slice(0, -1))
223
- });
224
- };
225
- const deleteCommitsThrough = async (store, documentId, seq) => {
226
- const range = keyRange().bound([documentId, 0], [documentId, seq]);
227
- await new Promise((resolve, reject) => {
228
- const cursor = store.openCursor(range);
229
- cursor.onerror = () => reject(cursor.error ?? /* @__PURE__ */ new Error("IndexedDB cursor failed."));
230
- cursor.onsuccess = () => {
231
- const current = cursor.result;
232
- if (!current) {
233
- resolve();
234
- return;
235
- }
236
- current.delete();
237
- current.continue();
238
- };
165
+ createdAt: nonNegativeInteger(value.createdAt, "commit.createdAt")
239
166
  });
240
167
  };
241
168
  const openIndexedDbTimeline = async (databaseName) => {
@@ -247,7 +174,7 @@ const openIndexedDbTimeline = async (databaseName) => {
247
174
  const db = open.result;
248
175
  if (!db.objectStoreNames.contains(DOCUMENTS)) db.createObjectStore(DOCUMENTS, { keyPath: "documentId" });
249
176
  if (!db.objectStoreNames.contains(COMMITS)) db.createObjectStore(COMMITS, { keyPath: ["documentId", "seq"] });
250
- if (!db.objectStoreNames.contains(ACTORS)) db.createObjectStore(ACTORS, { keyPath: ["documentId", "actorId"] });
177
+ if (db.objectStoreNames.contains(ACTORS)) db.deleteObjectStore(ACTORS);
251
178
  };
252
179
  open.onsuccess = () => resolve(open.result);
253
180
  });
@@ -291,60 +218,25 @@ const openIndexedDbTimeline = async (databaseName) => {
291
218
  await complete(transaction);
292
219
  return Object.freeze(values.map(commitRecord).sort((left, right) => left.seq - right.seq));
293
220
  },
294
- history: async (documentId, actorId) => {
295
- const transaction = database.transaction(ACTORS, "readonly");
296
- const value = await request(transaction.objectStore(ACTORS).get([documentId, actorId]));
297
- await complete(transaction);
298
- return actorRecord(value, documentId, actorId);
299
- },
300
221
  append: async (input) => {
301
- const transaction = database.transaction([
302
- DOCUMENTS,
303
- COMMITS,
304
- ACTORS
305
- ], "readwrite");
222
+ const transaction = database.transaction([DOCUMENTS, COMMITS], "readwrite");
306
223
  const documents = transaction.objectStore(DOCUMENTS);
307
- const commits = transaction.objectStore(COMMITS);
308
- const actors = transaction.objectStore(ACTORS);
309
224
  const current = await readDocument(transaction, input.documentId);
310
- if (current.headSeq !== input.expectedHeadSeq) throw new LocalSyncConsistencyError("Local document advanced before the writer lock was acquired.");
311
- const actor = actorRecord(await request(actors.get([input.documentId, input.actorId])), input.documentId, input.actorId);
312
- const seq = current.headSeq + 1;
225
+ if (current.headSeq !== input.expectedHeadSeq) throw new LocalSyncConsistencyError("Local document advanced before the leader could append its command.");
313
226
  const commit = {
314
227
  documentId: input.documentId,
315
- seq,
316
- commandId: input.commandId,
317
- actorId: input.actorId,
318
- kind: input.kind,
228
+ seq: current.headSeq + 1,
319
229
  operations: input.operations,
320
- inverse: input.inverse,
321
- footprint: input.footprint,
322
230
  createdAt: Date.now()
323
231
  };
324
- const nextDocument = {
232
+ documents.put({
325
233
  ...current,
326
- headSeq: seq
327
- };
328
- commits.put(commit);
329
- documents.put(nextDocument);
330
- actors.put(nextHistory(actor, input.history));
234
+ headSeq: commit.seq
235
+ });
236
+ transaction.objectStore(COMMITS).put(commit);
331
237
  await complete(transaction);
332
238
  return Object.freeze(commit);
333
239
  },
334
- compact: async (documentId, seq, checkpoint) => {
335
- const transaction = database.transaction([DOCUMENTS, COMMITS], "readwrite");
336
- const current = await readDocument(transaction, documentId);
337
- if (seq < current.checkpointSeq || seq > current.headSeq) throw new LocalSyncConsistencyError("Local checkpoint sequence is outside the durable timeline.");
338
- const next = {
339
- ...current,
340
- checkpointSeq: seq,
341
- checkpoint
342
- };
343
- transaction.objectStore(DOCUMENTS).put(next);
344
- await deleteCommitsThrough(transaction.objectStore(COMMITS), documentId, seq);
345
- await complete(transaction);
346
- return Object.freeze(next);
347
- },
348
240
  close: () => database.close()
349
241
  };
350
242
  };
@@ -364,66 +256,40 @@ const requiredString = (value, label) => {
364
256
  if (value.length > 0) return value;
365
257
  throw new TypeError(`${label} must not be empty.`);
366
258
  };
367
- const nonNegativeInteger = (value, label) => {
368
- if (Number.isSafeInteger(value) && value >= 0) return value;
369
- throw new TypeError(`${label} must be a non-negative safe integer.`);
370
- };
371
259
  const positiveInteger = (value, label) => {
372
260
  if (Number.isSafeInteger(value) && value > 0) return value;
373
261
  throw new TypeError(`${label} must be a positive safe integer.`);
374
262
  };
375
- const identifier = () => globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
376
263
  const notification = (value) => {
377
264
  if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
378
265
  const record = value;
379
- if (record.kind !== "commit" || typeof record.documentId !== "string" || typeof record.senderId !== "string" || typeof record.headSeq !== "number" || !Number.isSafeInteger(record.headSeq) || record.headSeq < 0) return void 0;
266
+ if (record.kind !== "commit" || typeof record.documentId !== "string" || typeof record.headSeq !== "number" || !Number.isSafeInteger(record.headSeq) || record.headSeq < 0) return void 0;
380
267
  return record;
381
268
  };
382
- const localCommit = (commit, stored) => Object.freeze({
383
- ...commit,
384
- seq: stored.seq,
385
- commandId: stored.commandId,
386
- actorId: stored.actorId
387
- });
388
- const operationResult = (result, stored) => {
389
- if (result.status !== "committed") throw new LocalSyncConsistencyError("A durable local command could not be applied.");
390
- return {
391
- status: "committed",
392
- commit: localCommit(result.commit, stored),
393
- observerErrors: result.observerErrors
394
- };
395
- };
396
- const openLocalDocument = async (input) => {
269
+ const attachLocalSync = async (input) => {
270
+ const runtime = input.runtime;
397
271
  const databaseName = requiredString(input.database, "database");
398
272
  const documentId = requiredString(input.documentId, "documentId");
399
- const actorId = requiredString(input.actorId ?? "local", "actorId");
400
273
  const schemaVersion = positiveInteger(input.schemaVersion ?? 1, "schemaVersion");
401
- const historyCapacity = nonNegativeInteger(input.historyCapacity ?? 100, "historyCapacity");
402
- const checkpointEvery = nonNegativeInteger(input.checkpointEvery ?? 500, "checkpointEvery");
403
- const commandLimits = input.commandLimits;
404
- const initial = json(input.initial, "initial document");
405
274
  const lockManager = locks();
406
275
  const BroadcastChannel = channelConstructor();
407
276
  const timeline = await openIndexedDbTimeline(databaseName);
408
277
  let channel;
409
- let runtime;
278
+ let unsubscribe;
279
+ let driver;
410
280
  try {
411
- const stored = await timeline.initialize(documentId, schemaVersion, initial);
412
- const documentRuntime = require_readable.createDocument({
413
- schema: input.schema,
414
- initial: stored.checkpoint,
415
- history: false
416
- });
417
- runtime = documentRuntime;
281
+ const stored = await timeline.initialize(documentId, schemaVersion, json(runtime.snapshot(), "runtime snapshot"));
418
282
  let headSeq = stored.checkpointSeq;
419
283
  let checkpointSeq = stored.checkpointSeq;
420
- let queued = Promise.resolve();
284
+ let role = "follower";
421
285
  let fault;
422
286
  let closing = false;
423
287
  let disposed = false;
424
- const senderId = identifier();
288
+ let releaseLeadership;
289
+ let leadershipLease;
290
+ let queued = Promise.resolve();
425
291
  const fail = (error) => {
426
- if (closing || disposed || fault) return;
292
+ if (fault || disposed) return;
427
293
  fault = error;
428
294
  try {
429
295
  input.onError?.(error);
@@ -433,198 +299,152 @@ const openLocalDocument = async (input) => {
433
299
  if (closing || disposed) throw new LocalSyncDisposedError();
434
300
  if (fault) throw fault;
435
301
  };
302
+ const assertLeader = (intent) => {
303
+ assertOpen();
304
+ if (role !== "leader") throw new LocalSyncReadOnlyError();
305
+ if (intent.kind === "replace" || intent.kind === "apply" && intent.source === "remote") throw new LocalSyncUnsupportedOperationError();
306
+ };
307
+ const runRuntime = (run) => driver?.run(run) ?? run();
436
308
  const applyStored = (commit) => {
437
309
  if (commit.seq !== headSeq + 1) throw new LocalSyncConsistencyError("Local commit log contains a sequence gap.");
438
- const local = operationResult(documentRuntime.apply(commit.operations, {
439
- source: "local",
310
+ if (runRuntime(() => runtime.apply(commit.operations, {
311
+ source: "remote",
440
312
  history: false
441
- }), commit);
442
- if (local.status !== "committed") throw new LocalSyncConsistencyError("A durable local command was not committed.");
313
+ })).status !== "committed") throw new LocalSyncConsistencyError("A stored local command could not be applied.");
443
314
  headSeq = commit.seq;
444
- return local.commit;
445
315
  };
446
- const catchUp = async () => {
316
+ const restore = async (reset = false) => {
447
317
  const current = await timeline.read(documentId);
448
- if (current.schemaVersion !== schemaVersion) throw new LocalSyncConsistencyError("Local document schema changed while this session was open.");
449
- if (headSeq < current.checkpointSeq) {
450
- const replaced = documentRuntime.replace(current.checkpoint, { source: "system" });
451
- if (replaced.status !== "committed" && replaced.status !== "unchanged") throw new LocalSyncConsistencyError("Local checkpoint could not be restored.");
318
+ if (current.schemaVersion !== schemaVersion) throw new LocalSyncConsistencyError("Local document schema changed while this attachment was open.");
319
+ if (headSeq > current.headSeq) throw new LocalSyncConsistencyError("The local timeline moved behind this attachment.");
320
+ if (reset || headSeq < current.checkpointSeq) {
321
+ if (runRuntime(() => runtime.replace(current.checkpoint, { source: "remote" })).status === "rejected") throw new LocalSyncConsistencyError("The local checkpoint could not be restored.");
452
322
  headSeq = current.checkpointSeq;
453
- checkpointSeq = current.checkpointSeq;
454
323
  }
455
324
  const tail = await timeline.tail(documentId, headSeq);
456
325
  for (const commit of tail) applyStored(commit);
457
326
  if (headSeq !== current.headSeq) throw new LocalSyncConsistencyError("Local commit log does not reach its recorded head sequence.");
458
327
  checkpointSeq = current.checkpointSeq;
459
328
  };
460
- const execute = (run) => {
329
+ const enqueue = (run) => {
461
330
  const next = queued.then(run, run);
462
- queued = next.then(() => void 0, () => void 0);
331
+ queued = next.then(() => void 0, (error) => {
332
+ fail(error);
333
+ });
463
334
  return next;
464
335
  };
465
- const withWriter = (run) => lockManager.request(`doxum:${documentId}`, { mode: "exclusive" }, async () => {
466
- assertOpen();
336
+ const persist = async (operations) => {
337
+ const storedCommit = await timeline.append({
338
+ documentId,
339
+ expectedHeadSeq: headSeq,
340
+ operations
341
+ });
342
+ if (storedCommit.seq !== headSeq + 1) throw new LocalSyncConsistencyError("A local command was assigned an unexpected sequence.");
343
+ headSeq = storedCommit.seq;
344
+ channel?.postMessage({
345
+ kind: "commit",
346
+ documentId,
347
+ headSeq
348
+ });
349
+ };
350
+ const record = (commit) => {
467
351
  try {
468
- await catchUp();
352
+ const operations = jsonArray(commit.operations, "local command operations", input.commandLimits);
353
+ enqueue(() => persist(operations)).catch(() => void 0);
469
354
  } catch (error) {
470
355
  fail(error);
471
- throw error;
472
356
  }
473
- return run();
357
+ };
358
+ const holdLeadership = () => new Promise((resolve) => {
359
+ releaseLeadership = resolve;
474
360
  });
475
- const persistAndApply = async (input) => {
361
+ const lead = async (activated) => {
362
+ if (closing || disposed || fault) return;
363
+ await restore();
364
+ if (closing || disposed || fault) return;
365
+ role = "leader";
366
+ activated?.();
476
367
  try {
477
- const storedCommit = await timeline.append({
478
- documentId,
479
- expectedHeadSeq: headSeq,
480
- commandId: input.commandId,
481
- actorId,
482
- kind: input.kind,
483
- operations: input.operations,
484
- inverse: input.inverse,
485
- footprint: input.footprint,
486
- history: input.history
487
- });
488
- const result = operationResult(documentRuntime.apply(storedCommit.operations, {
489
- source: "local",
490
- history: false
491
- }), storedCommit);
492
- headSeq = storedCommit.seq;
493
- if (checkpointEvery > 0 && headSeq - checkpointSeq >= checkpointEvery) {
494
- const checkpoint = json(documentRuntime.snapshot(), "local checkpoint");
495
- checkpointSeq = (await timeline.compact(documentId, headSeq, checkpoint)).checkpointSeq;
496
- }
497
- channel?.postMessage({
498
- kind: "commit",
499
- documentId,
500
- headSeq,
501
- senderId
502
- });
503
- return result;
504
- } catch (error) {
505
- fail(error);
506
- throw error;
368
+ await holdLeadership();
369
+ } finally {
370
+ releaseLeadership = void 0;
371
+ if (!closing && !disposed && !fault) role = "follower";
507
372
  }
508
373
  };
509
- const replay = (operations) => {
510
- const candidate = require_readable.createDocument({
511
- schema: input.schema,
512
- initial: documentRuntime.snapshot(),
513
- history: false
374
+ const watchLeadership = () => {
375
+ leadershipLease = lockManager.request(`doxum:${documentId}`, { mode: "exclusive" }, async () => {
376
+ try {
377
+ await lead();
378
+ } catch (error) {
379
+ fail(error);
380
+ }
381
+ }).catch((error) => {
382
+ if (!closing && !disposed) fail(error);
514
383
  });
515
- try {
516
- return candidate.apply(operations, {
517
- source: "local",
518
- history: false
519
- });
520
- } finally {
521
- candidate.dispose();
522
- }
523
384
  };
524
- const applyHistory = async (history, kind) => {
525
- const source = kind === "undo" ? history.undo : history.redo;
526
- const entry = source[source.length - 1];
527
- if (!entry) return {
528
- status: "unchanged",
529
- revision: documentRuntime.revision()
530
- };
531
- const preview = replay(kind === "undo" ? entry.inverse : entry.operations);
532
- if (preview.status !== "committed") return preview;
533
- const operations = jsonArray(preview.commit.operations, `${kind} operations`, commandLimits);
534
- const inverse = jsonArray(preview.commit.inverse, `${kind} inverse`, commandLimits);
535
- return persistAndApply({
536
- commandId: identifier(),
537
- kind,
538
- operations,
539
- inverse,
540
- footprint: require_readable.commandFootprint(preview.commit.operations),
541
- history: kind === "undo" ? {
542
- kind: "undo",
543
- expectedCommandId: entry.commandId,
544
- entry
545
- } : {
546
- kind: "redo",
547
- expectedCommandId: entry.commandId,
548
- entry
385
+ const claimInitialLeadership = async () => {
386
+ let resolve;
387
+ const claimed = new Promise((done) => {
388
+ resolve = done;
389
+ });
390
+ leadershipLease = lockManager.request(`doxum:${documentId}`, {
391
+ mode: "exclusive",
392
+ ifAvailable: true
393
+ }, async (lock) => {
394
+ if (lock === null) {
395
+ resolve(false);
396
+ return;
549
397
  }
398
+ try {
399
+ await lead(() => resolve(true));
400
+ } catch (error) {
401
+ fail(error);
402
+ resolve(false);
403
+ }
404
+ }).catch((error) => {
405
+ if (!closing && !disposed) fail(error);
406
+ resolve(false);
550
407
  });
408
+ if (!await claimed && !fault && !closing && !disposed) watchLeadership();
551
409
  };
552
- const initialTail = await timeline.tail(documentId, headSeq);
553
- for (const commit of initialTail) applyStored(commit);
554
- if (headSeq !== stored.headSeq) throw new LocalSyncConsistencyError("Local commit log does not reach its recorded head sequence.");
410
+ await restore(true);
411
+ runtime.history.clear();
412
+ driver = require_driver.installRuntimeWriteDriver(runtime, { assertWritable: assertLeader });
555
413
  channel = new BroadcastChannel(`doxum:${databaseName}:${documentId}`);
414
+ unsubscribe = runtime.subscribe((commit) => {
415
+ if ((commit.source === "local" || commit.source === "system" || commit.source === "history") && !closing && !disposed) record(commit);
416
+ });
556
417
  channel.onmessage = (event) => {
557
418
  const message = notification(event.data);
558
- if (!message || message.documentId !== documentId || message.senderId === senderId || message.headSeq <= headSeq || closing || disposed) return;
559
- execute(() => withWriter(async () => void 0)).catch(() => void 0);
419
+ if (!message || message.documentId !== documentId || message.headSeq <= headSeq || closing || disposed || fault) return;
420
+ enqueue(restore).catch(() => void 0);
560
421
  };
561
- const localDocument = {
562
- document: require_readable.asReadable(documentRuntime),
422
+ await claimInitialLeadership();
423
+ if (fault) throw fault;
424
+ return Object.freeze({
563
425
  state: () => {
564
426
  if (disposed) return { status: "disposed" };
565
427
  if (fault) return {
566
- status: "failed",
428
+ status: "error",
429
+ headSeq,
430
+ checkpointSeq,
567
431
  error: fault
568
432
  };
569
433
  return {
570
- status: "ready",
434
+ status: role,
571
435
  headSeq,
572
436
  checkpointSeq
573
437
  };
574
438
  },
575
- sync: () => execute(() => withWriter(async () => void 0)),
576
- update: (run) => {
577
- assertOpen();
578
- return execute(() => withWriter(async () => {
579
- const prepared = documentRuntime.prepare(run);
580
- if (prepared.status === "unchanged") return {
581
- status: "unchanged",
582
- value: prepared.value,
583
- revision: documentRuntime.revision(),
584
- reports: prepared.reports
585
- };
586
- if (prepared.status === "rejected") return {
587
- status: "rejected",
588
- issues: prepared.issues,
589
- revision: documentRuntime.revision()
590
- };
591
- const operations = jsonArray(prepared.operations, "local command operations", commandLimits);
592
- const inverse = jsonArray(prepared.inverse, "local command inverse", commandLimits);
593
- const commandId = identifier();
594
- const result = await persistAndApply({
595
- commandId,
596
- kind: "update",
597
- operations,
598
- inverse,
599
- footprint: prepared.footprint,
600
- history: {
601
- kind: "record",
602
- entry: Object.freeze({
603
- commandId,
604
- operations,
605
- inverse,
606
- footprint: prepared.footprint
607
- }),
608
- capacity: historyCapacity
609
- }
610
- });
611
- if (result.status !== "committed") throw new LocalSyncConsistencyError("Prepared local command was not committed.");
612
- return {
613
- status: "committed",
614
- value: prepared.value,
615
- commit: result.commit,
616
- reports: prepared.reports,
617
- observerErrors: result.observerErrors
618
- };
619
- }));
620
- },
621
- undo: () => {
439
+ flush: async () => {
622
440
  assertOpen();
623
- return execute(() => withWriter(async () => applyHistory(await timeline.history(documentId, actorId), "undo")));
624
- },
625
- redo: () => {
441
+ try {
442
+ await enqueue(restore);
443
+ } catch (error) {
444
+ fail(error);
445
+ throw error;
446
+ }
626
447
  assertOpen();
627
- return execute(() => withWriter(async () => applyHistory(await timeline.history(documentId, actorId), "redo")));
628
448
  },
629
449
  dispose: async () => {
630
450
  if (disposed) return;
@@ -633,17 +453,20 @@ const openLocalDocument = async (input) => {
633
453
  return;
634
454
  }
635
455
  closing = true;
456
+ unsubscribe?.();
636
457
  await queued;
458
+ releaseLeadership?.();
459
+ if (role === "leader") await leadershipLease;
637
460
  disposed = true;
461
+ driver?.dispose();
638
462
  channel?.close();
639
463
  timeline.close();
640
- documentRuntime.dispose();
641
464
  }
642
- };
643
- return Object.freeze(localDocument);
465
+ });
644
466
  } catch (error) {
467
+ unsubscribe?.();
468
+ driver?.dispose();
645
469
  channel?.close();
646
- runtime?.dispose();
647
470
  timeline.close();
648
471
  throw error;
649
472
  }
@@ -652,9 +475,11 @@ const openLocalDocument = async (input) => {
652
475
  exports.LocalSyncConsistencyError = LocalSyncConsistencyError;
653
476
  exports.LocalSyncDataError = LocalSyncDataError;
654
477
  exports.LocalSyncDisposedError = LocalSyncDisposedError;
478
+ exports.LocalSyncReadOnlyError = LocalSyncReadOnlyError;
655
479
  exports.LocalSyncSchemaError = LocalSyncSchemaError;
656
480
  exports.LocalSyncUnavailableError = LocalSyncUnavailableError;
481
+ exports.LocalSyncUnsupportedOperationError = LocalSyncUnsupportedOperationError;
482
+ exports.attachLocalSync = attachLocalSync;
657
483
  exports.defaultJsonCommandLimits = defaultJsonCommandLimits;
658
- exports.openLocalDocument = openLocalDocument;
659
484
 
660
485
  //# sourceMappingURL=local-sync.cjs.map