@superbee/core 0.2.0-pre.15 → 0.2.0-pre.16
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/README.md +213 -0
- package/dist/backend.d.ts +3 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +7 -0
- package/dist/backend.js.map +1 -1
- package/dist/blob-contract.d.ts +80 -0
- package/dist/blob-contract.d.ts.map +1 -0
- package/dist/blob-contract.js +131 -0
- package/dist/blob-contract.js.map +1 -0
- package/dist/blob-journal.d.ts +117 -0
- package/dist/blob-journal.d.ts.map +1 -0
- package/dist/blob-journal.js +168 -0
- package/dist/blob-journal.js.map +1 -0
- package/dist/engine.d.ts +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js.map +1 -1
- package/dist/file-journaled-backend-internal.d.ts +2 -0
- package/dist/file-journaled-backend-internal.d.ts.map +1 -1
- package/dist/file-journaled-backend-internal.js +2 -0
- package/dist/file-journaled-backend-internal.js.map +1 -1
- package/dist/file-journaled-backend.d.ts +14 -2
- package/dist/file-journaled-backend.d.ts.map +1 -1
- package/dist/file-journaled-backend.js +245 -37
- package/dist/file-journaled-backend.js.map +1 -1
- package/dist/hosted-client/binding.d.ts +7 -0
- package/dist/hosted-client/binding.d.ts.map +1 -0
- package/dist/hosted-client/binding.js +10 -0
- package/dist/hosted-client/binding.js.map +1 -0
- package/dist/hosted-client/blob-delivery.d.ts +61 -0
- package/dist/hosted-client/blob-delivery.d.ts.map +1 -0
- package/dist/hosted-client/blob-delivery.js +147 -0
- package/dist/hosted-client/blob-delivery.js.map +1 -0
- package/dist/hosted-client/bundle-listing.d.ts +46 -0
- package/dist/hosted-client/bundle-listing.d.ts.map +1 -0
- package/dist/hosted-client/bundle-listing.js +36 -0
- package/dist/hosted-client/bundle-listing.js.map +1 -0
- package/dist/hosted-client/client.d.ts +155 -0
- package/dist/hosted-client/client.d.ts.map +1 -0
- package/dist/hosted-client/client.js +278 -0
- package/dist/hosted-client/client.js.map +1 -0
- package/dist/hosted-client/errors.d.ts +16 -0
- package/dist/hosted-client/errors.d.ts.map +1 -0
- package/dist/hosted-client/errors.js +22 -0
- package/dist/hosted-client/errors.js.map +1 -0
- package/dist/hosted-client/index.d.ts +23 -0
- package/dist/hosted-client/index.d.ts.map +1 -0
- package/dist/hosted-client/index.js +25 -0
- package/dist/hosted-client/index.js.map +1 -0
- package/dist/hosted-client/node.d.ts +70 -0
- package/dist/hosted-client/node.d.ts.map +1 -0
- package/dist/hosted-client/node.js +214 -0
- package/dist/hosted-client/node.js.map +1 -0
- package/dist/hosted-client/reference.d.ts +12 -0
- package/dist/hosted-client/reference.d.ts.map +1 -0
- package/dist/hosted-client/reference.js +29 -0
- package/dist/hosted-client/reference.js.map +1 -0
- package/dist/hosted-client/write.d.ts +191 -0
- package/dist/hosted-client/write.d.ts.map +1 -0
- package/dist/hosted-client/write.js +396 -0
- package/dist/hosted-client/write.js.map +1 -0
- package/dist/hosted-transport/answer-rows.d.ts +26 -0
- package/dist/hosted-transport/answer-rows.d.ts.map +1 -1
- package/dist/hosted-transport/answer-rows.js +59 -1
- package/dist/hosted-transport/answer-rows.js.map +1 -1
- package/dist/hosted-transport/blob-reads.d.ts +35 -0
- package/dist/hosted-transport/blob-reads.d.ts.map +1 -0
- package/dist/hosted-transport/blob-reads.js +179 -0
- package/dist/hosted-transport/blob-reads.js.map +1 -0
- package/dist/hosted-transport/blob-write.d.ts +34 -0
- package/dist/hosted-transport/blob-write.d.ts.map +1 -0
- package/dist/hosted-transport/blob-write.js +115 -0
- package/dist/hosted-transport/blob-write.js.map +1 -0
- package/dist/hosted-transport/carrier.d.ts +8 -9
- package/dist/hosted-transport/carrier.d.ts.map +1 -1
- package/dist/hosted-transport/carrier.js +40 -41
- package/dist/hosted-transport/carrier.js.map +1 -1
- package/dist/hosted-transport/index.d.ts +4 -4
- package/dist/hosted-transport/index.d.ts.map +1 -1
- package/dist/hosted-transport/index.js +4 -4
- package/dist/hosted-transport/index.js.map +1 -1
- package/dist/hosted-transport/read-adapter.d.ts +6 -0
- package/dist/hosted-transport/read-adapter.d.ts.map +1 -1
- package/dist/hosted-transport/read-adapter.js +11 -0
- package/dist/hosted-transport/read-adapter.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/journaled-backend.d.ts +10 -2
- package/dist/journaled-backend.d.ts.map +1 -1
- package/dist/journaled-backend.js +3 -3
- package/dist/journaled-backend.js.map +1 -1
- package/dist/link-types.d.ts +26 -0
- package/dist/link-types.d.ts.map +1 -0
- package/dist/link-types.js +57 -0
- package/dist/link-types.js.map +1 -0
- package/dist/platform.d.ts +4 -0
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +16 -0
- package/dist/platform.js.map +1 -1
- package/dist/recipe-lifecycle/errors.d.ts +77 -0
- package/dist/recipe-lifecycle/errors.d.ts.map +1 -0
- package/dist/recipe-lifecycle/errors.js +96 -0
- package/dist/recipe-lifecycle/errors.js.map +1 -0
- package/dist/recipe-lifecycle/evolution.d.ts +71 -0
- package/dist/recipe-lifecycle/evolution.d.ts.map +1 -0
- package/dist/recipe-lifecycle/evolution.js +659 -0
- package/dist/recipe-lifecycle/evolution.js.map +1 -0
- package/dist/recipe-lifecycle/index.d.ts +23 -0
- package/dist/recipe-lifecycle/index.d.ts.map +1 -0
- package/dist/recipe-lifecycle/index.js +19 -0
- package/dist/recipe-lifecycle/index.js.map +1 -0
- package/dist/recipe-lifecycle/install.d.ts +111 -0
- package/dist/recipe-lifecycle/install.d.ts.map +1 -0
- package/dist/recipe-lifecycle/install.js +412 -0
- package/dist/recipe-lifecycle/install.js.map +1 -0
- package/dist/recipe-lifecycle/legacy-aliases.d.ts +16 -0
- package/dist/recipe-lifecycle/legacy-aliases.d.ts.map +1 -0
- package/dist/recipe-lifecycle/legacy-aliases.js +51 -0
- package/dist/recipe-lifecycle/legacy-aliases.js.map +1 -0
- package/dist/recipe-lifecycle/stamp.d.ts +98 -0
- package/dist/recipe-lifecycle/stamp.d.ts.map +1 -0
- package/dist/recipe-lifecycle/stamp.js +260 -0
- package/dist/recipe-lifecycle/stamp.js.map +1 -0
- package/dist/recipes.d.ts +5 -0
- package/dist/recipes.d.ts.map +1 -1
- package/dist/recipes.js +5 -0
- package/dist/recipes.js.map +1 -1
- package/dist/storage.d.ts +3 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +3 -0
- package/dist/storage.js.map +1 -1
- package/dist/uncertain-write.d.ts +70 -2
- package/dist/uncertain-write.d.ts.map +1 -1
- package/dist/uncertain-write.js +45 -32
- package/dist/uncertain-write.js.map +1 -1
- package/dist/write-identity.d.ts +5 -0
- package/dist/write-identity.d.ts.map +1 -0
- package/dist/write-identity.js +9 -0
- package/dist/write-identity.js.map +1 -0
- package/package.json +13 -1
|
@@ -37,12 +37,14 @@
|
|
|
37
37
|
*/
|
|
38
38
|
import { promises as fs } from "node:fs";
|
|
39
39
|
import path from "node:path";
|
|
40
|
+
import { assertBlobKey } from "./blob-contract.js";
|
|
41
|
+
import { BLOB_JOURNAL_BOUNDS, assertBlobJournalGuard, captureBlobIntentRecord, captureBlobMutationOptions, decodePreparedBlobIntent } from "./blob-journal.js";
|
|
40
42
|
import { resolveContentType } from "./content-type.js";
|
|
41
|
-
import { OPEN_LOG, STORE_RAW } from "./file-journaled-backend-internal.js";
|
|
43
|
+
import { OPEN_LOG, STORE_RAW, STORE_BYTE_LIMIT } from "./file-journaled-backend-internal.js";
|
|
42
44
|
import { captureFilesystemHostPolicy } from "./filesystem-host.js";
|
|
43
45
|
import { acquireFilesystemMutationLock } from "./filesystem-lock.js";
|
|
44
46
|
import { MalformedDocumentError, parseMarkdown, stringifyDoc } from "./frontmatter.js";
|
|
45
|
-
import { assertDeletionIntent, assertJournalGuard, assertJournalIntentChanges, assertJournalMetaChanges, assertJournalResolutionOptions, assertJournalSnapshot, assertMetaWrite, captureIntentUpdate, captureJournalDeleteOptions, captureJournalGuardOption, captureJournalValue, captureJournalWriteOptions, captureMetaWrite, captureRetireAcknowledged, deletionIntentRecord, IntentHoldConflict, IntentStateConflict, JournalGuardConflict, JournalSnapshotConflict, } from "./journaled-backend.js";
|
|
47
|
+
import { assertDeletionIntent, assertJournalGuard, assertJournalIntentChanges, assertJournalMetaChanges, assertJournalResolutionOptions, assertJournalSnapshot, assertMetaWrite, equalJournalValue, captureIntentUpdate, captureJournalDeleteOptions, captureJournalGuardOption, captureJournalValue, captureJournalWriteOptions, captureMetaWrite, captureRetireAcknowledged, deletionIntentRecord, IntentHoldConflict, IntentStateConflict, JournalGuardConflict, JournalSnapshotConflict, } from "./journaled-backend.js";
|
|
46
48
|
import { mutationActorFromFrontmatter } from "./mutation-attribution.js";
|
|
47
49
|
import { assertSafeBlobKey, assertSafeConceptId, assertSafeReservedDir, assertSafeReservedFilename, compareStorageKeys, pathFromConceptId } from "./paths.js";
|
|
48
50
|
import { parseLeadingFrontmatter } from "./portable-frontmatter.js";
|
|
@@ -50,7 +52,7 @@ import { sha256HexOfBytes } from "./sha256.js";
|
|
|
50
52
|
import { blobVersion, defaultActor, VersionConflict, versionOfBytes } from "./versioning.js";
|
|
51
53
|
// ── on-disk format ─────────────────────────────────────────────────────────────────────────
|
|
52
54
|
/** Bumping this requires a reader for the older layout; open refuses any other format. */
|
|
53
|
-
export const FILE_JOURNAL_FORMAT_VERSION =
|
|
55
|
+
export const FILE_JOURNAL_FORMAT_VERSION = 2;
|
|
54
56
|
export const FILE_JOURNAL_LOG = "store.log";
|
|
55
57
|
export const FILE_JOURNAL_SNAPSHOT = "store.snapshot";
|
|
56
58
|
const SNAPSHOT_TEMP = "store.snapshot.tmp";
|
|
@@ -62,8 +64,14 @@ const HEADER = 44;
|
|
|
62
64
|
/** A record larger than this is refused at write and read as corruption at open. */
|
|
63
65
|
const MAXIMUM_RECORD_BYTES = 256 * 1024 * 1024;
|
|
64
66
|
const DEFAULT_COMPACT_AFTER_BYTES = 8 * 1024 * 1024;
|
|
67
|
+
const isDocumentIntent = (row) => !("recordFamily" in row);
|
|
68
|
+
const documentIntents = (state) => [...state.intents.values()].filter(isDocumentIntent);
|
|
69
|
+
const documentIntent = (state, id) => {
|
|
70
|
+
const row = state.intents.get(id);
|
|
71
|
+
return row && isDocumentIntent(row) ? row : undefined;
|
|
72
|
+
};
|
|
65
73
|
function emptyState() {
|
|
66
|
-
return { documents: new Map(), reserved: new Map(), blobs: new Map(), intents: new Map(), meta: new Map(), sequence: 0 };
|
|
74
|
+
return { format: 1, documents: new Map(), reserved: new Map(), blobs: new Map(), intents: new Map(), meta: new Map(), sequence: 0 };
|
|
67
75
|
}
|
|
68
76
|
function applyChanges(state, changes) {
|
|
69
77
|
for (const change of changes) {
|
|
@@ -120,7 +128,7 @@ export class FileJournalFormatError extends FileJournalCorruptError {
|
|
|
120
128
|
name = "FileJournalFormatError";
|
|
121
129
|
format;
|
|
122
130
|
constructor(file, format, offset) {
|
|
123
|
-
super(file, `written in store format ${format}; this adapter reads
|
|
131
|
+
super(file, `written in store format ${format}; this adapter reads formats 1 and ${FILE_JOURNAL_FORMAT_VERSION}`, offset);
|
|
124
132
|
this.format = format;
|
|
125
133
|
}
|
|
126
134
|
}
|
|
@@ -297,10 +305,10 @@ const encodePayload = (value) => utf8.encode(JSON.stringify(encodeValue(value, n
|
|
|
297
305
|
const decodePayload = (bytes) => decodeValue(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
|
|
298
306
|
// ── framing ────────────────────────────────────────────────────────────────────────────────
|
|
299
307
|
/** magic (4) + format version (4) + payload length (4) + SHA-256 of the payload (32), then the payload. */
|
|
300
|
-
function frame(magic, payload) {
|
|
308
|
+
function frame(magic, payload, format) {
|
|
301
309
|
const out = Buffer.alloc(HEADER + payload.byteLength);
|
|
302
310
|
out.writeUInt32BE(magic, 0);
|
|
303
|
-
out.writeUInt32BE(
|
|
311
|
+
out.writeUInt32BE(format, 4);
|
|
304
312
|
out.writeUInt32BE(payload.byteLength, 8);
|
|
305
313
|
Buffer.from(sha256HexOfBytes(payload), "hex").copy(out, 12);
|
|
306
314
|
Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength).copy(out, HEADER);
|
|
@@ -347,7 +355,7 @@ function scanLog(bytes, file) {
|
|
|
347
355
|
throw new FileJournalCorruptError(file, "a damaged record is followed by a whole one", at);
|
|
348
356
|
return { records, validBytes: at, totalBytes: bytes.byteLength };
|
|
349
357
|
}
|
|
350
|
-
if (found.format !==
|
|
358
|
+
if (found.format !== 1 && found.format !== 2)
|
|
351
359
|
throw new FileJournalFormatError(file, found.format, at);
|
|
352
360
|
let record;
|
|
353
361
|
try {
|
|
@@ -358,6 +366,10 @@ function scanLog(bytes, file) {
|
|
|
358
366
|
}
|
|
359
367
|
if (!record || !Number.isSafeInteger(record.txn) || !Array.isArray(record.changes))
|
|
360
368
|
throw new FileJournalCorruptError(file, "record has no transaction number or changes", at);
|
|
369
|
+
if ((found.format === 2 ? record.format !== 2 : record.format !== undefined && record.format !== 1))
|
|
370
|
+
throw new FileJournalCorruptError(file, "record format does not match its frame", at);
|
|
371
|
+
validateIntentChanges(record.changes, found.format, file);
|
|
372
|
+
record.format = found.format;
|
|
361
373
|
records.push(record);
|
|
362
374
|
at = found.end;
|
|
363
375
|
}
|
|
@@ -369,7 +381,7 @@ function decodeSnapshot(bytes, file) {
|
|
|
369
381
|
const found = frameAt(bytes, 0, SNAPSHOT_MAGIC);
|
|
370
382
|
if (!found || found.end !== bytes.byteLength)
|
|
371
383
|
throw new FileJournalCorruptError(file, "snapshot checksum or length does not match");
|
|
372
|
-
if (found.format !==
|
|
384
|
+
if (found.format !== 1 && found.format !== 2)
|
|
373
385
|
throw new FileJournalFormatError(file, found.format);
|
|
374
386
|
let decoded;
|
|
375
387
|
try {
|
|
@@ -378,10 +390,68 @@ function decodeSnapshot(bytes, file) {
|
|
|
378
390
|
catch {
|
|
379
391
|
throw new FileJournalCorruptError(file, "snapshot checksum matches but its payload does not decode");
|
|
380
392
|
}
|
|
381
|
-
if (!decoded || decoded.format !==
|
|
393
|
+
if (!decoded || decoded.format !== found.format || !Number.isSafeInteger(decoded.txn) || !Number.isSafeInteger(decoded.sequence))
|
|
382
394
|
throw new FileJournalCorruptError(file, "snapshot has no transaction number");
|
|
395
|
+
if (!Array.isArray(decoded.intents))
|
|
396
|
+
throw new FileJournalCorruptError(file, "snapshot has no intent records");
|
|
397
|
+
validateIntentChanges(decoded.intents.map(([key, value]) => ({ family: "intent", key, value })), found.format, file);
|
|
383
398
|
return decoded;
|
|
384
399
|
}
|
|
400
|
+
function validateIntentChanges(changes, format, file) {
|
|
401
|
+
for (const change of changes) {
|
|
402
|
+
if (change.family !== "intent" || change.value === null)
|
|
403
|
+
continue;
|
|
404
|
+
const row = change.value;
|
|
405
|
+
if ("recordFamily" in row) {
|
|
406
|
+
if (row.recordFamily !== "blob" || format !== 2)
|
|
407
|
+
throw new FileJournalCorruptError(file, "blob intent is not allowed in this format");
|
|
408
|
+
try {
|
|
409
|
+
captureBlobIntentRecord(row);
|
|
410
|
+
assertReceiptBound(row);
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
throw new FileJournalCorruptError(file, "invalid blob intent payload");
|
|
414
|
+
}
|
|
415
|
+
if (row.requestId !== change.key)
|
|
416
|
+
throw new FileJournalCorruptError(file, "blob identity index mismatch");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function snapshotPayload(state, txn) {
|
|
421
|
+
return { format: state.format, txn, sequence: state.sequence, documents: [...state.documents], reserved: [...state.reserved], blobs: [...state.blobs], intents: [...state.intents], meta: [...state.meta] };
|
|
422
|
+
}
|
|
423
|
+
/** Terminal evidence must fit the space protected when its pending record was admitted. */
|
|
424
|
+
function assertReceiptBound(row) {
|
|
425
|
+
if (row.receipt !== undefined)
|
|
426
|
+
assertByteBound(encodePayload(row.receipt), BLOB_JOURNAL_BOUNDS.terminalReceiptBytes, "blob terminal receipt");
|
|
427
|
+
}
|
|
428
|
+
/** Derived from existing records only: no persisted reservation map or quota ledger. The
|
|
429
|
+
* mutable projection (including transaction-number width) is subtracted so claiming an
|
|
430
|
+
* attempt/state cannot consume its own reserve.
|
|
431
|
+
* The 4 KiB structural allowance covers the terminal record envelope/tagging and counter/time
|
|
432
|
+
* changes; the additional 16 KiB is aggregate encoded growth of ordinary baseline metadata.
|
|
433
|
+
* Arbitrary extra metadata growth still requires unreserved store capacity. */
|
|
434
|
+
function settlementReserve(state, txn) {
|
|
435
|
+
let reserved = 0;
|
|
436
|
+
for (const row of state.intents.values()) {
|
|
437
|
+
if (isDocumentIntent(row))
|
|
438
|
+
continue;
|
|
439
|
+
// All stored blob rows passed captureBlobIntentRecord and assertReceiptBound, including
|
|
440
|
+
// replay. Unknown/nonterminal evidence can never release this allocation.
|
|
441
|
+
if (row.receipt !== undefined && ["acknowledged", "refused", "conflict"].includes(row.state))
|
|
442
|
+
continue;
|
|
443
|
+
const mutableBytes = encodePayload({ state: row.state, attempts: row.attempts, updatedAt: row.updatedAt, transaction: txn, ...(row.notAppliedThrough === undefined ? {} : { notAppliedThrough: row.notAppliedThrough }), ...(row.deletionHold === undefined ? {} : { deletionHold: row.deletionHold }) }).byteLength;
|
|
444
|
+
const remaining = BLOB_JOURNAL_BOUNDS.terminalReceiptBytes + BLOB_JOURNAL_BOUNDS.settlementStructureBytes + BLOB_JOURNAL_BOUNDS.baselineBytes - mutableBytes;
|
|
445
|
+
if (!Number.isSafeInteger(remaining) || remaining < 0 || !Number.isSafeInteger(reserved + remaining))
|
|
446
|
+
throw new RangeError("blob settlement reserve is not representable");
|
|
447
|
+
reserved += remaining;
|
|
448
|
+
}
|
|
449
|
+
return reserved;
|
|
450
|
+
}
|
|
451
|
+
function assertByteBound(payload, maximum, family) {
|
|
452
|
+
if (payload.byteLength > maximum)
|
|
453
|
+
throw new RangeError(`a ${family} of ${payload.byteLength} bytes exceeds the store's ${maximum} byte bound`);
|
|
454
|
+
}
|
|
385
455
|
// ── helpers shared with the other adapters' record handling ────────────────────────────────
|
|
386
456
|
function notFound(id) {
|
|
387
457
|
const err = new Error(`no concept document '${id}'`);
|
|
@@ -506,6 +576,7 @@ export class FileJournaledBackend {
|
|
|
506
576
|
#release;
|
|
507
577
|
#policy;
|
|
508
578
|
#txn;
|
|
579
|
+
#maximumBytes = MAXIMUM_RECORD_BYTES;
|
|
509
580
|
#logBytes;
|
|
510
581
|
#queue = Promise.resolve();
|
|
511
582
|
#unavailable = null;
|
|
@@ -565,9 +636,13 @@ export class FileJournaledBackend {
|
|
|
565
636
|
const logRead = await readIfPresent(logFile);
|
|
566
637
|
const logBytes = logRead ?? Buffer.alloc(0);
|
|
567
638
|
const scanned = scanLog(logBytes, logFile);
|
|
639
|
+
const maximumBytes = options[STORE_BYTE_LIMIT] ?? MAXIMUM_RECORD_BYTES;
|
|
640
|
+
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1 || maximumBytes > MAXIMUM_RECORD_BYTES)
|
|
641
|
+
throw new TypeError("invalid store byte bound");
|
|
568
642
|
const state = emptyState();
|
|
569
643
|
let txn = 0;
|
|
570
644
|
if (snapshot) {
|
|
645
|
+
state.format = snapshot.format;
|
|
571
646
|
state.documents = new Map(snapshot.documents);
|
|
572
647
|
state.reserved = new Map(snapshot.reserved);
|
|
573
648
|
state.blobs = new Map(snapshot.blobs);
|
|
@@ -582,11 +657,15 @@ export class FileJournaledBackend {
|
|
|
582
657
|
continue;
|
|
583
658
|
if (record.txn !== txn + 1)
|
|
584
659
|
throw new FileJournalCorruptError(logFile, `transaction ${record.txn} follows ${txn}`);
|
|
660
|
+
if (record.format < state.format)
|
|
661
|
+
throw new FileJournalCorruptError(logFile, "store format regressed");
|
|
662
|
+
state.format = record.format;
|
|
585
663
|
applyChanges(state, record.changes);
|
|
586
664
|
txn = record.txn;
|
|
587
665
|
}
|
|
588
666
|
if (options.readOnly) {
|
|
589
667
|
const backend = new FileJournaledBackend(directory, state, READ_ONLY_LOG, release, txn, scanned.validBytes, policy);
|
|
668
|
+
backend.#maximumBytes = maximumBytes;
|
|
590
669
|
backend.#readOnly = true;
|
|
591
670
|
return backend;
|
|
592
671
|
}
|
|
@@ -601,6 +680,7 @@ export class FileJournaledBackend {
|
|
|
601
680
|
await log.sync();
|
|
602
681
|
}
|
|
603
682
|
const backend = new FileJournaledBackend(directory, state, log, release, txn, scanned.validBytes, policy);
|
|
683
|
+
backend.#maximumBytes = maximumBytes;
|
|
604
684
|
if (scanned.validBytes > compactAfter)
|
|
605
685
|
await backend.compact();
|
|
606
686
|
return backend;
|
|
@@ -639,17 +719,9 @@ export class FileJournaledBackend {
|
|
|
639
719
|
compact() {
|
|
640
720
|
return this.#serial(async () => {
|
|
641
721
|
const state = this.#state;
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
sequence: state.sequence,
|
|
646
|
-
documents: [...state.documents],
|
|
647
|
-
reserved: [...state.reserved],
|
|
648
|
-
blobs: [...state.blobs],
|
|
649
|
-
intents: [...state.intents],
|
|
650
|
-
meta: [...state.meta],
|
|
651
|
-
};
|
|
652
|
-
const bytes = frame(SNAPSHOT_MAGIC, encodePayload(payload));
|
|
722
|
+
const encoded = encodePayload(snapshotPayload(state, this.#txn));
|
|
723
|
+
assertByteBound(encoded, this.#maximumBytes, "snapshot");
|
|
724
|
+
const bytes = frame(SNAPSHOT_MAGIC, encoded, state.format);
|
|
653
725
|
const temp = path.join(this.directory, SNAPSHOT_TEMP);
|
|
654
726
|
const handle = await fs.open(temp, "w", 0o600);
|
|
655
727
|
try {
|
|
@@ -697,14 +769,23 @@ export class FileJournaledBackend {
|
|
|
697
769
|
*/
|
|
698
770
|
#mutate(decide) {
|
|
699
771
|
return this.#serial(async () => {
|
|
700
|
-
const { changes, result } = decide(this.#state);
|
|
772
|
+
const { changes, result, format } = decide(this.#state);
|
|
701
773
|
if (changes.length === 0)
|
|
702
774
|
return result;
|
|
703
|
-
const
|
|
775
|
+
const nextFormat = Math.max(this.#state.format, format ?? 1);
|
|
776
|
+
const record = { ...(nextFormat === 2 ? { format: 2 } : {}), txn: this.#txn + 1, changes };
|
|
704
777
|
const payload = encodePayload(record);
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
778
|
+
assertByteBound(payload, this.#maximumBytes, "transaction");
|
|
779
|
+
const projected = { ...this.#state, format: nextFormat, documents: new Map(this.#state.documents), reserved: new Map(this.#state.reserved), blobs: new Map(this.#state.blobs), intents: new Map(this.#state.intents), meta: new Map(this.#state.meta) };
|
|
780
|
+
applyChanges(projected, changes);
|
|
781
|
+
const snapshotBytes = encodePayload(snapshotPayload(projected, record.txn));
|
|
782
|
+
assertByteBound(snapshotBytes, this.#maximumBytes, "projected snapshot");
|
|
783
|
+
if (nextFormat === 2) {
|
|
784
|
+
const reserved = settlementReserve(projected, record.txn);
|
|
785
|
+
if (snapshotBytes.byteLength + reserved > this.#maximumBytes)
|
|
786
|
+
throw new RangeError(`a projected snapshot of ${snapshotBytes.byteLength} bytes plus ${reserved} bytes of blob settlement reserve exceeds the store's ${this.#maximumBytes} byte bound`);
|
|
787
|
+
}
|
|
788
|
+
const bytes = frame(RECORD_MAGIC, payload, nextFormat);
|
|
708
789
|
const at = this.#logBytes;
|
|
709
790
|
try {
|
|
710
791
|
await writeAll(this.#log, bytes, at);
|
|
@@ -722,6 +803,7 @@ export class FileJournaledBackend {
|
|
|
722
803
|
}
|
|
723
804
|
this.#logBytes = at + bytes.byteLength;
|
|
724
805
|
this.#txn = record.txn;
|
|
806
|
+
this.#state.format = nextFormat;
|
|
725
807
|
applyChanges(this.#state, changes);
|
|
726
808
|
return result;
|
|
727
809
|
});
|
|
@@ -876,12 +958,12 @@ export class FileJournaledBackend {
|
|
|
876
958
|
assertJournalGuard(guard, {
|
|
877
959
|
target: guard.target,
|
|
878
960
|
document: document ? { version: document.version, raw: document.raw } : null,
|
|
879
|
-
intents:
|
|
961
|
+
intents: documentIntents(state).filter((row) => row.target === guard.target),
|
|
880
962
|
meta: guard.meta.map((row) => ({ key: row.key, expected: state.meta.has(row.key) ? { present: true, value: state.meta.get(row.key) } : { present: false } })),
|
|
881
963
|
});
|
|
882
964
|
}
|
|
883
965
|
static #holder(state, id) {
|
|
884
|
-
return
|
|
966
|
+
return documentIntents(state).sort(bySequence).find((row) => row.target === id && row.state !== "acknowledged");
|
|
885
967
|
}
|
|
886
968
|
static #metaChanges(puts, removals = []) {
|
|
887
969
|
return [
|
|
@@ -908,6 +990,8 @@ export class FileJournaledBackend {
|
|
|
908
990
|
const meta = structuredClone(producedMeta);
|
|
909
991
|
const removeMeta = options.removeMeta ? [...options.removeMeta] : [];
|
|
910
992
|
const intent = options.intent ? structuredClone(options.intent) : undefined;
|
|
993
|
+
if (intent && "recordFamily" in intent)
|
|
994
|
+
throw new JournalGuardConflict(id);
|
|
911
995
|
const supersede = options.supersede ? { ...options.supersede } : undefined;
|
|
912
996
|
const resolveIntents = options.resolveIntents ? structuredClone(options.resolveIntents) : undefined;
|
|
913
997
|
const { requireSettled } = options;
|
|
@@ -917,8 +1001,10 @@ export class FileJournaledBackend {
|
|
|
917
1001
|
return this.#mutate((state) => {
|
|
918
1002
|
this.#checkGuard(state, snapshotGuard);
|
|
919
1003
|
assertJournalIntentChanges(snapshotGuard, intent ? state.intents.get(intent.requestId) : undefined, supersede ? state.intents.get(supersede.requestId) : undefined);
|
|
1004
|
+
if (intent && state.intents.has(intent.requestId))
|
|
1005
|
+
throw new JournalSnapshotConflict(id);
|
|
920
1006
|
if (resolveIntents)
|
|
921
|
-
assertJournalSnapshot(id, resolveIntents.expected,
|
|
1007
|
+
assertJournalSnapshot(id, resolveIntents.expected, documentIntents(state), intent?.requestId);
|
|
922
1008
|
if (requireSettled) {
|
|
923
1009
|
const holder = FileJournaledBackend.#holder(state, id);
|
|
924
1010
|
if (holder)
|
|
@@ -928,7 +1014,7 @@ export class FileJournaledBackend {
|
|
|
928
1014
|
if (expected !== undefined && expected !== current)
|
|
929
1015
|
throw new VersionConflict(id, expected, current);
|
|
930
1016
|
if (supersede) {
|
|
931
|
-
const existing = state
|
|
1017
|
+
const existing = documentIntent(state, supersede.requestId);
|
|
932
1018
|
if (!existing || existing.state !== supersede.expectedState || existing.attempts !== supersede.expectedAttempts)
|
|
933
1019
|
throw new IntentStateConflict(supersede.requestId, supersede.expectedState, existing?.state ?? null);
|
|
934
1020
|
}
|
|
@@ -954,12 +1040,14 @@ export class FileJournaledBackend {
|
|
|
954
1040
|
assertDeletionIntent(id, options);
|
|
955
1041
|
const captured = structuredClone({ meta: options.meta ?? [], removeMeta: [...(options.removeMeta ?? [])], onHeld: options.onHeld, resolveIntents: options.resolveIntents, intent: options.intent, supersede: options.supersede });
|
|
956
1042
|
const { guard, requireSettled } = options;
|
|
1043
|
+
if (captured.intent && "recordFamily" in captured.intent)
|
|
1044
|
+
throw new JournalGuardConflict(id);
|
|
957
1045
|
const expected = options.expectedVersion;
|
|
958
1046
|
const now = new Date().toISOString();
|
|
959
1047
|
return this.#mutate((state) => {
|
|
960
1048
|
this.#checkGuard(state, guard);
|
|
961
1049
|
if (captured.resolveIntents)
|
|
962
|
-
assertJournalSnapshot(id, captured.resolveIntents.expected,
|
|
1050
|
+
assertJournalSnapshot(id, captured.resolveIntents.expected, documentIntents(state), captured.intent?.requestId);
|
|
963
1051
|
if (captured.intent && state.intents.has(captured.intent.requestId))
|
|
964
1052
|
throw new JournalSnapshotConflict(id);
|
|
965
1053
|
if (requireSettled) {
|
|
@@ -975,7 +1063,7 @@ export class FileJournaledBackend {
|
|
|
975
1063
|
throw new VersionConflict(id, expected, current);
|
|
976
1064
|
const { supersede } = captured;
|
|
977
1065
|
if (supersede) {
|
|
978
|
-
const existing = state
|
|
1066
|
+
const existing = documentIntent(state, supersede.requestId);
|
|
979
1067
|
if (!existing || existing.target !== id || existing.state !== supersede.expectedState || existing.attempts !== supersede.expectedAttempts)
|
|
980
1068
|
throw new IntentStateConflict(supersede.requestId, supersede.expectedState, existing?.state ?? null);
|
|
981
1069
|
}
|
|
@@ -1001,7 +1089,7 @@ export class FileJournaledBackend {
|
|
|
1001
1089
|
this.#assertOpen();
|
|
1002
1090
|
const state = this.#state;
|
|
1003
1091
|
const record = state.documents.get(id);
|
|
1004
|
-
const intents =
|
|
1092
|
+
const intents = documentIntents(state).filter((row) => row.target === id).sort(bySequence).map((row) => structuredClone(row));
|
|
1005
1093
|
const meta = new Map();
|
|
1006
1094
|
for (const key of options.meta ?? []) {
|
|
1007
1095
|
if (state.meta.has(key))
|
|
@@ -1021,7 +1109,7 @@ export class FileJournaledBackend {
|
|
|
1021
1109
|
// One synchronous pass: no mutation can apply between the first row and the last.
|
|
1022
1110
|
const edition = editionOf(state.reserved.get(reservedKey("", "index.md")));
|
|
1023
1111
|
const byTarget = new Map();
|
|
1024
|
-
for (const row of state
|
|
1112
|
+
for (const row of documentIntents(state)) {
|
|
1025
1113
|
const list = byTarget.get(row.target);
|
|
1026
1114
|
if (list)
|
|
1027
1115
|
list.push(row);
|
|
@@ -1049,15 +1137,17 @@ export class FileJournaledBackend {
|
|
|
1049
1137
|
async listIntents(state) {
|
|
1050
1138
|
this.#assertOpen();
|
|
1051
1139
|
const wanted = state === undefined ? null : new Set(typeof state === "string" ? [state] : state);
|
|
1052
|
-
return
|
|
1140
|
+
return documentIntents(this.#state).filter((row) => !wanted || wanted.has(row.state)).sort(bySequence).map((row) => structuredClone(row));
|
|
1053
1141
|
}
|
|
1054
1142
|
async readIntent(requestId) {
|
|
1055
1143
|
this.#assertOpen();
|
|
1056
|
-
const row = this.#state
|
|
1144
|
+
const row = documentIntent(this.#state, requestId);
|
|
1057
1145
|
return row ? structuredClone(row) : undefined;
|
|
1058
1146
|
}
|
|
1059
1147
|
async updateIntent(requestId, expectedState, patch, options = {}) {
|
|
1060
1148
|
({ patch, options } = captureIntentUpdate(patch, options));
|
|
1149
|
+
if ("recordFamily" in patch)
|
|
1150
|
+
throw new JournalGuardConflict(requestId);
|
|
1061
1151
|
const now = new Date().toISOString();
|
|
1062
1152
|
const raw = options.document ? stringifyDoc(options.document.frontmatter, options.document.body ?? "") : undefined;
|
|
1063
1153
|
const replacement = raw === undefined ? undefined : { id: options.document.id, record: { raw, version: versionOfBytes(raw), updatedBy: defaultActor(), updatedAt: now } };
|
|
@@ -1065,7 +1155,7 @@ export class FileJournaledBackend {
|
|
|
1065
1155
|
const meta = options.meta ?? [];
|
|
1066
1156
|
return this.#mutate((state) => {
|
|
1067
1157
|
this.#checkGuard(state, guard);
|
|
1068
|
-
const current = state
|
|
1158
|
+
const current = documentIntent(state, requestId);
|
|
1069
1159
|
if (!current || current.state !== expectedState)
|
|
1070
1160
|
throw new IntentStateConflict(requestId, expectedState, current?.state ?? null);
|
|
1071
1161
|
if (guard && current.target !== guard.target)
|
|
@@ -1087,6 +1177,124 @@ export class FileJournaledBackend {
|
|
|
1087
1177
|
return { changes: [...changes, ...FileJournaledBackend.#metaChanges([], removeMeta ?? [])], result: undefined };
|
|
1088
1178
|
});
|
|
1089
1179
|
}
|
|
1180
|
+
// ── optional ordinary-file journal (no filesystem/body mirror) ─────────────────────────
|
|
1181
|
+
#blobGuard(state, guard) {
|
|
1182
|
+
assertBlobJournalGuard(guard, {
|
|
1183
|
+
key: guard.key,
|
|
1184
|
+
intents: [...state.intents.values()].filter((row) => !isDocumentIntent(row) && row.target === guard.key),
|
|
1185
|
+
meta: guard.meta.map(row => ({ key: row.key, expected: state.meta.has(row.key) ? { present: true, value: state.meta.get(row.key) } : { present: false } })),
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
async readBlobWithJournal(key, options = {}) {
|
|
1189
|
+
assertBlobKey(key);
|
|
1190
|
+
this.#assertOpen();
|
|
1191
|
+
const state = this.#state;
|
|
1192
|
+
const intents = [...state.intents.values()].filter((row) => !isDocumentIntent(row) && row.target === key).sort(bySequence).map(row => structuredClone(row));
|
|
1193
|
+
const meta = new Map();
|
|
1194
|
+
const premises = [];
|
|
1195
|
+
for (const name of new Set(options.meta ?? [])) {
|
|
1196
|
+
if (typeof name !== "string")
|
|
1197
|
+
throw new JournalGuardConflict(key);
|
|
1198
|
+
if (state.meta.has(name)) {
|
|
1199
|
+
const value = structuredClone(state.meta.get(name));
|
|
1200
|
+
meta.set(name, value);
|
|
1201
|
+
premises.push({ key: name, expected: { present: true, value: structuredClone(value) } });
|
|
1202
|
+
}
|
|
1203
|
+
else
|
|
1204
|
+
premises.push({ key: name, expected: { present: false } });
|
|
1205
|
+
}
|
|
1206
|
+
return { key, intents, meta, guard: { key, intents: structuredClone(intents), meta: premises } };
|
|
1207
|
+
}
|
|
1208
|
+
async writeBlobMeta(key, changes, options) {
|
|
1209
|
+
assertBlobKey(key);
|
|
1210
|
+
const captured = captureBlobMutationOptions({ ...captureJournalValue(changes), guard: options.guard });
|
|
1211
|
+
if (captured.guard.key !== key)
|
|
1212
|
+
throw new JournalGuardConflict(key);
|
|
1213
|
+
await this.#mutate(state => {
|
|
1214
|
+
this.#blobGuard(state, captured.guard);
|
|
1215
|
+
return { changes: FileJournaledBackend.#metaChanges(captured.meta ?? [], captured.removeMeta ?? []), result: undefined };
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
async admitPreparedBlob(prepared, options) {
|
|
1219
|
+
const captured = decodePreparedBlobIntent(prepared);
|
|
1220
|
+
options = captureBlobMutationOptions(options);
|
|
1221
|
+
const { guard, meta = [], removeMeta = [] } = options;
|
|
1222
|
+
if (guard.key !== captured.intent.key)
|
|
1223
|
+
throw new JournalGuardConflict(captured.intent.key);
|
|
1224
|
+
const now = new Date().toISOString();
|
|
1225
|
+
return this.#mutate(state => {
|
|
1226
|
+
this.#blobGuard(state, guard);
|
|
1227
|
+
if (state.intents.has(captured.identity.requestId))
|
|
1228
|
+
throw new JournalSnapshotConflict(guard.key);
|
|
1229
|
+
const sequence = state.sequence + 1;
|
|
1230
|
+
if (!Number.isSafeInteger(sequence))
|
|
1231
|
+
throw new RangeError("journal sequence exhausted");
|
|
1232
|
+
const record = captureBlobIntentRecord({ recordFamily: "blob", requestId: captured.identity.requestId, target: captured.intent.key, prepared, sequence, attempts: 0, state: "pending", updatedAt: now });
|
|
1233
|
+
return { format: 2, changes: [{ family: "sequence", value: sequence }, { family: "intent", key: record.requestId, value: structuredClone(record) }, ...FileJournaledBackend.#metaChanges(meta, removeMeta)], result: record };
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
async listBlobIntents(state) {
|
|
1237
|
+
this.#assertOpen();
|
|
1238
|
+
const wanted = state === undefined ? null : new Set(typeof state === "string" ? [state] : state);
|
|
1239
|
+
return [...this.#state.intents.values()].filter((row) => !isDocumentIntent(row) && (!wanted || wanted.has(row.state))).sort(bySequence).map(row => structuredClone(row));
|
|
1240
|
+
}
|
|
1241
|
+
async readBlobIntent(requestId) {
|
|
1242
|
+
this.#assertOpen();
|
|
1243
|
+
const row = this.#state.intents.get(requestId);
|
|
1244
|
+
return row && !isDocumentIntent(row) ? structuredClone(row) : undefined;
|
|
1245
|
+
}
|
|
1246
|
+
async updateBlobIntent(requestId, expectedState, patch, options) {
|
|
1247
|
+
patch = captureJournalValue(patch);
|
|
1248
|
+
options = captureBlobMutationOptions(options);
|
|
1249
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch) || Object.keys(patch).some(key => !["state", "attempts", "receipt", "notAppliedThrough", "deletionHold"].includes(key)))
|
|
1250
|
+
throw new JournalGuardConflict(options.guard.key);
|
|
1251
|
+
const { guard, meta = [], removeMeta = [] } = options;
|
|
1252
|
+
const now = new Date().toISOString();
|
|
1253
|
+
return this.#mutate(state => {
|
|
1254
|
+
this.#blobGuard(state, guard);
|
|
1255
|
+
const current = state.intents.get(requestId);
|
|
1256
|
+
if (!current || isDocumentIntent(current) || current.state !== expectedState)
|
|
1257
|
+
throw new IntentStateConflict(requestId, expectedState, current?.state ?? null);
|
|
1258
|
+
if (current.target !== guard.key)
|
|
1259
|
+
throw new JournalGuardConflict(guard.key);
|
|
1260
|
+
const next = captureBlobIntentRecord({ ...current, ...patch, updatedAt: now });
|
|
1261
|
+
assertReceiptBound(next);
|
|
1262
|
+
const beforePrefix = current.notAppliedThrough ?? 0, afterPrefix = next.notAppliedThrough ?? 0;
|
|
1263
|
+
const sameHold = equalJournalValue(current.deletionHold, next.deletionHold);
|
|
1264
|
+
if (current.receipt && (next.attempts !== current.attempts || next.state !== current.state || !equalJournalValue(current.receipt, next.receipt) || afterPrefix !== beforePrefix || !sameHold))
|
|
1265
|
+
throw new JournalGuardConflict(guard.key);
|
|
1266
|
+
if (next.attempts !== current.attempts) {
|
|
1267
|
+
// The durable claim consumes exactly one eligibility before any wire operation.
|
|
1268
|
+
if (current.receipt || !["pending", "unknown"].includes(current.state) || next.state !== "in_flight" || next.attempts !== current.attempts + 1 || beforePrefix !== current.attempts || afterPrefix !== beforePrefix || !sameHold)
|
|
1269
|
+
throw new JournalGuardConflict(guard.key);
|
|
1270
|
+
}
|
|
1271
|
+
else if (afterPrefix !== beforePrefix) {
|
|
1272
|
+
// Typed evidence belongs to this exact claimed count and must extend the complete prefix.
|
|
1273
|
+
if (current.receipt || !["in_flight", "unknown"].includes(current.state) || next.state !== "pending" || patch.attempts !== current.attempts || beforePrefix !== current.attempts - 1 || afterPrefix !== current.attempts || patch.deletionHold === undefined || next.receipt !== undefined)
|
|
1274
|
+
throw new JournalGuardConflict(guard.key);
|
|
1275
|
+
}
|
|
1276
|
+
else if (!sameHold || patch.state === "in_flight")
|
|
1277
|
+
throw new JournalGuardConflict(guard.key);
|
|
1278
|
+
return { changes: [{ family: "intent", key: requestId, value: structuredClone(next) }, ...FileJournaledBackend.#metaChanges(meta, removeMeta)], result: next };
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
async retireBlobIntents(key, requestIds, options) {
|
|
1282
|
+
assertBlobKey(key);
|
|
1283
|
+
const ids = captureJournalValue([...requestIds]);
|
|
1284
|
+
options = captureBlobMutationOptions(options);
|
|
1285
|
+
const { guard, meta = [], removeMeta = [] } = options;
|
|
1286
|
+
if (guard.key !== key || ids.length === 0 || ids.some(id => typeof id !== "string") || new Set(ids).size !== ids.length)
|
|
1287
|
+
throw new JournalGuardConflict(key);
|
|
1288
|
+
await this.#mutate(state => {
|
|
1289
|
+
this.#blobGuard(state, guard);
|
|
1290
|
+
for (const id of ids) {
|
|
1291
|
+
const row = state.intents.get(id);
|
|
1292
|
+
if (!row || isDocumentIntent(row) || row.target !== key || !row.receipt || !["acknowledged", "refused", "conflict"].includes(row.state))
|
|
1293
|
+
throw new JournalGuardConflict(key);
|
|
1294
|
+
}
|
|
1295
|
+
return { changes: [...ids.map((id) => ({ family: "intent", key: id, value: null })), ...FileJournaledBackend.#metaChanges(meta, removeMeta)], result: undefined };
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1090
1298
|
async readMeta(key) {
|
|
1091
1299
|
this.#assertOpen();
|
|
1092
1300
|
return this.#state.meta.has(key) ? structuredClone(this.#state.meta.get(key)) : undefined;
|