@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.
- package/README.md +127 -0
- package/dist/artifact-contract.d.ts +90 -0
- package/dist/artifact-contract.d.ts.map +1 -0
- package/dist/artifact-contract.js +205 -0
- package/dist/artifact-contract.js.map +1 -0
- package/dist/backend.d.ts +16 -1
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +112 -47
- package/dist/backend.js.map +1 -1
- package/dist/bundle-ops.d.ts +42 -0
- package/dist/bundle-ops.d.ts.map +1 -0
- package/dist/bundle-ops.js +88 -0
- package/dist/bundle-ops.js.map +1 -0
- package/dist/bundle.d.ts +10 -28
- package/dist/bundle.d.ts.map +1 -1
- package/dist/bundle.js +12 -69
- package/dist/bundle.js.map +1 -1
- package/dist/document-codec.d.ts +6 -1
- package/dist/document-codec.d.ts.map +1 -1
- package/dist/document-codec.js +8 -4
- package/dist/document-codec.js.map +1 -1
- package/dist/document-field-actions.d.ts +107 -0
- package/dist/document-field-actions.d.ts.map +1 -0
- package/dist/document-field-actions.js +302 -0
- package/dist/document-field-actions.js.map +1 -0
- package/dist/document-mutation.d.ts +61 -3
- package/dist/document-mutation.d.ts.map +1 -1
- package/dist/document-mutation.js +116 -25
- package/dist/document-mutation.js.map +1 -1
- package/dist/document-write-policy.d.ts +0 -1
- package/dist/document-write-policy.d.ts.map +1 -1
- package/dist/document-write-policy.js +7 -4
- package/dist/document-write-policy.js.map +1 -1
- package/dist/engine.d.ts +3 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +6 -4
- package/dist/engine.js.map +1 -1
- package/dist/file-journaled-backend-internal.d.ts +19 -0
- package/dist/file-journaled-backend-internal.d.ts.map +1 -0
- package/dist/file-journaled-backend-internal.js +10 -0
- package/dist/file-journaled-backend-internal.js.map +1 -0
- package/dist/file-journaled-backend.d.ts +162 -0
- package/dist/file-journaled-backend.d.ts.map +1 -0
- package/dist/file-journaled-backend.js +1113 -0
- package/dist/file-journaled-backend.js.map +1 -0
- package/dist/filesystem-host.d.ts +15 -0
- package/dist/filesystem-host.d.ts.map +1 -0
- package/dist/filesystem-host.js +20 -0
- package/dist/filesystem-host.js.map +1 -0
- package/dist/filesystem-identity.d.ts +86 -4
- package/dist/filesystem-identity.d.ts.map +1 -1
- package/dist/filesystem-identity.js +338 -102
- package/dist/filesystem-identity.js.map +1 -1
- package/dist/filesystem-lock.d.ts +16 -3
- package/dist/filesystem-lock.d.ts.map +1 -1
- package/dist/filesystem-lock.js +323 -77
- package/dist/filesystem-lock.js.map +1 -1
- package/dist/filesystem-push-role.d.ts +77 -0
- package/dist/filesystem-push-role.d.ts.map +1 -0
- package/dist/filesystem-push-role.js +165 -0
- package/dist/filesystem-push-role.js.map +1 -0
- package/dist/filesystem.d.ts +17 -0
- package/dist/filesystem.d.ts.map +1 -0
- package/dist/filesystem.js +18 -0
- package/dist/filesystem.js.map +1 -0
- package/dist/freshness.d.ts +10 -7
- package/dist/freshness.d.ts.map +1 -1
- package/dist/freshness.js +14 -11
- package/dist/freshness.js.map +1 -1
- package/dist/frontmatter-contract.d.ts +2 -2
- package/dist/frontmatter-contract.js +2 -2
- package/dist/frontmatter.d.ts +11 -1
- package/dist/frontmatter.d.ts.map +1 -1
- package/dist/frontmatter.js +20 -7
- package/dist/frontmatter.js.map +1 -1
- package/dist/governed-body-write.d.ts +106 -0
- package/dist/governed-body-write.d.ts.map +1 -0
- package/dist/governed-body-write.js +251 -0
- package/dist/governed-body-write.js.map +1 -0
- package/dist/heads-digest.d.ts +32 -0
- package/dist/heads-digest.d.ts.map +1 -0
- package/dist/heads-digest.js +37 -0
- package/dist/heads-digest.js.map +1 -0
- package/dist/host-policy-snapshot.d.ts +9 -0
- package/dist/host-policy-snapshot.d.ts.map +1 -0
- package/dist/host-policy-snapshot.js +33 -0
- package/dist/host-policy-snapshot.js.map +1 -0
- package/dist/host-text.d.ts +31 -0
- package/dist/host-text.d.ts.map +1 -0
- package/dist/host-text.js +77 -0
- package/dist/host-text.js.map +1 -0
- package/dist/hosted-transport/answer-rows.d.ts +216 -0
- package/dist/hosted-transport/answer-rows.d.ts.map +1 -0
- package/dist/hosted-transport/answer-rows.js +396 -0
- package/dist/hosted-transport/answer-rows.js.map +1 -0
- package/dist/hosted-transport/carrier.d.ts +142 -0
- package/dist/hosted-transport/carrier.d.ts.map +1 -0
- package/dist/hosted-transport/carrier.js +241 -0
- package/dist/hosted-transport/carrier.js.map +1 -0
- package/dist/hosted-transport/history-pages.d.ts +55 -0
- package/dist/hosted-transport/history-pages.d.ts.map +1 -0
- package/dist/hosted-transport/history-pages.js +69 -0
- package/dist/hosted-transport/history-pages.js.map +1 -0
- package/dist/hosted-transport/index.d.ts +18 -0
- package/dist/hosted-transport/index.d.ts.map +1 -0
- package/dist/hosted-transport/index.js +18 -0
- package/dist/hosted-transport/index.js.map +1 -0
- package/dist/hosted-transport/operations.d.ts +85 -0
- package/dist/hosted-transport/operations.d.ts.map +1 -0
- package/dist/hosted-transport/operations.js +128 -0
- package/dist/hosted-transport/operations.js.map +1 -0
- package/dist/hosted-transport/paged-reads.d.ts +87 -0
- package/dist/hosted-transport/paged-reads.d.ts.map +1 -0
- package/dist/hosted-transport/paged-reads.js +238 -0
- package/dist/hosted-transport/paged-reads.js.map +1 -0
- package/dist/hosted-transport/read-adapter.d.ts +250 -0
- package/dist/hosted-transport/read-adapter.d.ts.map +1 -0
- package/dist/hosted-transport/read-adapter.js +557 -0
- package/dist/hosted-transport/read-adapter.js.map +1 -0
- package/dist/hosted-transport/root-write.d.ts +114 -0
- package/dist/hosted-transport/root-write.d.ts.map +1 -0
- package/dist/hosted-transport/root-write.js +171 -0
- package/dist/hosted-transport/root-write.js.map +1 -0
- package/dist/hosted-transport/whole-document-transport.d.ts +174 -0
- package/dist/hosted-transport/whole-document-transport.d.ts.map +1 -0
- package/dist/hosted-transport/whole-document-transport.js +411 -0
- package/dist/hosted-transport/whole-document-transport.js.map +1 -0
- package/dist/index.d.ts +29 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -6
- package/dist/index.js.map +1 -1
- package/dist/indexeddb-backend.d.ts +197 -0
- package/dist/indexeddb-backend.d.ts.map +1 -0
- package/dist/indexeddb-backend.js +1183 -0
- package/dist/indexeddb-backend.js.map +1 -0
- package/dist/journaled-backend.d.ts +467 -0
- package/dist/journaled-backend.d.ts.map +1 -0
- package/dist/journaled-backend.js +389 -0
- package/dist/journaled-backend.js.map +1 -0
- package/dist/kind-coverage.d.ts +99 -0
- package/dist/kind-coverage.d.ts.map +1 -0
- package/dist/kind-coverage.js +186 -0
- package/dist/kind-coverage.js.map +1 -0
- package/dist/kind-field-mutation.d.ts +15 -0
- package/dist/kind-field-mutation.d.ts.map +1 -0
- package/dist/kind-field-mutation.js +160 -0
- package/dist/kind-field-mutation.js.map +1 -0
- package/dist/kinds.d.ts +31 -1
- package/dist/kinds.d.ts.map +1 -1
- package/dist/kinds.js +86 -17
- package/dist/kinds.js.map +1 -1
- package/dist/links.d.ts +1 -1
- package/dist/links.d.ts.map +1 -1
- package/dist/links.js +5 -10
- package/dist/links.js.map +1 -1
- package/dist/managed-document-metadata.d.ts +2 -0
- package/dist/managed-document-metadata.d.ts.map +1 -0
- package/dist/managed-document-metadata.js +9 -0
- package/dist/managed-document-metadata.js.map +1 -0
- package/dist/meaningful-change-time.d.ts +6 -6
- package/dist/meaningful-change-time.d.ts.map +1 -1
- package/dist/meaningful-change-time.js +12 -13
- package/dist/meaningful-change-time.js.map +1 -1
- package/dist/memory-backend.d.ts +2 -2
- package/dist/memory-backend.d.ts.map +1 -1
- package/dist/memory-backend.js +32 -16
- package/dist/memory-backend.js.map +1 -1
- package/dist/mutation.d.ts.map +1 -1
- package/dist/mutation.js +2 -2
- package/dist/mutation.js.map +1 -1
- package/dist/page.d.ts.map +1 -1
- package/dist/page.js +2 -1
- package/dist/page.js.map +1 -1
- package/dist/paths.d.ts +6 -2
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +35 -33
- package/dist/paths.js.map +1 -1
- package/dist/platform.d.ts +239 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +82 -0
- package/dist/platform.js.map +1 -0
- package/dist/portable-export.d.ts +119 -0
- package/dist/portable-export.d.ts.map +1 -0
- package/dist/portable-export.js +501 -0
- package/dist/portable-export.js.map +1 -0
- package/dist/publication-filesystem.d.ts +1 -1
- package/dist/publication-filesystem.d.ts.map +1 -1
- package/dist/publication-filesystem.js +1 -1
- package/dist/publication-filesystem.js.map +1 -1
- package/dist/query-filter.d.ts +7 -2
- package/dist/query-filter.d.ts.map +1 -1
- package/dist/query-filter.js +24 -1
- package/dist/query-filter.js.map +1 -1
- package/dist/query-order.d.ts +22 -0
- package/dist/query-order.d.ts.map +1 -0
- package/dist/query-order.js +42 -0
- package/dist/query-order.js.map +1 -0
- package/dist/recipes.d.ts +3 -1
- package/dist/recipes.d.ts.map +1 -1
- package/dist/recipes.js +4 -4
- package/dist/recipes.js.map +1 -1
- package/dist/remote-backend.d.ts +114 -22
- package/dist/remote-backend.d.ts.map +1 -1
- package/dist/remote-backend.js +285 -44
- package/dist/remote-backend.js.map +1 -1
- package/dist/remote-document-codec.d.ts +15 -0
- package/dist/remote-document-codec.d.ts.map +1 -0
- package/dist/remote-document-codec.js +103 -0
- package/dist/remote-document-codec.js.map +1 -0
- package/dist/remote-error.d.ts +38 -0
- package/dist/remote-error.d.ts.map +1 -0
- package/dist/remote-error.js +59 -0
- package/dist/remote-error.js.map +1 -0
- package/dist/remote-operations.d.ts +63 -0
- package/dist/remote-operations.d.ts.map +1 -0
- package/dist/remote-operations.js +125 -0
- package/dist/remote-operations.js.map +1 -0
- package/dist/remote-parsers.d.ts +86 -0
- package/dist/remote-parsers.d.ts.map +1 -0
- package/dist/remote-parsers.js +181 -0
- package/dist/remote-parsers.js.map +1 -0
- package/dist/remote.d.ts +7 -2
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +6 -1
- package/dist/remote.js.map +1 -1
- package/dist/sha256.d.ts +21 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +114 -0
- package/dist/sha256.js.map +1 -0
- package/dist/storage.d.ts +8 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +11 -2
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +40 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/uncertain-write.d.ts +178 -0
- package/dist/uncertain-write.d.ts.map +1 -0
- package/dist/uncertain-write.js +198 -0
- package/dist/uncertain-write.js.map +1 -0
- package/dist/version-transport.d.ts +7 -0
- package/dist/version-transport.d.ts.map +1 -1
- package/dist/version-transport.js +9 -0
- package/dist/version-transport.js.map +1 -1
- package/dist/versioning.d.ts +18 -7
- package/dist/versioning.d.ts.map +1 -1
- package/dist/versioning.js +23 -14
- package/dist/versioning.js.map +1 -1
- 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
|