@superbee/core 0.1.6 → 0.2.0-pre.10

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 (248) hide show
  1. package/README.md +127 -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 +112 -47
  9. package/dist/backend.js.map +1 -1
  10. package/dist/bundle-ops.d.ts +42 -0
  11. package/dist/bundle-ops.d.ts.map +1 -0
  12. package/dist/bundle-ops.js +88 -0
  13. package/dist/bundle-ops.js.map +1 -0
  14. package/dist/bundle.d.ts +10 -28
  15. package/dist/bundle.d.ts.map +1 -1
  16. package/dist/bundle.js +12 -69
  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 +107 -0
  23. package/dist/document-field-actions.d.ts.map +1 -0
  24. package/dist/document-field-actions.js +302 -0
  25. package/dist/document-field-actions.js.map +1 -0
  26. package/dist/document-mutation.d.ts +61 -3
  27. package/dist/document-mutation.d.ts.map +1 -1
  28. package/dist/document-mutation.js +116 -25
  29. package/dist/document-mutation.js.map +1 -1
  30. package/dist/document-write-policy.d.ts +0 -1
  31. package/dist/document-write-policy.d.ts.map +1 -1
  32. package/dist/document-write-policy.js +7 -4
  33. package/dist/document-write-policy.js.map +1 -1
  34. package/dist/engine.d.ts +3 -1
  35. package/dist/engine.d.ts.map +1 -1
  36. package/dist/engine.js +6 -4
  37. package/dist/engine.js.map +1 -1
  38. package/dist/file-journaled-backend-internal.d.ts +19 -0
  39. package/dist/file-journaled-backend-internal.d.ts.map +1 -0
  40. package/dist/file-journaled-backend-internal.js +10 -0
  41. package/dist/file-journaled-backend-internal.js.map +1 -0
  42. package/dist/file-journaled-backend.d.ts +162 -0
  43. package/dist/file-journaled-backend.d.ts.map +1 -0
  44. package/dist/file-journaled-backend.js +1113 -0
  45. package/dist/file-journaled-backend.js.map +1 -0
  46. package/dist/filesystem-host.d.ts +15 -0
  47. package/dist/filesystem-host.d.ts.map +1 -0
  48. package/dist/filesystem-host.js +20 -0
  49. package/dist/filesystem-host.js.map +1 -0
  50. package/dist/filesystem-identity.d.ts +86 -4
  51. package/dist/filesystem-identity.d.ts.map +1 -1
  52. package/dist/filesystem-identity.js +338 -102
  53. package/dist/filesystem-identity.js.map +1 -1
  54. package/dist/filesystem-lock.d.ts +16 -3
  55. package/dist/filesystem-lock.d.ts.map +1 -1
  56. package/dist/filesystem-lock.js +323 -77
  57. package/dist/filesystem-lock.js.map +1 -1
  58. package/dist/filesystem-push-role.d.ts +77 -0
  59. package/dist/filesystem-push-role.d.ts.map +1 -0
  60. package/dist/filesystem-push-role.js +165 -0
  61. package/dist/filesystem-push-role.js.map +1 -0
  62. package/dist/filesystem.d.ts +17 -0
  63. package/dist/filesystem.d.ts.map +1 -0
  64. package/dist/filesystem.js +18 -0
  65. package/dist/filesystem.js.map +1 -0
  66. package/dist/freshness.d.ts +10 -7
  67. package/dist/freshness.d.ts.map +1 -1
  68. package/dist/freshness.js +14 -11
  69. package/dist/freshness.js.map +1 -1
  70. package/dist/frontmatter-contract.d.ts +2 -2
  71. package/dist/frontmatter-contract.js +2 -2
  72. package/dist/frontmatter.d.ts +11 -1
  73. package/dist/frontmatter.d.ts.map +1 -1
  74. package/dist/frontmatter.js +20 -7
  75. package/dist/frontmatter.js.map +1 -1
  76. package/dist/governed-body-write.d.ts +106 -0
  77. package/dist/governed-body-write.d.ts.map +1 -0
  78. package/dist/governed-body-write.js +251 -0
  79. package/dist/governed-body-write.js.map +1 -0
  80. package/dist/heads-digest.d.ts +32 -0
  81. package/dist/heads-digest.d.ts.map +1 -0
  82. package/dist/heads-digest.js +37 -0
  83. package/dist/heads-digest.js.map +1 -0
  84. package/dist/host-policy-snapshot.d.ts +9 -0
  85. package/dist/host-policy-snapshot.d.ts.map +1 -0
  86. package/dist/host-policy-snapshot.js +33 -0
  87. package/dist/host-policy-snapshot.js.map +1 -0
  88. package/dist/host-text.d.ts +31 -0
  89. package/dist/host-text.d.ts.map +1 -0
  90. package/dist/host-text.js +77 -0
  91. package/dist/host-text.js.map +1 -0
  92. package/dist/hosted-transport/answer-rows.d.ts +216 -0
  93. package/dist/hosted-transport/answer-rows.d.ts.map +1 -0
  94. package/dist/hosted-transport/answer-rows.js +396 -0
  95. package/dist/hosted-transport/answer-rows.js.map +1 -0
  96. package/dist/hosted-transport/carrier.d.ts +142 -0
  97. package/dist/hosted-transport/carrier.d.ts.map +1 -0
  98. package/dist/hosted-transport/carrier.js +241 -0
  99. package/dist/hosted-transport/carrier.js.map +1 -0
  100. package/dist/hosted-transport/history-pages.d.ts +55 -0
  101. package/dist/hosted-transport/history-pages.d.ts.map +1 -0
  102. package/dist/hosted-transport/history-pages.js +69 -0
  103. package/dist/hosted-transport/history-pages.js.map +1 -0
  104. package/dist/hosted-transport/index.d.ts +18 -0
  105. package/dist/hosted-transport/index.d.ts.map +1 -0
  106. package/dist/hosted-transport/index.js +18 -0
  107. package/dist/hosted-transport/index.js.map +1 -0
  108. package/dist/hosted-transport/operations.d.ts +85 -0
  109. package/dist/hosted-transport/operations.d.ts.map +1 -0
  110. package/dist/hosted-transport/operations.js +128 -0
  111. package/dist/hosted-transport/operations.js.map +1 -0
  112. package/dist/hosted-transport/paged-reads.d.ts +87 -0
  113. package/dist/hosted-transport/paged-reads.d.ts.map +1 -0
  114. package/dist/hosted-transport/paged-reads.js +238 -0
  115. package/dist/hosted-transport/paged-reads.js.map +1 -0
  116. package/dist/hosted-transport/read-adapter.d.ts +250 -0
  117. package/dist/hosted-transport/read-adapter.d.ts.map +1 -0
  118. package/dist/hosted-transport/read-adapter.js +557 -0
  119. package/dist/hosted-transport/read-adapter.js.map +1 -0
  120. package/dist/hosted-transport/root-write.d.ts +114 -0
  121. package/dist/hosted-transport/root-write.d.ts.map +1 -0
  122. package/dist/hosted-transport/root-write.js +171 -0
  123. package/dist/hosted-transport/root-write.js.map +1 -0
  124. package/dist/hosted-transport/whole-document-transport.d.ts +174 -0
  125. package/dist/hosted-transport/whole-document-transport.d.ts.map +1 -0
  126. package/dist/hosted-transport/whole-document-transport.js +411 -0
  127. package/dist/hosted-transport/whole-document-transport.js.map +1 -0
  128. package/dist/index.d.ts +29 -8
  129. package/dist/index.d.ts.map +1 -1
  130. package/dist/index.js +26 -6
  131. package/dist/index.js.map +1 -1
  132. package/dist/indexeddb-backend.d.ts +197 -0
  133. package/dist/indexeddb-backend.d.ts.map +1 -0
  134. package/dist/indexeddb-backend.js +1183 -0
  135. package/dist/indexeddb-backend.js.map +1 -0
  136. package/dist/journaled-backend.d.ts +467 -0
  137. package/dist/journaled-backend.d.ts.map +1 -0
  138. package/dist/journaled-backend.js +389 -0
  139. package/dist/journaled-backend.js.map +1 -0
  140. package/dist/kind-coverage.d.ts +99 -0
  141. package/dist/kind-coverage.d.ts.map +1 -0
  142. package/dist/kind-coverage.js +186 -0
  143. package/dist/kind-coverage.js.map +1 -0
  144. package/dist/kind-field-mutation.d.ts +15 -0
  145. package/dist/kind-field-mutation.d.ts.map +1 -0
  146. package/dist/kind-field-mutation.js +160 -0
  147. package/dist/kind-field-mutation.js.map +1 -0
  148. package/dist/kinds.d.ts +31 -1
  149. package/dist/kinds.d.ts.map +1 -1
  150. package/dist/kinds.js +86 -17
  151. package/dist/kinds.js.map +1 -1
  152. package/dist/links.d.ts +1 -1
  153. package/dist/links.d.ts.map +1 -1
  154. package/dist/links.js +5 -10
  155. package/dist/links.js.map +1 -1
  156. package/dist/managed-document-metadata.d.ts +2 -0
  157. package/dist/managed-document-metadata.d.ts.map +1 -0
  158. package/dist/managed-document-metadata.js +9 -0
  159. package/dist/managed-document-metadata.js.map +1 -0
  160. package/dist/meaningful-change-time.d.ts +6 -6
  161. package/dist/meaningful-change-time.d.ts.map +1 -1
  162. package/dist/meaningful-change-time.js +12 -13
  163. package/dist/meaningful-change-time.js.map +1 -1
  164. package/dist/memory-backend.d.ts +2 -2
  165. package/dist/memory-backend.d.ts.map +1 -1
  166. package/dist/memory-backend.js +32 -16
  167. package/dist/memory-backend.js.map +1 -1
  168. package/dist/mutation.d.ts.map +1 -1
  169. package/dist/mutation.js +2 -2
  170. package/dist/mutation.js.map +1 -1
  171. package/dist/page.d.ts.map +1 -1
  172. package/dist/page.js +2 -1
  173. package/dist/page.js.map +1 -1
  174. package/dist/paths.d.ts +6 -2
  175. package/dist/paths.d.ts.map +1 -1
  176. package/dist/paths.js +35 -33
  177. package/dist/paths.js.map +1 -1
  178. package/dist/platform.d.ts +239 -0
  179. package/dist/platform.d.ts.map +1 -0
  180. package/dist/platform.js +82 -0
  181. package/dist/platform.js.map +1 -0
  182. package/dist/portable-export.d.ts +119 -0
  183. package/dist/portable-export.d.ts.map +1 -0
  184. package/dist/portable-export.js +501 -0
  185. package/dist/portable-export.js.map +1 -0
  186. package/dist/publication-filesystem.d.ts +1 -1
  187. package/dist/publication-filesystem.d.ts.map +1 -1
  188. package/dist/publication-filesystem.js +1 -1
  189. package/dist/publication-filesystem.js.map +1 -1
  190. package/dist/query-filter.d.ts +7 -2
  191. package/dist/query-filter.d.ts.map +1 -1
  192. package/dist/query-filter.js +24 -1
  193. package/dist/query-filter.js.map +1 -1
  194. package/dist/query-order.d.ts +22 -0
  195. package/dist/query-order.d.ts.map +1 -0
  196. package/dist/query-order.js +42 -0
  197. package/dist/query-order.js.map +1 -0
  198. package/dist/recipes.d.ts +3 -1
  199. package/dist/recipes.d.ts.map +1 -1
  200. package/dist/recipes.js +4 -4
  201. package/dist/recipes.js.map +1 -1
  202. package/dist/remote-backend.d.ts +114 -22
  203. package/dist/remote-backend.d.ts.map +1 -1
  204. package/dist/remote-backend.js +285 -44
  205. package/dist/remote-backend.js.map +1 -1
  206. package/dist/remote-document-codec.d.ts +15 -0
  207. package/dist/remote-document-codec.d.ts.map +1 -0
  208. package/dist/remote-document-codec.js +103 -0
  209. package/dist/remote-document-codec.js.map +1 -0
  210. package/dist/remote-error.d.ts +38 -0
  211. package/dist/remote-error.d.ts.map +1 -0
  212. package/dist/remote-error.js +59 -0
  213. package/dist/remote-error.js.map +1 -0
  214. package/dist/remote-operations.d.ts +63 -0
  215. package/dist/remote-operations.d.ts.map +1 -0
  216. package/dist/remote-operations.js +125 -0
  217. package/dist/remote-operations.js.map +1 -0
  218. package/dist/remote-parsers.d.ts +86 -0
  219. package/dist/remote-parsers.d.ts.map +1 -0
  220. package/dist/remote-parsers.js +181 -0
  221. package/dist/remote-parsers.js.map +1 -0
  222. package/dist/remote.d.ts +7 -2
  223. package/dist/remote.d.ts.map +1 -1
  224. package/dist/remote.js +6 -1
  225. package/dist/remote.js.map +1 -1
  226. package/dist/sha256.d.ts +21 -0
  227. package/dist/sha256.d.ts.map +1 -0
  228. package/dist/sha256.js +114 -0
  229. package/dist/sha256.js.map +1 -0
  230. package/dist/storage.d.ts +8 -2
  231. package/dist/storage.d.ts.map +1 -1
  232. package/dist/storage.js +11 -2
  233. package/dist/storage.js.map +1 -1
  234. package/dist/types.d.ts +40 -5
  235. package/dist/types.d.ts.map +1 -1
  236. package/dist/uncertain-write.d.ts +178 -0
  237. package/dist/uncertain-write.d.ts.map +1 -0
  238. package/dist/uncertain-write.js +198 -0
  239. package/dist/uncertain-write.js.map +1 -0
  240. package/dist/version-transport.d.ts +7 -0
  241. package/dist/version-transport.d.ts.map +1 -1
  242. package/dist/version-transport.js +9 -0
  243. package/dist/version-transport.js.map +1 -1
  244. package/dist/versioning.d.ts +18 -7
  245. package/dist/versioning.d.ts.map +1 -1
  246. package/dist/versioning.js +23 -14
  247. package/dist/versioning.js.map +1 -1
  248. package/package.json +75 -4
