@opendatalabs/personal-server-ts-lite 1.34.1 → 1.35.0

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 (97) hide show
  1. package/dist/additions-crypto.d.ts +1 -1
  2. package/dist/additions-crypto.d.ts.map +1 -1
  3. package/dist/additions-crypto.js +16 -6
  4. package/dist/additions-crypto.js.map +1 -1
  5. package/dist/additions-protocol.d.ts +9 -1
  6. package/dist/additions-protocol.d.ts.map +1 -1
  7. package/dist/additions-protocol.js.map +1 -1
  8. package/dist/additions-session.d.ts +2 -0
  9. package/dist/additions-session.d.ts.map +1 -1
  10. package/dist/additions-session.js +29 -4
  11. package/dist/additions-session.js.map +1 -1
  12. package/dist/additions-transport.d.ts +10 -2
  13. package/dist/additions-transport.d.ts.map +1 -1
  14. package/dist/additions-transport.js +143 -48
  15. package/dist/additions-transport.js.map +1 -1
  16. package/dist/additions-worker.bundle.js +538 -101
  17. package/dist/additions-worker.js +45 -24
  18. package/dist/additions-worker.js.map +1 -1
  19. package/dist/browser-runtime.d.ts +23 -0
  20. package/dist/browser-runtime.d.ts.map +1 -1
  21. package/dist/browser-runtime.js +38 -1
  22. package/dist/browser-runtime.js.map +1 -1
  23. package/dist/canonical-persistence.d.ts +53 -0
  24. package/dist/canonical-persistence.d.ts.map +1 -0
  25. package/dist/canonical-persistence.js +13 -0
  26. package/dist/canonical-persistence.js.map +1 -0
  27. package/dist/canonical-store.d.ts +395 -0
  28. package/dist/canonical-store.d.ts.map +1 -0
  29. package/dist/canonical-store.js +1807 -0
  30. package/dist/canonical-store.js.map +1 -0
  31. package/dist/client.d.ts.map +1 -1
  32. package/dist/client.js +154 -1
  33. package/dist/client.js.map +1 -1
  34. package/dist/diagnostics.d.ts +3 -0
  35. package/dist/diagnostics.d.ts.map +1 -1
  36. package/dist/diagnostics.js +9 -1
  37. package/dist/diagnostics.js.map +1 -1
  38. package/dist/history-probe-worker.bundle.js +31216 -0
  39. package/dist/history-probe.worker.d.ts +35 -0
  40. package/dist/history-probe.worker.d.ts.map +1 -0
  41. package/dist/history-probe.worker.js +160 -0
  42. package/dist/history-probe.worker.js.map +1 -0
  43. package/dist/index.d.ts +10 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +10 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/mcp-store.d.ts.map +1 -1
  48. package/dist/mcp-store.js +109 -19
  49. package/dist/mcp-store.js.map +1 -1
  50. package/dist/oura-import.d.ts +2 -0
  51. package/dist/oura-import.d.ts.map +1 -0
  52. package/dist/oura-import.js +3 -0
  53. package/dist/oura-import.js.map +1 -0
  54. package/dist/replication-coordinator-state.d.ts +19 -0
  55. package/dist/replication-coordinator-state.d.ts.map +1 -0
  56. package/dist/replication-coordinator-state.js +20 -0
  57. package/dist/replication-coordinator-state.js.map +1 -0
  58. package/dist/replication-door.d.ts +17 -0
  59. package/dist/replication-door.d.ts.map +1 -0
  60. package/dist/replication-door.js +23 -0
  61. package/dist/replication-door.js.map +1 -0
  62. package/dist/replication-engine.d.ts +49 -0
  63. package/dist/replication-engine.d.ts.map +1 -0
  64. package/dist/replication-engine.js +589 -0
  65. package/dist/replication-engine.js.map +1 -0
  66. package/dist/replication-host.d.ts +92 -0
  67. package/dist/replication-host.d.ts.map +1 -0
  68. package/dist/replication-host.js +135 -0
  69. package/dist/replication-host.js.map +1 -0
  70. package/dist/replication-ports.d.ts +194 -0
  71. package/dist/replication-ports.d.ts.map +1 -0
  72. package/dist/replication-ports.js +58 -0
  73. package/dist/replication-ports.js.map +1 -0
  74. package/dist/replication-runtime.d.ts +64 -0
  75. package/dist/replication-runtime.d.ts.map +1 -0
  76. package/dist/replication-runtime.js +79 -0
  77. package/dist/replication-runtime.js.map +1 -0
  78. package/dist/replication-storage.d.ts +42 -0
  79. package/dist/replication-storage.d.ts.map +1 -0
  80. package/dist/replication-storage.js +173 -0
  81. package/dist/replication-storage.js.map +1 -0
  82. package/dist/runtime.d.ts +20 -2
  83. package/dist/runtime.d.ts.map +1 -1
  84. package/dist/runtime.js +80 -1
  85. package/dist/runtime.js.map +1 -1
  86. package/dist/state.d.ts +1 -1
  87. package/dist/state.d.ts.map +1 -1
  88. package/dist/state.js.map +1 -1
  89. package/dist/storage.d.ts +17 -0
  90. package/dist/storage.d.ts.map +1 -1
  91. package/dist/storage.js +367 -142
  92. package/dist/storage.js.map +1 -1
  93. package/dist/sync.d.ts +8 -0
  94. package/dist/sync.d.ts.map +1 -1
  95. package/dist/sync.js +25 -7
  96. package/dist/sync.js.map +1 -1
  97. package/package.json +8 -4
