@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.
- 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 +17 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +367 -142
- 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";
|
|
@@ -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
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
702
|
-
if
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
739
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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 }) => {
|