@superbee/core 0.1.6 → 0.2.0-pre.1

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 (105) hide show
  1. package/dist/backend.d.ts.map +1 -1
  2. package/dist/backend.js +29 -7
  3. package/dist/backend.js.map +1 -1
  4. package/dist/bundle-ops.d.ts +42 -0
  5. package/dist/bundle-ops.d.ts.map +1 -0
  6. package/dist/bundle-ops.js +88 -0
  7. package/dist/bundle-ops.js.map +1 -0
  8. package/dist/bundle.d.ts +8 -27
  9. package/dist/bundle.d.ts.map +1 -1
  10. package/dist/bundle.js +9 -66
  11. package/dist/bundle.js.map +1 -1
  12. package/dist/document-field-actions.d.ts +95 -0
  13. package/dist/document-field-actions.d.ts.map +1 -0
  14. package/dist/document-field-actions.js +261 -0
  15. package/dist/document-field-actions.js.map +1 -0
  16. package/dist/document-mutation.d.ts +46 -2
  17. package/dist/document-mutation.d.ts.map +1 -1
  18. package/dist/document-mutation.js +79 -19
  19. package/dist/document-mutation.js.map +1 -1
  20. package/dist/document-write-policy.d.ts +0 -1
  21. package/dist/document-write-policy.d.ts.map +1 -1
  22. package/dist/document-write-policy.js +3 -2
  23. package/dist/document-write-policy.js.map +1 -1
  24. package/dist/engine.d.ts +2 -0
  25. package/dist/engine.d.ts.map +1 -1
  26. package/dist/engine.js +2 -1
  27. package/dist/engine.js.map +1 -1
  28. package/dist/freshness.d.ts +10 -7
  29. package/dist/freshness.d.ts.map +1 -1
  30. package/dist/freshness.js +14 -11
  31. package/dist/freshness.js.map +1 -1
  32. package/dist/frontmatter-contract.d.ts +2 -2
  33. package/dist/frontmatter-contract.js +2 -2
  34. package/dist/frontmatter.d.ts +3 -1
  35. package/dist/frontmatter.d.ts.map +1 -1
  36. package/dist/frontmatter.js +7 -4
  37. package/dist/frontmatter.js.map +1 -1
  38. package/dist/index.d.ts +10 -3
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +12 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/indexeddb-backend.d.ts +165 -0
  43. package/dist/indexeddb-backend.d.ts.map +1 -0
  44. package/dist/indexeddb-backend.js +718 -0
  45. package/dist/indexeddb-backend.js.map +1 -0
  46. package/dist/journaled-backend.d.ts +178 -0
  47. package/dist/journaled-backend.d.ts.map +1 -0
  48. package/dist/journaled-backend.js +77 -0
  49. package/dist/journaled-backend.js.map +1 -0
  50. package/dist/kind-field-mutation.d.ts +15 -0
  51. package/dist/kind-field-mutation.d.ts.map +1 -0
  52. package/dist/kind-field-mutation.js +160 -0
  53. package/dist/kind-field-mutation.js.map +1 -0
  54. package/dist/kinds.d.ts.map +1 -1
  55. package/dist/kinds.js +3 -1
  56. package/dist/kinds.js.map +1 -1
  57. package/dist/meaningful-change-time.d.ts +6 -6
  58. package/dist/meaningful-change-time.d.ts.map +1 -1
  59. package/dist/meaningful-change-time.js +12 -13
  60. package/dist/meaningful-change-time.js.map +1 -1
  61. package/dist/mutation.d.ts.map +1 -1
  62. package/dist/mutation.js +2 -2
  63. package/dist/mutation.js.map +1 -1
  64. package/dist/page.d.ts.map +1 -1
  65. package/dist/page.js +2 -1
  66. package/dist/page.js.map +1 -1
  67. package/dist/platform.d.ts +161 -0
  68. package/dist/platform.d.ts.map +1 -0
  69. package/dist/platform.js +77 -0
  70. package/dist/platform.js.map +1 -0
  71. package/dist/recipes.d.ts +3 -1
  72. package/dist/recipes.d.ts.map +1 -1
  73. package/dist/recipes.js +4 -4
  74. package/dist/recipes.js.map +1 -1
  75. package/dist/remote-backend.d.ts +28 -0
  76. package/dist/remote-backend.d.ts.map +1 -1
  77. package/dist/remote-backend.js +75 -2
  78. package/dist/remote-backend.js.map +1 -1
  79. package/dist/remote-operations.d.ts +60 -0
  80. package/dist/remote-operations.d.ts.map +1 -0
  81. package/dist/remote-operations.js +110 -0
  82. package/dist/remote-operations.js.map +1 -0
  83. package/dist/sha256.d.ts +21 -0
  84. package/dist/sha256.d.ts.map +1 -0
  85. package/dist/sha256.js +114 -0
  86. package/dist/sha256.js.map +1 -0
  87. package/dist/storage.d.ts +5 -1
  88. package/dist/storage.d.ts.map +1 -1
  89. package/dist/storage.js +7 -1
  90. package/dist/storage.js.map +1 -1
  91. package/dist/types.d.ts +12 -0
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/uncertain-write.d.ts +142 -0
  94. package/dist/uncertain-write.d.ts.map +1 -0
  95. package/dist/uncertain-write.js +174 -0
  96. package/dist/uncertain-write.js.map +1 -0
  97. package/dist/version-transport.d.ts +7 -0
  98. package/dist/version-transport.d.ts.map +1 -1
  99. package/dist/version-transport.js +9 -0
  100. package/dist/version-transport.js.map +1 -1
  101. package/dist/versioning.d.ts +9 -3
  102. package/dist/versioning.d.ts.map +1 -1
  103. package/dist/versioning.js +14 -10
  104. package/dist/versioning.js.map +1 -1
  105. package/package.json +42 -3
