@superbee/core 0.2.0-pre.1 → 0.2.0-pre.11

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 (219) hide show
  1. package/README.md +148 -0
  2. package/dist/artifact-contract.d.ts +90 -0
  3. package/dist/artifact-contract.d.ts.map +1 -0
  4. package/dist/artifact-contract.js +205 -0
  5. package/dist/artifact-contract.js.map +1 -0
  6. package/dist/backend.d.ts +16 -1
  7. package/dist/backend.d.ts.map +1 -1
  8. package/dist/backend.js +85 -42
  9. package/dist/backend.js.map +1 -1
  10. package/dist/bundle-ops.d.ts +1 -1
  11. package/dist/bundle-ops.d.ts.map +1 -1
  12. package/dist/bundle-ops.js +2 -2
  13. package/dist/bundle-ops.js.map +1 -1
  14. package/dist/bundle.d.ts +2 -1
  15. package/dist/bundle.d.ts.map +1 -1
  16. package/dist/bundle.js +3 -3
  17. package/dist/bundle.js.map +1 -1
  18. package/dist/document-codec.d.ts +6 -1
  19. package/dist/document-codec.d.ts.map +1 -1
  20. package/dist/document-codec.js +8 -4
  21. package/dist/document-codec.js.map +1 -1
  22. package/dist/document-field-actions.d.ts +13 -1
  23. package/dist/document-field-actions.d.ts.map +1 -1
  24. package/dist/document-field-actions.js +47 -6
  25. package/dist/document-field-actions.js.map +1 -1
  26. package/dist/document-mutation.d.ts +15 -1
  27. package/dist/document-mutation.d.ts.map +1 -1
  28. package/dist/document-mutation.js +42 -11
  29. package/dist/document-mutation.js.map +1 -1
  30. package/dist/document-write-policy.d.ts.map +1 -1
  31. package/dist/document-write-policy.js +4 -2
  32. package/dist/document-write-policy.js.map +1 -1
  33. package/dist/engine.d.ts +1 -1
  34. package/dist/engine.d.ts.map +1 -1
  35. package/dist/engine.js +4 -3
  36. package/dist/engine.js.map +1 -1
  37. package/dist/file-journaled-backend-internal.d.ts +19 -0
  38. package/dist/file-journaled-backend-internal.d.ts.map +1 -0
  39. package/dist/file-journaled-backend-internal.js +10 -0
  40. package/dist/file-journaled-backend-internal.js.map +1 -0
  41. package/dist/file-journaled-backend.d.ts +162 -0
  42. package/dist/file-journaled-backend.d.ts.map +1 -0
  43. package/dist/file-journaled-backend.js +1113 -0
  44. package/dist/file-journaled-backend.js.map +1 -0
  45. package/dist/filesystem-host.d.ts +15 -0
  46. package/dist/filesystem-host.d.ts.map +1 -0
  47. package/dist/filesystem-host.js +20 -0
  48. package/dist/filesystem-host.js.map +1 -0
  49. package/dist/filesystem-identity.d.ts +86 -4
  50. package/dist/filesystem-identity.d.ts.map +1 -1
  51. package/dist/filesystem-identity.js +338 -102
  52. package/dist/filesystem-identity.js.map +1 -1
  53. package/dist/filesystem-lock.d.ts +16 -3
  54. package/dist/filesystem-lock.d.ts.map +1 -1
  55. package/dist/filesystem-lock.js +323 -77
  56. package/dist/filesystem-lock.js.map +1 -1
  57. package/dist/filesystem-push-role.d.ts +77 -0
  58. package/dist/filesystem-push-role.d.ts.map +1 -0
  59. package/dist/filesystem-push-role.js +165 -0
  60. package/dist/filesystem-push-role.js.map +1 -0
  61. package/dist/filesystem.d.ts +17 -0
  62. package/dist/filesystem.d.ts.map +1 -0
  63. package/dist/filesystem.js +18 -0
  64. package/dist/filesystem.js.map +1 -0
  65. package/dist/frontmatter.d.ts +8 -0
  66. package/dist/frontmatter.d.ts.map +1 -1
  67. package/dist/frontmatter.js +14 -4
  68. package/dist/frontmatter.js.map +1 -1
  69. package/dist/governed-body-write.d.ts +106 -0
  70. package/dist/governed-body-write.d.ts.map +1 -0
  71. package/dist/governed-body-write.js +251 -0
  72. package/dist/governed-body-write.js.map +1 -0
  73. package/dist/heads-digest.d.ts +32 -0
  74. package/dist/heads-digest.d.ts.map +1 -0
  75. package/dist/heads-digest.js +37 -0
  76. package/dist/heads-digest.js.map +1 -0
  77. package/dist/host-policy-snapshot.d.ts +9 -0
  78. package/dist/host-policy-snapshot.d.ts.map +1 -0
  79. package/dist/host-policy-snapshot.js +33 -0
  80. package/dist/host-policy-snapshot.js.map +1 -0
  81. package/dist/host-text.d.ts +31 -0
  82. package/dist/host-text.d.ts.map +1 -0
  83. package/dist/host-text.js +77 -0
  84. package/dist/host-text.js.map +1 -0
  85. package/dist/hosted-transport/answer-rows.d.ts +216 -0
  86. package/dist/hosted-transport/answer-rows.d.ts.map +1 -0
  87. package/dist/hosted-transport/answer-rows.js +396 -0
  88. package/dist/hosted-transport/answer-rows.js.map +1 -0
  89. package/dist/hosted-transport/carrier.d.ts +142 -0
  90. package/dist/hosted-transport/carrier.d.ts.map +1 -0
  91. package/dist/hosted-transport/carrier.js +241 -0
  92. package/dist/hosted-transport/carrier.js.map +1 -0
  93. package/dist/hosted-transport/history-pages.d.ts +55 -0
  94. package/dist/hosted-transport/history-pages.d.ts.map +1 -0
  95. package/dist/hosted-transport/history-pages.js +69 -0
  96. package/dist/hosted-transport/history-pages.js.map +1 -0
  97. package/dist/hosted-transport/index.d.ts +18 -0
  98. package/dist/hosted-transport/index.d.ts.map +1 -0
  99. package/dist/hosted-transport/index.js +18 -0
  100. package/dist/hosted-transport/index.js.map +1 -0
  101. package/dist/hosted-transport/operations.d.ts +85 -0
  102. package/dist/hosted-transport/operations.d.ts.map +1 -0
  103. package/dist/hosted-transport/operations.js +128 -0
  104. package/dist/hosted-transport/operations.js.map +1 -0
  105. package/dist/hosted-transport/paged-reads.d.ts +87 -0
  106. package/dist/hosted-transport/paged-reads.d.ts.map +1 -0
  107. package/dist/hosted-transport/paged-reads.js +238 -0
  108. package/dist/hosted-transport/paged-reads.js.map +1 -0
  109. package/dist/hosted-transport/read-adapter.d.ts +250 -0
  110. package/dist/hosted-transport/read-adapter.d.ts.map +1 -0
  111. package/dist/hosted-transport/read-adapter.js +557 -0
  112. package/dist/hosted-transport/read-adapter.js.map +1 -0
  113. package/dist/hosted-transport/root-write.d.ts +114 -0
  114. package/dist/hosted-transport/root-write.d.ts.map +1 -0
  115. package/dist/hosted-transport/root-write.js +171 -0
  116. package/dist/hosted-transport/root-write.js.map +1 -0
  117. package/dist/hosted-transport/whole-document-transport.d.ts +203 -0
  118. package/dist/hosted-transport/whole-document-transport.d.ts.map +1 -0
  119. package/dist/hosted-transport/whole-document-transport.js +438 -0
  120. package/dist/hosted-transport/whole-document-transport.js.map +1 -0
  121. package/dist/index.d.ts +22 -8
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +15 -5
  124. package/dist/index.js.map +1 -1
  125. package/dist/indexeddb-backend.d.ts +38 -6
  126. package/dist/indexeddb-backend.d.ts.map +1 -1
  127. package/dist/indexeddb-backend.js +511 -46
  128. package/dist/indexeddb-backend.js.map +1 -1
  129. package/dist/journaled-backend.d.ts +296 -7
  130. package/dist/journaled-backend.d.ts.map +1 -1
  131. package/dist/journaled-backend.js +312 -0
  132. package/dist/journaled-backend.js.map +1 -1
  133. package/dist/kind-coverage.d.ts +99 -0
  134. package/dist/kind-coverage.d.ts.map +1 -0
  135. package/dist/kind-coverage.js +186 -0
  136. package/dist/kind-coverage.js.map +1 -0
  137. package/dist/kinds.d.ts +31 -1
  138. package/dist/kinds.d.ts.map +1 -1
  139. package/dist/kinds.js +83 -16
  140. package/dist/kinds.js.map +1 -1
  141. package/dist/links.d.ts +1 -1
  142. package/dist/links.d.ts.map +1 -1
  143. package/dist/links.js +5 -10
  144. package/dist/links.js.map +1 -1
  145. package/dist/managed-document-metadata.d.ts +2 -0
  146. package/dist/managed-document-metadata.d.ts.map +1 -0
  147. package/dist/managed-document-metadata.js +9 -0
  148. package/dist/managed-document-metadata.js.map +1 -0
  149. package/dist/memory-backend.d.ts +2 -2
  150. package/dist/memory-backend.d.ts.map +1 -1
  151. package/dist/memory-backend.js +32 -16
  152. package/dist/memory-backend.js.map +1 -1
  153. package/dist/paths.d.ts +6 -2
  154. package/dist/paths.d.ts.map +1 -1
  155. package/dist/paths.js +35 -33
  156. package/dist/paths.js.map +1 -1
  157. package/dist/platform.d.ts +84 -6
  158. package/dist/platform.d.ts.map +1 -1
  159. package/dist/platform.js +9 -4
  160. package/dist/platform.js.map +1 -1
  161. package/dist/portable-export.d.ts +119 -0
  162. package/dist/portable-export.d.ts.map +1 -0
  163. package/dist/portable-export.js +501 -0
  164. package/dist/portable-export.js.map +1 -0
  165. package/dist/publication-filesystem.d.ts +1 -1
  166. package/dist/publication-filesystem.d.ts.map +1 -1
  167. package/dist/publication-filesystem.js +1 -1
  168. package/dist/publication-filesystem.js.map +1 -1
  169. package/dist/query-filter.d.ts +7 -2
  170. package/dist/query-filter.d.ts.map +1 -1
  171. package/dist/query-filter.js +24 -1
  172. package/dist/query-filter.js.map +1 -1
  173. package/dist/query-order.d.ts +22 -0
  174. package/dist/query-order.d.ts.map +1 -0
  175. package/dist/query-order.js +42 -0
  176. package/dist/query-order.js.map +1 -0
  177. package/dist/remote-backend.d.ts +91 -27
  178. package/dist/remote-backend.d.ts.map +1 -1
  179. package/dist/remote-backend.js +217 -49
  180. package/dist/remote-backend.js.map +1 -1
  181. package/dist/remote-document-codec.d.ts +15 -0
  182. package/dist/remote-document-codec.d.ts.map +1 -0
  183. package/dist/remote-document-codec.js +103 -0
  184. package/dist/remote-document-codec.js.map +1 -0
  185. package/dist/remote-error.d.ts +38 -0
  186. package/dist/remote-error.d.ts.map +1 -0
  187. package/dist/remote-error.js +59 -0
  188. package/dist/remote-error.js.map +1 -0
  189. package/dist/remote-operations.d.ts +6 -3
  190. package/dist/remote-operations.d.ts.map +1 -1
  191. package/dist/remote-operations.js +20 -5
  192. package/dist/remote-operations.js.map +1 -1
  193. package/dist/remote-parsers.d.ts +86 -0
  194. package/dist/remote-parsers.d.ts.map +1 -0
  195. package/dist/remote-parsers.js +181 -0
  196. package/dist/remote-parsers.js.map +1 -0
  197. package/dist/remote.d.ts +7 -2
  198. package/dist/remote.d.ts.map +1 -1
  199. package/dist/remote.js +6 -1
  200. package/dist/remote.js.map +1 -1
  201. package/dist/site-capabilities.d.ts +339 -0
  202. package/dist/site-capabilities.d.ts.map +1 -0
  203. package/dist/site-capabilities.js +717 -0
  204. package/dist/site-capabilities.js.map +1 -0
  205. package/dist/storage.d.ts +4 -2
  206. package/dist/storage.d.ts.map +1 -1
  207. package/dist/storage.js +4 -1
  208. package/dist/storage.js.map +1 -1
  209. package/dist/types.d.ts +28 -5
  210. package/dist/types.d.ts.map +1 -1
  211. package/dist/uncertain-write.d.ts +49 -13
  212. package/dist/uncertain-write.d.ts.map +1 -1
  213. package/dist/uncertain-write.js +36 -12
  214. package/dist/uncertain-write.js.map +1 -1
  215. package/dist/versioning.d.ts +9 -4
  216. package/dist/versioning.d.ts.map +1 -1
  217. package/dist/versioning.js +9 -4
  218. package/dist/versioning.js.map +1 -1
  219. package/package.json +38 -2
