@kyneta/leveldb-store 1.8.0 → 2.0.0

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 CHANGED
@@ -18,7 +18,7 @@ import { createLevelDBStore } from "@kyneta/leveldb-store/server"
18
18
 
19
19
  const exchange = new Exchange({
20
20
  identity: { peerId: "my-server", name: "server" },
21
- stores: [createLevelDBStore("./data/exchange-db")],
21
+ stores: [await createLevelDBStore("./data/exchange-db")],
22
22
  transports: [networkTransport],
23
23
  })
24
24
 
@@ -32,28 +32,32 @@ That's it. The Exchange handles hydration (loading from storage on `get()` / `re
32
32
 
33
33
  ### `createLevelDBStore(dbPath)`
34
34
 
35
- Factory function that returns a `Store`. The `dbPath` is the directory where LevelDB stores its files.
35
+ Async factory that opens the database, runs the store-format gate (see below), and resolves to a `Store`. The `dbPath` is the directory where LevelDB stores its files. `await` it before passing to the `Exchange`.
36
36
 
37
37
  ```ts
38
38
  import { createLevelDBStore } from "@kyneta/leveldb-store/server"
39
39
 
40
- const store = createLevelDBStore("./data/exchange-db")
40
+ const store = await createLevelDBStore("./data/exchange-db")
41
41
  ```
42
42
 
43
43
  ### `LevelDBStore`
44
44
 
45
- The class implementing the `Store` interface. Use `createLevelDBStore` for most cases; use the class directly if you need access to `close()` outside of the Exchange lifecycle.
45
+ The class implementing the `Store` interface. Use `createLevelDBStore` (or `LevelDBStore.open(dbPath)`) for most cases both are async and run the store-format gate. The bare `new LevelDBStore(dbPath)` constructor opens the database **without** the gate and is for advanced use only.
46
46
 
47
47
  ```ts
48
48
  import { LevelDBStore } from "@kyneta/leveldb-store/server"
49
49
 
50
- const store = new LevelDBStore("./data/exchange-db")
50
+ const store = await LevelDBStore.open("./data/exchange-db")
51
51
 
52
52
  // ... use with Exchange ...
53
53
 
54
54
  await store.close() // release file handles
55
55
  ```
56
56
 
57
+ ### Store-format gate
58
+
59
+ On open, the store stamps a `{ major, minor }` format version into a `store-meta\x00` key namespace (separate from the per-doc `doc-meta\x00` keys), and on subsequent opens refuses — with `StoreFormatVersionError` — a store whose stamped major is incompatible with the running build, or an unversioned store that already holds documents. No automatic migration is performed.
60
+
57
61
  ### Binary Codec
58
62
 
59
63
  The module also exports `encodeStoreEntry` and `decodeStoreEntry` for the compact binary envelope format. These are pure functions — useful for debugging, migration scripts, or building custom tooling over the LevelDB data files.
@@ -99,7 +103,7 @@ Flags byte layout:
99
103
 
100
104
  ### Atomicity
101
105
 
102
- `replace()` uses LevelDB's batch operation to atomically delete all existing entries and write the single replacement. A concurrent reader never observes an empty intermediate state.
106
+ Every write commits through a single LevelDB `batch`. `append()` of a metadata record commits the materialized index update and the record together, so a crash can never leave the `doc-meta` index advanced past its backing record (an entry-record append is a single record write). `replace()` atomically deletes all existing entries and writes the replacements. A concurrent reader never observes a partial intermediate state.
103
107
 
104
108
  ## Testing
105
109
 
package/dist/index.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { DocId, Store, StoreMeta, StoreRecord } from "@kyneta/exchange";
2
+ import { ClassicLevel } from "classic-level";
2
3
 
3
4
  //#region src/index.d.ts
4
5
  declare function encodeStoreRecord(record: StoreRecord): Uint8Array;
5
6
  declare function decodeStoreRecord(bytes: Uint8Array): StoreRecord;
6
7
  declare class LevelDBStore implements Store {
7
8
  #private;
8
- constructor(dbPath: string);
9
+ constructor(dbPathOrDb: string | ClassicLevel<string, Uint8Array>);
9
10
  currentMeta(docId: DocId): Promise<StoreMeta | null>;
10
11
  append(docId: DocId, record: StoreRecord): Promise<void>;
11
12
  loadAll(docId: DocId): AsyncIterable<StoreRecord>;
@@ -13,11 +14,15 @@ declare class LevelDBStore implements Store {
13
14
  delete(docId: DocId): Promise<void>;
14
15
  listDocIds(prefix?: string): AsyncIterable<DocId>;
15
16
  close(): Promise<void>;
17
+ /** Open the store and run the store-format gate. Used by `createLevelDBStore`. */
18
+ static open(dbPathOrDb: string | ClassicLevel<string, Uint8Array>): Promise<Store>;
16
19
  }
17
20
  /**
18
21
  * Create a LevelDB storage backend for server-side persistence.
19
22
  *
20
- * Returns a `Store` pass directly to `Exchange({ stores: [...] })`.
23
+ * Async: it opens the database and runs the store-format gate (stamping a
24
+ * brand-new store, accepting a compatible one, or throwing
25
+ * `StoreFormatVersionError`). `await` it before passing to the `Exchange`.
21
26
  *
22
27
  * @param dbPath - Directory path where LevelDB stores its files
23
28
  *
@@ -26,11 +31,11 @@ declare class LevelDBStore implements Store {
26
31
  * import { createLevelDBStore } from "@kyneta/leveldb-store"
27
32
  *
28
33
  * const exchange = new Exchange({
29
- * stores: [createLevelDBStore("./data/exchange-db")],
34
+ * stores: [await createLevelDBStore("./data/exchange-db")],
30
35
  * })
31
36
  * ```
32
37
  */
33
- declare function createLevelDBStore(dbPath: string): Store;
38
+ declare function createLevelDBStore(dbPath: string): Promise<Store>;
34
39
  //#endregion
35
40
  export { LevelDBStore, createLevelDBStore, decodeStoreRecord, encodeStoreRecord };
36
41
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;iBAuDgB,iBAAA,CAAkB,MAAA,EAAQ,WAAA,GAAc,UAAU;AAAA,iBAmClD,iBAAA,CAAkB,KAAA,EAAO,UAAA,GAAa,WAAW;AAAA,cAsEpD,YAAA,YAAwB,KAAA;EAAA;cAIvB,MAAA;EAUN,WAAA,CAAY,KAAA,EAAO,KAAA,GAAQ,OAAA,CAAQ,SAAA;EAUnC,MAAA,CAAO,KAAA,EAAO,KAAA,EAAO,MAAA,EAAQ,WAAA,GAAc,OAAA;EA0B1C,OAAA,CAAQ,KAAA,EAAO,KAAA,GAAQ,aAAA,CAAc,WAAA;EAUtC,OAAA,CAAQ,KAAA,EAAO,KAAA,EAAO,OAAA,EAAS,WAAA,KAAgB,OAAA;EA2C/C,MAAA,CAAO,KAAA,EAAO,KAAA,GAAQ,OAAA;EAoBrB,UAAA,CAAW,MAAA,YAAkB,aAAA,CAAc,KAAA;EAW5C,KAAA,CAAA,GAAS,OAAA;AAAA;;;;;;;AA5MgD;AAsEjE;;;;;;;;;iBA+JgB,kBAAA,CAAmB,MAAA,WAAiB,KAAK"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;iBA4EgB,iBAAA,CAAkB,MAAA,EAAQ,WAAA,GAAc,UAAU;AAAA,iBAmClD,iBAAA,CAAkB,KAAA,EAAO,UAAA,GAAa,WAAW;AAAA,cAwHpD,YAAA,YAAwB,KAAA;EAAA;cAOvB,UAAA,WAAqB,YAAA,SAAqB,UAAA;EAWhD,WAAA,CAAY,KAAA,EAAO,KAAA,GAAQ,OAAA,CAAQ,SAAA;EAUnC,MAAA,CAAO,KAAA,EAAO,KAAA,EAAO,MAAA,EAAQ,WAAA,GAAc,OAAA;EA0B1C,OAAA,CAAQ,KAAA,EAAO,KAAA,GAAQ,aAAA,CAAc,WAAA;EAUtC,OAAA,CAAQ,KAAA,EAAO,KAAA,EAAO,OAAA,EAAS,WAAA,KAAgB,OAAA;EA2C/C,MAAA,CAAO,KAAA,EAAO,KAAA,GAAQ,OAAA;EAoBrB,UAAA,CAAW,MAAA,YAAkB,aAAA,CAAc,KAAA;EAW5C,KAAA,CAAA,GAAS,OAAA;EAlQgB;EAAA,OA4TlB,IAAA,CACX,UAAA,WAAqB,YAAA,SAAqB,UAAA,IACzC,OAAA,CAAQ,KAAA;AAAA;;;;;AA9ToD;AAwHjE;;;;;;;;;;;;;iBAyOgB,kBAAA,CAAmB,MAAA,WAAiB,OAAO,CAAC,KAAA"}
package/dist/index.js CHANGED
@@ -1,10 +1,15 @@
1
- import { SeqNoTracker, resolveMetaFromBatch, validateAppend } from "@kyneta/exchange";
1
+ import { STORE_META_FORMAT_KEY, SeqNoTracker, StoreFormatVersionError, decideStoreFormat, parseStoreFormat, resolveMetaFromBatch, validateAppend } from "@kyneta/exchange";
2
2
  import { ClassicLevel } from "classic-level";
