@opendatalabs/personal-server-ts-lite 1.34.2 → 1.35.1

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 +15 -0
  90. package/dist/storage.d.ts.map +1 -1
  91. package/dist/storage.js +352 -146
  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";
@@ -167,6 +168,39 @@ export async function createOpfsPsLiteDataFileStore() {
167
168
  await writable.close();
168
169
  return new TextEncoder().encode(encoded).length;
169
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
+ },
170
204
  async deleteEnvelope(path) {
171
205
  const parts = path.split("/").filter(Boolean);
172
206
  const fileName = parts.pop();
@@ -325,10 +359,18 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
325
359
  files: fileStore.kind,
326
360
  opfsAvailable: await isOpfsAvailable(),
327
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.
328
365
  let persistQueue = Promise.resolve();
329
- async function persist() {
330
- const snapshot = {
331
- ...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,
332
374
  readyBlockManifests: Array.from(readyBlockManifests),
333
375
  envelopes: fileStore.kind === "indexeddb"
334
376
  ? Array.from(fallbackEnvelopes.values())
@@ -339,9 +381,92 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
339
381
  ? Array.from(pendingLedgerDeletes)
340
382
  : undefined,
341
383
  };
342
- const write = persistQueue.then(() => persistence.write(snapshot));
343
- persistQueue = write.catch(() => undefined);
344
- 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;
345
470
  }
346
471
  function entriesForScope(scope) {
347
472
  return sortEntries(state.entries.filter((entry) => entry.scope === scope));
@@ -382,10 +507,13 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
382
507
  },
383
508
  async writeEnvelope(envelope) {
384
509
  const path = envelopePath(envelope.scope, envelope.collectedAt);
385
- const sizeBytes = await fileStore.writeEnvelope(path, envelope);
386
- if (fileStore.kind !== "indexeddb") {
387
- await fallbackStore.deleteEnvelope(path);
388
- }
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
+ });
389
517
  await persist();
390
518
  return {
391
519
  path,
@@ -393,6 +521,67 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
393
521
  sizeBytes,
394
522
  };
395
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
+ : {}),
396
585
  async readScopeBlocks(scope, collectedAt, options) {
397
586
  const manifestPath = blockManifestPath(scope, collectedAt);
398
587
  let manifest = (await fileStore.readBlockManifest?.(manifestPath)) ??
@@ -577,79 +766,20 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
577
766
  readyBlockManifests.add(blockManifestPath(scope, collectedAt));
578
767
  await persist();
579
768
  },