@@ -32,8 +32,8 @@
32
32
  import { resolveContentType } from "./content-type.js";
33
33
  import { MalformedDocumentError, parseMarkdown, stringifyDoc } from "./frontmatter.js";
34
34
  import { mutationActorFromFrontmatter } from "./mutation-attribution.js";
35
- import { IntentHoldConflict, IntentStateConflict, } from "./journaled-backend.js";
36
- import { assertSafeBlobKey, assertSafeConceptId, assertSafeReservedDir, pathFromConceptId, toPosix } from "./paths.js";
35
+ import { assertDeletionIntent, deletionIntentRecord, JournalSnapshotConflict, assertJournalGuard, assertJournalIntentChanges, assertJournalMetaChanges, assertMetaWrite, captureJournalGuardOption, captureJournalDeleteOptions, captureJournalWriteOptions, captureIntentUpdate, captureMetaWrite, captureJournalValue, captureRetireAcknowledged, JournalGuardConflict, assertJournalResolutionOptions, assertJournalSnapshot, IntentHoldConflict, IntentStateConflict, } from "./journaled-backend.js";
36
+ import { assertSafeBlobKey, assertSafeConceptId, assertSafeReservedDir, assertSafeReservedFilename, compareStorageKeys, pathFromConceptId } from "./paths.js";
37
37
  import { parseLeadingFrontmatter } from "./portable-frontmatter.js";
