@opendatalabs/personal-server-ts-lite 1.34.2 → 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.
- package/dist/additions-crypto.d.ts +1 -1
- package/dist/additions-crypto.d.ts.map +1 -1
- package/dist/additions-crypto.js +16 -6
- package/dist/additions-crypto.js.map +1 -1
- package/dist/additions-protocol.d.ts +9 -1
- package/dist/additions-protocol.d.ts.map +1 -1
- package/dist/additions-protocol.js.map +1 -1
- package/dist/additions-session.d.ts +2 -0
- package/dist/additions-session.d.ts.map +1 -1
- package/dist/additions-session.js +29 -4
- package/dist/additions-session.js.map +1 -1
- package/dist/additions-transport.d.ts +10 -2
- package/dist/additions-transport.d.ts.map +1 -1
- package/dist/additions-transport.js +143 -48
- package/dist/additions-transport.js.map +1 -1
- package/dist/additions-worker.bundle.js +538 -101
- package/dist/additions-worker.js +45 -24
- package/dist/additions-worker.js.map +1 -1
- package/dist/browser-runtime.d.ts +23 -0
- package/dist/browser-runtime.d.ts.map +1 -1
- package/dist/browser-runtime.js +38 -1
- package/dist/browser-runtime.js.map +1 -1
- package/dist/canonical-persistence.d.ts +53 -0
- package/dist/canonical-persistence.d.ts.map +1 -0
- package/dist/canonical-persistence.js +13 -0
- package/dist/canonical-persistence.js.map +1 -0
- package/dist/canonical-store.d.ts +395 -0
- package/dist/canonical-store.d.ts.map +1 -0
- package/dist/canonical-store.js +1807 -0
- package/dist/canonical-store.js.map +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +154 -1
- package/dist/client.js.map +1 -1
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +9 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/history-probe-worker.bundle.js +31216 -0
- package/dist/history-probe.worker.d.ts +35 -0
- package/dist/history-probe.worker.d.ts.map +1 -0
- package/dist/history-probe.worker.js +160 -0
- package/dist/history-probe.worker.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-store.d.ts.map +1 -1
- package/dist/mcp-store.js +109 -19
- package/dist/mcp-store.js.map +1 -1
- package/dist/oura-import.d.ts +2 -0
- package/dist/oura-import.d.ts.map +1 -0
- package/dist/oura-import.js +3 -0
- package/dist/oura-import.js.map +1 -0
- package/dist/replication-coordinator-state.d.ts +19 -0
- package/dist/replication-coordinator-state.d.ts.map +1 -0
- package/dist/replication-coordinator-state.js +20 -0
- package/dist/replication-coordinator-state.js.map +1 -0
- package/dist/replication-door.d.ts +17 -0
- package/dist/replication-door.d.ts.map +1 -0
- package/dist/replication-door.js +23 -0
- package/dist/replication-door.js.map +1 -0
- package/dist/replication-engine.d.ts +49 -0
- package/dist/replication-engine.d.ts.map +1 -0
- package/dist/replication-engine.js +589 -0
- package/dist/replication-engine.js.map +1 -0
- package/dist/replication-host.d.ts +92 -0
- package/dist/replication-host.d.ts.map +1 -0
- package/dist/replication-host.js +135 -0
- package/dist/replication-host.js.map +1 -0
- package/dist/replication-ports.d.ts +194 -0
- package/dist/replication-ports.d.ts.map +1 -0
- package/dist/replication-ports.js +58 -0
- package/dist/replication-ports.js.map +1 -0
- package/dist/replication-runtime.d.ts +64 -0
- package/dist/replication-runtime.d.ts.map +1 -0
- package/dist/replication-runtime.js +79 -0
- package/dist/replication-runtime.js.map +1 -0
- package/dist/replication-storage.d.ts +42 -0
- package/dist/replication-storage.d.ts.map +1 -0
- package/dist/replication-storage.js +173 -0
- package/dist/replication-storage.js.map +1 -0
- package/dist/runtime.d.ts +20 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +80 -1
- package/dist/runtime.js.map +1 -1
- package/dist/state.d.ts +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js.map +1 -1
- package/dist/storage.d.ts +15 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +352 -146
- package/dist/storage.js.map +1 -1
- package/dist/sync.d.ts +8 -0
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +25 -7
- package/dist/sync.js.map +1 -1
- 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
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
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
|
-
|
|
343
|
-
|
|
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
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
717
|
-
if
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
754
|
-
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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 }) => {
|