3
3
  //#region src/index.ts
4
4
  const SEP = "\0";
5
- const META_PREFIX = `meta${SEP}`;
5
+ const DOC_META_PREFIX = `doc-meta${SEP}`;
6
6
  const RECORD_PREFIX = `record${SEP}`;
7
7
  const SEQ_PAD = 16;
8
+ const STORE_FORMAT_KEY = `${`store-meta${SEP}`}${STORE_META_FORMAT_KEY}`;
9
+ const STORE_FORMAT_VERSION = {
10
+ major: 1,
11
+ minor: 0
12
+ };
8
13
  const encoder = new TextEncoder();
9
14
  const decoder = new TextDecoder();
10
15
  function encodeStoreRecord(record) {
@@ -61,8 +66,8 @@ function decodeStoreRecord(bytes) {
61
66
  version
62
67
  };
63
68
  }
64
- function metaKey(docId) {
65
- return `${META_PREFIX}${docId}`;
69
+ function docMetaKey(docId) {
70
+ return `${DOC_META_PREFIX}${docId}`;
66
71
  }
67
72
  function recordPrefix(docId) {
68
73
  return `${RECORD_PREFIX}${docId}${SEP}`;
@@ -70,31 +75,57 @@ function recordPrefix(docId) {
70
75
  function recordKey(docId, seqNo) {
71
76
  return `${recordPrefix(docId)}${String(seqNo).padStart(SEQ_PAD, "0")}`;
72
77
  }
73
- function parseDocIdFromMetaKey(key) {
74
- return key.slice(META_PREFIX.length);
78
+ function parseDocIdFromDocMetaKey(key) {
79
+ return key.slice(DOC_META_PREFIX.length);
75
80
  }
76
81
  function parseSeqNoFromRecordKey(key, docId) {
77
82
  const prefix = recordPrefix(docId);
78
83
  return Number.parseInt(key.slice(prefix.length), 10);
79
84
  }
80
- var LevelDBStore = class {
85
+ function encodeDocMeta(meta) {
86
+ return encoder.encode(JSON.stringify(meta));
87
+ }
88
+ function decodeDocMeta(bytes) {
89
+ return JSON.parse(decoder.decode(bytes));
90
+ }
91
+ /**
92
+ * Pure: validate the record against existing meta and return the LevelDB batch
93
+ * ops to write. Mirrors sql-core's `planAppend` (the gather/plan/execute split,
94
+ * jj:pzuytnvo). An entry record yields a single record `put`; a meta record
95
+ * adds the doc-meta index `put`, and the two commit together in one batch.
96
+ * `validateAppend` throws on an entry-before-meta violation — a pure,
97
+ * input-deterministic throw.
98
+ */
99
+ function planAppend(docId, record, existingMeta, seq) {
100
+ const resolved = validateAppend(docId, record, existingMeta);
101
+ const ops = [{
102
+ type: "put",
103
+ key: recordKey(docId, seq),
104
+ value: encodeStoreRecord(record)
105
+ }];
106
+ if (resolved !== null) ops.push({
107
+ type: "put",
108
+ key: docMetaKey(docId),
109
+ value: encodeDocMeta(resolved)
110
+ });
111
+ return ops;
112
+ }
113
+ var LevelDBStore = class LevelDBStore {
81
114
  #db;
82
115
  #seqNos = new SeqNoTracker();
83
- constructor(dbPath) {
84
- this.#db = new ClassicLevel(dbPath, { valueEncoding: "binary" });
116
+ constructor(dbPathOrDb) {
117
+ this.#db = typeof dbPathOrDb === "string" ? new ClassicLevel(dbPathOrDb, { valueEncoding: "binary" }) : dbPathOrDb;
85
118
  }
86
119
  async currentMeta(docId) {
87
120
  try {
88
- const raw = await this.#db.get(metaKey(docId));
89
- return JSON.parse(decoder.decode(raw));
121
+ return decodeDocMeta(await this.#db.get(docMetaKey(docId)));
90
122
  } catch (error) {
91
123
  if (error.code === "LEVEL_NOT_FOUND") return null;
92
124
  throw error;
93
125
  }
94
126
  }
95
127
  async append(docId, record) {
96
- const resolved = validateAppend(docId, record, await this.currentMeta(docId));
97
- if (resolved !== null) await this.#db.put(metaKey(docId), encoder.encode(JSON.stringify(resolved)));
128
+ const existingMeta = await this.currentMeta(docId);
98
129
  const seq = await this.#seqNos.next(docId, async () => {
99
130
  const prefix = recordPrefix(docId);
100
131
  for await (const key of this.#db.keys({
@@ -105,7 +136,7 @@ var LevelDBStore = class {
105
136
  })) return parseSeqNoFromRecordKey(key, docId);
106
137
  return null;
107
138
  });
108
- await this.#db.put(recordKey(docId, seq), encodeStoreRecord(record));
139
+ await this.#db.batch(planAppend(docId, record, existingMeta, seq));
109
140
  }
110
141
  async *loadAll(docId) {
111
142
  const prefix = recordPrefix(docId);
@@ -133,15 +164,15 @@ var LevelDBStore = class {
133
164
  });
134
165
  ops.push({
135
166
  type: "put",
136
- key: metaKey(docId),
137
- value: encoder.encode(JSON.stringify(resolved))
167
+ key: docMetaKey(docId),
168
+ value: encodeDocMeta(resolved)
138
169
  });
139
170
  await this.#db.batch(ops);
140
171
  this.#seqNos.reset(docId, records.length - 1);
141
172
  }
142
173
  async delete(docId) {
143
174
  const prefix = recordPrefix(docId);
144
- const keysToDelete = [metaKey(docId)];
175
+ const keysToDelete = [docMetaKey(docId)];
145
176
  for await (const key of this.#db.keys({
146
177
  gte: prefix,
147
178
  lt: `${prefix}\xff`
@@ -153,20 +184,66 @@ var LevelDBStore = class {
153
184
  this.#seqNos.remove(docId);
154
185
  }
155
186
  async *listDocIds(prefix) {
156
- const rangePrefix = prefix !== void 0 ? `${META_PREFIX}${prefix}` : META_PREFIX;
187
+ const rangePrefix = prefix !== void 0 ? `${DOC_META_PREFIX}${prefix}` : DOC_META_PREFIX;
157
188
  for await (const key of this.#db.keys({
158
189
  gte: rangePrefix,
159
190
  lt: `${rangePrefix}\xff`
160
- })) yield parseDocIdFromMetaKey(key);
191
+ })) yield parseDocIdFromDocMetaKey(key);
161
192
  }
162
193
  async close() {
163
194
  await this.#db.close();
164
195
  }
196
+ async #assertFormat() {
197
+ let parsed = null;
198
+ try {
199
+ const raw = await this.#db.get(STORE_FORMAT_KEY);
200
+ parsed = parseStoreFormat(decoder.decode(raw));
201
+ } catch (error) {
202
+ if (error.code !== "LEVEL_NOT_FOUND") throw error;
203
+ }
204
+ if (parsed === "malformed") throw new StoreFormatVersionError({
205
+ reason: "malformed-version",
206
+ backend: "leveldb",
207
+ stored: null,
208
+ current: STORE_FORMAT_VERSION
209
+ });
210
+ let hasData = false;
211
+ for await (const _key of this.#db.keys({
212
+ gte: DOC_META_PREFIX,
213
+ lt: `${DOC_META_PREFIX}\xff`,
214
+ limit: 1
215
+ })) hasData = true;
216
+ const decision = decideStoreFormat({
217
+ current: STORE_FORMAT_VERSION,
218
+ stored: parsed,
219
+ storeHasData: hasData
220
+ });
221
+ if (decision.action === "refuse") throw new StoreFormatVersionError({
222
+ reason: decision.reason,
223
+ backend: "leveldb",
224
+ stored: parsed,
225
+ current: STORE_FORMAT_VERSION
226
+ });
227
+ if (decision.action === "stamp") await this.#db.put(STORE_FORMAT_KEY, encoder.encode(JSON.stringify(decision.value)));
228
+ }
229
+ /** Open the store and run the store-format gate. Used by `createLevelDBStore`. */
230
+ static async open(dbPathOrDb) {
231
+ const store = new LevelDBStore(dbPathOrDb);
232
+ try {
233
+ await store.#assertFormat();
234
+ } catch (error) {
235
+ await store.#db.close();
236
+ throw error;
237
+ }
238
+ return store;
239
+ }
165
240
  };