580
- async insertEntry(entry) {
581
- const version = entry.version ??
582
- state.entries.reduce((max, e) => e.scope === entry.scope && e.version > max ? e.version : max, 0) + 1;
583
- const indexed = {
584
- ...entry,
585
- schemaId: entry.schemaId ?? null,
586
- version,
587
- dataPointId: entry.dataPointId ?? null,
588
- afterTombstoneVersion: entry.afterTombstoneVersion ?? null,
589
- id: state.nextId,
590
- createdAt: new Date().toISOString(),
591
- };
592
- state = {
593
- ...state,
594
- nextId: state.nextId + 1,
595
- entries: [
596
- ...state.entries.filter((item) => item.path !== entry.path),
597
- indexed,
598
- ],
599
- };
600
- await persist();
601
- return indexed;
602
- },
603
- async updateFileId(path, fileId) {
604
- let updated = false;
605
- state = {
606
- ...state,
607
- entries: state.entries.map((entry) => {
608
- if (entry.path !== path)
609
- return entry;
610
- updated = true;
611
- return { ...entry, fileId };
612
- }),
613
- };
614
- if (updated) {
615
- await persist();
616
- }
617
- return updated;
769
+ insertEntry(entry) {
770
+ return withPathLock(entry.path, () => installEntry(entry));
771
+ },
772
+ updateFileId(path, fileId) {
773
+ return updateEntryField(path, { fileId });
618
774
  },
619
775
  findLatestVersionByScope(scope) {
620
776
  return state.entries.reduce((max, e) => (e.scope === scope && e.version > max ? e.version : max), 0);
621
777
  },
622
- async updateDataPointId(path, dataPointId) {
623
- let updated = false;
624
- state = {
625
- ...state,
626
- entries: state.entries.map((entry) => {
627
- if (entry.path !== path)
628
- return entry;
629
- updated = true;
630
- return { ...entry, dataPointId };
631
- }),
632
- };
633
- if (updated) {
634
- await persist();
635
- }
636
- return updated;
637
- },
638
- async updateEntryVersion(path, version) {
639
- let updated = false;
640
- state = {
641
- ...state,
642
- entries: state.entries.map((entry) => {
643
- if (entry.path !== path)
644
- return entry;
645
- updated = true;
646
- return { ...entry, version };
647
- }),
648
- };
649
- if (updated) {
650
- await persist();
651
- }
652
- return updated;
778
+ updateDataPointId(path, dataPointId) {
779
+ return updateEntryField(path, { dataPointId });
780
+ },
781
+ updateEntryVersion(path, version) {
782
+ return updateEntryField(path, { version });
653
783
  },
654
784
  async readFirstSeenLedger(scope) {
655
785
  if (!firstSeenLedgers.has(scope)) {
@@ -667,56 +797,72 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
667
797
  firstSeenLedgers.delete(scope);
668
798
  await persistence.deleteAux?.(ledgerAux(scope));
669
799
  },
670
- // Deletions run under the scope's first-seen sidecar lock (a rebuild in
671
- // flight cannot write it back), and the sidecar goes with the scope's last
672
- // version, so no delete path leaves a stale ledger behind.
673
- deleteScope(scope) {
674
- 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 () => {
675
807
  await dropLedger(scope);
676
808
  for (const entry of entriesForScope(scope)) {
677
809
  readyBlockManifests.delete(blockManifestPath(scope, entry.collectedAt));
678
810
  }
679
811
  // Repeat the barrier on retry, even if a failed write cleared the set.
680
812
  await persist();
681
- let deleted = 0;
682
- const deletedPaths = [];
683
- const deletedBlockTrees = [];
684
- state = {
685
- ...state,
686
- entries: state.entries.filter((entry) => {
687
- if (entry.scope !== scope)
688
- return true;
689
- deleted += 1;
690
- deletedPaths.push(envelopePath(entry.scope, entry.collectedAt));
691
- deletedBlockTrees.push(blockTreePath(entry.scope, entry.collectedAt));
692
- return false;
693
- }),
694
- };
695
- await Promise.all([
696
- ...deletedPaths.flatMap((path) => [
697
- fileStore.deleteEnvelope(path),
698
- fallbackStore.deleteEnvelope(path),
699
- ]),
700
- ...deletedBlockTrees.flatMap((path) => [
701
- fileStore.deleteBlockTree?.(path),
702
- fallbackStore.deleteBlockTree?.(path),
703
- ]),
704
- ].filter((promise) => promise !== undefined));
705
- await persist();
706
- return deleted;
707
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;
708
825
  },
709
- async deleteVersion(scope, collectedAt) {
710
- const entry = state.entries.find((e) => e.scope === scope && e.collectedAt === collectedAt);
711
- if (!entry)
712
- return false;
713
- 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
+ });
714
854
  },
715
855
  async deleteByFileId(fileId) {
716
- const entry = state.entries.find((e) => e.fileId === fileId);
717
- if (!entry)
718
- return false;
719
- 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
+ }
720
866
  },
721
867
  };
722
868
  /**
@@ -750,34 +896,94 @@ export async function createPersistentPsLiteStorage(adapter, persistence = creat
750
896
  await persist().catch(() => undefined);
751
897
  }
752
898
  }
753
- async function removeEntry(entry) {
754
- 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
+ });
755
928
  }
756
- async function removeEntryLocked(entry) {
757
- const blobPath = envelopePath(entry.scope, entry.collectedAt);
758
- // Delete the blob FIRST (both stores tolerate a missing blob); only drop the index row once
759
- // the blob is gone. If blob deletion throws for a real reason, the row is preserved so the
760
- // next sync retry re-attempts instead of orphaning the local blob.
761
- // A partial delete can remove the tree while leaving the index row.
762
- // Persist the conservative hint before any destructive file operation.
763
- readyBlockManifests.delete(blockManifestPath(entry.scope, entry.collectedAt));
764
- await persist();
765
- await Promise.all([
766
- fileStore.deleteEnvelope(blobPath),
767
- fallbackStore.deleteEnvelope(blobPath),
768
- fileStore.deleteBlockTree?.(blockTreePath(entry.scope, entry.collectedAt)) ?? Promise.resolve(),
769
- fallbackStore.deleteBlockTree?.(blockTreePath(entry.scope, entry.collectedAt)) ?? Promise.resolve(),
770
- ]);
771
- state = {
772
- ...state,
773
- entries: state.entries.filter((e) => e !== entry),
774
- };
775
- // The sidecar goes with the scope's last version.
776
- await persist();
777
- if (!state.entries.some((e) => e.scope === entry.scope)) {
778
- await dropLedger(entry.scope);
779
- }
780
- 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
+ });
781
987
  }
782
988
  if (fileStore.readEnvelopePreview) {
783
989
  storagePort.readEnvelopePreview = async (scope, collectedAt, { maxBytes }) => {