38
38
  import { blobVersion, defaultActor, VersionConflict, versionOfBytes } from "./versioning.js";
39
39
  // Re-exports: the journal's record, option, and error names moved to the seam module
@@ -70,8 +70,7 @@ function notFound(id) {
70
70
  }
71
71
  /** Bundle-relative key for a reserved file (`""` = bundle root), the filesystem adapter's layout. */
72
72
  function reservedKey(dir, name) {
73
- const d = toPosix(dir).replace(/^\.?\//, "").replace(/\/$/, "");
74
- return d === "" ? name : `${d}/${name}`;
73
+ return dir === "" ? name : `${dir}/${name}`;
75
74
  }
76
75
  function firstString(...vals) {
77
76
  for (const v of vals) {
@@ -89,7 +88,7 @@ function asError(error) {
89
88
  }
90
89
  function sorted(keys, prefix) {
91
90
  const out = keys.filter((key) => typeof key === "string" && (!prefix || key.startsWith(prefix)));
92
- out.sort((a, b) => a.localeCompare(b));
91
+ out.sort(compareStorageKeys);
93
92
  return out;
94
93
  }
95
94
  /** The okf edition declared by a bundle root, from a raw `index.md`; malformed metadata is no edition. */
@@ -106,12 +105,31 @@ function editionOf(index) {
106
105
  throw error;
107
106
  }
108
107
  }
108
+ /**
109
+ * One listing row from a stored record: the frontmatter is what `readWithJournal` parses for
110
+ * the record under `edition` (the split takes the body as a substring it never parses), or
111
+ * `null` with the parser's error when the leading block does not parse.
112
+ */
113
+ function headOf(record, intents, meta, edition) {
114
+ const head = { id: record.id, version: record.version, updatedBy: record.updatedBy, updatedAt: record.updatedAt, raw: record.raw, intents, meta };
115
+ let frontmatter;
116
+ try {
117
+ frontmatter = parseMarkdown(record.raw, pathFromConceptId(record.id), { okfVersion: edition }).frontmatter;
118
+ }
119
+ catch (error) {
120
+ if (!(error instanceof MalformedDocumentError))
121
+ throw error;
122
+ return { ...head, frontmatter: null, malformed: error };
123
+ }
124
+ return { ...head, frontmatter };
125
+ }
109
126
  /**
110
127
  * A persistent browser-local OKF store implementing the {@link JournaledBackend} contract over
111
128
  * IndexedDB. One instance per bundle per JavaScript realm; several instances over the same
112
129
  * database name are peers whose conditional writes are serialized by the database.
113
130
  */
114
131
  export class IndexedDbBackend {
132
+ journalSnapshotCas = true;
115
133
  /**
116
134
  * What a resolved write promises: the IndexedDB transaction reported `complete`. Whether the
117
135
  * committed pages survive power loss is the browser's and the operating system's decision.
@@ -157,13 +175,23 @@ export class IndexedDbBackend {
157
175
  if (!factory) {
158
176
  return Promise.reject(new Error("IndexedDB is not available in this host; pass a factory or use another backend."));
159
177
  }
178
+ // `open()` can throw synchronously rather than failing through `onerror`: an opaque origin, or
179
+ // storage denied or partitioned for this context. Calling it before the promise exists keeps
180
+ // that failure out of `#opening`, so the next call starts a fresh attempt instead of replaying
181
+ // a settled rejection forever. The `onerror` path below already recovers this way.
182
+ let request;
183
+ try {
184
+ request = factory.open(this.#name, INDEXEDDB_SCHEMA_VERSION);
185
+ }
186
+ catch (error) {
187
+ return Promise.reject(asError(error));
188
+ }
160
189
  // The promise is this attempt's token: `close()` drops it, and a later call may start another
161
190
  // attempt, so each handler below adopts or clears instance state only while it is still the
162
191
  // instance's current attempt.
163
192
  const opening = new Promise((resolve, reject) => {
164
193
  let refused = null;
165
194
  const isCurrent = () => this.#opening === opening;
166
- const request = factory.open(this.#name, INDEXEDDB_SCHEMA_VERSION);
167
195
  request.onupgradeneeded = (event) => {
168
196
  const oldVersion = typeof event?.oldVersion === "number" ? event.oldVersion : 0;
169
197
  if (oldVersion !== 0) {
@@ -242,9 +270,47 @@ export class IndexedDbBackend {
242
270
  * report the transaction's AbortError and the cause would be lost.
243
271
  */
244
272
  async #transact(stores, mode, body) {
245
- const db = await this.#open();
273
+ // Starting a transaction throws synchronously on a handle closed between the open resolving
274
+ // and this line, which is what a `close()` issued in the same turn as an in-flight call
275
+ // produces. `close()` promises that the next operation reopens lazily, and the cold path gets
276
+ // that for free by joining the next open; this is the warm equivalent. Retrying is safe here
277
+ // and only here: the throw means no transaction exists, so nothing was attempted and the
278
+ // retry cannot repeat work. A second failure is the host's own answer and propagates, which
279
+ // is also what a genuinely bad store name does after one wasted reopen.
280
+ let db = await this.#open();
281
+ let transaction;
282
+ try {
283
+ transaction = db.transaction(stores, mode);
284
+ }
285
+ catch (firstFailure) {
286
+ if (this.#db === db)
287
+ this.close();
288
+ db = await this.#open();
289
+ try {
290
+ transaction = db.transaction(stores, mode);
291
+ }
292
+ catch (retryFailure) {
293
+ // The retry's failure is what the caller acts on. When the two differ the first one was
294
+ // never about a closed handle, so it is the more informative of the pair and is kept as
295
+ // the cause rather than discarded.
296
+ const failure = asError(retryFailure);
297
+ if (failure !== firstFailure && failure.cause == null) {
298
+ // Attaching a cause is a convenience and is never worth replacing the host's own error.
299
+ // This error belongs to the host, which may hand back a frozen one or one whose `cause`
300
+ // is a getter; assigning to either throws in strict mode, and that TypeError would reach
301
+ // the caller in place of the real failure.
302
+ try {
303
+ failure.cause = firstFailure;
304
+ }
305
+ catch {
306
+ // Surface the host's error exactly as it came.
307
+ }
308
+ }
309
+ throw failure;
310
+ }
311
+ }
246
312
  return new Promise((resolve, reject) => {
247
- const tx = db.transaction(stores, mode);
313
+ const tx = transaction;
248
314
  let result = null;
249
315
  let failure = null;
250
316
  tx.oncomplete = () => {
@@ -451,6 +517,7 @@ export class IndexedDbBackend {
451
517
  // ── reserved files ─────────────────────────────────────────────────────────────────────
452
518
  async readReserved(dir, name) {
453
519
  assertSafeReservedDir(dir);
520
+ assertSafeReservedFilename(name);
454
521
  const record = await this.#getOne(RESERVED, reservedKey(dir, name));
455
522
  if (!record)
456
523
  return null;
@@ -458,6 +525,7 @@ export class IndexedDbBackend {
458
525
  }
459
526
  async writeReserved(dir, name, content, options = {}) {
460
527
  assertSafeReservedDir(dir);
528
+ assertSafeReservedFilename(name);
461
529
  const path = reservedKey(dir, name);
462
530
  return this.#compareAndSwap(RESERVED, path, path, options.expectedVersion, () => ({
463
531
  record: { path, content, version: versionOfBytes(content) },
@@ -470,6 +538,54 @@ export class IndexedDbBackend {
470
538
  // pending-change record and no record exists for an edit that never committed. Settling an
471
539
  // intent is a compare-and-swap on the intent's own `state`, so a stale realm cannot settle
472
540
  // an intent another realm already settled.
541
+ /**
542
+ * Remove acknowledged intents of one target and named meta rows in one transaction, after
543
+ * comparing the target's full guard in that same transaction.
544
+ */
545
+ async retireAcknowledged(target, options) {
546
+ assertSafeConceptId(target);
547
+ const { guard: expected, requestIds, removeMeta } = captureRetireAcknowledged(target, options);
548
+ await this.#transact([DOCUMENTS, INTENTS, META], "readwrite", (tx, done, fail, guard) => {
549
+ this.#checkJournalGuard(tx, expected, () => {
550
+ for (const requestId of requestIds) {
551
+ const removal = tx.objectStore(INTENTS).delete(requestId);
552
+ removal.onerror = () => fail(requestError(removal, `IndexedDB intent retirement failed for '${requestId}'`));
553
+ }
554
+ for (const key of removeMeta ?? []) {
555
+ const removal = tx.objectStore(META).delete(key);
556
+ removal.onerror = () => fail(requestError(removal, `IndexedDB meta delete failed for '${key}'`));
557
+ }
558
+ done(undefined);
559
+ }, fail, guard);
560
+ });
561
+ }
562
+ /** Read and compare a complete guard inside its caller's still-active transaction. */
563
+ #checkJournalGuard(tx, expected, next, fail, guard) {
564
+ if (expected === undefined) {
565
+ next();
566
+ return;
567
+ }
568
+ const current = { target: expected.target, document: null, intents: [], meta: [] };
569
+ let pending = 2 + expected.meta.length;
570
+ const finish = () => { if (--pending === 0) {
571
+ assertJournalGuard(expected, current);
572
+ next();
573
+ } };
574
+ const read = (request, accept) => {
575
+ request.onerror = () => fail(requestError(request, "IndexedDB journal guard read failed"));
576
+ request.onsuccess = guard(() => { accept(request.result); finish(); });
577
+ };
578
+ read(tx.objectStore(DOCUMENTS).get(expected.target), value => {
579
+ const row = value;
580
+ current.document = row ? { version: row.version, raw: row.raw } : null;
581
+ });
582
+ read(tx.objectStore(INTENTS).getAll(), value => { current.intents = value.filter(row => row.target === expected.target); });
583
+ for (const entry of expected.meta)
584
+ read(tx.objectStore(META).get(entry.key), value => {
585
+ const row = value;
586
+ current.meta.push({ key: entry.key, expected: row === undefined ? { present: false } : { present: true, value: row.value } });
587
+ });
588
+ }
473
589
  /**
474
590
  * One transaction over documents, intents, and meta: the document compare-and-swap of
475
591
  * {@link write}, plus (optionally) deleting a superseded intent, recording a new intent for
@@ -480,14 +596,25 @@ export class IndexedDbBackend {
480
596
  * {@link IntentHoldConflict} before the document is touched.
481
597
  */
482
598
  async writeJournaled(id, doc, options = {}) {
599
+ const snapshotGuard = captureJournalGuardOption(options, id);
600
+ if (snapshotGuard !== undefined) {
601
+ options = captureJournalWriteOptions(options);
602
+ doc = captureJournalValue(doc);
603
+ }
483
604
  assertSafeConceptId(id);
605
+ assertJournalResolutionOptions(id, options);
484
606
  const raw = stringifyDoc(doc.frontmatter, doc.body ?? "");
485
607
  const version = versionOfBytes(raw);
608
+ if (snapshotGuard && (doc.id !== id || (options.intent && options.intent.target !== id)))
609
+ throw new JournalGuardConflict(id);
610
+ const suppliedMeta = typeof options.meta === "function" ? options.meta({ version, raw }) : options.meta;
611
+ assertJournalMetaChanges(snapshotGuard, suppliedMeta ?? [], options.removeMeta);
612
+ options = structuredClone({ ...options, meta: suppliedMeta, ...(snapshotGuard ? { guard: snapshotGuard } : {}) });
486
613
  const updatedBy = options.actor?.trim() || defaultActor();
487
614
  const now = new Date().toISOString();
488
615
  const expected = options.expectedVersion;
489
616
  const { intent, supersede, requireSettled } = options;
490
- const meta = typeof options.meta === "function" ? options.meta({ version, raw }) : options.meta;
617
+ const meta = options.meta;
491
618
  return this.#transact([DOCUMENTS, INTENTS, META], "readwrite", (tx, done, fail, guard) => {
492
619
  const documents = tx.objectStore(DOCUMENTS);
493
620
  const intents = tx.objectStore(INTENTS);
@@ -501,6 +628,10 @@ export class IndexedDbBackend {
501
628
  const put = metaStore.put(row);
502
629
  put.onerror = () => fail(requestError(put, `IndexedDB meta write failed for '${row.key}'`));
503
630
  }
631
+ for (const key of options.removeMeta ?? []) {
632
+ const remove = metaStore.delete(key);
633
+ remove.onerror = () => fail(requestError(remove, `IndexedDB meta delete failed for '${key}'`));
634
+ }
504
635
  };
505
636
  const recordIntent = () => {
506
637
  if (!intent) {
@@ -557,20 +688,199 @@ export class IndexedDbBackend {
557
688
  removeSuperseded();
558
689
  });
559
690
  };
560
- if (!requireSettled) {
561
- writeDocument();
562
- return;
563
- }
564
- // The journal has no index by target, so the hold check scans it; the scan runs inside
565
- // this transaction, which is what makes the answer hold for the write that follows.
566
- request(intents.getAll(), "intent scan", (rows) => {
567
- const holder = rows.find((row) => row.target === id && row.state !== "acknowledged");
568
- if (holder) {
569
- fail(new IntentHoldConflict(id, holder.requestId, holder.state));
691
+ const apply = () => {
692
+ if (options.resolveIntents) {
693
+ request(intents.getAll(), "resolution snapshot", (rows) => {
694
+ assertJournalSnapshot(id, options.resolveIntents.expected, rows, intent?.requestId);
695
+ for (const row of options.resolveIntents.expected) {
696
+ const removal = intents.delete(row.requestId);
697
+ removal.onerror = () => fail(requestError(removal, "IndexedDB resolution delete failed"));
698
+ }
699
+ writeDocument();
700
+ });
570
701
  return;
571
702
  }
572
- writeDocument();
573
- });
703
+ if (!requireSettled) {
704
+ writeDocument();
705
+ return;
706
+ }
707
+ // The journal has no target index; this scan shares the write's transaction.
708
+ request(intents.getAll(), "intent scan", (rows) => {
709
+ const holder = rows.find((row) => row.target === id && row.state !== "acknowledged");
710
+ if (holder) {
711
+ fail(new IntentHoldConflict(id, holder.requestId, holder.state));
712
+ return;
713
+ }
714
+ writeDocument();
715
+ });
716
+ };
717
+ const checkIdentities = () => {
718
+ if (!snapshotGuard) {
719
+ apply();
720
+ return;
721
+ }
722
+ const checkSuperseded = (existingIdentity) => {
723
+ if (!supersede) {
724
+ assertJournalIntentChanges(snapshotGuard, existingIdentity, undefined);
725
+ apply();
726
+ return;
727
+ }
728
+ request(intents.get(supersede.requestId), "superseded identity read", row => {
729
+ assertJournalIntentChanges(snapshotGuard, existingIdentity, row);
730
+ apply();
731
+ });
732
+ };
733
+ if (!intent) {
734
+ checkSuperseded(undefined);
735
+ return;
736
+ }
737
+ request(intents.get(intent.requestId), "new identity read", row => checkSuperseded(row));
738
+ };
739
+ this.#checkJournalGuard(tx, snapshotGuard, checkIdentities, fail, guard);
740
+ });
741
+ }
742
+ /**
743
+ * One transaction over documents, intents, and meta: the document compare-and-swap of
744
+ * {@link delete}, plus putting and removing meta rows. With `requireSettled`, an unsettled
745
+ * intent on the target refuses it before anything is touched: a rejection with
746
+ * {@link IntentHoldConflict}, or with `onHeld` the `held` outcome whose meta rows are put in
747
+ * this same transaction, so the hold the caller acts on is the one that refused it. An absent
748
+ * target resolves `absent` with the meta changes applied, as {@link delete} answers absence
749
+ * with `false` even under a compare-and-swap.
750
+ */
751
+ async deleteJournaled(id, options = {}) {
752
+ options = captureJournalDeleteOptions(id, options);
753
+ assertSafeConceptId(id);
754
+ assertJournalResolutionOptions(id, options);
755
+ assertDeletionIntent(id, options);
756
+ const expected = options.expectedVersion;
757
+ const { requireSettled, onHeld } = options;
758
+ const puts = options.meta ?? [];
759
+ const removals = options.removeMeta ?? [];
760
+ const intent = options.intent ? structuredClone(options.intent) : undefined;
761
+ const supersede = options.supersede ? { ...options.supersede } : undefined;
762
+ const now = new Date().toISOString();
763
+ return this.#transact([DOCUMENTS, INTENTS, META], "readwrite", (tx, done, fail, guard) => {
764
+ this.#checkJournalGuard(tx, options.guard, () => {
765
+ const documents = tx.objectStore(DOCUMENTS);
766
+ const intents = tx.objectStore(INTENTS);
767
+ const metaStore = tx.objectStore(META);
768
+ const request = (req, label, next) => {
769
+ req.onerror = () => fail(requestError(req, `IndexedDB ${label} failed for '${id}'`));
770
+ req.onsuccess = guard(() => next(req.result));
771
+ };
772
+ const putMeta = (rows) => {
773
+ for (const row of rows) {
774
+ const put = metaStore.put(row);
775
+ put.onerror = () => fail(requestError(put, `IndexedDB meta write failed for '${row.key}'`));
776
+ }
777
+ };
778
+ const applyMeta = () => {
779
+ putMeta(puts);
780
+ for (const key of removals) {
781
+ const removal = metaStore.delete(key);
782
+ removal.onerror = () => fail(requestError(removal, `IndexedDB meta delete failed for '${key}'`));
783
+ }
784
+ };
785
+ // The superseded intent and the recorded deletion intent, in the same transaction as the
786
+ // deletion and its meta rows, as `writeJournaled` records a write's.
787
+ const journal = (outcome) => {
788
+ const record = (sequence) => {
789
+ if (!intent) {
790
+ applyMeta();
791
+ done({ outcome });
792
+ return;
793
+ }
794
+ const row = deletionIntentRecord(intent, sequence, now);
795
+ const counter = metaStore.put({ key: INTENT_SEQUENCE_KEY, value: sequence });
796
+ counter.onerror = () => fail(requestError(counter, "IndexedDB sequence write failed"));
797
+ const put = intents.put(row);
798
+ put.onerror = () => fail(requestError(put, `IndexedDB intent write failed for '${row.requestId}'`));
799
+ applyMeta();
800
+ done({ outcome, intent: row });
801
+ };
802
+ const next = () => {
803
+ if (!intent) {
804
+ record(0);
805
+ return;
806
+ }
807
+ request(intents.get(intent.requestId), "intent read", (taken) => {
808
+ if (taken !== undefined) {
809
+ fail(new JournalSnapshotConflict(id));
810
+ return;
811
+ }
812
+ request(metaStore.get(INTENT_SEQUENCE_KEY), "sequence read", (current) => {
813
+ const previous = current?.value;
814
+ record((typeof previous === "number" ? previous : 0) + 1);
815
+ });
816
+ });
817
+ };
818
+ if (!supersede) {
819
+ next();
820
+ return;
821
+ }
822
+ request(intents.get(supersede.requestId), "intent read", (current) => {
823
+ const existing = current;
824
+ if (!existing || existing.target !== id || existing.state !== supersede.expectedState || existing.attempts !== supersede.expectedAttempts) {
825
+ fail(new IntentStateConflict(supersede.requestId, supersede.expectedState, existing?.state ?? null));
826
+ return;
827
+ }
828
+ const removal = intents.delete(supersede.requestId);
829
+ removal.onerror = () => fail(requestError(removal, `IndexedDB intent delete failed for '${supersede.requestId}'`));
830
+ next();
831
+ });
832
+ };
833
+ const deleteDocument = () => {
834
+ request(documents.get(id), "read", (current) => {
835
+ const record = current;
836
+ if (options.resolveIntents && expected !== (record?.version ?? null)) {
837
+ fail(new VersionConflict(id, expected, record?.version ?? null));
838
+ return;
839
+ }
840
+ if (!record) {
841
+ journal("absent");
842
+ return;
843
+ }
844
+ if (expected !== undefined && expected !== record.version) {
845
+ fail(new VersionConflict(id, expected, record.version));
846
+ return;
847
+ }
848
+ const removal = documents.delete(id);
849
+ removal.onerror = () => fail(requestError(removal, `IndexedDB delete failed for '${id}'`));
850
+ journal("deleted");
851
+ });
852
+ };
853
+ if (options.resolveIntents) {
854
+ request(intents.getAll(), "resolution snapshot", (rows) => {
855
+ assertJournalSnapshot(id, options.resolveIntents.expected, rows, intent?.requestId);
856
+ for (const row of options.resolveIntents.expected) {
857
+ const removal = intents.delete(row.requestId);
858
+ removal.onerror = () => fail(requestError(removal, "IndexedDB resolution delete failed"));
859
+ }
860
+ deleteDocument();
861
+ });
862
+ return;
863
+ }
864
+ if (!requireSettled) {
865
+ deleteDocument();
866
+ return;
867
+ }
868
+ // The same in-transaction hold scan as `writeJournaled`: the answer holds for the deletion
869
+ // that follows, and for the `onHeld` rows put in its place.
870
+ request(intents.getAll(), "intent scan", (rows) => {
871
+ const holder = rows.find((row) => row.target === id && row.state !== "acknowledged");
872
+ if (holder) {
873
+ if (!onHeld) {
874
+ fail(new IntentHoldConflict(id, holder.requestId, holder.state));
875
+ return;
876
+ }
877
+ putMeta(onHeld.meta);
878
+ done({ outcome: "held", requestId: holder.requestId, state: holder.state });
879
+ return;
880
+ }
881
+ deleteDocument();
882
+ });
883
+ }, fail, guard);
574
884
  });
575
885
  }