package/dist/storage.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { buildDataBlocksAsync } from "@opendatalabs/personal-server-ts-core/storage/blocks";
2
2
  import { DataBlockStorageError, encodeDataBlockCursor, selectScopeBlocksByIds, validateDataBlockCursor, } from "@opendatalabs/personal-server-ts-core/storage/blocks";
3
3
  import { withScopeLock } from "@opendatalabs/personal-server-ts-core/additions";
4
+ import { IngestPersistedError, VersionSupersededError, } from "@opendatalabs/personal-server-ts-core/contracts";
4
5
  import { createStorageReadMethods, sortEntries } from "./storage-utils.js";
5
6
  import { previewEnvelopeValue, previewJsonEnvelopePrefix, } from "@opendatalabs/personal-server-ts-core/storage/preview";
6
7
  const DEFAULT_INDEXED_DB_NAME = "personal-server-lite-storage";
@@ -45,6 +46,7 @@ function normalizeState(state) {
45
46
  schemaId: entry.schemaId ?? null,
46
47
  })),
47
48
  envelopes: state.envelopes,
49
+ readyBlockManifests: state.readyBlockManifests ?? [],
48
50
  blockManifests: state.blockManifests ?? [],
49
51
  blockPayloads: state.blockPayloads ?? [],
50
52
  ...(state.pendingLedgerDeletes?.length
@@ -166,6 +168,39 @@ export async function createOpfsPsLiteDataFileStore() {
166
168
  await writable.close();
167
169
  return new TextEncoder().encode(encoded).length;
168
170
  },
171
+ async openEnvelopeWriter(path) {
172
+ const parts = path.split("/").filter(Boolean);
173
+ const fileName = parts.pop();
174
+ if (!fileName)
175
+ throw new Error("OPFS path must include a file name");
176
+ const dir = await getOrCreateOpfsDirectory(root, parts);
177
+ const handle = await dir.getFileHandle(fileName, { create: true });
178
+ // A writable stream writes to a swap file; `close` replaces the file in
179
+ // one step and `abort` drops the swap file.
180
+ const writable = await handle.createWritable();
181
+ let sizeBytes = 0;
182
+ let open = true;
183
+ return {
184
+ async write(text) {
185
+ const bytes = textEncoder.encode(text);
186
+ await writable.write(bytes);
187
+ sizeBytes += bytes.byteLength;
188
+ },
189
+ async commit() {
190
+ open = false;
191
+ await writable.close();
192
+ return sizeBytes;
193
+ },
194
+ async abort() {
195
+ if (!open)
196
+ return;
197
+ open = false;
198
+ await writable.abort();
199
+ // `create: true` left an empty, unindexed file at the path.
200
+ await dir.removeEntry(fileName).catch(() => undefined);
201
+ },
202
+ };
203
+ },
169
204
  async deleteEnvelope(path) {
170
205
  const parts = path.split("/").filter(Boolean);
171
206
  const fileName = parts.pop();
@@ -303,6 +338,10 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
303
338
  envelope,
304
339
  ]));