@@ -0,0 +1,718 @@
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 { IntentHoldConflict, IntentStateConflict, } from "./journaled-backend.js";
36
+ import { assertSafeBlobKey, assertSafeConceptId, assertSafeReservedDir, pathFromConceptId, toPosix } 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
+ const d = toPosix(dir).replace(/^\.?\//, "").replace(/\/$/, "");
74
+ return d === "" ? name : `${d}/${name}`;
75
+ }
76
+ function firstString(...vals) {
77
+ for (const v of vals) {
78
+ if (typeof v === "string" && v.trim() !== "")
79
+ return v;
80
+ }
81
+ return undefined;
82
+ }
83
+ function requestError(request, fallback) {
84
+ const error = request.error;
85
+ return error instanceof Error ? error : new Error(`${fallback}: ${String(error)}`);
86
+ }
87
+ function asError(error) {
88
+ return error instanceof Error ? error : new Error(String(error));
89
+ }
90
+ function sorted(keys, prefix) {
91
+ const out = keys.filter((key) => typeof key === "string" && (!prefix || key.startsWith(prefix)));
92
+ out.sort((a, b) => a.localeCompare(b));
93
+ return out;
94
+ }
95
+ /** The okf edition declared by a bundle root, from a raw `index.md`; malformed metadata is no edition. */
96
+ function editionOf(index) {
97
+ if (!index)
98
+ return undefined;
99
+ try {
100
+ const value = parseLeadingFrontmatter(index.content, "index.md").okf_version;
101
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
102
+ }
103
+ catch (error) {
104
+ if (error instanceof MalformedDocumentError)
105
+ return undefined;
106
+ throw error;
107
+ }
108
+ }
109
+ /**
110
+ * A persistent browser-local OKF store implementing the {@link JournaledBackend} contract over
111
+ * IndexedDB. One instance per bundle per JavaScript realm; several instances over the same
112
+ * database name are peers whose conditional writes are serialized by the database.
113
+ */
114
+ export class IndexedDbBackend {
115
+ /**
116
+ * What a resolved write promises: the IndexedDB transaction reported `complete`. Whether the
117
+ * committed pages survive power loss is the browser's and the operating system's decision.
118
+ */
119
+ durability = "transaction-committed";
120
+ #name;
121
+ #factory;
122
+ #db = null;
123
+ #opening = null;
124
+ constructor(options) {
125
+ if (typeof options.databaseName !== "string" || options.databaseName.trim() === "") {
126
+ throw new Error("IndexedDbBackend requires a non-empty databaseName.");
127
+ }
128
+ this.#name = options.databaseName;
129
+ this.#factory = options.indexedDB;
130
+ }
131
+ /** Whether an IndexedDB factory is reachable, so a caller can fall back to another store. */
132
+ static available(indexedDB) {
133
+ const factory = indexedDB ?? globalThis.indexedDB;
134
+ return typeof factory === "object" && factory !== null && typeof factory.open === "function";
135
+ }
136
+ /** The database name this adapter opens. */
137
+ get databaseName() {
138
+ return this.#name;
139
+ }
140
+ capabilities() {
141
+ return { history: false, enforced_cas: true, blobs: true, projections: false, backlinks: false };
142
+ }
143
+ /** Release the database handle. The next operation reopens it lazily. */
144
+ close() {
145
+ const db = this.#db;
146
+ this.#db = null;
147
+ this.#opening = null;
148
+ db?.close();
149
+ }
150
+ // ── open and transact ────────────────────────────────────────────────────────────────
151
+ #open() {
152
+ if (this.#db)
153
+ return Promise.resolve(this.#db);
154
+ if (this.#opening)
155
+ return this.#opening;
156
+ const factory = this.#factory ?? globalThis.indexedDB;
157
+ if (!factory) {
158
+ return Promise.reject(new Error("IndexedDB is not available in this host; pass a factory or use another backend."));
159
+ }
160
+ // The promise is this attempt's token: `close()` drops it, and a later call may start another
161
+ // attempt, so each handler below adopts or clears instance state only while it is still the
162
+ // instance's current attempt.
163
+ const opening = new Promise((resolve, reject) => {
164
+ let refused = null;
165
+ const isCurrent = () => this.#opening === opening;
166
+ const request = factory.open(this.#name, INDEXEDDB_SCHEMA_VERSION);
167
+ request.onupgradeneeded = (event) => {
168
+ const oldVersion = typeof event?.oldVersion === "number" ? event.oldVersion : 0;
169
+ if (oldVersion !== 0) {
170
+ // No migration exists for an older layout. Abort the upgrade so the existing data is
171
+ // left exactly as it was rather than reshaped by a guess.
172
+ 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}.`);
173
+ request.transaction?.abort();
174
+ return;
175
+ }
176
+ const db = request.result;
177
+ for (const store of STORES) {
178
+ db.createObjectStore(store, { keyPath: KEY_PATHS[store] });
179
+ }
180
+ };
181
+ request.onblocked = () => {
182
+ // Another connection holds an older version open; the request stays pending until it
183
+ // closes. Nothing to do here but let the caller's await wait.
184
+ };
185
+ request.onerror = () => {
186
+ if (isCurrent())
187
+ this.#opening = null;
188
+ if (refused) {
189
+ reject(refused);
190
+ return;
191
+ }
192
+ const error = request.error;
193
+ const name = error instanceof Error ? error.name : "";
194
+ if (name === "VersionError") {
195
+ reject(new IndexedDbSchemaError(`IndexedDB database '${this.#name}' is at a newer schema version than ${INDEXEDDB_SCHEMA_VERSION}; refusing to open it.`));
196
+ return;
197
+ }
198
+ reject(requestError(request, `IndexedDB open failed for '${this.#name}'`));
199
+ };
200
+ request.onsuccess = () => {
201
+ const db = request.result;
202
+ const missing = STORES.filter((store) => !db.objectStoreNames.contains(store));
203
+ if (missing.length > 0 || db.objectStoreNames.length !== STORES.length) {
204
+ db.close();
205
+ if (isCurrent())
206
+ this.#opening = null;
207
+ reject(new IndexedDbSchemaError(`IndexedDB database '${this.#name}' does not carry this adapter's object stores (missing: ${missing.join(", ") || "none"}; expected exactly ${STORES.join(", ")}).`));
208
+ return;
209
+ }
210
+ if (!isCurrent()) {
211
+ // `close()` ran while this open was in flight. Adopting the handle now would leak it (a
212
+ // later attempt overwrites it and nothing closes it, so upgrades and deletes block
213
+ // forever). Close it and give the waiting caller whatever the instance opens next, the
214
+ // same lazy reopen a call issued after `close()` gets.
215
+ db.close();
216
+ resolve(this.#open());
217
+ return;
218
+ }
219
+ // Another realm is upgrading this database; drop the handle so it is not blocked. This
220
+ // closes the handle the event fired on, whether or not it is still the instance's current.
221
+ db.onversionchange = () => {
222
+ if (this.#db === db)
223
+ this.close();
224
+ else
225
+ db.close();
226
+ };
227
+ this.#db = db;
228
+ this.#opening = null;
229
+ resolve(db);
230
+ };
231
+ });
232
+ this.#opening = opening;
233
+ return opening;
234
+ }
235
+ /**
236
+ * Run `body` inside one transaction and resolve with its recorded result only after the
237
+ * transaction reports `complete`. `fail` records an error and aborts; the rejection carries that
238
+ * error. An abort from anywhere else (the host, a failed request) rejects with the transaction's
239
+ * own error. Nothing outside IndexedDB is awaited inside `body`, which is what keeps a
240
+ * check-then-write section inside the transaction's lifetime. `guard` wraps a request handler
241
+ * so an exception thrown inside it also routes through `fail`; unguarded, the host would only
242
+ * report the transaction's AbortError and the cause would be lost.
243
+ */
244
+ async #transact(stores, mode, body) {
245
+ const db = await this.#open();
246
+ return new Promise((resolve, reject) => {
247
+ const tx = db.transaction(stores, mode);
248
+ let result = null;
249
+ let failure = null;
250
+ tx.oncomplete = () => {
251
+ if (result)
252
+ resolve(result.value);
253
+ else
254
+ reject(new Error("IndexedDB transaction completed without a result."));
255
+ };
256
+ const rejectWith = () => {
257
+ if (failure)
258
+ reject(failure);
259
+ else {
260
+ // A host-initiated abort carries no error object; the transaction simply never committed.
261
+ const error = tx.error;
262
+ reject(error instanceof Error ? error : new Error("IndexedDB transaction aborted before commit."));
263
+ }
264
+ };
265
+ tx.onabort = rejectWith;
266
+ tx.onerror = () => {
267
+ // An erroring request aborts the transaction; `onabort` reports it once.
268
+ };
269
+ const done = (value) => {
270
+ result = { value };
271
+ };
272
+ const fail = (error) => {
273
+ failure = error;
274
+ try {
275
+ tx.abort();
276
+ }
277
+ catch {
278
+ // Already aborting or finished; the recorded failure still wins in `onabort`.
279
+ }
280
+ };
281
+ const guard = (handler) => () => {
282
+ try {
283
+ handler();
284
+ }
285
+ catch (error) {
286
+ fail(asError(error));
287
+ }
288
+ };
289
+ guard(() => body(tx, done, fail, guard))();
290
+ });
291
+ }
292
+ /** One conditional read-compare-put over a keyed store, inside one readwrite transaction. */
293
+ #compareAndSwap(store, key, conflictId, expected, decide) {
294
+ return this.#transact(store, "readwrite", (tx, done, fail, guard) => {
295
+ const objects = tx.objectStore(store);
296
+ const read = objects.get(key);
297
+ read.onerror = () => fail(requestError(read, `IndexedDB read failed for '${key}'`));
298
+ read.onsuccess = guard(() => {
299
+ const current = read.result;
300
+ const currentVersion = current?.version ?? null;
301
+ if (expected !== undefined && expected !== currentVersion) {
302
+ fail(new VersionConflict(conflictId, expected, currentVersion));
303
+ return;
304
+ }
305
+ const decision = decide(current);
306
+ if ("record" in decision) {
307
+ const put = objects.put(decision.record);
308
+ put.onerror = () => fail(requestError(put, `IndexedDB write failed for '${key}'`));
309
+ done(decision.record.version);
310
+ }
311
+ else {
312
+ done(decision.version);
313
+ }
314
+ });
315
+ });
316
+ }
317
+ /** One conditional read-compare-delete over a keyed store, inside one readwrite transaction. */
318
+ #compareAndDelete(store, key, conflictId, expected) {
319
+ return this.#transact(store, "readwrite", (tx, done, fail, guard) => {
320
+ const objects = tx.objectStore(store);
321
+ const read = objects.get(key);
322
+ read.onerror = () => fail(requestError(read, `IndexedDB read failed for '${key}'`));
323
+ read.onsuccess = guard(() => {
324
+ const current = read.result;
325
+ if (!current) {
326
+ done(false); // absent: idempotent no-op, even under CAS
327
+ return;
328
+ }
329
+ if (expected !== undefined && expected !== current.version) {
330
+ fail(new VersionConflict(conflictId, expected, current.version));
331
+ return;
332
+ }
333
+ const removal = objects.delete(key);
334
+ removal.onerror = () => fail(requestError(removal, `IndexedDB delete failed for '${key}'`));
335
+ done(true);
336
+ });
337
+ });
338
+ }
339
+ #getOne(store, key) {
340
+ return this.#transact(store, "readonly", (tx, done) => {
341
+ const request = tx.objectStore(store).get(key);
342
+ request.onsuccess = () => done(request.result);
343
+ });
344
+ }
345
+ #keys(store, prefix) {
346
+ return this.#transact(store, "readonly", (tx, done) => {
347
+ const request = tx.objectStore(store).getAllKeys();
348
+ request.onsuccess = () => done(sorted(request.result, prefix));
349
+ });
350
+ }
351
+ #has(store, key) {
352
+ return this.#transact(store, "readonly", (tx, done) => {
353
+ const request = tx.objectStore(store).count(key);
354
+ request.onsuccess = () => done(request.result > 0);
355
+ });
356
+ }
357
+ // ── documents ─────────────────────────────────────────────────────────────────────────
358
+ async read(id) {
359
+ const [result] = await this.readMany([id]);
360
+ return result;
361
+ }
362
+ async readMany(ids) {
363
+ for (const id of ids)
364
+ assertSafeConceptId(id);
365
+ if (ids.length === 0)
366
+ return [];
367
+ // One readonly transaction fetches the documents and the root index.md together, so the
368
+ // edition used to normalize frontmatter is the one that held when the documents were read.
369
+ const { records, index } = await this.#transact([DOCUMENTS, RESERVED], "readonly", (tx, done) => {
370
+ const documents = tx.objectStore(DOCUMENTS);
371
+ const records = new Array(ids.length);
372
+ let index;
373
+ let pending = ids.length + 1;
374
+ const finish = () => {
375
+ if (--pending === 0)
376
+ done({ records, index });
377
+ };
378
+ ids.forEach((id, position) => {
379
+ const request = documents.get(id);
380
+ request.onsuccess = () => {
381
+ records[position] = request.result;
382
+ finish();
383
+ };
384
+ });
385
+ const rootIndex = tx.objectStore(RESERVED).get(reservedKey("", "index.md"));
386
+ rootIndex.onsuccess = () => {
387
+ index = rootIndex.result;
388
+ finish();
389
+ };
390
+ });
391
+ let edition;
392
+ let editionResolved = false;
393
+ const out = [];
394
+ ids.forEach((id, position) => {
395
+ const record = records[position];
396
+ if (!record)
397
+ throw notFound(id);
398
+ if (!editionResolved) {
399
+ edition = editionOf(index);
400
+ editionResolved = true;
401
+ }
402
+ // Parse the stored serialization exactly as the filesystem adapter parses on-disk bytes,
403
+ // so the read body is the normalized form and carries the same token.
404
+ const { frontmatter, body } = parseMarkdown(record.raw, pathFromConceptId(id), { okfVersion: edition });
405
+ out.push({ doc: { id, frontmatter, body }, version: record.version });
406
+ });
407
+ return out;
408
+ }
409
+ async write(id, doc, options = {}) {
410
+ assertSafeConceptId(id);
411
+ const raw = stringifyDoc(doc.frontmatter, doc.body ?? "");
412
+ const version = versionOfBytes(raw);
413
+ // `options.agent` is accepted for contract parity and not persisted; the adapter keeps no
414
+ // revision chain to attach it to.
415
+ const updatedBy = options.actor?.trim() || defaultActor();
416
+ return this.#compareAndSwap(DOCUMENTS, id, id, options.expectedVersion, () => ({
417
+ record: { id, raw, version, updatedBy, updatedAt: new Date().toISOString() },
418
+ }));
419
+ }
420
+ async delete(id, options = {}) {
421
+ assertSafeConceptId(id);
422
+ return this.#compareAndDelete(DOCUMENTS, id, id, options.expectedVersion);
423
+ }
424
+ async exists(id) {
425
+ assertSafeConceptId(id);
426
+ return this.#has(DOCUMENTS, id);
427
+ }
428
+ async list(prefix) {
429
+ return this.#keys(DOCUMENTS, prefix);
430
+ }
431
+ async versions(id) {
432
+ assertSafeConceptId(id);
433
+ const record = await this.#getOne(DOCUMENTS, id);
434
+ if (!record)
435
+ return [];
436
+ let frontmatter = {};
437
+ try {
438
+ frontmatter = parseMarkdown(record.raw, pathFromConceptId(id)).frontmatter;
439
+ }
440
+ catch (error) {
441
+ if (!(error instanceof MalformedDocumentError))
442
+ throw error;
443
+ }
444
+ // Portable attribution in the document wins, as on disk; the recorded writer is the fallback
445
+ // the filesystem cannot offer, and it defaults to the same local identity.
446
+ const actor = mutationActorFromFrontmatter(frontmatter) ?? record.updatedBy;
447
+ const timestamp = firstString(frontmatter.timestamp) ?? record.updatedAt;
448
+ // Single current revision: this store retains no prior versions.
449
+ return [{ version: record.version, actor, timestamp }];
450
+ }
451
+ // ── reserved files ─────────────────────────────────────────────────────────────────────
452
+ async readReserved(dir, name) {
453
+ assertSafeReservedDir(dir);
454
+ const record = await this.#getOne(RESERVED, reservedKey(dir, name));
455
+ if (!record)
456
+ return null;
457
+ return { content: record.content, version: record.version };
458
+ }
459
+ async writeReserved(dir, name, content, options = {}) {
460
+ assertSafeReservedDir(dir);
461
+ const path = reservedKey(dir, name);
462
+ return this.#compareAndSwap(RESERVED, path, path, options.expectedVersion, () => ({
463
+ record: { path, content, version: versionOfBytes(content) },
464
+ }));
465
+ }
466
+ // ── intent journal and meta ────────────────────────────────────────────────────────────
467
+ //
468
+ // The journal is what makes a local write durable as an intent: the document record and the
469
+ // intent describing it commit in one transaction, so no committed edit exists without its
470
+ // pending-change record and no record exists for an edit that never committed. Settling an
471
+ // intent is a compare-and-swap on the intent's own `state`, so a stale realm cannot settle
472
+ // an intent another realm already settled.
473
+ /**
474
+ * One transaction over documents, intents, and meta: the document compare-and-swap of
475
+ * {@link write}, plus (optionally) deleting a superseded intent, recording a new intent for
476
+ * the written bytes, and putting meta rows. Every step is conditional on every other: a
477
+ * failed document CAS records no intent, and a superseded intent whose state moved fails the
478
+ * whole write with {@link IntentStateConflict} so the caller composes against fresh state.
479
+ * With `requireSettled`, an unsettled intent on the target fails it with
480
+ * {@link IntentHoldConflict} before the document is touched.
481
+ */
482
+ async writeJournaled(id, doc, options = {}) {
483
+ assertSafeConceptId(id);
484
+ const raw = stringifyDoc(doc.frontmatter, doc.body ?? "");
485
+ const version = versionOfBytes(raw);
486
+ const updatedBy = options.actor?.trim() || defaultActor();
487
+ const now = new Date().toISOString();
488
+ const expected = options.expectedVersion;
489
+ const { intent, supersede, requireSettled } = options;
490
+ const meta = typeof options.meta === "function" ? options.meta({ version, raw }) : options.meta;
491
+ return this.#transact([DOCUMENTS, INTENTS, META], "readwrite", (tx, done, fail, guard) => {
492
+ const documents = tx.objectStore(DOCUMENTS);
493
+ const intents = tx.objectStore(INTENTS);
494
+ const metaStore = tx.objectStore(META);
495
+ const request = (req, label, next) => {
496
+ req.onerror = () => fail(requestError(req, `IndexedDB ${label} failed for '${id}'`));
497
+ req.onsuccess = guard(() => next(req.result));
498
+ };
499
+ const putMeta = () => {
500
+ for (const row of meta ?? []) {
501
+ const put = metaStore.put(row);
502
+ put.onerror = () => fail(requestError(put, `IndexedDB meta write failed for '${row.key}'`));
503
+ }
504
+ };
505
+ const recordIntent = () => {
506
+ if (!intent) {
507
+ putMeta();
508
+ done({ version, raw, intent: null });
509
+ return;
510
+ }
511
+ request(metaStore.get(INTENT_SEQUENCE_KEY), "sequence read", (current) => {
512
+ const previous = current?.value;
513
+ const sequence = (typeof previous === "number" ? previous : 0) + 1;
514
+ const counter = metaStore.put({ key: INTENT_SEQUENCE_KEY, value: sequence });
515
+ counter.onerror = () => fail(requestError(counter, "IndexedDB sequence write failed"));
516
+ const record = {
517
+ ...intent,
518
+ local: version,
519
+ content: raw,
520
+ sequence,
521
+ attempts: 0,
522
+ state: "pending",
523
+ updatedAt: now,
524
+ };
525
+ const put = intents.put(record);
526
+ put.onerror = () => fail(requestError(put, `IndexedDB intent write failed for '${record.requestId}'`));
527
+ putMeta();
528
+ done({ version, raw, intent: record });
529
+ });
530
+ };
531
+ const removeSuperseded = () => {
532
+ if (!supersede) {
533
+ recordIntent();
534
+ return;
535
+ }
536
+ request(intents.get(supersede.requestId), "intent read", (current) => {
537
+ const existing = current;
538
+ if (!existing || existing.state !== supersede.expectedState || existing.attempts !== supersede.expectedAttempts) {
539
+ fail(new IntentStateConflict(supersede.requestId, supersede.expectedState, existing?.state ?? null));
540
+ return;
541
+ }
542
+ const removal = intents.delete(supersede.requestId);
543
+ removal.onerror = () => fail(requestError(removal, `IndexedDB intent delete failed for '${supersede.requestId}'`));
544
+ recordIntent();
545
+ });
546
+ };
547
+ const writeDocument = () => {
548
+ request(documents.get(id), "read", (current) => {
549
+ const currentVersion = current?.version ?? null;
550
+ if (expected !== undefined && expected !== currentVersion) {
551
+ fail(new VersionConflict(id, expected, currentVersion));
552
+ return;
553
+ }
554
+ const record = { id, raw, version, updatedBy, updatedAt: now };
555
+ const put = documents.put(record);
556
+ put.onerror = () => fail(requestError(put, `IndexedDB write failed for '${id}'`));
557
+ removeSuperseded();
558
+ });
559
+ };
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));
570
+ return;
571
+ }
572
+ writeDocument();
573
+ });
574
+ });
575
+ }
576
+ /**
577
+ * The document, every intent targeting it, and the named meta rows, from ONE readonly
578
+ * transaction over documents, reserved, intents, and meta. A caller deriving where a document
579
+ * stands (its bytes against its shared base and its journal) reads them here so a write in
580
+ * another realm between separate reads can never show it a document of one moment beside a
581
+ * journal of another; with this snapshot a mismatch is a genuine working-copy defect.
582
+ */
583
+ async readWithJournal(id, options = {}) {
584
+ assertSafeConceptId(id);
585
+ const keys = options.meta ?? [];
586
+ const { record, index, rows, meta } = await this.#transact([DOCUMENTS, RESERVED, INTENTS, META], "readonly", (tx, done) => {
587
+ let record;
588
+ let index;
589
+ let rows = [];
590
+ const meta = new Map();
591
+ let pending = 3 + keys.length;
592
+ const finish = () => {
593
+ if (--pending === 0)
594
+ done({ record, index, rows, meta });
595
+ };
596
+ const document = tx.objectStore(DOCUMENTS).get(id);
597
+ document.onsuccess = () => {
598
+ record = document.result;
599
+ finish();
600
+ };
601
+ const rootIndex = tx.objectStore(RESERVED).get(reservedKey("", "index.md"));
602
+ rootIndex.onsuccess = () => {
603
+ index = rootIndex.result;
604
+ finish();
605
+ };
606
+ // The journal has no index by target; the scan runs inside this transaction.
607
+ const scan = tx.objectStore(INTENTS).getAll();
608
+ scan.onsuccess = () => {
609
+ rows = scan.result.filter((row) => row.target === id);
610
+ finish();
611
+ };
612
+ const metaStore = tx.objectStore(META);
613
+ for (const key of keys) {
614
+ const request = metaStore.get(key);
615
+ request.onsuccess = () => {
616
+ const row = request.result;
617
+ if (row !== undefined)
618
+ meta.set(key, row.value);
619
+ finish();
620
+ };
621
+ }
622
+ });
623
+ rows.sort((a, b) => a.sequence - b.sequence);
624
+ if (!record)
625
+ return { document: null, raw: null, intents: rows, meta };
626
+ const { frontmatter, body } = parseMarkdown(record.raw, pathFromConceptId(id), { okfVersion: editionOf(index) });
627
+ return { document: { doc: { id, frontmatter, body }, version: record.version }, raw: record.raw, intents: rows, meta };
628
+ }
629
+ /** Intents in local commit order, optionally restricted to one or more states. */
630
+ async listIntents(state) {
631
+ const wanted = state === undefined ? null : new Set(typeof state === "string" ? [state] : state);
632
+ const rows = await this.#transact(INTENTS, "readonly", (tx, done) => {
633
+ const request = tx.objectStore(INTENTS).getAll();
634
+ request.onsuccess = () => done(request.result);
635
+ });
636
+ const out = wanted ? rows.filter((row) => wanted.has(row.state)) : rows;
637
+ out.sort((a, b) => a.sequence - b.sequence);
638
+ return out;
639
+ }
640
+ async readIntent(requestId) {
641
+ return this.#getOne(INTENTS, requestId);
642
+ }
643
+ /**
644
+ * Compare-and-swap on an intent's `state`: the patch applies only while the record is in
645
+ * `expectedState`, together with any meta rows, in one transaction. A different state or a
646
+ * missing record rejects with {@link IntentStateConflict} and writes nothing.
647
+ */
648
+ async updateIntent(requestId, expectedState, patch, options = {}) {
649
+ 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
+ });
670
+ });
671
+ }
672
+ async readMeta(key) {
673
+ const row = await this.#getOne(META, key);
674
+ return row === undefined ? undefined : row.value;
675
+ }
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);
681
+ });
682
+ }
683
+ // ── blobs ──────────────────────────────────────────────────────────────────────────────
684
+ async readBlob(key) {
685
+ assertSafeBlobKey(key);
686
+ const record = await this.#getOne(BLOBS, key);
687
+ if (!record)
688
+ return null;
689
+ // A fresh copy: the caller must not alias the stored bytes.
690
+ return { bytes: new Uint8Array(record.bytes), contentType: record.contentType, version: record.version };
691
+ }
692
+ async writeBlob(key, bytes, contentType, options = {}) {
693
+ assertSafeBlobKey(key);
694
+ const version = blobVersion(bytes);
695
+ // This adapter keeps state, so an explicit content-type override persists, as in MemoryBackend.
696
+ const resolvedType = resolveContentType(key, contentType);
697
+ // `new Uint8Array(bytes)` copies element by element into a plain array, whatever TypedArray
698
+ // subclass the caller handed over, so a later caller-side mutation cannot reach the store.
699
+ const stored = new Uint8Array(bytes);
700
+ return this.#compareAndSwap(BLOBS, key, key, options.expectedVersion, (current) => {
701
+ if (current && current.version === version && current.contentType === resolvedType)
702
+ return { version };
703
+ return { record: { key, bytes: stored, contentType: resolvedType, version } };
704
+ });
705
+ }
706
+ async deleteBlob(key, options = {}) {
707
+ assertSafeBlobKey(key);
708
+ return this.#compareAndDelete(BLOBS, key, key, options.expectedVersion);
709
+ }
710
+ async existsBlob(key) {
711
+ assertSafeBlobKey(key);
712
+ return this.#has(BLOBS, key);
713
+ }
714
+ async listBlobs(prefix) {
715
+ return this.#keys(BLOBS, prefix);
716
+ }
717
+ }
718
+ //# sourceMappingURL=indexeddb-backend.js.map