@kyneta/leveldb-store 1.7.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 +10 -6
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +99 -22
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/leveldb-storage.test.ts +85 -10
- package/src/index.ts +179 -34
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
`
|
|
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(
|
|
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
|
-
*
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":"
|
|
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
|
|
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
|
|
65
|
-
return `${
|
|
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
|
|
74
|
-
return key.slice(
|
|
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
|
-
|
|
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(
|
|
84
|
-
this.#db = new ClassicLevel(
|
|
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
|
-
|
|
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
|
|
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.
|
|
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:
|
|
137
|
-
value:
|
|
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 = [
|
|
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 ? `${
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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": "
|
|
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": "^
|
|
32
|
-
"@kyneta/schema": "^
|
|
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": "^
|
|
43
|
-
"@kyneta/schema": "^
|
|
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 {
|
|
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", () =>
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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}
|
|
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
|
|
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
|
|
137
|
-
return `${
|
|
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
|
|
149
|
-
return key.slice(
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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(
|
|
178
|
-
return
|
|
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
|
-
|
|
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:
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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:
|
|
255
|
-
value:
|
|
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[] = [
|
|
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 ? `${
|
|
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
|
|
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
|
-
*
|
|
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
|
|
465
|
+
export function createLevelDBStore(dbPath: string): Promise<Store> {
|
|
466
|
+
return LevelDBStore.open(dbPath)
|
|
322
467
|
}
|