305
340
  const fallbackBlockManifests = new Map((state.blockManifests ?? []).map(({ path, manifest }) => [path, manifest]));
341
+ const readyBlockManifests = new Set([
342
+ ...(state.readyBlockManifests ?? []),
343
+ ...fallbackBlockManifests.keys(),
344
+ ]);
306
345
  const fallbackBlockPayloads = new Map((state.blockPayloads ?? []).map(({ path, block }) => [path, block]));
307
346
  // First-seen ledgers: each persisted on its own (`persistence.*Aux`), read
308
347
  // lazily, never part of the main state. A ledger the previous build kept
@@ -320,10 +359,19 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
320
359
  files: fileStore.kind,
321
360
  opfsAvailable: await isOpfsAvailable(),
322
361
  };
362
+ // Every write of the persisted state runs in this queue, one at a time, in
363
+ // call order. A snapshot is taken when its turn comes, so each write carries
364
+ // the latest index, and index changes never interleave.
323
365
  let persistQueue = Promise.resolve();
324
- async function persist() {
325
- const snapshot = {
326
- ...state,
366
+ function enqueue(step) {
367
+ const run = persistQueue.then(step);
368
+ persistQueue = run.then(() => undefined, () => undefined);
369
+ return run;
370
+ }
371
+ function snapshotOf(source) {
372
+ return {
373
+ ...source,
374
+ readyBlockManifests: Array.from(readyBlockManifests),
327
375
  envelopes: fileStore.kind === "indexeddb"
328
376
  ? Array.from(fallbackEnvelopes.values())
329
377
  : [],
@@ -333,9 +381,92 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
333
381
  ? Array.from(pendingLedgerDeletes)
334
382
  : undefined,
335
383
  };
336
- const write = persistQueue.then(() => persistence.write(snapshot));
337
- persistQueue = write.catch(() => undefined);
338
- await write;
384
+ }
385
+ async function persist() {
386
+ await enqueue(() => persistence.write(snapshotOf(state)));
387
+ }
388
+ /**
389
+ * Make `next` the live index, once it is durable. Call only inside
390
+ * `enqueue`. A failed write leaves the live index as it was, so a reader
391
+ * never sees a change that could be lost on restart.
392
+ */
393
+ async function publish(next) {
394
+ await persistence.write(snapshotOf(next));
395
+ state = next;
396
+ }
397
+ /**
398
+ * Replace the live index with `change(index)`. `change` returns null to
399
+ * leave the index alone. Resolves to whether the index was written.
400
+ */
401
+ function updateIndex(change) {
402
+ return enqueue(async () => {
403
+ const next = change(state);
404
+ if (!next)
405
+ return false;
406
+ await publish(next);
407
+ return true;
408
+ });
409
+ }
410
+ function updateEntryField(path, patch) {
411
+ return updateIndex((current) => {
412
+ if (!current.entries.some((entry) => entry.path === path))
413
+ return null;
414
+ return {
415
+ ...current,
416
+ entries: current.entries.map((entry) => entry.path === path ? { ...entry, ...patch } : entry),
417
+ };
418
+ });
419
+ }
420
+ /**
421
+ * Remove a blob that no index entry names, after its index write failed. A
422
+ * blob that an entry does name is kept. Best effort: the commit's own error
423
+ * is what the caller sees.
424
+ */
425
+ function dropUnindexedEnvelope(path) {
426
+ return enqueue(async () => {
427
+ if (state.entries.some((entry) => entry.path === path))
428
+ return;
429
+ await Promise.all([
430
+ fileStore.deleteEnvelope(path),
431
+ fallbackStore.deleteEnvelope(path),
432
+ ]);
433
+ }).catch(() => undefined);
434
+ }
435
+ /**
436
+ * Install a row at its path, replacing any row there. Caller holds the path
437
+ * lock. With `onlyIfNewest`, the row is refused when the scope already has a
438
+ * later `collectedAt`; that check runs in the index queue, so it is atomic
439
+ * with the publication.
440
+ */
441
+ async function installEntry(entry, options = {}) {
442
+ let indexed;
443
+ await updateIndex((current) => {
444
+ if (options.onlyIfNewest &&
445
+ current.entries.some((e) => e.scope === entry.scope &&
446
+ Date.parse(e.collectedAt) > Date.parse(entry.collectedAt))) {
447
+ throw new VersionSupersededError(entry.scope);
448
+ }
449
+ const version = entry.version ??
450
+ current.entries.reduce((max, e) => e.scope === entry.scope && e.version > max ? e.version : max, 0) + 1;
451
+ indexed = {
452
+ ...entry,
453
+ schemaId: entry.schemaId ?? null,
454
+ version,
455
+ dataPointId: entry.dataPointId ?? null,
456
+ afterTombstoneVersion: entry.afterTombstoneVersion ?? null,
457
+ id: current.nextId,
458
+ createdAt: new Date().toISOString(),
459
+ };
460
+ return {
461
+ ...current,
462
+ nextId: current.nextId + 1,
463
+ entries: [
464
+ ...current.entries.filter((item) => item.path !== entry.path),
465
+ indexed,
466
+ ],
467
+ };
468
+ });
469
+ return indexed;
339
470
  }
340
471
  function entriesForScope(scope) {
341
472
  return sortEntries(state.entries.filter((entry) => entry.scope === scope));
@@ -376,10 +507,13 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
376
507
  },
377
508
  async writeEnvelope(envelope) {
378
509
  const path = envelopePath(envelope.scope, envelope.collectedAt);
379
- const sizeBytes = await fileStore.writeEnvelope(path, envelope);
380
- if (fileStore.kind !== "indexeddb") {
381
- await fallbackStore.deleteEnvelope(path);
382
- }
510
+ const sizeBytes = await withPathLock(path, async () => {
511
+ const size = await fileStore.writeEnvelope(path, envelope);
512
+ if (fileStore.kind !== "indexeddb") {
513
+ await fallbackStore.deleteEnvelope(path);
514
+ }
515
+ return size;
516
+ });
383
517
  await persist();
384
518
  return {
385
519
  path,
@@ -387,6 +521,67 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
387
521
  sizeBytes,
388
522
  };
389
523
  },
524
+ async writeEnvelopeAndIndex(envelope, entry) {
525
+ const path = envelopePath(envelope.scope, envelope.collectedAt);
526
+ // The blob and its row are one step under the path lock, so no erase or
527
+ // replacement of this path runs between them.
528
+ return withPathLock(path, async () => {
529
+ const size = await fileStore.writeEnvelope(path, envelope);
530
+ if (fileStore.kind !== "indexeddb") {
531
+ await fallbackStore.deleteEnvelope(path);
532
+ }
533
+ try {
534
+ return await installEntry({
535
+ ...entry,
536
+ scope: envelope.scope,
537
+ collectedAt: envelope.collectedAt,
538
+ path,
539
+ sizeBytes: entry.sizeBytes ?? size,
540
+ });
541
+ }
542
+ catch (error) {
543
+ // The bytes are durable here; the caller's recovery depends on that.
544
+ throw new IngestPersistedError(path, error);
545
+ }
546
+ });
547
+ },
548
+ ...(fileStore.openEnvelopeWriter
549
+ ? {
550
+ async openVersionWriter(scope, collectedAt) {
551
+ const path = envelopePath(scope, collectedAt);
552
+ const file = await fileStore.openEnvelopeWriter(path);
553
+ return {
554
+ write: (text) => file.write(text),
555
+ abort: () => file.abort(),
556
+ async commit(entry, options) {
557
+ // The rename and the index install are one step under the path
558
+ // lock, so no erase of this path runs between them.
559
+ return withPathLock(path, async () => {
560
+ const sizeBytes = await file.commit();
561
+ try {
562
+ // The index entry is what makes the version visible.
563
+ await installEntry({
564
+ ...entry,
565
+ scope,
566
+ collectedAt,
567
+ path,
568
+ sizeBytes,
569
+ }, options);
570
+ }
571
+ catch (error) {
572
+ await dropUnindexedEnvelope(path);
573
+ throw error;
574
+ }
575
+ if (fileStore.kind !== "indexeddb") {
576
+ await fallbackStore.deleteEnvelope(path);
577
+ }
578
+ return { path, relativePath: path, sizeBytes };
579
+ });
580
+ },
581
+ };
582
+ },
583
+ }
584
+ : {}),
390
585
  async readScopeBlocks(scope, collectedAt, options) {
391
586
  const manifestPath = blockManifestPath(scope, collectedAt);
392
587
  let manifest = (await fileStore.readBlockManifest?.(manifestPath)) ??
@@ -514,7 +709,7 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
514
709
  null);
515
710
  },
516
711
  async hasScopeBlocks(scope, collectedAt) {
517
- return Boolean(await storagePort.readBlockManifest?.(scope, collectedAt));
712
+ return readyBlockManifests.has(blockManifestPath(scope, collectedAt));
518
713
  },
519
714
  async canReadScopeBlocks(scope, collectedAt) {
520
715
  if (await storagePort.hasScopeBlocks?.(scope, collectedAt)) {
@@ -534,6 +729,9 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
534
729
  return Boolean(envelope);
535
730
  },
536
731
  async writeBlockManifest(scope, collectedAt, manifest, blocks) {
732
+ // Invalidate before replacing a tree; a failed build is not ready.
733
+ readyBlockManifests.delete(blockManifestPath(scope, collectedAt));
734
+ await persist();
537
735
  const writeTree = async (store) => {
538
736
  if (!store.writeBlockManifest || !store.writeBlockPayload) {
539
737
  throw new Error("Block sidecar storage is not available");
@@ -565,81 +763,23 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
565
763
  if (wrotePrimary && fileStore.kind !== "indexeddb") {
566
764
  await fallbackStore.deleteBlockTree?.(blockTreePath(scope, collectedAt));
567
765
  }
766
+ readyBlockManifests.add(blockManifestPath(scope, collectedAt));
568
767
  await persist();
569
768
  },
570
- async insertEntry(entry) {
571
- const version = entry.version ??
572
- state.entries.reduce((max, e) => e.scope === entry.scope && e.version > max ? e.version : max, 0) + 1;
573
- const indexed = {
574
- ...entry,
575
- schemaId: entry.schemaId ?? null,
576
- version,
577
- dataPointId: entry.dataPointId ?? null,
578
- afterTombstoneVersion: entry.afterTombstoneVersion ?? null,
579
- id: state.nextId,
580
- createdAt: new Date().toISOString(),
581
- };
582
- state = {
583
- ...state,
584
- nextId: state.nextId + 1,
585
- entries: [
586
- ...state.entries.filter((item) => item.path !== entry.path),
587
- indexed,
588
- ],
589
- };
590
- await persist();
591
- return indexed;
592
- },
593
- async updateFileId(path, fileId) {
594
- let updated = false;
595
- state = {
596
- ...state,
597
- entries: state.entries.map((entry) => {
598
- if (entry.path !== path)
599
- return entry;
600
- updated = true;
601
- return { ...entry, fileId };
602
- }),
603
- };
604
- if (updated) {
605
- await persist();
606
- }
607
- return updated;
769
+ insertEntry(entry) {
770
+ return withPathLock(entry.path, () => installEntry(entry));
771
+ },
772
+ updateFileId(path, fileId) {
773
+ return updateEntryField(path, { fileId });
608
774
  },
609
775
  findLatestVersionByScope(scope) {
610
776
  return state.entries.reduce((max, e) => (e.scope === scope && e.version > max ? e.version : max), 0);
611
777
  },
612
- async updateDataPointId(path, dataPointId) {
613
- let updated = false;
614
- state = {
615
- ...state,
616
- entries: state.entries.map((entry) => {
617
- if (entry.path !== path)
618
- return entry;
619
- updated = true;
620
- return { ...entry, dataPointId };
621
- }),
622
- };
623
- if (updated) {
624
- await persist();
625
- }
626
- return updated;
627
- },
628
- async updateEntryVersion(path, version) {
629
- let updated = false;
630
- state = {
631
- ...state,
632
- entries: state.entries.map((entry) => {
633
- if (entry.path !== path)
634
- return entry;
635
- updated = true;
636
- return { ...entry, version };
637
- }),
638
- };
639
- if (updated) {
640
- await persist();
641
- }
642
- return updated;
778
+ updateDataPointId(path, dataPointId) {
779
+ return updateEntryField(path, { dataPointId });
780
+ },
781
+ updateEntryVersion(path, version) {
782
+ return updateEntryField(path, { version });
643
783
  },
644
784
  async readFirstSeenLedger(scope) {
645
785
  if (!firstSeenLedgers.has(scope)) {
@@ -657,51 +797,72 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
657
797
  firstSeenLedgers.delete(scope);
658
798
  await persistence.deleteAux?.(ledgerAux(scope));
659
799
  },
660
- // Deletions run under the scope's first-seen sidecar lock (a rebuild in
661
- // flight cannot write it back), and the sidecar goes with the scope's last
662
- // version, so no delete path leaves a stale ledger behind.
663
- deleteScope(scope) {
664
- return withScopeLock(scope, async () => {
800
+ // The ledger barrier runs under the scope lock (a rebuild in flight cannot
801
+ // write it back). Each row is then erased under its own path lock, the
802
+ // same owner an ordinary write takes, so no write to a path interleaves
803
+ // with the erase of that path. The scope lock is never held across those
804
+ // path locks: lock order is path, scope, queue.
805
+ async deleteScope(scope) {
806
+ await withScopeLock(scope, async () => {
665
807
  await dropLedger(scope);
666
- let deleted = 0;
667
- const deletedPaths = [];
668
- const deletedBlockTrees = [];
669
- state = {
670
- ...state,
671
- entries: state.entries.filter((entry) => {
672
- if (entry.scope !== scope)
673
- return true;
674
- deleted += 1;
675
- deletedPaths.push(envelopePath(entry.scope, entry.collectedAt));
676
- deletedBlockTrees.push(blockTreePath(entry.scope, entry.collectedAt));
677
- return false;
678
- }),
679
- };
680
- await Promise.all([
681
- ...deletedPaths.flatMap((path) => [
682
- fileStore.deleteEnvelope(path),
683
- fallbackStore.deleteEnvelope(path),
684
- ]),
685
- ...deletedBlockTrees.flatMap((path) => [
686
- fileStore.deleteBlockTree?.(path),
687
- fallbackStore.deleteBlockTree?.(path),
688
- ]),
689
- ].filter((promise) => promise !== undefined));
808
+ for (const entry of entriesForScope(scope)) {
809
+ readyBlockManifests.delete(blockManifestPath(scope, entry.collectedAt));
810
+ }
811
+ // Repeat the barrier on retry, even if a failed write cleared the set.
690
812
  await persist();
691
- return deleted;
692
813
  });
814
+ // A row a later write adds is not in this snapshot; it is not deleted.
815
+ const paths = await enqueue(async () => Array.from(new Set(state.entries
816
+ .filter((entry) => entry.scope === scope)
817
+ .map((entry) => entry.path))));
818
+ // Each path settles on its own, so one failed blob delete does not stop
819
+ // the others. Rows whose delete failed stay, for the next retry.
820
+ const outcomes = await Promise.allSettled(paths.map((path) => withPathLock(path, () => eraseUnderPathLock(path, (e) => e.scope === scope && e.path === path))));
821
+ const failed = outcomes.find((outcome) => outcome.status === "rejected");
822
+ if (failed)
823
+ throw failed.reason;
824
+ return outcomes.filter((outcome) => outcome.status === "fulfilled" && outcome.value === true).length;
693
825
  },
694
- async deleteVersion(scope, collectedAt) {
695
- const entry = state.entries.find((e) => e.scope === scope && e.collectedAt === collectedAt);
696
- if (!entry)
697
- return false;
698
- return removeEntry(entry);
826
+ // Unfenced: by (scope, collectedAt) alone. Fenced: only the row the caller
827
+ // decided on, by its id and path.
828
+ deleteVersion(scope, collectedAt, fence) {
829
+ // The path is fixed by (scope, collectedAt), so the lock is known now.
830
+ const path = envelopePath(scope, collectedAt);
831
+ return withPathLock(path, () => eraseUnderPathLock(path, (e) => e.scope === scope &&
832
+ e.collectedAt === collectedAt &&
833
+ e.path === path &&
834
+ (fence === undefined || e.id === fence.id))).then((outcome) => outcome === true);
835
+ },
836
+ // Only while the envelope is absent: the check and the erase both run
837
+ // under the path lock that every write of this path takes. A store that
838
+ // throws on read is a fault, not absence, and the row stays.
839
+ deleteVersionIfEnvelopeMissing(scope, collectedAt, fence) {
840
+ const path = envelopePath(scope, collectedAt);
841
+ if (fence.path !== path)
842
+ return Promise.resolve(false);
843
+ return withPathLock(path, async () => {
844
+ const present = (await fileStore.readEnvelope(path)) ??
845
+ (await fallbackStore.readEnvelope(path));
846
+ if (present)
847
+ return false;
848
+ const outcome = await eraseUnderPathLock(path, (e) => e.scope === scope &&
849
+ e.collectedAt === collectedAt &&
850
+ e.path === path &&
851
+ e.id === fence.id);
852
+ return outcome === true;
853
+ });
699
854
  },
700
855
  async deleteByFileId(fileId) {
701
- const entry = state.entries.find((e) => e.fileId === fileId);
702
- if (!entry)
703
- return false;
704
- return removeEntry(entry);
856
+ // The fileId only names the path whose lock to take. The row is
857
+ // re-resolved under that lock, and looked again if it moved.
858
+ for (;;) {
859
+ const path = await enqueue(async () => state.entries.find((e) => e.fileId === fileId)?.path ?? null);
860
+ if (path === null)
861
+ return false;
862
+ const outcome = await withPathLock(path, () => eraseUnderPathLock(path, (e) => e.fileId === fileId));
863
+ if (outcome !== "moved")
864
+ return outcome;
865
+ }
705
866
  },
706
867
  };
707
868
  /**
@@ -735,30 +896,94 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
735
896
  await persist().catch(() => undefined);
736
897
  }
737
898
  }
738
- async function removeEntry(entry) {
739
- return withScopeLock(entry.scope, () => removeEntryLocked(entry));
899
+ /**
900
+ * The per-path FIFO lock: the one owner of a path's blob and row. An
901
+ * operation holds it for its whole duration, so operations on one path never
902
+ * interleave. The slot is taken synchronously at call time, so calls run in
903
+ * call order.
904
+ *
905
+ * Lock order, never reversed: path lock, then scope lock, then the index
906
+ * queue. Never take a path lock while holding the scope lock or inside the
907
+ * queue (`enqueue`), or two operations can wait on each other.
908
+ */
909
+ const pathTails = new Map();
910
+ function withPathLock(path, fn) {
911
+ const previous = pathTails.get(path);
912
+ let release;
913
+ const mine = new Promise((resolve) => {
914
+ release = resolve;
915
+ });
916
+ const tail = previous ? previous.then(() => mine) : mine;
917
+ pathTails.set(path, tail);
918
+ // A free path starts `fn` now, not a microtask later, so the caller's
919
+ // index-queue slot is taken in call order.
920
+ const run = previous
921
+ ? previous.then(fn)
922
+ : new Promise((resolve) => resolve(fn()));
923
+ return run.finally(() => {
924
+ release();
925
+ if (pathTails.get(path) === tail)
926
+ pathTails.delete(path);
927
+ });
740
928
  }
741
- async function removeEntryLocked(entry) {
742
- const blobPath = envelopePath(entry.scope, entry.collectedAt);
743
- // Delete the blob FIRST (both stores tolerate a missing blob); only drop the index row once
744
- // the blob is gone. If blob deletion throws for a real reason, the row is preserved so the
745
- // next sync retry re-attempts instead of orphaning the local blob.
746
- await Promise.all([
747
- fileStore.deleteEnvelope(blobPath),
748
- fallbackStore.deleteEnvelope(blobPath),
749
- fileStore.deleteBlockTree?.(blockTreePath(entry.scope, entry.collectedAt)) ?? Promise.resolve(),
750
- fallbackStore.deleteBlockTree?.(blockTreePath(entry.scope, entry.collectedAt)) ?? Promise.resolve(),
751
- ]);
752
- state = {
753
- ...state,
754
- entries: state.entries.filter((e) => e !== entry),
755
- };
756
- // The sidecar goes with the scope's last version.
757
- await persist();
758
- if (!state.entries.some((e) => e.scope === entry.scope)) {
759
- await dropLedger(entry.scope);
760
- }
761
- return true;
929
+ /**
930
+ * Erase the row `match` picks, and its blob. The caller holds the path lock
931
+ * for `path`. The row is resolved inside the queue at the moment the lock is
932
+ * held, so an earlier update or replacement is seen. Resolves "moved" when
933
+ * the row found sits at another path, and the caller must look again.
934
+ *
935
+ * The blob parts run together, and all of them settle before this returns.
936
+ * If any part fails, the row stays and the first error is rethrown.
937
+ */
938
+ async function eraseUnderPathLock(path, match) {
939
+ const found = await enqueue(async () => {
940
+ const entry = state.entries.find(match);
941
+ if (!entry)
942
+ return null;
943
+ if (entry.path !== path)
944
+ return "moved";
945
+ // Persist the conservative hint before any destructive file operation.
946
+ // A partial delete can remove the tree while leaving the row.
947
+ readyBlockManifests.delete(blockManifestPath(entry.scope, entry.collectedAt));
948
+ await persistence.write(snapshotOf(state));
949
+ return {
950
+ id: entry.id,
951
+ scope: entry.scope,
952
+ collectedAt: entry.collectedAt,
953
+ path: entry.path,
954
+ };
955
+ });
956
+ if (found === null)
957
+ return false;
958
+ if (found === "moved")
959
+ return "moved";
960
+ return withScopeLock(found.scope, async () => {
961
+ const blob = envelopePath(found.scope, found.collectedAt);
962
+ const tree = blockTreePath(found.scope, found.collectedAt);
963
+ const parts = await Promise.allSettled([
964
+ fileStore.deleteEnvelope(blob),
965
+ fallbackStore.deleteEnvelope(blob),
966
+ fileStore.deleteBlockTree?.(tree) ?? Promise.resolve(),
967
+ fallbackStore.deleteBlockTree?.(tree) ?? Promise.resolve(),
968
+ ]);
969
+ const failed = parts.find((part) => part.status === "rejected");
970
+ if (failed)
971
+ throw failed.reason;
972
+ await enqueue(async () => {
973
+ if (!state.entries.some((e) => e.id === found.id))
974
+ return;
975
+ await publish({
976
+ ...state,
977
+ entries: state.entries.filter((e) => e.id !== found.id),
978
+ });
979
+ });
980
+ // The sidecar goes with the scope's last version.
981
+ await persist();
982
+ if (!state.entries.some((e) => e.scope === found.scope)) {
983
+ await dropLedger(found.scope);
984
+ }
985
+ return true;
986
+ });
762
987
  }
763
988
  if (fileStore.readEnvelopePreview) {
764
989
  storagePort.readEnvelopePreview = async (scope, collectedAt, { maxBytes }) => {