576
886
  /**
@@ -626,6 +936,132 @@ export class IndexedDbBackend {
626
936
  const { frontmatter, body } = parseMarkdown(record.raw, pathFromConceptId(id), { okfVersion: editionOf(index) });
627
937
  return { document: { doc: { id, frontmatter, body }, version: record.version }, raw: record.raw, intents: rows, meta };
628
938
  }
939
+ /**
940
+ * Every document's head with its intents and the meta rows named for it, from ONE readonly
941
+ * transaction over documents, reserved, intents, and meta: {@link readWithJournal} over the
942
+ * whole store. The root index, the journal (grouped by target, since it has no index by
943
+ * target) and the `shared` keys are read once, then the documents are walked by cursor, each
944
+ * row's own keys read with it, so a caller's `project` sees one record at a time and the
945
+ * listing holds only what it keeps plus the journal it read up front, intents' content
946
+ * included. The frontmatter is the one `readWithJournal` would parse
947
+ * for the record, under the edition the root index declared at this moment; the body is
948
+ * never parsed. Rows come back in `list` order.
949
+ */
950
+ async readHeads(options = {}) {
951
+ const keysOf = options.meta ?? (() => []);
952
+ const sharedKeys = [...new Set(options.shared ?? [])];
953
+ const project = options.project ?? ((head) => head);
954
+ const rows = await this.#transact([DOCUMENTS, RESERVED, INTENTS, META], "readonly", (tx, done, fail, guard) => {
955
+ const out = [];
956
+ let edition;
957
+ const byTarget = new Map();
958
+ const shared = new Map();
959
+ // Rows still being assembled, plus one for the cursor walk itself; the listing is done
960
+ // when the walk has ended and every row's meta reads have landed.
961
+ let outstanding = 1;
962
+ const finish = () => {
963
+ if (--outstanding === 0)
964
+ done(out);
965
+ };
966
+ // A parse or projection failure aborts the transaction through `fail`; after it no
967
+ // request is placed and no request's own error is reported, so the caller's error is
968
+ // the rejection, not the aborted transaction's refusal of the requests still in flight.
969
+ let stopped = false;
970
+ const stop = (error) => {
971
+ if (stopped)
972
+ return;
973
+ stopped = true;
974
+ fail(error);
975
+ };
976
+ const metaStore = tx.objectStore(META);
977
+ const readMeta = (key, into, then) => {
978
+ const request = metaStore.get(key);
979
+ request.onerror = () => stop(requestError(request, `IndexedDB meta read failed for '${key}'`));
980
+ request.onsuccess = guard(() => {
981
+ const row = request.result;
982
+ if (row !== undefined)
983
+ into.set(key, row.value);
984
+ then();
985
+ });
986
+ };
987
+ const walk = () => {
988
+ const cursor = tx.objectStore(DOCUMENTS).openCursor();
989
+ cursor.onerror = () => stop(requestError(cursor, "IndexedDB document scan failed"));
990
+ cursor.onsuccess = guard(() => {
991
+ const current = cursor.result;
992
+ if (!current) {
993
+ finish();
994
+ return;
995
+ }
996
+ const record = current.value;
997
+ const intents = byTarget.get(record.id) ?? [];
998
+ const keys = [...new Set(keysOf(record.id, intents))].filter((key) => !shared.has(key) && !sharedKeys.includes(key));
999
+ const meta = new Map(shared);
1000
+ let pending = keys.length;
1001
+ outstanding += 1;
1002
+ const complete = () => {
1003
+ try {
1004
+ out.push({ id: record.id, row: project(headOf(record, intents, meta, edition)) });
1005
+ }
1006
+ catch (error) {
1007
+ stop(asError(error));
1008
+ return;
1009
+ }
1010
+ finish();
1011
+ };
1012
+ if (pending === 0)
1013
+ complete();
1014
+ else {
1015
+ for (const key of keys) {
1016
+ readMeta(key, meta, () => {
1017
+ if (--pending === 0)
1018
+ complete();
1019
+ });
1020
+ }
1021
+ }
1022
+ if (!stopped)
1023
+ current.continue();
1024
+ });
1025
+ };
1026
+ const readShared = () => {
1027
+ let pending = sharedKeys.length;
1028
+ if (pending === 0) {
1029
+ walk();
1030
+ return;
1031
+ }
1032
+ for (const key of sharedKeys) {
1033
+ readMeta(key, shared, () => {
1034
+ if (--pending === 0)
1035
+ walk();
1036
+ });
1037
+ }
1038
+ };
1039
+ // The index, the journal and the shared rows land before the walk starts, so every row
1040
+ // is built under one edition and sees its whole journal, whatever order the host
1041
+ // answers requests in.
1042
+ const rootIndex = tx.objectStore(RESERVED).get(reservedKey("", "index.md"));
1043
+ rootIndex.onerror = () => stop(requestError(rootIndex, "IndexedDB root index read failed"));
1044
+ rootIndex.onsuccess = guard(() => {
1045
+ edition = editionOf(rootIndex.result);
1046
+ const scan = tx.objectStore(INTENTS).getAll();
1047
+ scan.onerror = () => stop(requestError(scan, "IndexedDB journal scan failed"));
1048
+ scan.onsuccess = guard(() => {
1049
+ for (const row of scan.result) {
1050
+ const list = byTarget.get(row.target);
1051
+ if (list)
1052
+ list.push(row);
1053
+ else
1054
+ byTarget.set(row.target, [row]);
1055
+ }
1056
+ for (const list of byTarget.values())
1057
+ list.sort((a, b) => a.sequence - b.sequence);
1058
+ readShared();
1059
+ });
1060
+ });
1061
+ });
1062
+ rows.sort((a, b) => compareStorageKeys(a.id, b.id));
1063
+ return rows.map((entry) => entry.row);
1064
+ }
629
1065
  /** Intents in local commit order, optionally restricted to one or more states. */