166
241
  /**
167
242
  * Create a LevelDB storage backend for server-side persistence.
168
243
  *
169
- * Returns a `Store` pass directly to `Exchange({ stores: [...] })`.
244
+ * Async: it opens the database and runs the store-format gate (stamping a
245
+ * brand-new store, accepting a compatible one, or throwing
246
+ * `StoreFormatVersionError`). `await` it before passing to the `Exchange`.
170
247
  *
171
248
  * @param dbPath - Directory path where LevelDB stores its files
172
249
  *
@@ -175,12 +252,12 @@ var LevelDBStore = class {
175
252
  * import { createLevelDBStore } from "@kyneta/leveldb-store"
176
253
  *
177
254
  * const exchange = new Exchange({
178
- * stores: [createLevelDBStore("./data/exchange-db")],
255
+ * stores: [await createLevelDBStore("./data/exchange-db")],
179
256
  * })
180
257
  * ```
181
258
  */
182
259
  function createLevelDBStore(dbPath) {
183
- return new LevelDBStore(dbPath);
260
+ return LevelDBStore.open(dbPath);
184
261
  }
185
262
  //#endregion
186
263
  export { LevelDBStore, createLevelDBStore, decodeStoreRecord, encodeStoreRecord };
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["#db","#seqNos"],"sources":["../src/index.ts"],"sourcesContent":["// server — LevelDB storage backend for @kyneta/exchange.\n//\n// Implements the Store interface using classic-level.\n//\n// Key-space design (FoundationDB convention — \\x00 null-byte separator):\n// meta\\x00{docId} → JSON-encoded StoreMeta (materialized index)\n// record\\x00{docId}\\x00{seqNo} → binary-encoded StoreRecord (unified stream)\n//\n// The \\x00 separator cannot appear in valid UTF-8 strings, so no docId\n// validation is needed — the key-space imposes zero constraints on callers.\n//\n// SeqNo is a zero-padded 16-digit monotonic counter per doc, tracked in\n// memory. On reboot, the max seqNo for a doc is lazily discovered via a\n// single reverse-iterator seek on first append.\n\nimport {\n type DocId,\n resolveMetaFromBatch,\n SeqNoTracker,\n type Store,\n type StoreMeta,\n type StoreRecord,\n validateAppend,\n} from \"@kyneta/exchange\"\nimport { ClassicLevel } from \"classic-level\"\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst SEP = \"\\x00\"\nconst META_PREFIX = `meta${SEP}`\nconst RECORD_PREFIX = `record${SEP}`\nconst SEQ_PAD = 16\n\n// ---------------------------------------------------------------------------\n// Binary envelope v2 — pure encode/decode for StoreRecord\n// ---------------------------------------------------------------------------\n\n// Flags byte layout:\n// bit 0: payload kind (0 = entirety, 1 = since) — entry records only\n// bit 1: encoding (0 = json, 1 = binary) — entry records only\n// bit 2: data type (0 = string, 1 = Uint8Array) — entry records only\n// bit 3: record kind (0 = entry, 1 = meta)\n// bit 7: future-format (0 = current format, reserved)\n//\n// Meta records (bit 3 = 1):\n// [1 byte flags] [remaining: JSON-encoded StoreMeta]\n//\n// Entry records (bit 3 = 0):\n// [1 byte flags] [4 bytes version length BE] [N bytes version UTF-8] [remaining: payload data]\n\nconst encoder = new TextEncoder()\nconst decoder = new TextDecoder()\n\nexport function encodeStoreRecord(record: StoreRecord): Uint8Array {\n if (record.kind === \"meta\") {\n const metaBytes = encoder.encode(JSON.stringify(record.meta))\n const buf = new Uint8Array(1 + metaBytes.length)\n buf[0] = 0x08 // bit 3 set (meta)\n buf.set(metaBytes, 1)\n return buf\n }\n\n // Entry record\n const { payload, version } = record\n\n let flags = 0\n if (payload.kind === \"since\") flags |= 0x01\n if (payload.encoding === \"binary\") flags |= 0x02\n\n const isDataBinary = payload.data instanceof Uint8Array\n if (isDataBinary) flags |= 0x04\n\n const versionBytes = encoder.encode(version)\n const dataBytes = isDataBinary\n ? (payload.data as Uint8Array)\n : encoder.encode(payload.data as string)\n\n const buf = new Uint8Array(1 + 4 + versionBytes.length + dataBytes.length)\n const view = new DataView(buf.buffer)\n\n buf[0] = flags\n view.setUint32(1, versionBytes.length, false) // big-endian\n buf.set(versionBytes, 5)\n buf.set(dataBytes, 5 + versionBytes.length)\n\n return buf\n}\n\nexport function decodeStoreRecord(bytes: Uint8Array): StoreRecord {\n const flagByte = bytes[0]\n if (flagByte === undefined) throw new Error(\"empty store record bytes\")\n const flags = flagByte\n\n // Check future-format flag (bit 7)\n if ((flags & 0x80) !== 0) {\n throw new Error(\"unknown store record format (future-format bit set)\")\n }\n\n const isMeta = (flags & 0x08) !== 0\n\n if (isMeta) {\n const metaJson = decoder.decode(bytes.subarray(1))\n return { kind: \"meta\", meta: JSON.parse(metaJson) as StoreMeta }\n }\n\n // Entry record\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)\n\n const kind = (flags & 0x01) !== 0 ? \"since\" : \"entirety\"\n const encoding = (flags & 0x02) !== 0 ? \"binary\" : \"json\"\n const isDataBinary = (flags & 0x04) !== 0\n\n const versionLen = view.getUint32(1, false)\n const version = decoder.decode(bytes.subarray(5, 5 + versionLen))\n\n const dataStart = 5 + versionLen\n const rawData = bytes.subarray(dataStart)\n\n const data: string | Uint8Array = isDataBinary\n ? new Uint8Array(rawData)\n : decoder.decode(rawData)\n\n return {\n kind: \"entry\",\n payload: { kind, encoding, data },\n version,\n }\n}\n\n// ---------------------------------------------------------------------------\n// Key helpers\n// ---------------------------------------------------------------------------\n\nfunction metaKey(docId: DocId): string {\n return `${META_PREFIX}${docId}`\n}\n\nfunction recordPrefix(docId: DocId): string {\n return `${RECORD_PREFIX}${docId}${SEP}`\n}\n\nfunction recordKey(docId: DocId, seqNo: number): string {\n return `${recordPrefix(docId)}${String(seqNo).padStart(SEQ_PAD, \"0\")}`\n}\n\nfunction parseDocIdFromMetaKey(key: string): DocId {\n return key.slice(META_PREFIX.length)\n}\n\nfunction parseSeqNoFromRecordKey(key: string, docId: DocId): number {\n const prefix = recordPrefix(docId)\n return Number.parseInt(key.slice(prefix.length), 10)\n}\n\n// ---------------------------------------------------------------------------\n// LevelDBStore\n// ---------------------------------------------------------------------------\n\nexport class LevelDBStore implements Store {\n readonly #db: ClassicLevel<string, Uint8Array>\n readonly #seqNos = new SeqNoTracker()\n\n constructor(dbPath: string) {\n this.#db = new ClassicLevel(dbPath, {\n valueEncoding: \"binary\",\n })\n }\n\n // -------------------------------------------------------------------------\n // Store interface\n // -------------------------------------------------------------------------\n\n async currentMeta(docId: DocId): Promise<StoreMeta | null> {\n try {\n const raw = await this.#db.get(metaKey(docId))\n return JSON.parse(decoder.decode(raw)) as StoreMeta\n } catch (error: any) {\n if (error.code === \"LEVEL_NOT_FOUND\") return null\n throw error\n }\n }\n\n async append(docId: DocId, record: StoreRecord): Promise<void> {\n const existingMeta = await this.currentMeta(docId)\n const resolved = validateAppend(docId, record, existingMeta)\n\n if (resolved !== null) {\n await this.#db.put(\n metaKey(docId),\n encoder.encode(JSON.stringify(resolved)),\n )\n }\n\n const seq = await this.#seqNos.next(docId, async () => {\n const prefix = recordPrefix(docId)\n for await (const key of this.#db.keys({\n gte: prefix,\n lt: `${prefix}\\xff`,\n reverse: true,\n limit: 1,\n })) {\n return parseSeqNoFromRecordKey(key, docId)\n }\n return null\n })\n await this.#db.put(recordKey(docId, seq), encodeStoreRecord(record))\n }\n\n async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {\n const prefix = recordPrefix(docId)\n for await (const value of this.#db.values({\n gte: prefix,\n lt: `${prefix}\\xff`,\n })) {\n yield decodeStoreRecord(value)\n }\n }\n\n async replace(docId: DocId, records: StoreRecord[]): Promise<void> {\n const existingMeta = await this.currentMeta(docId)\n\n // Resolve validates: at least one meta present, immutable fields match.\n const resolved = resolveMetaFromBatch(records, existingMeta)\n\n const prefix = recordPrefix(docId)\n\n // Collect existing record keys to delete\n const keysToDelete: string[] = []\n for await (const key of this.#db.keys({\n gte: prefix,\n lt: `${prefix}\\xff`,\n })) {\n keysToDelete.push(key)\n }\n\n // Atomic batch: delete all existing records, write replacements, upsert meta\n const ops: Array<\n | { type: \"del\"; key: string }\n | { type: \"put\"; key: string; value: Uint8Array }\n > = keysToDelete.map(key => ({ type: \"del\" as const, key }))\n\n for (let i = 0; i < records.length; i++) {\n ops.push({\n type: \"put\",\n key: recordKey(docId, i),\n value: encodeStoreRecord(records[i]!),\n })\n }\n\n ops.push({\n type: \"put\",\n key: metaKey(docId),\n value: encoder.encode(JSON.stringify(resolved)),\n })\n\n await this.#db.batch(ops)\n\n // Reset seqNo counter to the last written index\n this.#seqNos.reset(docId, records.length - 1)\n }\n\n async delete(docId: DocId): Promise<void> {\n const prefix = recordPrefix(docId)\n\n // Collect record keys to delete\n const keysToDelete: string[] = [metaKey(docId)]\n for await (const key of this.#db.keys({\n gte: prefix,\n lt: `${prefix}\\xff`,\n })) {\n keysToDelete.push(key)\n }\n\n await this.#db.batch(\n keysToDelete.map(key => ({ type: \"del\" as const, key })),\n )\n\n // Remove from in-memory seqNo tracker\n this.#seqNos.remove(docId)\n }\n\n async *listDocIds(prefix?: string): AsyncIterable<DocId> {\n const rangePrefix =\n prefix !== undefined ? `${META_PREFIX}${prefix}` : META_PREFIX\n for await (const key of this.#db.keys({\n gte: rangePrefix,\n lt: `${rangePrefix}\\xff`,\n })) {\n yield parseDocIdFromMetaKey(key)\n }\n }\n\n async close(): Promise<void> {\n await this.#db.close()\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory function\n// ---------------------------------------------------------------------------\n\n/**\n * Create a LevelDB storage backend for server-side persistence.\n *\n * Returns a `Store` — pass directly to `Exchange({ stores: [...] })`.\n *\n * @param dbPath - Directory path where LevelDB stores its files\n *\n * @example\n * ```typescript\n * import { createLevelDBStore } from \"@kyneta/leveldb-store\"\n *\n * const exchange = new Exchange({\n * stores: [createLevelDBStore(\"./data/exchange-db\")],\n * })\n * ```\n */\nexport function createLevelDBStore(dbPath: string): Store {\n return new LevelDBStore(dbPath)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":["#db","#seqNos","#assertFormat"],"sources":["../src/index.ts"],"sourcesContent":["// server — LevelDB storage backend for @kyneta/exchange.\n//\n// Implements the Store interface using classic-level.\n//\n// Key-space design (FoundationDB convention — \\x00 null-byte separator):\n// doc-meta\\x00{docId} → JSON-encoded StoreMeta (materialized index)\n// record\\x00{docId}\\x00{seqNo} → binary-encoded StoreRecord (unified stream)\n// store-meta\\x00{key} → store-global metadata (e.g. format version)\n//\n// The \\x00 separator cannot appear in valid UTF-8 strings, so no docId\n// validation is needed — the key-space imposes zero constraints on callers.\n//\n// SeqNo is a zero-padded 16-digit monotonic counter per doc, tracked in\n// memory. On reboot, the max seqNo for a doc is lazily discovered via a\n// single reverse-iterator seek on first append.\n\nimport {\n type DocId,\n decideStoreFormat,\n parseStoreFormat,\n resolveMetaFromBatch,\n SeqNoTracker,\n STORE_META_FORMAT_KEY,\n type Store,\n type StoreFormatVersion,\n StoreFormatVersionError,\n type StoreMeta,\n type StoreRecord,\n validateAppend,\n} from \"@kyneta/exchange\"\nimport { ClassicLevel } from \"classic-level\"\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst SEP = \"\\x00\"\nconst DOC_META_PREFIX = `doc-meta${SEP}`\nconst RECORD_PREFIX = `record${SEP}`\nconst SEQ_PAD = 16\n\n// Store-global metadata namespace, keyed `store-meta\\x00{key}`. It sorts\n// above `doc-meta\\x00` ('d' < 's') and `record\\x00` ('r' < 's'), so it is\n// outside every `doc-meta`/`record` iteration range and needs no filtering.\n// Do not relocate it into those ranges. The on-disk format version lives at\n// `store-meta\\x00format`. This is read by a bootstrap reader on open, never\n// through the Store interface. Context: jj:uvssotsy.\nconst STORE_META_PREFIX = `store-meta${SEP}`\nconst STORE_FORMAT_KEY = `${STORE_META_PREFIX}${STORE_META_FORMAT_KEY}`\n\n// LevelDB owns its own on-disk format version (its binary envelope), gated on\n// open via `decideStoreFormat`. Distinct from the envelope's per-record bit-7\n// guard (`decodeStoreRecord`): bit-7 guards one record's decode; this marker\n// guards opening the store at all.\nconst STORE_FORMAT_VERSION: StoreFormatVersion = { major: 1, minor: 0 }\n\n// ---------------------------------------------------------------------------\n// Binary envelope v2 — pure encode/decode for StoreRecord\n// ---------------------------------------------------------------------------\n\n// Flags byte layout:\n// bit 0: payload kind (0 = entirety, 1 = since) — entry records only\n// bit 1: encoding (0 = json, 1 = binary) — entry records only\n// bit 2: data type (0 = string, 1 = Uint8Array) — entry records only\n// bit 3: record kind (0 = entry, 1 = meta)\n// bit 7: future-format (0 = current format, reserved)\n//\n// Meta records (bit 3 = 1):\n// [1 byte flags] [remaining: JSON-encoded StoreMeta]\n//\n// Entry records (bit 3 = 0):\n// [1 byte flags] [4 bytes version length BE] [N bytes version UTF-8] [remaining: payload data]\n\nconst encoder = new TextEncoder()\nconst decoder = new TextDecoder()\n\nexport function encodeStoreRecord(record: StoreRecord): Uint8Array {\n if (record.kind === \"meta\") {\n const metaBytes = encoder.encode(JSON.stringify(record.meta))\n const buf = new Uint8Array(1 + metaBytes.length)\n buf[0] = 0x08 // bit 3 set (meta)\n buf.set(metaBytes, 1)\n return buf\n }\n\n // Entry record\n const { payload, version } = record\n\n let flags = 0\n if (payload.kind === \"since\") flags |= 0x01\n if (payload.encoding === \"binary\") flags |= 0x02\n\n const isDataBinary = payload.data instanceof Uint8Array\n if (isDataBinary) flags |= 0x04\n\n const versionBytes = encoder.encode(version)\n const dataBytes = isDataBinary\n ? (payload.data as Uint8Array)\n : encoder.encode(payload.data as string)\n\n const buf = new Uint8Array(1 + 4 + versionBytes.length + dataBytes.length)\n const view = new DataView(buf.buffer)\n\n buf[0] = flags\n view.setUint32(1, versionBytes.length, false) // big-endian\n buf.set(versionBytes, 5)\n buf.set(dataBytes, 5 + versionBytes.length)\n\n return buf\n}\n\nexport function decodeStoreRecord(bytes: Uint8Array): StoreRecord {\n const flagByte = bytes[0]\n if (flagByte === undefined) throw new Error(\"empty store record bytes\")\n const flags = flagByte\n\n // Check future-format flag (bit 7)\n if ((flags & 0x80) !== 0) {\n throw new Error(\"unknown store record format (future-format bit set)\")\n }\n\n const isMeta = (flags & 0x08) !== 0\n\n if (isMeta) {\n const metaJson = decoder.decode(bytes.subarray(1))\n return { kind: \"meta\", meta: JSON.parse(metaJson) as StoreMeta }\n }\n\n // Entry record\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)\n\n const kind = (flags & 0x01) !== 0 ? \"since\" : \"entirety\"\n const encoding = (flags & 0x02) !== 0 ? \"binary\" : \"json\"\n const isDataBinary = (flags & 0x04) !== 0\n\n const versionLen = view.getUint32(1, false)\n const version = decoder.decode(bytes.subarray(5, 5 + versionLen))\n\n const dataStart = 5 + versionLen\n const rawData = bytes.subarray(dataStart)\n\n const data: string | Uint8Array = isDataBinary\n ? new Uint8Array(rawData)\n : decoder.decode(rawData)\n\n return {\n kind: \"entry\",\n payload: { kind, encoding, data },\n version,\n }\n}\n\n// ---------------------------------------------------------------------------\n// Key helpers\n// ---------------------------------------------------------------------------\n\nfunction docMetaKey(docId: DocId): string {\n return `${DOC_META_PREFIX}${docId}`\n}\n\nfunction recordPrefix(docId: DocId): string {\n return `${RECORD_PREFIX}${docId}${SEP}`\n}\n\nfunction recordKey(docId: DocId, seqNo: number): string {\n return `${recordPrefix(docId)}${String(seqNo).padStart(SEQ_PAD, \"0\")}`\n}\n\nfunction parseDocIdFromDocMetaKey(key: string): DocId {\n return key.slice(DOC_META_PREFIX.length)\n}\n\nfunction parseSeqNoFromRecordKey(key: string, docId: DocId): number {\n const prefix = recordPrefix(docId)\n return Number.parseInt(key.slice(prefix.length), 10)\n}\n\n// ---------------------------------------------------------------------------\n// Write planning — pure gather/plan/execute split (mirrors sql-core)\n// ---------------------------------------------------------------------------\n\ntype BatchOp =\n | { readonly type: \"put\"; readonly key: string; readonly value: Uint8Array }\n | { readonly type: \"del\"; readonly key: string }\n\n// Pure StoreMeta JSON envelope, shared by the writers (append/replace) and the\n// reader (currentMeta). The store-format marker keeps its own JSON.stringify —\n// it serializes a StoreFormatVersion, not a StoreMeta.\nfunction encodeDocMeta(meta: StoreMeta): Uint8Array {\n return encoder.encode(JSON.stringify(meta))\n}\nfunction decodeDocMeta(bytes: Uint8Array): StoreMeta {\n return JSON.parse(decoder.decode(bytes)) as StoreMeta\n}\n\n/**\n * Pure: validate the record against existing meta and return the LevelDB batch\n * ops to write. Mirrors sql-core's `planAppend` (the gather/plan/execute split,\n * jj:pzuytnvo). An entry record yields a single record `put`; a meta record\n * adds the doc-meta index `put`, and the two commit together in one batch.\n * `validateAppend` throws on an entry-before-meta violation — a pure,\n * input-deterministic throw.\n */\nfunction planAppend(\n docId: DocId,\n record: StoreRecord,\n existingMeta: StoreMeta | null,\n seq: number,\n): BatchOp[] {\n const resolved = validateAppend(docId, record, existingMeta)\n const ops: BatchOp[] = [\n {\n type: \"put\",\n key: recordKey(docId, seq),\n value: encodeStoreRecord(record),\n },\n ]\n if (resolved !== null) {\n ops.push({\n type: \"put\",\n key: docMetaKey(docId),\n value: encodeDocMeta(resolved),\n })\n }\n return ops\n}\n\n// ---------------------------------------------------------------------------\n// LevelDBStore\n// ---------------------------------------------------------------------------\n\nexport class LevelDBStore implements Store {\n readonly #db: ClassicLevel<string, Uint8Array>\n readonly #seqNos = new SeqNoTracker()\n\n // Accepts a path (constructs a ClassicLevel) or an already-built handle. The\n // handle form is the test seam for fault injection — production callers pass\n // a path via `createLevelDBStore` / `open`. jj:pzuytnvo\n constructor(dbPathOrDb: string | ClassicLevel<string, Uint8Array>) {\n this.#db =\n typeof dbPathOrDb === \"string\"\n ? new ClassicLevel(dbPathOrDb, { valueEncoding: \"binary\" })\n : dbPathOrDb\n }\n\n // -------------------------------------------------------------------------\n // Store interface\n // -------------------------------------------------------------------------\n\n async currentMeta(docId: DocId): Promise<StoreMeta | null> {\n try {\n const raw = await this.#db.get(docMetaKey(docId))\n return decodeDocMeta(raw)\n } catch (error: any) {\n if (error.code === \"LEVEL_NOT_FOUND\") return null\n throw error\n }\n }\n\n async append(docId: DocId, record: StoreRecord): Promise<void> {\n const existingMeta = await this.currentMeta(docId)\n\n // SeqNoTracker.next advances the in-memory counter before the write lands.\n // On a caught write failure the counter runs one ahead of disk → a benign\n // sparse seqNo (records are range-scanned, not indexed contiguously); it\n // self-heals on reopen via the cold-start seek. Context: jj:pzuytnvo.\n const seq = await this.#seqNos.next(docId, async () => {\n const prefix = recordPrefix(docId)\n for await (const key of this.#db.keys({\n gte: prefix,\n lt: `${prefix}\\xff`,\n reverse: true,\n limit: 1,\n })) {\n return parseSeqNoFromRecordKey(key, docId)\n }\n return null\n })\n\n // Single atomic batch: an entry append is a one-op batch (record only); a\n // meta append commits the record and the doc-meta index together, so a\n // crash never advances the index past its backing record. jj:pzuytnvo\n await this.#db.batch(planAppend(docId, record, existingMeta, seq))\n }\n\n async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {\n const prefix = recordPrefix(docId)\n for await (const value of this.#db.values({\n gte: prefix,\n lt: `${prefix}\\xff`,\n })) {\n yield decodeStoreRecord(value)\n }\n }\n\n async replace(docId: DocId, records: StoreRecord[]): Promise<void> {\n const existingMeta = await this.currentMeta(docId)\n\n // Resolve validates: at least one meta present, immutable fields match.\n const resolved = resolveMetaFromBatch(records, existingMeta)\n\n const prefix = recordPrefix(docId)\n\n // Collect existing record keys to delete\n const keysToDelete: string[] = []\n for await (const key of this.#db.keys({\n gte: prefix,\n lt: `${prefix}\\xff`,\n })) {\n keysToDelete.push(key)\n }\n\n // Atomic batch: delete all existing records, write replacements, upsert meta\n const ops: BatchOp[] = keysToDelete.map(key => ({\n type: \"del\" as const,\n key,\n }))\n\n for (let i = 0; i < records.length; i++) {\n ops.push({\n type: \"put\",\n key: recordKey(docId, i),\n value: encodeStoreRecord(records[i]!),\n })\n }\n\n ops.push({\n type: \"put\",\n key: docMetaKey(docId),\n value: encodeDocMeta(resolved),\n })\n\n await this.#db.batch(ops)\n\n // Reset seqNo counter to the last written index\n this.#seqNos.reset(docId, records.length - 1)\n }\n\n async delete(docId: DocId): Promise<void> {\n const prefix = recordPrefix(docId)\n\n // Collect record keys to delete\n const keysToDelete: string[] = [docMetaKey(docId)]\n for await (const key of this.#db.keys({\n gte: prefix,\n lt: `${prefix}\\xff`,\n })) {\n keysToDelete.push(key)\n }\n\n await this.#db.batch(\n keysToDelete.map(key => ({ type: \"del\" as const, key })),\n )\n\n // Remove from in-memory seqNo tracker\n this.#seqNos.remove(docId)\n }\n\n async *listDocIds(prefix?: string): AsyncIterable<DocId> {\n const rangePrefix =\n prefix !== undefined ? `${DOC_META_PREFIX}${prefix}` : DOC_META_PREFIX\n for await (const key of this.#db.keys({\n gte: rangePrefix,\n lt: `${rangePrefix}\\xff`,\n })) {\n yield parseDocIdFromDocMetaKey(key)\n }\n }\n\n async close(): Promise<void> {\n await this.#db.close()\n }\n\n // Bootstrap reader: consult the store-format marker before trusting any\n // bytes. Stamps a brand-new store, accepts a compatible one, or throws.\n // A `static open` reaches this private method so the gate stays internal.\n async #assertFormat(): Promise<void> {\n let parsed: StoreFormatVersion | \"malformed\" | null = null\n try {\n const raw = await this.#db.get(STORE_FORMAT_KEY)\n parsed = parseStoreFormat(decoder.decode(raw))\n } catch (error: any) {\n if (error.code !== \"LEVEL_NOT_FOUND\") throw error\n // absent → parsed stays null\n }\n if (parsed === \"malformed\") {\n throw new StoreFormatVersionError({\n reason: \"malformed-version\",\n backend: \"leveldb\",\n stored: null,\n current: STORE_FORMAT_VERSION,\n })\n }\n\n // Empty-store probe: does any doc-meta key exist?\n let hasData = false\n for await (const _key of this.#db.keys({\n gte: DOC_META_PREFIX,\n lt: `${DOC_META_PREFIX}\\xff`,\n limit: 1,\n })) {\n hasData = true\n }\n\n const decision = decideStoreFormat({\n current: STORE_FORMAT_VERSION,\n stored: parsed,\n storeHasData: hasData,\n })\n\n if (decision.action === \"refuse\") {\n throw new StoreFormatVersionError({\n reason: decision.reason,\n backend: \"leveldb\",\n stored: parsed,\n current: STORE_FORMAT_VERSION,\n })\n }\n if (decision.action === \"stamp\") {\n await this.#db.put(\n STORE_FORMAT_KEY,\n encoder.encode(JSON.stringify(decision.value)),\n )\n }\n }\n\n /** Open the store and run the store-format gate. Used by `createLevelDBStore`. */\n static async open(\n dbPathOrDb: string | ClassicLevel<string, Uint8Array>,\n ): Promise<Store> {\n const store = new LevelDBStore(dbPathOrDb)\n try {\n await store.#assertFormat()\n } catch (error) {\n // A refused store must not leak its file handle / lock.\n await store.#db.close()\n throw error\n }\n return store\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory function\n// ---------------------------------------------------------------------------\n\n/**\n * Create a LevelDB storage backend for server-side persistence.\n *\n * Async: it opens the database and runs the store-format gate (stamping a\n * brand-new store, accepting a compatible one, or throwing\n * `StoreFormatVersionError`). `await` it before passing to the `Exchange`.\n *\n * @param dbPath - Directory path where LevelDB stores its files\n *\n * @example\n * ```typescript\n * import { createLevelDBStore } from \"@kyneta/leveldb-store\"\n *\n * const exchange = new Exchange({\n * stores: [await createLevelDBStore(\"./data/exchange-db\")],\n * })\n * ```\n */\nexport function createLevelDBStore(dbPath: string): Promise<Store> {\n return LevelDBStore.open(dbPath)\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyneta/leveldb-store",
3
- "version": "1.8.0",
3
+ "version": "2.0.0",
4
4
  "description": "LevelDB storage backend for @kyneta/exchange — server-side persistent storage",
5
5
  "author": "Duane Johnson",
6
6
  "license": "MIT",
@@ -28,8 +28,8 @@
28
28
  }
29
29
  },
