@superbee/core 0.2.0-pre.14 → 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.
Files changed (145) hide show
  1. package/README.md +213 -0
  2. package/dist/backend.d.ts +3 -0
  3. package/dist/backend.d.ts.map +1 -1
  4. package/dist/backend.js +7 -0
  5. package/dist/backend.js.map +1 -1
  6. package/dist/blob-contract.d.ts +80 -0
  7. package/dist/blob-contract.d.ts.map +1 -0
  8. package/dist/blob-contract.js +131 -0
  9. package/dist/blob-contract.js.map +1 -0
  10. package/dist/blob-journal.d.ts +117 -0
  11. package/dist/blob-journal.d.ts.map +1 -0
  12. package/dist/blob-journal.js +168 -0
  13. package/dist/blob-journal.js.map +1 -0
  14. package/dist/engine.d.ts +1 -1
  15. package/dist/engine.d.ts.map +1 -1
  16. package/dist/engine.js.map +1 -1
  17. package/dist/file-journaled-backend-internal.d.ts +2 -0
  18. package/dist/file-journaled-backend-internal.d.ts.map +1 -1
  19. package/dist/file-journaled-backend-internal.js +2 -0
  20. package/dist/file-journaled-backend-internal.js.map +1 -1
  21. package/dist/file-journaled-backend.d.ts +14 -2
  22. package/dist/file-journaled-backend.d.ts.map +1 -1
  23. package/dist/file-journaled-backend.js +245 -37
  24. package/dist/file-journaled-backend.js.map +1 -1
  25. package/dist/hosted-client/binding.d.ts +7 -0
  26. package/dist/hosted-client/binding.d.ts.map +1 -0
  27. package/dist/hosted-client/binding.js +10 -0
  28. package/dist/hosted-client/binding.js.map +1 -0
  29. package/dist/hosted-client/blob-delivery.d.ts +61 -0
  30. package/dist/hosted-client/blob-delivery.d.ts.map +1 -0
  31. package/dist/hosted-client/blob-delivery.js +147 -0
  32. package/dist/hosted-client/blob-delivery.js.map +1 -0
  33. package/dist/hosted-client/bundle-listing.d.ts +46 -0
  34. package/dist/hosted-client/bundle-listing.d.ts.map +1 -0
  35. package/dist/hosted-client/bundle-listing.js +36 -0
  36. package/dist/hosted-client/bundle-listing.js.map +1 -0
  37. package/dist/hosted-client/client.d.ts +155 -0
  38. package/dist/hosted-client/client.d.ts.map +1 -0
  39. package/dist/hosted-client/client.js +278 -0
  40. package/dist/hosted-client/client.js.map +1 -0
  41. package/dist/hosted-client/errors.d.ts +16 -0
  42. package/dist/hosted-client/errors.d.ts.map +1 -0
  43. package/dist/hosted-client/errors.js +22 -0
  44. package/dist/hosted-client/errors.js.map +1 -0
  45. package/dist/hosted-client/index.d.ts +23 -0
  46. package/dist/hosted-client/index.d.ts.map +1 -0
  47. package/dist/hosted-client/index.js +25 -0
  48. package/dist/hosted-client/index.js.map +1 -0
  49. package/dist/hosted-client/node.d.ts +70 -0
  50. package/dist/hosted-client/node.d.ts.map +1 -0
  51. package/dist/hosted-client/node.js +214 -0
  52. package/dist/hosted-client/node.js.map +1 -0
  53. package/dist/hosted-client/reference.d.ts +12 -0
  54. package/dist/hosted-client/reference.d.ts.map +1 -0
  55. package/dist/hosted-client/reference.js +29 -0
  56. package/dist/hosted-client/reference.js.map +1 -0
  57. package/dist/hosted-client/write.d.ts +191 -0
  58. package/dist/hosted-client/write.d.ts.map +1 -0
  59. package/dist/hosted-client/write.js +396 -0
  60. package/dist/hosted-client/write.js.map +1 -0
  61. package/dist/hosted-transport/answer-rows.d.ts +26 -0
  62. package/dist/hosted-transport/answer-rows.d.ts.map +1 -1
  63. package/dist/hosted-transport/answer-rows.js +59 -1
  64. package/dist/hosted-transport/answer-rows.js.map +1 -1
  65. package/dist/hosted-transport/blob-reads.d.ts +35 -0
  66. package/dist/hosted-transport/blob-reads.d.ts.map +1 -0
  67. package/dist/hosted-transport/blob-reads.js +179 -0
  68. package/dist/hosted-transport/blob-reads.js.map +1 -0
  69. package/dist/hosted-transport/blob-write.d.ts +34 -0
  70. package/dist/hosted-transport/blob-write.d.ts.map +1 -0
  71. package/dist/hosted-transport/blob-write.js +115 -0
  72. package/dist/hosted-transport/blob-write.js.map +1 -0
  73. package/dist/hosted-transport/carrier.d.ts +8 -9
  74. package/dist/hosted-transport/carrier.d.ts.map +1 -1
  75. package/dist/hosted-transport/carrier.js +40 -41
  76. package/dist/hosted-transport/carrier.js.map +1 -1
  77. package/dist/hosted-transport/index.d.ts +4 -4
  78. package/dist/hosted-transport/index.d.ts.map +1 -1
  79. package/dist/hosted-transport/index.js +4 -4
  80. package/dist/hosted-transport/index.js.map +1 -1
  81. package/dist/hosted-transport/read-adapter.d.ts +6 -0
  82. package/dist/hosted-transport/read-adapter.d.ts.map +1 -1
  83. package/dist/hosted-transport/read-adapter.js +11 -0
  84. package/dist/hosted-transport/read-adapter.js.map +1 -1
  85. package/dist/hosted-transport/whole-document-transport.d.ts +9 -8
  86. package/dist/hosted-transport/whole-document-transport.d.ts.map +1 -1
  87. package/dist/hosted-transport/whole-document-transport.js +10 -9
  88. package/dist/hosted-transport/whole-document-transport.js.map +1 -1
  89. package/dist/index.d.ts +3 -1
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +4 -1
  92. package/dist/index.js.map +1 -1
  93. package/dist/journaled-backend.d.ts +10 -2
  94. package/dist/journaled-backend.d.ts.map +1 -1
  95. package/dist/journaled-backend.js +3 -3
  96. package/dist/journaled-backend.js.map +1 -1
  97. package/dist/link-types.d.ts +26 -0
  98. package/dist/link-types.d.ts.map +1 -0
  99. package/dist/link-types.js +57 -0
  100. package/dist/link-types.js.map +1 -0
  101. package/dist/platform.d.ts +4 -0
  102. package/dist/platform.d.ts.map +1 -1
  103. package/dist/platform.js +16 -0
  104. package/dist/platform.js.map +1 -1
  105. package/dist/recipe-lifecycle/errors.d.ts +77 -0
  106. package/dist/recipe-lifecycle/errors.d.ts.map +1 -0
  107. package/dist/recipe-lifecycle/errors.js +96 -0
  108. package/dist/recipe-lifecycle/errors.js.map +1 -0
  109. package/dist/recipe-lifecycle/evolution.d.ts +71 -0
  110. package/dist/recipe-lifecycle/evolution.d.ts.map +1 -0
  111. package/dist/recipe-lifecycle/evolution.js +659 -0
  112. package/dist/recipe-lifecycle/evolution.js.map +1 -0
  113. package/dist/recipe-lifecycle/index.d.ts +23 -0
  114. package/dist/recipe-lifecycle/index.d.ts.map +1 -0
  115. package/dist/recipe-lifecycle/index.js +19 -0
  116. package/dist/recipe-lifecycle/index.js.map +1 -0
  117. package/dist/recipe-lifecycle/install.d.ts +111 -0
  118. package/dist/recipe-lifecycle/install.d.ts.map +1 -0
  119. package/dist/recipe-lifecycle/install.js +412 -0
  120. package/dist/recipe-lifecycle/install.js.map +1 -0
  121. package/dist/recipe-lifecycle/legacy-aliases.d.ts +16 -0
  122. package/dist/recipe-lifecycle/legacy-aliases.d.ts.map +1 -0
  123. package/dist/recipe-lifecycle/legacy-aliases.js +51 -0
  124. package/dist/recipe-lifecycle/legacy-aliases.js.map +1 -0
  125. package/dist/recipe-lifecycle/stamp.d.ts +98 -0
  126. package/dist/recipe-lifecycle/stamp.d.ts.map +1 -0
  127. package/dist/recipe-lifecycle/stamp.js +260 -0
  128. package/dist/recipe-lifecycle/stamp.js.map +1 -0
  129. package/dist/recipes.d.ts +5 -0
  130. package/dist/recipes.d.ts.map +1 -1
  131. package/dist/recipes.js +5 -0
  132. package/dist/recipes.js.map +1 -1
  133. package/dist/storage.d.ts +3 -0
  134. package/dist/storage.d.ts.map +1 -1
  135. package/dist/storage.js +3 -0
  136. package/dist/storage.js.map +1 -1
  137. package/dist/uncertain-write.d.ts +70 -2
  138. package/dist/uncertain-write.d.ts.map +1 -1
  139. package/dist/uncertain-write.js +45 -32
  140. package/dist/uncertain-write.js.map +1 -1
  141. package/dist/write-identity.d.ts +5 -0
  142. package/dist/write-identity.d.ts.map +1 -0
  143. package/dist/write-identity.js +9 -0
  144. package/dist/write-identity.js.map +1 -0
  145. 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 = 1;
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 only format ${FILE_JOURNAL_FORMAT_VERSION}`, offset);
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(FILE_JOURNAL_FORMAT_VERSION, 4);
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 !== FILE_JOURNAL_FORMAT_VERSION)
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 !== FILE_JOURNAL_FORMAT_VERSION)
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 !== FILE_JOURNAL_FORMAT_VERSION || !Number.isSafeInteger(decoded.txn) || !Number.isSafeInteger(decoded.sequence))
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 payload = {
643
- format: FILE_JOURNAL_FORMAT_VERSION,
644
- txn: this.#txn,
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 record = { txn: this.#txn + 1, changes };
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
- if (payload.byteLength > MAXIMUM_RECORD_BYTES)
706
- throw new RangeError(`a transaction of ${payload.byteLength} bytes exceeds the log's record bound`);
707
- const bytes = frame(RECORD_MAGIC, payload);
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: [...state.intents.values()].filter((row) => row.target === guard.target),
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 [...state.intents.values()].sort(bySequence).find((row) => row.target === id && row.state !== "acknowledged");
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, [...state.intents.values()], intent?.requestId);
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.intents.get(supersede.requestId);
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, [...state.intents.values()], captured.intent?.requestId);
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.intents.get(supersede.requestId);
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 = [...state.intents.values()].filter((row) => row.target === id).sort(bySequence).map((row) => structuredClone(row));
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.intents.values()) {
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 [...this.#state.intents.values()].filter((row) => !wanted || wanted.has(row.state)).sort(bySequence).map((row) => structuredClone(row));
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.intents.get(requestId);
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.intents.get(requestId);
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;