630
1066
  async listIntents(state) {
631
1067
  const wanted = state === undefined ? null : new Set(typeof state === "string" ? [state] : state);
@@ -646,38 +1082,67 @@ export class IndexedDbBackend {
646
1082
  * missing record rejects with {@link IntentStateConflict} and writes nothing.
647
1083
  */
648
1084
  async updateIntent(requestId, expectedState, patch, options = {}) {
1085
+ ({ patch, options } = captureIntentUpdate(patch, options));
649
1086
  const now = new Date().toISOString();
650
- return this.#transact([INTENTS, META], "readwrite", (tx, done, fail, guard) => {
651
- const intents = tx.objectStore(INTENTS);
652
- const read = intents.get(requestId);
653
- read.onerror = () => fail(requestError(read, `IndexedDB intent read failed for '${requestId}'`));
654
- read.onsuccess = guard(() => {
655
- const current = read.result;
656
- if (!current || current.state !== expectedState) {
657
- fail(new IntentStateConflict(requestId, expectedState, current?.state ?? null));
658
- return;
659
- }
660
- const next = { ...current, ...patch, requestId, sequence: current.sequence, updatedAt: now };
661
- const put = intents.put(next);
662
- put.onerror = () => fail(requestError(put, `IndexedDB intent write failed for '${requestId}'`));
663
- const metaStore = tx.objectStore(META);
664
- for (const row of options.meta ?? []) {
665
- const metaPut = metaStore.put(row);
666
- metaPut.onerror = () => fail(requestError(metaPut, `IndexedDB meta write failed for '${row.key}'`));
667
- }
668
- done(next);
669
- });
1087
+ const raw = options.document ? stringifyDoc(options.document.frontmatter, options.document.body ?? "") : undefined;
1088
+ const replacement = raw === undefined ? undefined : { id: options.document.id, raw, version: versionOfBytes(raw), updatedBy: defaultActor(), updatedAt: now };
1089
+ return this.#transact([DOCUMENTS, INTENTS, META], "readwrite", (tx, done, fail, guard) => {
1090
+ this.#checkJournalGuard(tx, options.guard, () => {
1091
+ const intents = tx.objectStore(INTENTS);
1092
+ const read = intents.get(requestId);
1093
+ read.onerror = () => fail(requestError(read, `IndexedDB intent read failed for '${requestId}'`));
1094
+ read.onsuccess = guard(() => {
1095
+ const current = read.result;
1096
+ if (!current || current.state !== expectedState) {
1097
+ fail(new IntentStateConflict(requestId, expectedState, current?.state ?? null));
1098
+ return;
1099
+ }
1100
+ if (options.guard && current.target !== options.guard.target)
1101
+ throw new JournalGuardConflict(current.target);
1102
+ const next = { ...current, ...patch, requestId, sequence: current.sequence, updatedAt: now };
1103
+ if (replacement) {
1104
+ const write = tx.objectStore(DOCUMENTS).put(replacement);
1105
+ write.onerror = () => fail(requestError(write, "IndexedDB replacement write failed"));
1106
+ }
1107
+ const put = intents.put(next);
1108
+ put.onerror = () => fail(requestError(put, `IndexedDB intent write failed for '${requestId}'`));
1109
+ const metaStore = tx.objectStore(META);
1110
+ for (const row of options.meta ?? []) {
1111
+ const metaPut = metaStore.put(row);
1112
+ metaPut.onerror = () => fail(requestError(metaPut, `IndexedDB meta write failed for '${row.key}'`));
1113
+ }
1114
+ done(next);
1115
+ });
1116
+ }, fail, guard);
670
1117
  });