@@ -0,0 +1,1183 @@
1
+ /**
2
+ * IndexedDB-backed {@link JournaledBackend}: the browser-local working copy's first persistent
3
+ * store candidate, sitting behind the same core seam as {@link FilesystemBackend} and
4
+ * {@link MemoryBackend} and proven by the same adapter contract kit, plus the journal seam the
5
+ * sync runtime needs (`journaled-backend.ts`), proven by that kit's journal rows.
6
+ *
7
+ * Storage shape mirrors the filesystem adapter, not the memory adapter: a concept document is
8
+ * stored as its exact OKF-serialized string and parsed on every read, so the read body carries
9
+ * the serializer's normalization (the trailing newline) and the version token is
10
+ * `versionOfBytes(raw)` over the same bytes the disk copy would hold. Reserved files are stored
11
+ * as raw strings and blobs as raw bytes, both content-addressed with the shared primitives, so a
12
+ * document, reserved file, or blob carries one token on disk, in memory, and in the browser.
13
+ *
14
+ * Compare-and-swap is one IndexedDB readwrite transaction per mutation: the current record is
15
+ * read, compared, and replaced (or the transaction aborted with a {@link VersionConflict}) before
16
+ * the transaction commits. IndexedDB serializes readwrite transactions that overlap on a store,
17
+ * so two adapter instances (two tabs, two workers) over the same database cannot both win a
18
+ * conditional write. A write resolves only after the transaction's `complete` event, which is
19
+ * the point a local-save indicator may follow. That is a commit promise, not a power-loss
20
+ * promise: the browser decides when committed pages reach stable storage.
21
+ *
22
+ * Two further stores serve the working copy's synchronization: `intents` journals every local
23
+ * write as a pending-change intent committed in the same transaction as the document record
24
+ * ({@link IndexedDbBackend.writeJournaled}), and `meta` holds opaque key-value rows such as the
25
+ * bootstrap marker and per-document shared bases. The adapter owns the transaction mechanics;
26
+ * what the rows mean belongs to the sync component that writes them, and what the verbs
27
+ * promise is stated once on the seam.
28
+ *
29
+ * This module imports no Node builtin so it bundles for the browser; the IndexedDB factory is
30
+ * injectable so a Node test can supply an in-memory implementation without touching globals.
31
+ */
32
+ import { resolveContentType } from "./content-type.js";
33
+ import { MalformedDocumentError, parseMarkdown, stringifyDoc } from "./frontmatter.js";
34
+ import { mutationActorFromFrontmatter } from "./mutation-attribution.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
+ import { parseLeadingFrontmatter } from "./portable-frontmatter.js";
38
+ import { blobVersion, defaultActor, VersionConflict, versionOfBytes } from "./versioning.js";
39
+ // Re-exports: the journal's record, option, and error names moved to the seam module
40
+ // (`journaled-backend.ts`); they stay reachable here so existing importers keep working.
41
+ export { IntentHoldConflict, IntentStateConflict } from "./journaled-backend.js";
42
+ /** The database does not carry this adapter's schema and the adapter refuses to touch it. */
43
+ export class IndexedDbSchemaError extends Error {
44
+ name = "IndexedDbSchemaError";
45
+ }
46
+ // ── schema ─────────────────────────────────────────────────────────────────────────────────
47
+ /** Bumping this requires a migration in `upgrade`; the handler refuses any other older layout. */
48
+ export const INDEXEDDB_SCHEMA_VERSION = 1;
49
+ const DOCUMENTS = "documents";
50
+ const RESERVED = "reserved";
51
+ const BLOBS = "blobs";
52
+ const INTENTS = "intents";
53
+ const META = "meta";
54
+ const STORES = [DOCUMENTS, RESERVED, BLOBS, INTENTS, META];
55
+ const KEY_PATHS = {
56
+ [DOCUMENTS]: "id",
57
+ [RESERVED]: "path",
58
+ [BLOBS]: "key",
59
+ [INTENTS]: "requestId",
60
+ [META]: "key",
61
+ };
62
+ /** The meta key holding the intent journal's monotonic sequence counter. */
63
+ const INTENT_SEQUENCE_KEY = "intents:sequence";
64
+ // ── helpers ────────────────────────────────────────────────────────────────────────────────
65
+ /** An ENOENT-shaped rejection so missing-document handling matches the other adapters. */
66
+ function notFound(id) {
67
+ const err = new Error(`no concept document '${id}'`);
68
+ err.code = "ENOENT";
69
+ return err;
70
+ }
71
+ /** Bundle-relative key for a reserved file (`""` = bundle root), the filesystem adapter's layout. */
72
+ function reservedKey(dir, name) {
73
+ return dir === "" ? name : `${dir}/${name}`;
74
+ }
75
+ function firstString(...vals) {
76
+ for (const v of vals) {
77
+ if (typeof v === "string" && v.trim() !== "")
78
+ return v;
79
+ }
80
+ return undefined;
81
+ }
82
+ function requestError(request, fallback) {
83
+ const error = request.error;
84
+ return error instanceof Error ? error : new Error(`${fallback}: ${String(error)}`);
85
+ }
86
+ function asError(error) {
87
+ return error instanceof Error ? error : new Error(String(error));
88
+ }
89
+ function sorted(keys, prefix) {
90
+ const out = keys.filter((key) => typeof key === "string" && (!prefix || key.startsWith(prefix)));
91
+ out.sort(compareStorageKeys);
92
+ return out;
93
+ }
94
+ /** The okf edition declared by a bundle root, from a raw `index.md`; malformed metadata is no edition. */
95
+ function editionOf(index) {
96
+ if (!index)
97
+ return undefined;
98
+ try {
99
+ const value = parseLeadingFrontmatter(index.content, "index.md").okf_version;
100
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
101
+ }
102
+ catch (error) {
103
+ if (error instanceof MalformedDocumentError)
104
+ return undefined;
105
+ throw error;
106
+ }
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
+ }
126
+ /**
127
+ * A persistent browser-local OKF store implementing the {@link JournaledBackend} contract over
128
+ * IndexedDB. One instance per bundle per JavaScript realm; several instances over the same
129
+ * database name are peers whose conditional writes are serialized by the database.
130
+ */
131
+ export class IndexedDbBackend {
132
+ journalSnapshotCas = true;
133
+ /**
134
+ * What a resolved write promises: the IndexedDB transaction reported `complete`. Whether the
135
+ * committed pages survive power loss is the browser's and the operating system's decision.
136
+ */
137
+ durability = "transaction-committed";
138
+ #name;
139
+ #factory;
140
+ #db = null;
141
+ #opening = null;
142
+ constructor(options) {
143
+ if (typeof options.databaseName !== "string" || options.databaseName.trim() === "") {
144
+ throw new Error("IndexedDbBackend requires a non-empty databaseName.");
145
+ }
146
+ this.#name = options.databaseName;
147
+ this.#factory = options.indexedDB;
148
+ }
149
+ /** Whether an IndexedDB factory is reachable, so a caller can fall back to another store. */
150
+ static available(indexedDB) {
151
+ const factory = indexedDB ?? globalThis.indexedDB;
152
+ return typeof factory === "object" && factory !== null && typeof factory.open === "function";
153
+ }
154
+ /** The database name this adapter opens. */
155
+ get databaseName() {
156
+ return this.#name;
157
+ }
158
+ capabilities() {
159
+ return { history: false, enforced_cas: true, blobs: true, projections: false, backlinks: false };
160
+ }
161
+ /** Release the database handle. The next operation reopens it lazily. */
162
+ close() {
163
+ const db = this.#db;
164
+ this.#db = null;
165
+ this.#opening = null;
166
+ db?.close();
167
+ }
168
+ // ── open and transact ────────────────────────────────────────────────────────────────
169
+ #open() {
170
+ if (this.#db)
171
+ return Promise.resolve(this.#db);
172
+ if (this.#opening)
173
+ return this.#opening;
174
+ const factory = this.#factory ?? globalThis.indexedDB;
175
+ if (!factory) {
176
+ return Promise.reject(new Error("IndexedDB is not available in this host; pass a factory or use another backend."));
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
+ }
189
+ // The promise is this attempt's token: `close()` drops it, and a later call may start another
190
+ // attempt, so each handler below adopts or clears instance state only while it is still the
191
+ // instance's current attempt.
192
+ const opening = new Promise((resolve, reject) => {
193
+ let refused = null;
194
+ const isCurrent = () => this.#opening === opening;
195
+ request.onupgradeneeded = (event) => {
196
+ const oldVersion = typeof event?.oldVersion === "number" ? event.oldVersion : 0;
197
+ if (oldVersion !== 0) {
198
+ // No migration exists for an older layout. Abort the upgrade so the existing data is
199
+ // left exactly as it was rather than reshaped by a guess.
200
+ refused = new IndexedDbSchemaError(`IndexedDB database '${this.#name}' is at schema version ${oldVersion}; this adapter migrates only from an empty database to version ${INDEXEDDB_SCHEMA_VERSION}.`);
201
+ request.transaction?.abort();
202
+ return;
203
+ }
204
+ const db = request.result;
205
+ for (const store of STORES) {
206
+ db.createObjectStore(store, { keyPath: KEY_PATHS[store] });
207
+ }
208
+ };
209
+ request.onblocked = () => {
210
+ // Another connection holds an older version open; the request stays pending until it
211
+ // closes. Nothing to do here but let the caller's await wait.
212
+ };
213
+ request.onerror = () => {
214
+ if (isCurrent())
215
+ this.#opening = null;
216
+ if (refused) {
217
+ reject(refused);
218
+ return;
219
+ }
220
+ const error = request.error;
221
+ const name = error instanceof Error ? error.name : "";
222
+ if (name === "VersionError") {
223
+ reject(new IndexedDbSchemaError(`IndexedDB database '${this.#name}' is at a newer schema version than ${INDEXEDDB_SCHEMA_VERSION}; refusing to open it.`));
224
+ return;
225
+ }
226
+ reject(requestError(request, `IndexedDB open failed for '${this.#name}'`));
227
+ };
228
+ request.onsuccess = () => {
229
+ const db = request.result;
230
+ const missing = STORES.filter((store) => !db.objectStoreNames.contains(store));
231
+ if (missing.length > 0 || db.objectStoreNames.length !== STORES.length) {
232
+ db.close();
233
+ if (isCurrent())
234
+ this.#opening = null;
235
+ reject(new IndexedDbSchemaError(`IndexedDB database '${this.#name}' does not carry this adapter's object stores (missing: ${missing.join(", ") || "none"}; expected exactly ${STORES.join(", ")}).`));
236
+ return;
237
+ }
238
+ if (!isCurrent()) {
239
+ // `close()` ran while this open was in flight. Adopting the handle now would leak it (a
240
+ // later attempt overwrites it and nothing closes it, so upgrades and deletes block
241
+ // forever). Close it and give the waiting caller whatever the instance opens next, the
242
+ // same lazy reopen a call issued after `close()` gets.
243
+ db.close();
244
+ resolve(this.#open());
245
+ return;
246
+ }
247
+ // Another realm is upgrading this database; drop the handle so it is not blocked. This
248
+ // closes the handle the event fired on, whether or not it is still the instance's current.
249
+ db.onversionchange = () => {
250
+ if (this.#db === db)
251
+ this.close();
252
+ else
253
+ db.close();
254
+ };
255
+ this.#db = db;
256
+ this.#opening = null;
257
+ resolve(db);
258
+ };
259
+ });
260
+ this.#opening = opening;
261
+ return opening;
262
+ }
263
+ /**
264
+ * Run `body` inside one transaction and resolve with its recorded result only after the
265
+ * transaction reports `complete`. `fail` records an error and aborts; the rejection carries that
266
+ * error. An abort from anywhere else (the host, a failed request) rejects with the transaction's
267
+ * own error. Nothing outside IndexedDB is awaited inside `body`, which is what keeps a
268
+ * check-then-write section inside the transaction's lifetime. `guard` wraps a request handler
269
+ * so an exception thrown inside it also routes through `fail`; unguarded, the host would only
270
+ * report the transaction's AbortError and the cause would be lost.
271
+ */
272
+ async #transact(stores, mode, body) {
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
+ }
312
+ return new Promise((resolve, reject) => {
313
+ const tx = transaction;
314
+ let result = null;
315
+ let failure = null;
316
+ tx.oncomplete = () => {
317
+ if (result)
318
+ resolve(result.value);
319
+ else
320
+ reject(new Error("IndexedDB transaction completed without a result."));
321
+ };
322
+ const rejectWith = () => {
323
+ if (failure)
324
+ reject(failure);
325
+ else {
326
+ // A host-initiated abort carries no error object; the transaction simply never committed.
327
+ const error = tx.error;
328
+ reject(error instanceof Error ? error : new Error("IndexedDB transaction aborted before commit."));
329
+ }
330
+ };
331
+ tx.onabort = rejectWith;
332
+ tx.onerror = () => {
333
+ // An erroring request aborts the transaction; `onabort` reports it once.
334
+ };
335
+ const done = (value) => {
336
+ result = { value };
337
+ };
338
+ const fail = (error) => {
339
+ failure = error;
340
+ try {
341
+ tx.abort();
342
+ }
343
+ catch {
344
+ // Already aborting or finished; the recorded failure still wins in `onabort`.
345
+ }
346
+ };
347
+ const guard = (handler) => () => {
348
+ try {
349
+ handler();
350
+ }
351
+ catch (error) {
352
+ fail(asError(error));
353
+ }
354
+ };
355
+ guard(() => body(tx, done, fail, guard))();
356
+ });
357
+ }
358
+ /** One conditional read-compare-put over a keyed store, inside one readwrite transaction. */
359
+ #compareAndSwap(store, key, conflictId, expected, decide) {
360
+ return this.#transact(store, "readwrite", (tx, done, fail, guard) => {
361
+ const objects = tx.objectStore(store);
362
+ const read = objects.get(key);
363
+ read.onerror = () => fail(requestError(read, `IndexedDB read failed for '${key}'`));
364
+ read.onsuccess = guard(() => {
365
+ const current = read.result;
366
+ const currentVersion = current?.version ?? null;
367
+ if (expected !== undefined && expected !== currentVersion) {
368
+ fail(new VersionConflict(conflictId, expected, currentVersion));
369
+ return;
370
+ }
371
+ const decision = decide(current);
372
+ if ("record" in decision) {
373
+ const put = objects.put(decision.record);
374
+ put.onerror = () => fail(requestError(put, `IndexedDB write failed for '${key}'`));
375
+ done(decision.record.version);
376
+ }
377
+ else {
378
+ done(decision.version);
379
+ }
380
+ });
381
+ });
382
+ }
383
+ /** One conditional read-compare-delete over a keyed store, inside one readwrite transaction. */
384
+ #compareAndDelete(store, key, conflictId, expected) {
385
+ return this.#transact(store, "readwrite", (tx, done, fail, guard) => {
386
+ const objects = tx.objectStore(store);
387
+ const read = objects.get(key);
388
+ read.onerror = () => fail(requestError(read, `IndexedDB read failed for '${key}'`));
389
+ read.onsuccess = guard(() => {
390
+ const current = read.result;
391
+ if (!current) {
392
+ done(false); // absent: idempotent no-op, even under CAS
393
+ return;
394
+ }
395
+ if (expected !== undefined && expected !== current.version) {
396
+ fail(new VersionConflict(conflictId, expected, current.version));
397
+ return;
398
+ }
399
+ const removal = objects.delete(key);
400
+ removal.onerror = () => fail(requestError(removal, `IndexedDB delete failed for '${key}'`));
401
+ done(true);
402
+ });
403
+ });
404
+ }
405
+ #getOne(store, key) {
406
+ return this.#transact(store, "readonly", (tx, done) => {
407
+ const request = tx.objectStore(store).get(key);
408
+ request.onsuccess = () => done(request.result);
409
+ });
410
+ }
411
+ #keys(store, prefix) {
412
+ return this.#transact(store, "readonly", (tx, done) => {
413
+ const request = tx.objectStore(store).getAllKeys();
414
+ request.onsuccess = () => done(sorted(request.result, prefix));
415
+ });
416
+ }
417
+ #has(store, key) {
418
+ return this.#transact(store, "readonly", (tx, done) => {
419
+ const request = tx.objectStore(store).count(key);
420
+ request.onsuccess = () => done(request.result > 0);
421
+ });
422
+ }
423
+ // ── documents ─────────────────────────────────────────────────────────────────────────
424
+ async read(id) {
425
+ const [result] = await this.readMany([id]);
426
+ return result;
427
+ }
428
+ async readMany(ids) {
429
+ for (const id of ids)
430
+ assertSafeConceptId(id);
431
+ if (ids.length === 0)
432
+ return [];
433
+ // One readonly transaction fetches the documents and the root index.md together, so the
434
+ // edition used to normalize frontmatter is the one that held when the documents were read.
435
+ const { records, index } = await this.#transact([DOCUMENTS, RESERVED], "readonly", (tx, done) => {
436
+ const documents = tx.objectStore(DOCUMENTS);
437
+ const records = new Array(ids.length);
438
+ let index;
439
+ let pending = ids.length + 1;
440
+ const finish = () => {
441
+ if (--pending === 0)
442
+ done({ records, index });
443
+ };
444
+ ids.forEach((id, position) => {
445
+ const request = documents.get(id);
446
+ request.onsuccess = () => {
447
+ records[position] = request.result;
448
+ finish();
449
+ };
450
+ });
451
+ const rootIndex = tx.objectStore(RESERVED).get(reservedKey("", "index.md"));
452
+ rootIndex.onsuccess = () => {
453
+ index = rootIndex.result;
454
+ finish();
455
+ };
456
+ });
457
+ let edition;
458
+ let editionResolved = false;
459
+ const out = [];
460
+ ids.forEach((id, position) => {
461
+ const record = records[position];
462
+ if (!record)
463
+ throw notFound(id);
464
+ if (!editionResolved) {
465
+ edition = editionOf(index);
466
+ editionResolved = true;
467
+ }
468
+ // Parse the stored serialization exactly as the filesystem adapter parses on-disk bytes,
469
+ // so the read body is the normalized form and carries the same token.
470
+ const { frontmatter, body } = parseMarkdown(record.raw, pathFromConceptId(id), { okfVersion: edition });
471
+ out.push({ doc: { id, frontmatter, body }, version: record.version });
472
+ });
473
+ return out;
474
+ }
475
+ async write(id, doc, options = {}) {
476
+ assertSafeConceptId(id);
477
+ const raw = stringifyDoc(doc.frontmatter, doc.body ?? "");
478
+ const version = versionOfBytes(raw);
479
+ // `options.agent` is accepted for contract parity and not persisted; the adapter keeps no
480
+ // revision chain to attach it to.
481
+ const updatedBy = options.actor?.trim() || defaultActor();
482
+ return this.#compareAndSwap(DOCUMENTS, id, id, options.expectedVersion, () => ({
483
+ record: { id, raw, version, updatedBy, updatedAt: new Date().toISOString() },
484
+ }));
485
+ }
486
+ async delete(id, options = {}) {
487
+ assertSafeConceptId(id);
488
+ return this.#compareAndDelete(DOCUMENTS, id, id, options.expectedVersion);
489
+ }
490
+ async exists(id) {
491
+ assertSafeConceptId(id);
492
+ return this.#has(DOCUMENTS, id);
493
+ }
494
+ async list(prefix) {
495
+ return this.#keys(DOCUMENTS, prefix);
496
+ }
497
+ async versions(id) {
498
+ assertSafeConceptId(id);
499
+ const record = await this.#getOne(DOCUMENTS, id);
500
+ if (!record)
501
+ return [];
502
+ let frontmatter = {};
503
+ try {
504
+ frontmatter = parseMarkdown(record.raw, pathFromConceptId(id)).frontmatter;
505
+ }
506
+ catch (error) {
507
+ if (!(error instanceof MalformedDocumentError))
508
+ throw error;
509
+ }
510
+ // Portable attribution in the document wins, as on disk; the recorded writer is the fallback
511
+ // the filesystem cannot offer, and it defaults to the same local identity.
512
+ const actor = mutationActorFromFrontmatter(frontmatter) ?? record.updatedBy;
513
+ const timestamp = firstString(frontmatter.timestamp) ?? record.updatedAt;
514
+ // Single current revision: this store retains no prior versions.
515
+ return [{ version: record.version, actor, timestamp }];
516
+ }
517
+ // ── reserved files ─────────────────────────────────────────────────────────────────────
518
+ async readReserved(dir, name) {
519
+ assertSafeReservedDir(dir);
520
+ assertSafeReservedFilename(name);
521
+ const record = await this.#getOne(RESERVED, reservedKey(dir, name));
522
+ if (!record)
523
+ return null;
524
+ return { content: record.content, version: record.version };
525
+ }
526
+ async writeReserved(dir, name, content, options = {}) {
527
+ assertSafeReservedDir(dir);
528
+ assertSafeReservedFilename(name);
529
+ const path = reservedKey(dir, name);
530
+ return this.#compareAndSwap(RESERVED, path, path, options.expectedVersion, () => ({
531
+ record: { path, content, version: versionOfBytes(content) },
532
+ }));
533
+ }
534
+ // ── intent journal and meta ────────────────────────────────────────────────────────────
535
+ //
536
+ // The journal is what makes a local write durable as an intent: the document record and the
537
+ // intent describing it commit in one transaction, so no committed edit exists without its
538
+ // pending-change record and no record exists for an edit that never committed. Settling an
539
+ // intent is a compare-and-swap on the intent's own `state`, so a stale realm cannot settle
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
+ }
589
+ /**
590
+ * One transaction over documents, intents, and meta: the document compare-and-swap of
591
+ * {@link write}, plus (optionally) deleting a superseded intent, recording a new intent for
592
+ * the written bytes, and putting meta rows. Every step is conditional on every other: a
593
+ * failed document CAS records no intent, and a superseded intent whose state moved fails the
594
+ * whole write with {@link IntentStateConflict} so the caller composes against fresh state.
595
+ * With `requireSettled`, an unsettled intent on the target fails it with
596
+ * {@link IntentHoldConflict} before the document is touched.
597
+ */
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
+ }
604
+ assertSafeConceptId(id);
605
+ assertJournalResolutionOptions(id, options);
606
+ const raw = stringifyDoc(doc.frontmatter, doc.body ?? "");
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 } : {}) });
613
+ const updatedBy = options.actor?.trim() || defaultActor();
614
+ const now = new Date().toISOString();
615
+ const expected = options.expectedVersion;
616
+ const { intent, supersede, requireSettled } = options;
617
+ const meta = options.meta;
618
+ return this.#transact([DOCUMENTS, INTENTS, META], "readwrite", (tx, done, fail, guard) => {
619
+ const documents = tx.objectStore(DOCUMENTS);
620
+ const intents = tx.objectStore(INTENTS);
621
+ const metaStore = tx.objectStore(META);
622
+ const request = (req, label, next) => {
623
+ req.onerror = () => fail(requestError(req, `IndexedDB ${label} failed for '${id}'`));
624
+ req.onsuccess = guard(() => next(req.result));
625
+ };
626
+ const putMeta = () => {
627
+ for (const row of meta ?? []) {
628
+ const put = metaStore.put(row);
629
+ put.onerror = () => fail(requestError(put, `IndexedDB meta write failed for '${row.key}'`));
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
+ }
635
+ };
636
+ const recordIntent = () => {
637
+ if (!intent) {
638
+ putMeta();
639
+ done({ version, raw, intent: null });
640
+ return;
641
+ }
642
+ request(metaStore.get(INTENT_SEQUENCE_KEY), "sequence read", (current) => {
643
+ const previous = current?.value;
644
+ const sequence = (typeof previous === "number" ? previous : 0) + 1;
645
+ const counter = metaStore.put({ key: INTENT_SEQUENCE_KEY, value: sequence });
646
+ counter.onerror = () => fail(requestError(counter, "IndexedDB sequence write failed"));
647
+ const record = {
648
+ ...intent,
649
+ local: version,
650
+ content: raw,
651
+ sequence,
652
+ attempts: 0,
653
+ state: "pending",
654
+ updatedAt: now,
655
+ };
656
+ const put = intents.put(record);
657
+ put.onerror = () => fail(requestError(put, `IndexedDB intent write failed for '${record.requestId}'`));
658
+ putMeta();
659
+ done({ version, raw, intent: record });
660
+ });
661
+ };
662
+ const removeSuperseded = () => {
663
+ if (!supersede) {
664
+ recordIntent();
665
+ return;
666
+ }
667
+ request(intents.get(supersede.requestId), "intent read", (current) => {
668
+ const existing = current;
669
+ if (!existing || existing.state !== supersede.expectedState || existing.attempts !== supersede.expectedAttempts) {
670
+ fail(new IntentStateConflict(supersede.requestId, supersede.expectedState, existing?.state ?? null));
671
+ return;
672
+ }
673
+ const removal = intents.delete(supersede.requestId);
674
+ removal.onerror = () => fail(requestError(removal, `IndexedDB intent delete failed for '${supersede.requestId}'`));
675
+ recordIntent();
676
+ });
677
+ };
678
+ const writeDocument = () => {
679
+ request(documents.get(id), "read", (current) => {
680
+ const currentVersion = current?.version ?? null;
681
+ if (expected !== undefined && expected !== currentVersion) {
682
+ fail(new VersionConflict(id, expected, currentVersion));
683
+ return;
684
+ }
685
+ const record = { id, raw, version, updatedBy, updatedAt: now };
686
+ const put = documents.put(record);
687
+ put.onerror = () => fail(requestError(put, `IndexedDB write failed for '${id}'`));
688
+ removeSuperseded();
689
+ });
690
+ };
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
+ });
701
+ return;
702
+ }
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);
884
+ });
885
+ }
886
+ /**
887
+ * The document, every intent targeting it, and the named meta rows, from ONE readonly
888
+ * transaction over documents, reserved, intents, and meta. A caller deriving where a document
889
+ * stands (its bytes against its shared base and its journal) reads them here so a write in
890
+ * another realm between separate reads can never show it a document of one moment beside a
891
+ * journal of another; with this snapshot a mismatch is a genuine working-copy defect.
892
+ */
893
+ async readWithJournal(id, options = {}) {
894
+ assertSafeConceptId(id);
895
+ const keys = options.meta ?? [];
896
+ const { record, index, rows, meta } = await this.#transact([DOCUMENTS, RESERVED, INTENTS, META], "readonly", (tx, done) => {
897
+ let record;
898
+ let index;
899
+ let rows = [];
900
+ const meta = new Map();
901
+ let pending = 3 + keys.length;
902
+ const finish = () => {
903
+ if (--pending === 0)
904
+ done({ record, index, rows, meta });
905
+ };
906
+ const document = tx.objectStore(DOCUMENTS).get(id);
907
+ document.onsuccess = () => {
908
+ record = document.result;
909
+ finish();
910
+ };
911
+ const rootIndex = tx.objectStore(RESERVED).get(reservedKey("", "index.md"));
912
+ rootIndex.onsuccess = () => {
913
+ index = rootIndex.result;
914
+ finish();
915
+ };
916
+ // The journal has no index by target; the scan runs inside this transaction.
917
+ const scan = tx.objectStore(INTENTS).getAll();
918
+ scan.onsuccess = () => {
919
+ rows = scan.result.filter((row) => row.target === id);
920
+ finish();
921
+ };
922
+ const metaStore = tx.objectStore(META);
923
+ for (const key of keys) {
924
+ const request = metaStore.get(key);
925
+ request.onsuccess = () => {
926
+ const row = request.result;
927
+ if (row !== undefined)
928
+ meta.set(key, row.value);
929
+ finish();
930
+ };
931
+ }
932
+ });
933
+ rows.sort((a, b) => a.sequence - b.sequence);
934
+ if (!record)
935
+ return { document: null, raw: null, intents: rows, meta };
936
+ const { frontmatter, body } = parseMarkdown(record.raw, pathFromConceptId(id), { okfVersion: editionOf(index) });
937
+ return { document: { doc: { id, frontmatter, body }, version: record.version }, raw: record.raw, intents: rows, meta };
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
+ }
1065
+ /** Intents in local commit order, optionally restricted to one or more states. */
1066
+ async listIntents(state) {
1067
+ const wanted = state === undefined ? null : new Set(typeof state === "string" ? [state] : state);
1068
+ const rows = await this.#transact(INTENTS, "readonly", (tx, done) => {
1069
+ const request = tx.objectStore(INTENTS).getAll();
1070
+ request.onsuccess = () => done(request.result);
1071
+ });
1072
+ const out = wanted ? rows.filter((row) => wanted.has(row.state)) : rows;
1073
+ out.sort((a, b) => a.sequence - b.sequence);
1074
+ return out;
1075
+ }
1076
+ async readIntent(requestId) {
1077
+ return this.#getOne(INTENTS, requestId);
1078
+ }
1079
+ /**
1080
+ * Compare-and-swap on an intent's `state`: the patch applies only while the record is in
1081
+ * `expectedState`, together with any meta rows, in one transaction. A different state or a
1082
+ * missing record rejects with {@link IntentStateConflict} and writes nothing.
1083
+ */
1084
+ async updateIntent(requestId, expectedState, patch, options = {}) {
1085
+ ({ patch, options } = captureIntentUpdate(patch, options));
1086
+ const now = new Date().toISOString();
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);
1117
+ });
1118
+ }
1119
+ async readMeta(key) {
1120
+ const row = await this.#getOne(META, key);
1121
+ return row === undefined ? undefined : row.value;
1122
+ }
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
+ });
1146
+ });
1147
+ }
1148
+ // ── blobs ──────────────────────────────────────────────────────────────────────────────
1149
+ async readBlob(key) {
1150
+ assertSafeBlobKey(key);
1151
+ const record = await this.#getOne(BLOBS, key);
1152
+ if (!record)
1153
+ return null;
1154
+ // A fresh copy: the caller must not alias the stored bytes.
1155
+ return { bytes: new Uint8Array(record.bytes), contentType: record.contentType, version: record.version };
1156
+ }
1157
+ async writeBlob(key, bytes, contentType, options = {}) {
1158
+ assertSafeBlobKey(key);
1159
+ const version = blobVersion(bytes);
1160
+ // This adapter keeps state, so an explicit content-type override persists, as in MemoryBackend.
1161
+ const resolvedType = resolveContentType(key, contentType);
1162
+ // `new Uint8Array(bytes)` copies element by element into a plain array, whatever TypedArray
1163
+ // subclass the caller handed over, so a later caller-side mutation cannot reach the store.
1164
+ const stored = new Uint8Array(bytes);
1165
+ return this.#compareAndSwap(BLOBS, key, key, options.expectedVersion, (current) => {
1166
+ if (current && current.version === version && current.contentType === resolvedType)
1167
+ return { version };
1168
+ return { record: { key, bytes: stored, contentType: resolvedType, version } };
1169
+ });
1170
+ }
1171
+ async deleteBlob(key, options = {}) {
1172
+ assertSafeBlobKey(key);
1173
+ return this.#compareAndDelete(BLOBS, key, key, options.expectedVersion);
1174
+ }
1175
+ async existsBlob(key) {
1176
+ assertSafeBlobKey(key);
1177
+ return this.#has(BLOBS, key);
1178
+ }
1179
+ async listBlobs(prefix) {
1180
+ return this.#keys(BLOBS, prefix);
1181
+ }
1182
+ }
1183
+ //# sourceMappingURL=indexeddb-backend.js.map