30
30
  "peerDependencies": {
31
- "@kyneta/exchange": "^1.8.0",
32
- "@kyneta/schema": "^1.8.0"
31
+ "@kyneta/exchange": "^2.0.0",
32
+ "@kyneta/schema": "^2.0.0"
33
33
  },
34
34
  "dependencies": {
35
35
  "classic-level": "^1.4.1"
@@ -39,8 +39,8 @@
39
39
  "tsdown": "^0.22.0",
40
40
  "typescript": "^5.9.2",
41
41
  "vitest": "^4.0.17",
42
- "@kyneta/exchange": "^1.8.0",
43
- "@kyneta/schema": "^1.8.0"
42
+ "@kyneta/exchange": "^2.0.0",
43
+ "@kyneta/schema": "^2.0.0"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsdown",
@@ -11,13 +11,20 @@ import type { StoreRecord } from "@kyneta/exchange"
11
11
  import {
12
12
  collectAll,
13
13
  describeStore,
14
+ makeArmedFault,
14
15
  makeBinaryEntryRecord,
15
16
  makeEntryRecord,
16
17
  makeMetaRecord,
17
18
  plainMeta,
18
19
  } from "@kyneta/exchange/testing"
20
+ import { ClassicLevel } from "classic-level"
19
21
  import { afterAll, describe, expect, it } from "vitest"
20
- import { decodeStoreRecord, encodeStoreRecord, LevelDBStore } from "../index.js"
22
+ import {
23
+ createLevelDBStore,
24
+ decodeStoreRecord,
25
+ encodeStoreRecord,
26
+ LevelDBStore,
27
+ } from "../index.js"
21
28
 
22
29
  // ---------------------------------------------------------------------------
23
30
  // Temp directory management
@@ -45,10 +52,43 @@ afterAll(() => {
45
52
  // Conformance suite — validates the full Store contract
46
53
  // ---------------------------------------------------------------------------
47
54
 
48
- describeStore("LevelDBStore", () => new LevelDBStore(makeTmpDir()), {
55
+ describeStore("LevelDBStore", () => createLevelDBStore(makeTmpDir()), {
49
56
  cleanup: async backend => {
50
57
  await backend.close()
51
58
  },
59
+ // Atomicity property: wrap the raw ClassicLevel so the Nth write op fails.
60
+ // Op-weighting (put = 1, batch = ops.length) makes the harness's
61
+ // injectFault(2) land inside the meta-append's 2-op batch, so the throw fires
62
+ // before the atomic batch commits — nothing leaks. jj:pzuytnvo
63
+ faultFactory: async () => {
64
+ const dir = makeTmpDir()
65
+ const raw = new ClassicLevel<string, Uint8Array>(dir, {
66
+ valueEncoding: "binary",
67
+ })
68
+ const { proxy, arm } = makeArmedFault(raw, {
69
+ put: 1,
70
+ batch: ops => (ops as readonly unknown[]).length,
71
+ })
72
+ const store = await LevelDBStore.open(proxy)
73
+ return {
74
+ store,
75
+ injectFault: arm,
76
+ // LevelDB takes a single-process directory lock, so the fresh store
77
+ // cannot open a second handle on `dir` while `raw` is live — release it
78
+ // first, then reopen non-faulting on the same dir.
79
+ freshStore: async () => {
80
+ await raw.close()
81
+ return createLevelDBStore(dir)
82
+ },
83
+ cleanup: async () => {
84
+ try {
85
+ await raw.close()
86
+ } catch {
87
+ // already closed by freshStore
88
+ }
89
+ },
90
+ }
91
+ },
52
92
  })
53
93
 
54
94
  // ---------------------------------------------------------------------------
@@ -60,14 +100,14 @@ describe("LevelDBStore — close + reopen", () => {
60
100
  const dir = makeTmpDir()
61
101
 
62
102
  // Phase 1: write data then close
63
- const backend1 = new LevelDBStore(dir)
103
+ const backend1 = await createLevelDBStore(dir)
64
104
  await backend1.append("doc-1", makeMetaRecord())
65
105
  await backend1.append("doc-1", makeEntryRecord("entirety", "v1"))
66
106
  await backend1.append("doc-1", makeEntryRecord("since", "v2"))
67
107
  await backend1.close()
68
108
 
69
109
  // Phase 2: reopen and verify
70
- const backend2 = new LevelDBStore(dir)
110
+ const backend2 = await createLevelDBStore(dir)
71
111
  expect(await backend2.currentMeta("doc-1")).toEqual(plainMeta)
72
112
 
73
113
  const records = await collectAll(backend2.loadAll("doc-1"))
@@ -87,14 +127,14 @@ describe("LevelDBStore — close + reopen", () => {
87
127
  it("append after reopen continues with correct seqNo ordering", async () => {
88
128
  const dir = makeTmpDir()
89
129
 
90
- const backend1 = new LevelDBStore(dir)
130
+ const backend1 = await createLevelDBStore(dir)
91
131
  await backend1.append("doc-1", makeMetaRecord())
92
132
  await backend1.append("doc-1", makeEntryRecord("entirety", "v1"))
93
133
  await backend1.append("doc-1", makeEntryRecord("since", "v2"))
94
134
  await backend1.close()
95
135
 
96
136
  // Reopen and append more
97
- const backend2 = new LevelDBStore(dir)
137
+ const backend2 = await createLevelDBStore(dir)
98
138
  await backend2.append("doc-1", makeEntryRecord("since", "v3"))
99
139
 
100
140
  const records = await collectAll(backend2.loadAll("doc-1"))
@@ -115,7 +155,7 @@ describe("LevelDBStore — close + reopen", () => {
115
155
  it("replace then reopen preserves the replacement records", async () => {
116
156
  const dir = makeTmpDir()
117
157
 
118
- const backend1 = new LevelDBStore(dir)
158
+ const backend1 = await createLevelDBStore(dir)
119
159
  await backend1.append("doc-1", makeMetaRecord())
120
160
  await backend1.append("doc-1", makeEntryRecord("since", "v1"))
121
161
  await backend1.append("doc-1", makeEntryRecord("since", "v2"))
@@ -125,7 +165,7 @@ describe("LevelDBStore — close + reopen", () => {
125
165
  ])
126
166
  await backend1.close()
127
167
 
128
- const backend2 = new LevelDBStore(dir)
168
+ const backend2 = await createLevelDBStore(dir)
129
169
  const records = await collectAll(backend2.loadAll("doc-1"))
130
170
  expect(records).toHaveLength(2)
131
171
  expect(records[0]?.kind).toBe("meta")
@@ -139,19 +179,54 @@ describe("LevelDBStore — close + reopen", () => {
139
179
  it("listDocIds works after reopen", async () => {
140
180
  const dir = makeTmpDir()
141
181
 
142
- const backend1 = new LevelDBStore(dir)
182
+ const backend1 = await createLevelDBStore(dir)
143
183
  await backend1.append("alpha", makeMetaRecord())
144
184
  await backend1.append("beta", makeMetaRecord())
145
185
  await backend1.append("gamma", makeMetaRecord())
146
186
  await backend1.close()
147
187
 
148
- const backend2 = new LevelDBStore(dir)
188
+ const backend2 = await createLevelDBStore(dir)
149
189
  const docIds = await collectAll(backend2.listDocIds())
150
190
  expect(docIds.sort()).toEqual(["alpha", "beta", "gamma"])
151
191
  await backend2.close()
152
192
  })
153
193
  })
154
194
 
195
+ // ---------------------------------------------------------------------------
196
+ // Store-format gate
197
+ // ---------------------------------------------------------------------------
198
+
199
+ describe("LevelDBStore — store-format gate", () => {
200
+ it("refuses an incompatible major and releases the lock so the dir reopens", async () => {
201
+ const dir = makeTmpDir()
202
+
203
+ // First open stamps {major:1,minor:0}; then corrupt it to a future major.
204
+ const backend1 = await createLevelDBStore(dir)
205
+ await backend1.append("doc-1", makeMetaRecord())
206
+ await backend1.close()
207
+
208
+ const raw = new ClassicLevel<string, Uint8Array>(dir, {
209
+ valueEncoding: "binary",
210
+ })
211
+ await raw.put(
212
+ "store-meta\x00format",
213
+ new TextEncoder().encode(JSON.stringify({ major: 99, minor: 0 })),
214
+ )
215
+ await raw.close()
216
+
217
+ // Open twice. Each must refuse *through the gate* with the major-mismatch
218
+ // reason. A refused open that leaked its handle would hold the LevelDB
219
+ // lock, so the second open would reject with a lock error — which has no
220
+ // `reason` and would fail this match.
221
+ const refusal = {
222
+ name: "StoreFormatVersionError",
223
+ reason: "incompatible-major",
224
+ }
225
+ await expect(createLevelDBStore(dir)).rejects.toMatchObject(refusal)
226
+ await expect(createLevelDBStore(dir)).rejects.toMatchObject(refusal)
227
+ })
228
+ })
229
+
155
230
  // ---------------------------------------------------------------------------
156
231
  // encode/decode round-trip — pure function unit tests
157
232
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  // Implements the Store interface using classic-level.
4
4
  //
5
5
  // Key-space design (FoundationDB convention — \x00 null-byte separator):
6
- // meta\x00{docId} → JSON-encoded StoreMeta (materialized index)
6
+ // doc-meta\x00{docId} → JSON-encoded StoreMeta (materialized index)
7
7
  // record\x00{docId}\x00{seqNo} → binary-encoded StoreRecord (unified stream)
8
+ // store-meta\x00{key} → store-global metadata (e.g. format version)
8
9
  //
9
10
  // The \x00 separator cannot appear in valid UTF-8 strings, so no docId
10
11
  // validation is needed — the key-space imposes zero constraints on callers.
@@ -15,9 +16,14 @@
15
16
 
16
17
  import {
17
18
  type DocId,
19
+ decideStoreFormat,
20
+ parseStoreFormat,
18
21
  resolveMetaFromBatch,
19
22
  SeqNoTracker,
23
+ STORE_META_FORMAT_KEY,
20
24
  type Store,
25
+ type StoreFormatVersion,
26
+ StoreFormatVersionError,
21
27
  type StoreMeta,
22
28
  type StoreRecord,
23
29
  validateAppend,
@@ -29,10 +35,25 @@ import { ClassicLevel } from "classic-level"
29
35
  // ---------------------------------------------------------------------------
30
36
 
31
37
  const SEP = "\x00"
32
- const META_PREFIX = `meta${SEP}`
38
+ const DOC_META_PREFIX = `doc-meta${SEP}`
33
39
  const RECORD_PREFIX = `record${SEP}`
34
40
  const SEQ_PAD = 16
35
41
 
42
+ // Store-global metadata namespace, keyed `store-meta\x00{key}`. It sorts
43
+ // above `doc-meta\x00` ('d' < 's') and `record\x00` ('r' < 's'), so it is
44
+ // outside every `doc-meta`/`record` iteration range and needs no filtering.
45
+ // Do not relocate it into those ranges. The on-disk format version lives at
46
+ // `store-meta\x00format`. This is read by a bootstrap reader on open, never
47
+ // through the Store interface. Context: jj:uvssotsy.
48
+ const STORE_META_PREFIX = `store-meta${SEP}`
49
+ const STORE_FORMAT_KEY = `${STORE_META_PREFIX}${STORE_META_FORMAT_KEY}`
50
+
51
+ // LevelDB owns its own on-disk format version (its binary envelope), gated on
52
+ // open via `decideStoreFormat`. Distinct from the envelope's per-record bit-7
53
+ // guard (`decodeStoreRecord`): bit-7 guards one record's decode; this marker
54
+ // guards opening the store at all.
55
+ const STORE_FORMAT_VERSION: StoreFormatVersion = { major: 1, minor: 0 }
56
+
36
57
  // ---------------------------------------------------------------------------
37
58
  // Binary envelope v2 — pure encode/decode for StoreRecord
38
59
  // ---------------------------------------------------------------------------
@@ -133,8 +154,8 @@ export function decodeStoreRecord(bytes: Uint8Array): StoreRecord {
133
154
  // Key helpers
134
155
  // ---------------------------------------------------------------------------
135
156
 
136
- function metaKey(docId: DocId): string {
137
- return `${META_PREFIX}${docId}`
157
+ function docMetaKey(docId: DocId): string {
158
+ return `${DOC_META_PREFIX}${docId}`
138
159
  }
139
160
 
140
161
  function recordPrefix(docId: DocId): string {
@@ -145,8 +166,8 @@ function recordKey(docId: DocId, seqNo: number): string {
145
166
  return `${recordPrefix(docId)}${String(seqNo).padStart(SEQ_PAD, "0")}`
146
167
  }
147
168
 
148
- function parseDocIdFromMetaKey(key: string): DocId {
149
- return key.slice(META_PREFIX.length)
169
+ function parseDocIdFromDocMetaKey(key: string): DocId {
170
+ return key.slice(DOC_META_PREFIX.length)
150
171
  }
151
172
 
152
173
  function parseSeqNoFromRecordKey(key: string, docId: DocId): number {
@@ -154,6 +175,56 @@ function parseSeqNoFromRecordKey(key: string, docId: DocId): number {
154
175
  return Number.parseInt(key.slice(prefix.length), 10)
155
176
  }
156
177
 
178
+ // ---------------------------------------------------------------------------
179
+ // Write planning — pure gather/plan/execute split (mirrors sql-core)
180
+ // ---------------------------------------------------------------------------
181
+
182
+ type BatchOp =
183
+ | { readonly type: "put"; readonly key: string; readonly value: Uint8Array }
184
+ | { readonly type: "del"; readonly key: string }
185
+
186
+ // Pure StoreMeta JSON envelope, shared by the writers (append/replace) and the
187
+ // reader (currentMeta). The store-format marker keeps its own JSON.stringify —
188
+ // it serializes a StoreFormatVersion, not a StoreMeta.
189
+ function encodeDocMeta(meta: StoreMeta): Uint8Array {
190
+ return encoder.encode(JSON.stringify(meta))
191
+ }
192
+ function decodeDocMeta(bytes: Uint8Array): StoreMeta {
193
+ return JSON.parse(decoder.decode(bytes)) as StoreMeta
194
+ }
195
+
196
+ /**
197
+ * Pure: validate the record against existing meta and return the LevelDB batch
198
+ * ops to write. Mirrors sql-core's `planAppend` (the gather/plan/execute split,
199
+ * jj:pzuytnvo). An entry record yields a single record `put`; a meta record
200
+ * adds the doc-meta index `put`, and the two commit together in one batch.
201
+ * `validateAppend` throws on an entry-before-meta violation — a pure,
202
+ * input-deterministic throw.
203
+ */
204
+ function planAppend(
205
+ docId: DocId,
206
+ record: StoreRecord,
207
+ existingMeta: StoreMeta | null,
208
+ seq: number,
209
+ ): BatchOp[] {
210
+ const resolved = validateAppend(docId, record, existingMeta)
211
+ const ops: BatchOp[] = [
212
+ {
213
+ type: "put",
214
+ key: recordKey(docId, seq),
215
+ value: encodeStoreRecord(record),
216
+ },
217
+ ]
218
+ if (resolved !== null) {
219
+ ops.push({
220
+ type: "put",
221
+ key: docMetaKey(docId),
222
+ value: encodeDocMeta(resolved),
223
+ })
224
+ }
225
+ return ops
226
+ }
227
+
157
228
  // ---------------------------------------------------------------------------
158
229
  // LevelDBStore
159
230
  // ---------------------------------------------------------------------------
@@ -162,10 +233,14 @@ export class LevelDBStore implements Store {
162
233
  readonly #db: ClassicLevel<string, Uint8Array>
163
234
  readonly #seqNos = new SeqNoTracker()
164
235
 
165
- constructor(dbPath: string) {
166
- this.#db = new ClassicLevel(dbPath, {
167
- valueEncoding: "binary",
168
- })
236
+ // Accepts a path (constructs a ClassicLevel) or an already-built handle. The
237
+ // handle form is the test seam for fault injection — production callers pass
238
+ // a path via `createLevelDBStore` / `open`. jj:pzuytnvo
239
+ constructor(dbPathOrDb: string | ClassicLevel<string, Uint8Array>) {
240
+ this.#db =
241
+ typeof dbPathOrDb === "string"
242
+ ? new ClassicLevel(dbPathOrDb, { valueEncoding: "binary" })
243
+ : dbPathOrDb
169
244
  }
170
245
 
171
246
  // -------------------------------------------------------------------------
@@ -174,8 +249,8 @@ export class LevelDBStore implements Store {
174
249
 
175
250
  async currentMeta(docId: DocId): Promise<StoreMeta | null> {
176
251
  try {
177
- const raw = await this.#db.get(metaKey(docId))
178
- return JSON.parse(decoder.decode(raw)) as StoreMeta
252
+ const raw = await this.#db.get(docMetaKey(docId))
253
+ return decodeDocMeta(raw)
179
254
  } catch (error: any) {
180
255
  if (error.code === "LEVEL_NOT_FOUND") return null
181
256
  throw error
@@ -184,15 +259,11 @@ export class LevelDBStore implements Store {
184
259
 
185
260
  async append(docId: DocId, record: StoreRecord): Promise<void> {
186
261
  const existingMeta = await this.currentMeta(docId)
187
- const resolved = validateAppend(docId, record, existingMeta)
188
-
189
- if (resolved !== null) {
190
- await this.#db.put(
191
- metaKey(docId),
192
- encoder.encode(JSON.stringify(resolved)),
193
- )
194
- }
195
262
 
263
+ // SeqNoTracker.next advances the in-memory counter before the write lands.
264
+ // On a caught write failure the counter runs one ahead of disk → a benign
265
+ // sparse seqNo (records are range-scanned, not indexed contiguously); it
266
+ // self-heals on reopen via the cold-start seek. Context: jj:pzuytnvo.
196
267
  const seq = await this.#seqNos.next(docId, async () => {
197
268
  const prefix = recordPrefix(docId)
198
269
  for await (const key of this.#db.keys({
@@ -205,7 +276,11 @@ export class LevelDBStore implements Store {
205
276
  }
206
277
  return null
207
278
  })
208
- await this.#db.put(recordKey(docId, seq), encodeStoreRecord(record))
279
+
280
+ // Single atomic batch: an entry append is a one-op batch (record only); a
281
+ // meta append commits the record and the doc-meta index together, so a
282
+ // crash never advances the index past its backing record. jj:pzuytnvo
283
+ await this.#db.batch(planAppend(docId, record, existingMeta, seq))
209
284
  }
210
285
 
211
286
  async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {
@@ -236,10 +311,10 @@ export class LevelDBStore implements Store {
236
311
  }
237
312
 
238
313
  // Atomic batch: delete all existing records, write replacements, upsert meta
239
- const ops: Array<
240
- | { type: "del"; key: string }
241
- | { type: "put"; key: string; value: Uint8Array }
242
- > = keysToDelete.map(key => ({ type: "del" as const, key }))
314
+ const ops: BatchOp[] = keysToDelete.map(key => ({
315
+ type: "del" as const,
316
+ key,
317
+ }))
243
318
 
244
319
  for (let i = 0; i < records.length; i++) {
245
320
  ops.push({
@@ -251,8 +326,8 @@ export class LevelDBStore implements Store {
251
326
 
252
327
  ops.push({
253
328
  type: "put",
254
- key: metaKey(docId),
255
- value: encoder.encode(JSON.stringify(resolved)),
329
+ key: docMetaKey(docId),
330
+ value: encodeDocMeta(resolved),
256
331
  })
257
332
 
258
333
  await this.#db.batch(ops)
@@ -265,7 +340,7 @@ export class LevelDBStore implements Store {
265
340
  const prefix = recordPrefix(docId)
266
341
 
267
342
  // Collect record keys to delete
268
- const keysToDelete: string[] = [metaKey(docId)]
343
+ const keysToDelete: string[] = [docMetaKey(docId)]
269
344
  for await (const key of this.#db.keys({
270
345
  gte: prefix,
271
346
  lt: `${prefix}\xff`,
@@ -283,18 +358,86 @@ export class LevelDBStore implements Store {
283
358
 
284
359
  async *listDocIds(prefix?: string): AsyncIterable<DocId> {
285
360
  const rangePrefix =
286
- prefix !== undefined ? `${META_PREFIX}${prefix}` : META_PREFIX
361
+ prefix !== undefined ? `${DOC_META_PREFIX}${prefix}` : DOC_META_PREFIX
287
362
  for await (const key of this.#db.keys({
288
363
  gte: rangePrefix,
289
364
  lt: `${rangePrefix}\xff`,
290
365
  })) {
291
- yield parseDocIdFromMetaKey(key)
366
+ yield parseDocIdFromDocMetaKey(key)
292
367
  }
293
368
  }
294
369
 
295
370
  async close(): Promise<void> {
296
371
  await this.#db.close()
297
372
  }
373
+
374
+ // Bootstrap reader: consult the store-format marker before trusting any
375
+ // bytes. Stamps a brand-new store, accepts a compatible one, or throws.
376
+ // A `static open` reaches this private method so the gate stays internal.
377
+ async #assertFormat(): Promise<void> {
378
+ let parsed: StoreFormatVersion | "malformed" | null = null
379
+ try {
380
+ const raw = await this.#db.get(STORE_FORMAT_KEY)
381
+ parsed = parseStoreFormat(decoder.decode(raw))
382
+ } catch (error: any) {
383
+ if (error.code !== "LEVEL_NOT_FOUND") throw error
384
+ // absent → parsed stays null
385
+ }
386
+ if (parsed === "malformed") {
387
+ throw new StoreFormatVersionError({
388
+ reason: "malformed-version",
389
+ backend: "leveldb",
390
+ stored: null,
391
+ current: STORE_FORMAT_VERSION,
392
+ })
393
+ }
394
+
395
+ // Empty-store probe: does any doc-meta key exist?
396
+ let hasData = false
397
+ for await (const _key of this.#db.keys({
398
+ gte: DOC_META_PREFIX,
399
+ lt: `${DOC_META_PREFIX}\xff`,
400
+ limit: 1,
401
+ })) {
402
+ hasData = true
403
+ }
404
+
405
+ const decision = decideStoreFormat({
406
+ current: STORE_FORMAT_VERSION,
407
+ stored: parsed,
408
+ storeHasData: hasData,
409
+ })
410
+
411
+ if (decision.action === "refuse") {
412
+ throw new StoreFormatVersionError({
413
+ reason: decision.reason,
414
+ backend: "leveldb",
415
+ stored: parsed,
416
+ current: STORE_FORMAT_VERSION,
417
+ })
418
+ }
419
+ if (decision.action === "stamp") {
420
+ await this.#db.put(
421
+ STORE_FORMAT_KEY,
422
+ encoder.encode(JSON.stringify(decision.value)),
423
+ )
424
+ }
425
+ }
426
+
427
+ /** Open the store and run the store-format gate. Used by `createLevelDBStore`. */
428
+ static async open(
429
+ dbPathOrDb: string | ClassicLevel<string, Uint8Array>,
430
+ ): Promise<Store> {
431
+ const store = new LevelDBStore(dbPathOrDb)
432
+ try {
433
+ await store.#assertFormat()
434
+ } catch (error) {
435
+ // A refused store must not leak its file handle / lock.
436
+ await store.#db.close()
437
+ throw error
438
+ }
439
+ return store
440
+ }
298
441
  }
299
442
 
300
443
  // ---------------------------------------------------------------------------
@@ -304,7 +447,9 @@ export class LevelDBStore implements Store {
304
447
  /**
305
448
  * Create a LevelDB storage backend for server-side persistence.
306
449
  *
307
- * Returns a `Store` pass directly to `Exchange({ stores: [...] })`.
450
+ * Async: it opens the database and runs the store-format gate (stamping a
451
+ * brand-new store, accepting a compatible one, or throwing
452
+ * `StoreFormatVersionError`). `await` it before passing to the `Exchange`.
308
453
  *
309
454
  * @param dbPath - Directory path where LevelDB stores its files
310
455
  *
@@ -313,10 +458,10 @@ export class LevelDBStore implements Store {
313
458
  * import { createLevelDBStore } from "@kyneta/leveldb-store"
314
459
  *
315
460
  * const exchange = new Exchange({
316
- * stores: [createLevelDBStore("./data/exchange-db")],
461
+ * stores: [await createLevelDBStore("./data/exchange-db")],
317
462
  * })
318
463
  * ```
319
464
  */
320
- export function createLevelDBStore(dbPath: string): Store {
321
- return new LevelDBStore(dbPath)
465
+ export function createLevelDBStore(dbPath: string): Promise<Store> {
466
+ return LevelDBStore.open(dbPath)
322
467
  }