671
1118
  }
672
1119
  async readMeta(key) {
673
1120
  const row = await this.#getOne(META, key);
674
1121
  return row === undefined ? undefined : row.value;
675
1122
  }
676
- async writeMeta(key, value) {
677
- await this.#transact(META, "readwrite", (tx, done, fail) => {
678
- const put = tx.objectStore(META).put({ key, value });
679
- put.onerror = () => fail(requestError(put, `IndexedDB meta write failed for '${key}'`));
680
- done(undefined);
1123
+ async writeMeta(key, value, options = {}) {
1124
+ options = captureMetaWrite(options);
1125
+ value = options.expected !== undefined || options.requireEmptyJournal ? captureJournalValue(value) : structuredClone(value);
1126
+ await this.#transact([INTENTS, META], "readwrite", (tx, done, fail, guard) => {
1127
+ const meta = tx.objectStore(META);
1128
+ const read = meta.get(key);
1129
+ read.onerror = () => fail(requestError(read, "IndexedDB admission read failed"));
1130
+ read.onsuccess = guard(() => {
1131
+ const row = read.result;
1132
+ const finish = (intents) => {
1133
+ assertMetaWrite(key, options, row === undefined ? { present: false } : { present: true, value: row.value }, intents);
1134
+ const put = meta.put({ key, value });
1135
+ put.onerror = () => fail(requestError(put, `IndexedDB meta write failed for '${key}'`));
1136
+ done(undefined);
1137
+ };
1138
+ if (!options.requireEmptyJournal) {
1139
+ finish([]);
1140
+ return;
1141
+ }
1142
+ const scan = tx.objectStore(INTENTS).getAll();
1143
+ scan.onerror = () => fail(requestError(scan, "IndexedDB admission journal scan failed"));
1144
+ scan.onsuccess = guard(() => finish(scan.result));
1145
+ });
681
1146
  });
682
1147
  }
683
1148
  // ── blobs ──────────────────────────────────────────────────────────────────────────────