@kyneta/leveldb-store 1.3.0 → 1.4.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.
@@ -0,0 +1,36 @@
1
+ import { DocId, Store, StoreMeta, StoreRecord } from "@kyneta/exchange";
2
+
3
+ //#region src/index.d.ts
4
+ declare function encodeStoreRecord(record: StoreRecord): Uint8Array;
5
+ declare function decodeStoreRecord(bytes: Uint8Array): StoreRecord;
6
+ declare class LevelDBStore implements Store {
7
+ #private;
8
+ constructor(dbPath: string);
9
+ currentMeta(docId: DocId): Promise<StoreMeta | null>;
10
+ append(docId: DocId, record: StoreRecord): Promise<void>;
11
+ loadAll(docId: DocId): AsyncIterable<StoreRecord>;
12
+ replace(docId: DocId, records: StoreRecord[]): Promise<void>;
13
+ delete(docId: DocId): Promise<void>;
14
+ listDocIds(prefix?: string): AsyncIterable<DocId>;
15
+ close(): Promise<void>;
16
+ }
17
+ /**
18
+ * Create a LevelDB storage backend for server-side persistence.
19
+ *
20
+ * Returns a `Store` — pass directly to `Exchange({ stores: [...] })`.
21
+ *
22
+ * @param dbPath - Directory path where LevelDB stores its files
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * import { createLevelDBStore } from "@kyneta/leveldb-store"
27
+ *
28
+ * const exchange = new Exchange({
29
+ * stores: [createLevelDBStore("./data/exchange-db")],
30
+ * })
31
+ * ```
32
+ */
33
+ declare function createLevelDBStore(dbPath: string): Store;
34
+ //#endregion
35
+ export { LevelDBStore, createLevelDBStore, decodeStoreRecord, encodeStoreRecord };
36
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;iBAqDgB,iBAAA,CAAkB,MAAA,EAAQ,WAAA,GAAc,UAAA;AAAA,iBAmCxC,iBAAA,CAAkB,KAAA,EAAO,UAAA,GAAa,WAAA;AAAA,cAsEzC,YAAA,YAAwB,KAAA;EAAA;cAIvB,MAAA;EA2CN,WAAA,CAAY,KAAA,EAAO,KAAA,GAAQ,OAAA,CAAQ,SAAA;EAUnC,MAAA,CAAO,KAAA,EAAO,KAAA,EAAO,MAAA,EAAQ,WAAA,GAAc,OAAA;EAsB1C,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;;;;;;;;AAnKjB;;;;;;;;;iBA4LgB,kBAAA,CAAmB,MAAA,WAAiB,KAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,207 @@
1
+ import { resolveMetaFromBatch } from "@kyneta/exchange";
2
+ import { ClassicLevel } from "classic-level";
3
+ //#region src/index.ts
4
+ const SEP = "\0";
5
+ const META_PREFIX = `meta${SEP}`;
6
+ const RECORD_PREFIX = `record${SEP}`;
7
+ const SEQ_PAD = 16;
8
+ const encoder = new TextEncoder();
9
+ const decoder = new TextDecoder();
10
+ function encodeStoreRecord(record) {
11
+ if (record.kind === "meta") {
12
+ const metaBytes = encoder.encode(JSON.stringify(record.meta));
13
+ const buf = new Uint8Array(1 + metaBytes.length);
14
+ buf[0] = 8;
15
+ buf.set(metaBytes, 1);
16
+ return buf;
17
+ }
18
+ const { payload, version } = record;
19
+ let flags = 0;
20
+ if (payload.kind === "since") flags |= 1;
21
+ if (payload.encoding === "binary") flags |= 2;
22
+ const isDataBinary = payload.data instanceof Uint8Array;
23
+ if (isDataBinary) flags |= 4;
24
+ const versionBytes = encoder.encode(version);
25
+ const dataBytes = isDataBinary ? payload.data : encoder.encode(payload.data);
26
+ const buf = new Uint8Array(5 + versionBytes.length + dataBytes.length);
27
+ const view = new DataView(buf.buffer);
28
+ buf[0] = flags;
29
+ view.setUint32(1, versionBytes.length, false);
30
+ buf.set(versionBytes, 5);
31
+ buf.set(dataBytes, 5 + versionBytes.length);
32
+ return buf;
33
+ }
34
+ function decodeStoreRecord(bytes) {
35
+ const flagByte = bytes[0];
36
+ if (flagByte === void 0) throw new Error("empty store record bytes");
37
+ const flags = flagByte;
38
+ if ((flags & 128) !== 0) throw new Error("unknown store record format (future-format bit set)");
39
+ if ((flags & 8) !== 0) {
40
+ const metaJson = decoder.decode(bytes.subarray(1));
41
+ return {
42
+ kind: "meta",
43
+ meta: JSON.parse(metaJson)
44
+ };
45
+ }
46
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
47
+ const kind = (flags & 1) !== 0 ? "since" : "entirety";
48
+ const encoding = (flags & 2) !== 0 ? "binary" : "json";
49
+ const isDataBinary = (flags & 4) !== 0;
50
+ const versionLen = view.getUint32(1, false);
51
+ const version = decoder.decode(bytes.subarray(5, 5 + versionLen));
52
+ const dataStart = 5 + versionLen;
53
+ const rawData = bytes.subarray(dataStart);
54
+ return {
55
+ kind: "entry",
56
+ payload: {
57
+ kind,
58
+ encoding,
59
+ data: isDataBinary ? new Uint8Array(rawData) : decoder.decode(rawData)
60
+ },
61
+ version
62
+ };
63
+ }
64
+ function metaKey(docId) {
65
+ return `${META_PREFIX}${docId}`;
66
+ }
67
+ function recordPrefix(docId) {
68
+ return `${RECORD_PREFIX}${docId}${SEP}`;
69
+ }
70
+ function recordKey(docId, seqNo) {
71
+ return `${recordPrefix(docId)}${String(seqNo).padStart(SEQ_PAD, "0")}`;
72
+ }
73
+ function parseDocIdFromMetaKey(key) {
74
+ return key.slice(META_PREFIX.length);
75
+ }
76
+ function parseSeqNoFromRecordKey(key, docId) {
77
+ const prefix = recordPrefix(docId);
78
+ return Number.parseInt(key.slice(prefix.length), 10);
79
+ }
80
+ var LevelDBStore = class {
81
+ #db;
82
+ #seqNos = /* @__PURE__ */ new Map();
83
+ constructor(dbPath) {
84
+ this.#db = new ClassicLevel(dbPath, { valueEncoding: "binary" });
85
+ }
86
+ /**
87
+ * Get the next seqNo for a doc. On first call after reboot, discovers
88
+ * the max existing seqNo via a single reverse-iterator seek.
89
+ */
90
+ async #nextSeqNo(docId) {
91
+ const cached = this.#seqNos.get(docId);
92
+ if (cached !== void 0) {
93
+ const next = cached + 1;
94
+ this.#seqNos.set(docId, next);
95
+ return next;
96
+ }
97
+ const prefix = recordPrefix(docId);
98
+ let maxSeq = -1;
99
+ for await (const key of this.#db.keys({
100
+ gte: prefix,
101
+ lt: `${prefix}\xff`,
102
+ reverse: true,
103
+ limit: 1
104
+ })) maxSeq = parseSeqNoFromRecordKey(key, docId);
105
+ const next = maxSeq + 1;
106
+ this.#seqNos.set(docId, next);
107
+ return next;
108
+ }
109
+ async currentMeta(docId) {
110
+ try {
111
+ const raw = await this.#db.get(metaKey(docId));
112
+ return JSON.parse(decoder.decode(raw));
113
+ } catch (error) {
114
+ if (error.code === "LEVEL_NOT_FOUND") return null;
115
+ throw error;
116
+ }
117
+ }
118
+ async append(docId, record) {
119
+ const existingMeta = await this.currentMeta(docId);
120
+ if (record.kind === "entry") {
121
+ if (existingMeta === null) throw new Error(`Store: first record for doc '${docId}' must be meta, got entry`);
122
+ } else {
123
+ const resolved = resolveMetaFromBatch([record], existingMeta);
124
+ await this.#db.put(metaKey(docId), encoder.encode(JSON.stringify(resolved)));
125
+ }
126
+ const seq = await this.#nextSeqNo(docId);
127
+ await this.#db.put(recordKey(docId, seq), encodeStoreRecord(record));
128
+ }
129
+ async *loadAll(docId) {
130
+ const prefix = recordPrefix(docId);
131
+ for await (const value of this.#db.values({
132
+ gte: prefix,
133
+ lt: `${prefix}\xff`
134
+ })) yield decodeStoreRecord(value);
135
+ }
136
+ async replace(docId, records) {
137
+ const resolved = resolveMetaFromBatch(records, await this.currentMeta(docId));
138
+ const prefix = recordPrefix(docId);
139
+ const keysToDelete = [];
140
+ for await (const key of this.#db.keys({
141
+ gte: prefix,
142
+ lt: `${prefix}\xff`
143
+ })) keysToDelete.push(key);
144
+ const ops = keysToDelete.map((key) => ({
145
+ type: "del",
146
+ key
147
+ }));
148
+ for (let i = 0; i < records.length; i++) ops.push({
149
+ type: "put",
150
+ key: recordKey(docId, i),
151
+ value: encodeStoreRecord(records[i])
152
+ });
153
+ ops.push({
154
+ type: "put",
155
+ key: metaKey(docId),
156
+ value: encoder.encode(JSON.stringify(resolved))
157
+ });
158
+ await this.#db.batch(ops);
159
+ this.#seqNos.set(docId, records.length - 1);
160
+ }
161
+ async delete(docId) {
162
+ const prefix = recordPrefix(docId);
163
+ const keysToDelete = [metaKey(docId)];
164
+ for await (const key of this.#db.keys({
165
+ gte: prefix,
166
+ lt: `${prefix}\xff`
167
+ })) keysToDelete.push(key);
168
+ await this.#db.batch(keysToDelete.map((key) => ({
169
+ type: "del",
170
+ key
171
+ })));
172
+ this.#seqNos.delete(docId);
173
+ }
174
+ async *listDocIds(prefix) {
175
+ const rangePrefix = prefix !== void 0 ? `${META_PREFIX}${prefix}` : META_PREFIX;
176
+ for await (const key of this.#db.keys({
177
+ gte: rangePrefix,
178
+ lt: `${rangePrefix}\xff`
179
+ })) yield parseDocIdFromMetaKey(key);
180
+ }
181
+ async close() {
182
+ await this.#db.close();
183
+ }
184
+ };
185
+ /**
186
+ * Create a LevelDB storage backend for server-side persistence.
187
+ *
188
+ * Returns a `Store` — pass directly to `Exchange({ stores: [...] })`.
189
+ *
190
+ * @param dbPath - Directory path where LevelDB stores its files
191
+ *
192
+ * @example
193
+ * ```typescript
194
+ * import { createLevelDBStore } from "@kyneta/leveldb-store"
195
+ *
196
+ * const exchange = new Exchange({
197
+ * stores: [createLevelDBStore("./data/exchange-db")],
198
+ * })
199
+ * ```
200
+ */
201
+ function createLevelDBStore(dbPath) {
202
+ return new LevelDBStore(dbPath);
203
+ }
204
+ //#endregion
205
+ export { LevelDBStore, createLevelDBStore, decodeStoreRecord, encodeStoreRecord };
206
+
207
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["#db","#seqNos","#nextSeqNo"],"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 type Store,\n type StoreMeta,\n type StoreRecord,\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: Map<DocId, number> = new Map()\n\n constructor(dbPath: string) {\n this.#db = new ClassicLevel(dbPath, {\n valueEncoding: \"binary\",\n })\n }\n\n // -------------------------------------------------------------------------\n // Private — seqNo management\n // -------------------------------------------------------------------------\n\n /**\n * Get the next seqNo for a doc. On first call after reboot, discovers\n * the max existing seqNo via a single reverse-iterator seek.\n */\n async #nextSeqNo(docId: DocId): Promise<number> {\n const cached = this.#seqNos.get(docId)\n if (cached !== undefined) {\n const next = cached + 1\n this.#seqNos.set(docId, next)\n return next\n }\n\n // Lazy discovery: reverse iterate to find the highest existing seqNo\n const prefix = recordPrefix(docId)\n let maxSeq = -1\n for await (const key of this.#db.keys({\n gte: prefix,\n lt: `${prefix}\\xff`,\n reverse: true,\n limit: 1,\n })) {\n maxSeq = parseSeqNoFromRecordKey(key, docId)\n }\n\n const next = maxSeq + 1\n this.#seqNos.set(docId, next)\n return next\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\n if (record.kind === \"entry\") {\n if (existingMeta === null) {\n throw new Error(\n `Store: first record for doc '${docId}' must be meta, got entry`,\n )\n }\n } else {\n // record.kind === \"meta\" — validate immutability via resolution\n const resolved = resolveMetaFromBatch([record], existingMeta)\n await this.#db.put(\n metaKey(docId),\n encoder.encode(JSON.stringify(resolved)),\n )\n }\n\n const seq = await this.#nextSeqNo(docId)\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.set(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.delete(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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyneta/leveldb-store",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "LevelDB storage backend for @kyneta/exchange — server-side persistent storage",
5
5
  "author": "Duane Johnson",
6
6
  "license": "MIT",
@@ -13,34 +13,39 @@
13
13
  "access": "public"
14
14
  },
15
15
  "type": "module",
16
+ "main": "./dist/index.js",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
16
19
  "files": [
17
20
  "dist",
18
21
  "src"
19
22
  ],
20
23
  "exports": {
21
- "./server": {
22
- "types": "./dist/server.d.ts",
23
- "import": "./dist/server.js"
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js",
27
+ "default": "./dist/index.js"
24
28
  },
29
+ "./src": "./src/index.ts",
25
30
  "./src/*": "./src/*"
26
31
  },
27
32
  "peerDependencies": {
28
- "@kyneta/exchange": "^1.3.0",
29
- "@kyneta/schema": "^1.3.0"
33
+ "@kyneta/exchange": "^1.4.0",
34
+ "@kyneta/schema": "^1.4.0"
30
35
  },
31
36
  "dependencies": {
32
37
  "classic-level": "^1.4.1"
33
38
  },
34
39
  "devDependencies": {
35
40
  "@types/node": "^22",
36
- "tsup": "^8.5.0",
41
+ "tsdown": "^0.21.9",
37
42
  "typescript": "^5.9.2",
38
43
  "vitest": "^4.0.17",
39
- "@kyneta/exchange": "^1.3.0",
40
- "@kyneta/schema": "^1.3.0"
44
+ "@kyneta/schema": "^1.4.0",
45
+ "@kyneta/exchange": "^1.4.0"
41
46
  },
42
47
  "scripts": {
43
- "build": "tsup",
48
+ "build": "tsdown",
44
49
  "test": "verify logic",
45
50
  "verify": "verify"
46
51
  }
@@ -7,15 +7,17 @@
7
7
  import * as fs from "node:fs"
8
8
  import * as os from "node:os"
9
9
  import * as path from "node:path"
10
- import type { StoreEntry } from "@kyneta/exchange/src/store/store.js"
10
+ import type { StoreRecord } from "@kyneta/exchange"
11
11
  import {
12
12
  collectAll,
13
13
  describeStore,
14
- makeEntry,
15
- plainMetadata,
16
- } from "@kyneta/exchange/src/testing/store-conformance.js"
14
+ makeBinaryEntryRecord,
15
+ makeEntryRecord,
16
+ makeMetaRecord,
17
+ plainMeta,
18
+ } from "@kyneta/exchange/testing"
17
19
  import { afterAll, describe, expect, it } from "vitest"
18
- import { decodeStoreEntry, encodeStoreEntry, LevelDBStore } from "../server.js"
20
+ import { decodeStoreRecord, encodeStoreRecord, LevelDBStore } from "../index.js"
19
21
 
20
22
  // ---------------------------------------------------------------------------
21
23
  // Temp directory management
@@ -47,7 +49,7 @@ describeStore(
47
49
  "LevelDBStore",
48
50
  () => new LevelDBStore(makeTmpDir()),
49
51
  async backend => {
50
- if (backend.close) await backend.close()
52
+ await backend.close()
51
53
  },
52
54
  )
53
55
 
@@ -61,19 +63,26 @@ describe("LevelDBStore — close + reopen", () => {
61
63
 
62
64
  // Phase 1: write data then close
63
65
  const backend1 = new LevelDBStore(dir)
64
- await backend1.ensureDoc("doc-1", plainMetadata)
65
- await backend1.append("doc-1", makeEntry("entirety", "v1"))
66
- await backend1.append("doc-1", makeEntry("since", "v2"))
66
+ await backend1.append("doc-1", makeMetaRecord())
67
+ await backend1.append("doc-1", makeEntryRecord("entirety", "v1"))
68
+ await backend1.append("doc-1", makeEntryRecord("since", "v2"))
67
69
  await backend1.close()
68
70
 
69
71
  // Phase 2: reopen and verify
70
72
  const backend2 = new LevelDBStore(dir)
71
- expect(await backend2.lookup("doc-1")).toEqual(plainMetadata)
73
+ expect(await backend2.currentMeta("doc-1")).toEqual(plainMeta)
72
74
 
73
- const entries = await collectAll(backend2.loadAll("doc-1"))
75
+ const records = await collectAll(backend2.loadAll("doc-1"))
76
+ expect(records).toHaveLength(3)
77
+ expect(records[0]).toEqual(makeMetaRecord())
78
+ const entries = records.filter(r => r.kind === "entry")
74
79
  expect(entries).toHaveLength(2)
75
- expect(entries[0]?.version).toBe("v1")
76
- expect(entries[1]?.version).toBe("v2")
80
+ expect((entries[0] as { kind: "entry"; version: string }).version).toBe(
81
+ "v1",
82
+ )
83
+ expect((entries[1] as { kind: "entry"; version: string }).version).toBe(
84
+ "v2",
85
+ )
77
86
  await backend2.close()
78
87
  })
79
88
 
@@ -81,37 +90,51 @@ describe("LevelDBStore — close + reopen", () => {
81
90
  const dir = makeTmpDir()
82
91
 
83
92
  const backend1 = new LevelDBStore(dir)
84
- await backend1.ensureDoc("doc-1", plainMetadata)
85
- await backend1.append("doc-1", makeEntry("entirety", "v1"))
86
- await backend1.append("doc-1", makeEntry("since", "v2"))
93
+ await backend1.append("doc-1", makeMetaRecord())
94
+ await backend1.append("doc-1", makeEntryRecord("entirety", "v1"))
95
+ await backend1.append("doc-1", makeEntryRecord("since", "v2"))
87
96
  await backend1.close()
88
97
 
89
98
  // Reopen and append more
90
99
  const backend2 = new LevelDBStore(dir)
91
- await backend2.append("doc-1", makeEntry("since", "v3"))
100
+ await backend2.append("doc-1", makeEntryRecord("since", "v3"))
92
101
 
93
- const entries = await collectAll(backend2.loadAll("doc-1"))
102
+ const records = await collectAll(backend2.loadAll("doc-1"))
103
+ const entries = records.filter(r => r.kind === "entry")
94
104
  expect(entries).toHaveLength(3)
95
- expect(entries[0]?.version).toBe("v1")
96
- expect(entries[1]?.version).toBe("v2")
97
- expect(entries[2]?.version).toBe("v3")
105
+ expect((entries[0] as { kind: "entry"; version: string }).version).toBe(
106
+ "v1",
107
+ )
108
+ expect((entries[1] as { kind: "entry"; version: string }).version).toBe(
109
+ "v2",
110
+ )
111
+ expect((entries[2] as { kind: "entry"; version: string }).version).toBe(
112
+ "v3",
113
+ )
98
114
  await backend2.close()
99
115
  })
100
116
 
101
- it("replace then reopen preserves the single entry", async () => {
117
+ it("replace then reopen preserves the replacement records", async () => {
102
118
  const dir = makeTmpDir()
103
119
 
104
120
  const backend1 = new LevelDBStore(dir)
105
- await backend1.ensureDoc("doc-1", plainMetadata)
106
- await backend1.append("doc-1", makeEntry("since", "v1"))
107
- await backend1.append("doc-1", makeEntry("since", "v2"))
108
- await backend1.replace("doc-1", makeEntry("entirety", "v3"))
121
+ await backend1.append("doc-1", makeMetaRecord())
122
+ await backend1.append("doc-1", makeEntryRecord("since", "v1"))
123
+ await backend1.append("doc-1", makeEntryRecord("since", "v2"))
124
+ await backend1.replace("doc-1", [
125
+ makeMetaRecord(),
126
+ makeEntryRecord("entirety", "v3"),
127
+ ])
109
128
  await backend1.close()
110
129
 
111
130
  const backend2 = new LevelDBStore(dir)
112
- const entries = await collectAll(backend2.loadAll("doc-1"))
113
- expect(entries).toHaveLength(1)
114
- expect(entries[0]?.version).toBe("v3")
131
+ const records = await collectAll(backend2.loadAll("doc-1"))
132
+ expect(records).toHaveLength(2)
133
+ expect(records[0]!.kind).toBe("meta")
134
+ expect(records[1]!.kind).toBe("entry")
135
+ expect((records[1] as { kind: "entry"; version: string }).version).toBe(
136
+ "v3",
137
+ )
115
138
  await backend2.close()
116
139
  })
117
140
 
@@ -119,9 +142,9 @@ describe("LevelDBStore — close + reopen", () => {
119
142
  const dir = makeTmpDir()
120
143
 
121
144
  const backend1 = new LevelDBStore(dir)
122
- await backend1.ensureDoc("alpha", plainMetadata)
123
- await backend1.ensureDoc("beta", plainMetadata)
124
- await backend1.ensureDoc("gamma", plainMetadata)
145
+ await backend1.append("alpha", makeMetaRecord())
146
+ await backend1.append("beta", makeMetaRecord())
147
+ await backend1.append("gamma", makeMetaRecord())
125
148
  await backend1.close()
126
149
 
127
150
  const backend2 = new LevelDBStore(dir)
@@ -135,9 +158,26 @@ describe("LevelDBStore — close + reopen", () => {
135
158
  // encode/decode round-trip — pure function unit tests
136
159
  // ---------------------------------------------------------------------------
137
160
 
138
- describe("encodeStoreEntry / decodeStoreEntry", () => {
139
- it("round-trips a JSON string payload (entirety)", () => {
140
- const entry: StoreEntry = {
161
+ describe("encodeStoreRecord / decodeStoreRecord", () => {
162
+ it("round-trips a meta record", () => {
163
+ const record: StoreRecord = makeMetaRecord()
164
+ const decoded = decodeStoreRecord(encodeStoreRecord(record))
165
+ expect(decoded).toEqual(record)
166
+ })
167
+
168
+ it("round-trips a meta record with custom schemaHash", () => {
169
+ const record: StoreRecord = makeMetaRecord({ schemaHash: "00custom" })
170
+ const decoded = decodeStoreRecord(encodeStoreRecord(record))
171
+ expect(decoded).toEqual(record)
172
+ expect(decoded.kind).toBe("meta")
173
+ if (decoded.kind === "meta") {
174
+ expect(decoded.meta.schemaHash).toBe("00custom")
175
+ }
176
+ })
177
+
178
+ it("round-trips a JSON string payload entry (entirety)", () => {
179
+ const record: StoreRecord = {
180
+ kind: "entry",
141
181
  payload: {
142
182
  kind: "entirety",
143
183
  encoding: "json",
@@ -145,51 +185,75 @@ describe("encodeStoreEntry / decodeStoreEntry", () => {
145
185
  },
146
186
  version: "42",
147
187
  }
148
- const decoded = decodeStoreEntry(encodeStoreEntry(entry))
149
- expect(decoded).toEqual(entry)
188
+ const decoded = decodeStoreRecord(encodeStoreRecord(record))
189
+ expect(decoded).toEqual(record)
150
190
  })
151
191
 
152
- it("round-trips a binary Uint8Array payload (since)", () => {
192
+ it("round-trips a binary Uint8Array payload entry (since)", () => {
153
193
  const bytes = new Uint8Array([0x00, 0x01, 0x02, 0xff, 0xfe])
154
- const entry: StoreEntry = {
194
+ const record: StoreRecord = {
195
+ kind: "entry",
155
196
  payload: { kind: "since", encoding: "binary", data: bytes },
156
197
  version: "v7",
157
198
  }
158
- const decoded = decodeStoreEntry(encodeStoreEntry(entry))
159
- expect(decoded.version).toBe("v7")
160
- expect(decoded.payload.kind).toBe("since")
161
- expect(decoded.payload.encoding).toBe("binary")
162
- expect(decoded.payload.data).toBeInstanceOf(Uint8Array)
163
- expect(decoded.payload.data).toEqual(bytes)
199
+ const decoded = decodeStoreRecord(encodeStoreRecord(record))
200
+ expect(decoded.kind).toBe("entry")
201
+ if (decoded.kind === "entry") {
202
+ expect(decoded.version).toBe("v7")
203
+ expect(decoded.payload.kind).toBe("since")
204
+ expect(decoded.payload.encoding).toBe("binary")
205
+ expect(decoded.payload.data).toBeInstanceOf(Uint8Array)
206
+ expect(decoded.payload.data).toEqual(bytes)
207
+ }
208
+ })
209
+
210
+ it("round-trips a binary entry record via makeBinaryEntryRecord", () => {
211
+ const bytes = new Uint8Array([0xde, 0xad, 0xbe, 0xef])
212
+ const record: StoreRecord = makeBinaryEntryRecord(
213
+ "entirety",
214
+ "bin-v1",
215
+ bytes,
216
+ )
217
+ const decoded = decodeStoreRecord(encodeStoreRecord(record))
218
+ expect(decoded).toEqual(record)
164
219
  })
165
220
 
166
221
  it("handles empty string data", () => {
167
- const entry: StoreEntry = {
222
+ const record: StoreRecord = {
223
+ kind: "entry",
168
224
  payload: { kind: "entirety", encoding: "json", data: "" },
169
225
  version: "v0",
170
226
  }
171
- const decoded = decodeStoreEntry(encodeStoreEntry(entry))
172
- expect(decoded).toEqual(entry)
227
+ const decoded = decodeStoreRecord(encodeStoreRecord(record))
228
+ expect(decoded).toEqual(record)
173
229
  })
174
230
 
175
231
  it("handles empty Uint8Array data", () => {
176
- const entry: StoreEntry = {
232
+ const record: StoreRecord = {
233
+ kind: "entry",
177
234
  payload: { kind: "since", encoding: "binary", data: new Uint8Array(0) },
178
235
  version: "v0",
179
236
  }
180
- const decoded = decodeStoreEntry(encodeStoreEntry(entry))
181
- expect(decoded.payload.data).toBeInstanceOf(Uint8Array)
182
- expect((decoded.payload.data as Uint8Array).length).toBe(0)
237
+ const decoded = decodeStoreRecord(encodeStoreRecord(record))
238
+ expect(decoded.kind).toBe("entry")
239
+ if (decoded.kind === "entry") {
240
+ expect(decoded.payload.data).toBeInstanceOf(Uint8Array)
241
+ expect((decoded.payload.data as Uint8Array).length).toBe(0)
242
+ }
183
243
  })
184
244
 
185
245
  it("handles empty version string", () => {
186
- const entry: StoreEntry = {
246
+ const record: StoreRecord = {
247
+ kind: "entry",
187
248
  payload: { kind: "entirety", encoding: "json", data: "{}" },
188
249
  version: "",
189
250
  }
190
- const decoded = decodeStoreEntry(encodeStoreEntry(entry))
191
- expect(decoded.version).toBe("")
192
- expect(decoded.payload.data).toBe("{}")
251
+ const decoded = decodeStoreRecord(encodeStoreRecord(record))
252
+ expect(decoded.kind).toBe("entry")
253
+ if (decoded.kind === "entry") {
254
+ expect(decoded.version).toBe("")
255
+ expect(decoded.payload.data).toBe("{}")
256
+ }
193
257
  })
194
258
 
195
259
  it("handles large binary payload", () => {
@@ -197,11 +261,40 @@ describe("encodeStoreEntry / decodeStoreEntry", () => {
197
261
  for (let i = 0; i < largeData.length; i++) {
198
262
  largeData[i] = i % 256
199
263
  }
200
- const entry: StoreEntry = {
264
+ const record: StoreRecord = {
265
+ kind: "entry",
201
266
  payload: { kind: "entirety", encoding: "binary", data: largeData },
202
267
  version: "large-v1",
203
268
  }
204
- const decoded = decodeStoreEntry(encodeStoreEntry(entry))
205
- expect(decoded.payload.data).toEqual(largeData)
269
+ const decoded = decodeStoreRecord(encodeStoreRecord(record))
270
+ expect(decoded.kind).toBe("entry")
271
+ if (decoded.kind === "entry") {
272
+ expect(decoded.payload.data).toEqual(largeData)
273
+ }
274
+ })
275
+
276
+ it("meta record flags byte has bit 3 set", () => {
277
+ const record: StoreRecord = makeMetaRecord()
278
+ const encoded = encodeStoreRecord(record)
279
+ expect(encoded[0]! & 0x08).toBe(0x08) // bit 3 set
280
+ expect(encoded[0]! & 0x80).toBe(0x00) // bit 7 clear (current format)
281
+ })
282
+
283
+ it("entry record flags byte has bit 3 clear", () => {
284
+ const record: StoreRecord = makeEntryRecord("entirety", "v1")
285
+ const encoded = encodeStoreRecord(record)
286
+ expect(encoded[0]! & 0x08).toBe(0x00) // bit 3 clear
287
+ expect(encoded[0]! & 0x80).toBe(0x00) // bit 7 clear (current format)
288
+ })
289
+
290
+ it("rejects bytes with future-format bit set", () => {
291
+ const record: StoreRecord = makeEntryRecord("entirety", "v1")
292
+ const encoded = encodeStoreRecord(record)
293
+ encoded[0] = encoded[0]! | 0x80 // set bit 7
294
+ expect(() => decodeStoreRecord(encoded)).toThrow(/future-format/)
295
+ })
296
+
297
+ it("throws on empty bytes", () => {
298
+ expect(() => decodeStoreRecord(new Uint8Array(0))).toThrow()
206
299
  })
207
300
  })
@@ -3,19 +3,23 @@
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 DocMetadata
7
- // entry\x00{docId}\x00{seqNo} → binary-encoded StoreEntry
6
+ // meta\x00{docId} → JSON-encoded StoreMeta (materialized index)
7
+ // record\x00{docId}\x00{seqNo} → binary-encoded StoreRecord (unified stream)
8
8
  //
9
9
  // The \x00 separator cannot appear in valid UTF-8 strings, so no docId
10
10
  // validation is needed — the key-space imposes zero constraints on callers.
11
11
  //
12
- // SeqNo is a zero-padded 10-digit monotonic counter per doc, tracked in
12
+ // SeqNo is a zero-padded 16-digit monotonic counter per doc, tracked in
13
13
  // memory. On reboot, the max seqNo for a doc is lazily discovered via a
14
14
  // single reverse-iterator seek on first append.
15
15
 
16
- import type { Store, StoreEntry } from "@kyneta/exchange/src/store/store.js"
17
- import type { DocId } from "@kyneta/exchange/src/types.js"
18
- import type { DocMetadata } from "@kyneta/schema"
16
+ import {
17
+ type DocId,
18
+ resolveMetaFromBatch,
19
+ type Store,
20
+ type StoreMeta,
21
+ type StoreRecord,
22
+ } from "@kyneta/exchange"
19
23
  import { ClassicLevel } from "classic-level"
20
24
 
21
25
  // ---------------------------------------------------------------------------
@@ -24,25 +28,40 @@ import { ClassicLevel } from "classic-level"
24
28
 
25
29
  const SEP = "\x00"
26
30
  const META_PREFIX = `meta${SEP}`
27
- const ENTRY_PREFIX = `entry${SEP}`
28
- const SEQ_PAD = 10
31
+ const RECORD_PREFIX = `record${SEP}`
32
+ const SEQ_PAD = 16
29
33
 
30
34
  // ---------------------------------------------------------------------------
31
- // Binary envelope — pure encode/decode for StoreEntry
35
+ // Binary envelope v2 — pure encode/decode for StoreRecord
32
36
  // ---------------------------------------------------------------------------
33
37
 
34
38
  // Flags byte layout:
35
- // bit 0: kind (0 = entirety, 1 = since)
36
- // bit 1: encoding (0 = json, 1 = binary)
37
- // bit 2: data type (0 = string, 1 = Uint8Array)
39
+ // bit 0: payload kind (0 = entirety, 1 = since) — entry records only
40
+ // bit 1: encoding (0 = json, 1 = binary) — entry records only
41
+ // bit 2: data type (0 = string, 1 = Uint8Array) — entry records only
42
+ // bit 3: record kind (0 = entry, 1 = meta)
43
+ // bit 7: future-format (0 = current format, reserved)
38
44
  //
39
- // Layout: [1 byte flags] [4 bytes version length BE] [N bytes version UTF-8] [remaining: payload data]
45
+ // Meta records (bit 3 = 1):
46
+ // [1 byte flags] [remaining: JSON-encoded StoreMeta]
47
+ //
48
+ // Entry records (bit 3 = 0):
49
+ // [1 byte flags] [4 bytes version length BE] [N bytes version UTF-8] [remaining: payload data]
40
50
 
41
51
  const encoder = new TextEncoder()
42
52
  const decoder = new TextDecoder()
43
53
 
44
- export function encodeStoreEntry(entry: StoreEntry): Uint8Array {
45
- const { payload, version } = entry
54
+ export function encodeStoreRecord(record: StoreRecord): Uint8Array {
55
+ if (record.kind === "meta") {
56
+ const metaBytes = encoder.encode(JSON.stringify(record.meta))
57
+ const buf = new Uint8Array(1 + metaBytes.length)
58
+ buf[0] = 0x08 // bit 3 set (meta)
59
+ buf.set(metaBytes, 1)
60
+ return buf
61
+ }
62
+
63
+ // Entry record
64
+ const { payload, version } = record
46
65
 
47
66
  let flags = 0
48
67
  if (payload.kind === "since") flags |= 0x01
@@ -67,12 +86,26 @@ export function encodeStoreEntry(entry: StoreEntry): Uint8Array {
67
86
  return buf
68
87
  }
69
88
 
70
- export function decodeStoreEntry(bytes: Uint8Array): StoreEntry {
71
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
72
-
89
+ export function decodeStoreRecord(bytes: Uint8Array): StoreRecord {
73
90
  const flagByte = bytes[0]
74
- if (flagByte === undefined) throw new Error("empty store entry bytes")
91
+ if (flagByte === undefined) throw new Error("empty store record bytes")
75
92
  const flags = flagByte
93
+
94
+ // Check future-format flag (bit 7)
95
+ if ((flags & 0x80) !== 0) {
96
+ throw new Error("unknown store record format (future-format bit set)")
97
+ }
98
+
99
+ const isMeta = (flags & 0x08) !== 0
100
+
101
+ if (isMeta) {
102
+ const metaJson = decoder.decode(bytes.subarray(1))
103
+ return { kind: "meta", meta: JSON.parse(metaJson) as StoreMeta }
104
+ }
105
+
106
+ // Entry record
107
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
108
+
76
109
  const kind = (flags & 0x01) !== 0 ? "since" : "entirety"
77
110
  const encoding = (flags & 0x02) !== 0 ? "binary" : "json"
78
111
  const isDataBinary = (flags & 0x04) !== 0
@@ -88,6 +121,7 @@ export function decodeStoreEntry(bytes: Uint8Array): StoreEntry {
88
121
  : decoder.decode(rawData)
89
122
 
90
123
  return {
124
+ kind: "entry",
91
125
  payload: { kind, encoding, data },
92
126
  version,
93
127
  }
@@ -101,20 +135,20 @@ function metaKey(docId: DocId): string {
101
135
  return `${META_PREFIX}${docId}`
102
136
  }
103
137
 
104
- function entryPrefix(docId: DocId): string {
105
- return `${ENTRY_PREFIX}${docId}${SEP}`
138
+ function recordPrefix(docId: DocId): string {
139
+ return `${RECORD_PREFIX}${docId}${SEP}`
106
140
  }
107
141
 
108
- function entryKey(docId: DocId, seqNo: number): string {
109
- return `${entryPrefix(docId)}${String(seqNo).padStart(SEQ_PAD, "0")}`
142
+ function recordKey(docId: DocId, seqNo: number): string {
143
+ return `${recordPrefix(docId)}${String(seqNo).padStart(SEQ_PAD, "0")}`
110
144
  }
111
145
 
112
146
  function parseDocIdFromMetaKey(key: string): DocId {
113
147
  return key.slice(META_PREFIX.length)
114
148
  }
115
149
 
116
- function parseSeqNoFromEntryKey(key: string, docId: DocId): number {
117
- const prefix = entryPrefix(docId)
150
+ function parseSeqNoFromRecordKey(key: string, docId: DocId): number {
151
+ const prefix = recordPrefix(docId)
118
152
  return Number.parseInt(key.slice(prefix.length), 10)
119
153
  }
120
154
 
@@ -149,7 +183,7 @@ export class LevelDBStore implements Store {
149
183
  }
150
184
 
151
185
  // Lazy discovery: reverse iterate to find the highest existing seqNo
152
- const prefix = entryPrefix(docId)
186
+ const prefix = recordPrefix(docId)
153
187
  let maxSeq = -1
154
188
  for await (const key of this.#db.keys({
155
189
  gte: prefix,
@@ -157,7 +191,7 @@ export class LevelDBStore implements Store {
157
191
  reverse: true,
158
192
  limit: 1,
159
193
  })) {
160
- maxSeq = parseSeqNoFromEntryKey(key, docId)
194
+ maxSeq = parseSeqNoFromRecordKey(key, docId)
161
195
  }
162
196
 
163
197
  const next = maxSeq + 1
@@ -169,51 +203,57 @@ export class LevelDBStore implements Store {
169
203
  // Store interface
170
204
  // -------------------------------------------------------------------------
171
205
 
172
- async lookup(docId: DocId): Promise<DocMetadata | null> {
206
+ async currentMeta(docId: DocId): Promise<StoreMeta | null> {
173
207
  try {
174
208
  const raw = await this.#db.get(metaKey(docId))
175
- return JSON.parse(decoder.decode(raw)) as DocMetadata
209
+ return JSON.parse(decoder.decode(raw)) as StoreMeta
176
210
  } catch (error: any) {
177
211
  if (error.code === "LEVEL_NOT_FOUND") return null
178
212
  throw error
179
213
  }
180
214
  }
181
215
 
182
- async ensureDoc(docId: DocId, metadata: DocMetadata): Promise<void> {
183
- try {
184
- await this.#db.get(metaKey(docId))
185
- // Already exists — no-op (first call wins)
186
- } catch (error: any) {
187
- if (error.code === "LEVEL_NOT_FOUND") {
188
- await this.#db.put(
189
- metaKey(docId),
190
- encoder.encode(JSON.stringify(metadata)),
216
+ async append(docId: DocId, record: StoreRecord): Promise<void> {
217
+ const existingMeta = await this.currentMeta(docId)
218
+
219
+ if (record.kind === "entry") {
220
+ if (existingMeta === null) {
221
+ throw new Error(
222
+ `Store: first record for doc '${docId}' must be meta, got entry`,
191
223
  )
192
- return
193
224
  }
194
- throw error
225
+ } else {
226
+ // record.kind === "meta" — validate immutability via resolution
227
+ const resolved = resolveMetaFromBatch([record], existingMeta)
228
+ await this.#db.put(
229
+ metaKey(docId),
230
+ encoder.encode(JSON.stringify(resolved)),
231
+ )
195
232
  }
196
- }
197
233
 
198
- async append(docId: DocId, entry: StoreEntry): Promise<void> {
199
234
  const seq = await this.#nextSeqNo(docId)
200
- await this.#db.put(entryKey(docId, seq), encodeStoreEntry(entry))
235
+ await this.#db.put(recordKey(docId, seq), encodeStoreRecord(record))
201
236
  }
202
237
 
203
- async *loadAll(docId: DocId): AsyncIterable<StoreEntry> {
204
- const prefix = entryPrefix(docId)
238
+ async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {
239
+ const prefix = recordPrefix(docId)
205
240
  for await (const value of this.#db.values({
206
241
  gte: prefix,
207
242
  lt: `${prefix}\xff`,
208
243
  })) {
209
- yield decodeStoreEntry(value)
244
+ yield decodeStoreRecord(value)
210
245
  }
211
246
  }
212
247
 
213
- async replace(docId: DocId, entry: StoreEntry): Promise<void> {
214
- const prefix = entryPrefix(docId)
248
+ async replace(docId: DocId, records: StoreRecord[]): Promise<void> {
249
+ const existingMeta = await this.currentMeta(docId)
215
250
 
216
- // Collect keys to delete
251
+ // Resolve validates: at least one meta present, immutable fields match.
252
+ const resolved = resolveMetaFromBatch(records, existingMeta)
253
+
254
+ const prefix = recordPrefix(docId)
255
+
256
+ // Collect existing record keys to delete
217
257
  const keysToDelete: string[] = []
218
258
  for await (const key of this.#db.keys({
219
259
  gte: prefix,
@@ -222,26 +262,36 @@ export class LevelDBStore implements Store {
222
262
  keysToDelete.push(key)
223
263
  }
224
264
 
225
- // Atomic batch: delete all existing entries + write the single replacement
265
+ // Atomic batch: delete all existing records, write replacements, upsert meta
226
266
  const ops: Array<
227
267
  | { type: "del"; key: string }
228
268
  | { type: "put"; key: string; value: Uint8Array }
229
269
  > = keysToDelete.map(key => ({ type: "del" as const, key }))
270
+
271
+ for (let i = 0; i < records.length; i++) {
272
+ ops.push({
273
+ type: "put",
274
+ key: recordKey(docId, i),
275
+ value: encodeStoreRecord(records[i]!),
276
+ })
277
+ }
278
+
230
279
  ops.push({
231
280
  type: "put",
232
- key: entryKey(docId, 0),
233
- value: encodeStoreEntry(entry),
281
+ key: metaKey(docId),
282
+ value: encoder.encode(JSON.stringify(resolved)),
234
283
  })
284
+
235
285
  await this.#db.batch(ops)
236
286
 
237
- // Reset seqNo counter
238
- this.#seqNos.set(docId, 0)
287
+ // Reset seqNo counter to the last written index
288
+ this.#seqNos.set(docId, records.length - 1)
239
289
  }
240
290
 
241
291
  async delete(docId: DocId): Promise<void> {
242
- const prefix = entryPrefix(docId)
292
+ const prefix = recordPrefix(docId)
243
293
 
244
- // Collect entry keys to delete
294
+ // Collect record keys to delete
245
295
  const keysToDelete: string[] = [metaKey(docId)]
246
296
  for await (const key of this.#db.keys({
247
297
  gte: prefix,
@@ -258,10 +308,12 @@ export class LevelDBStore implements Store {
258
308
  this.#seqNos.delete(docId)
259
309
  }
260
310
 
261
- async *listDocIds(): AsyncIterable<DocId> {
311
+ async *listDocIds(prefix?: string): AsyncIterable<DocId> {
312
+ const rangePrefix =
313
+ prefix !== undefined ? `${META_PREFIX}${prefix}` : META_PREFIX
262
314
  for await (const key of this.#db.keys({
263
- gte: META_PREFIX,
264
- lt: `${META_PREFIX}\xff`,
315
+ gte: rangePrefix,
316
+ lt: `${rangePrefix}\xff`,
265
317
  })) {
266
318
  yield parseDocIdFromMetaKey(key)
267
319
  }
@@ -285,7 +337,7 @@ export class LevelDBStore implements Store {
285
337
  *
286
338
  * @example
287
339
  * ```typescript
288
- * import { createLevelDBStore } from "@kyneta/leveldb-store/server"
340
+ * import { createLevelDBStore } from "@kyneta/leveldb-store"
289
341
  *
290
342
  * const exchange = new Exchange({
291
343
  * stores: [createLevelDBStore("./data/exchange-db")],
package/dist/server.d.ts DELETED
@@ -1,37 +0,0 @@
1
- import { Store, StoreEntry } from '@kyneta/exchange/src/store/store.js';
2
- import { DocId } from '@kyneta/exchange/src/types.js';
3
- import { DocMetadata } from '@kyneta/schema';
4
-
5
- declare function encodeStoreEntry(entry: StoreEntry): Uint8Array;
6
- declare function decodeStoreEntry(bytes: Uint8Array): StoreEntry;
7
- declare class LevelDBStore implements Store {
8
- #private;
9
- constructor(dbPath: string);
10
- lookup(docId: DocId): Promise<DocMetadata | null>;
11
- ensureDoc(docId: DocId, metadata: DocMetadata): Promise<void>;
12
- append(docId: DocId, entry: StoreEntry): Promise<void>;
13
- loadAll(docId: DocId): AsyncIterable<StoreEntry>;
14
- replace(docId: DocId, entry: StoreEntry): Promise<void>;
15
- delete(docId: DocId): Promise<void>;
16
- listDocIds(): AsyncIterable<DocId>;
17
- close(): Promise<void>;
18
- }
19
- /**
20
- * Create a LevelDB storage backend for server-side persistence.
21
- *
22
- * Returns a `Store` — pass directly to `Exchange({ stores: [...] })`.
23
- *
24
- * @param dbPath - Directory path where LevelDB stores its files
25
- *
26
- * @example
27
- * ```typescript
28
- * import { createLevelDBStore } from "@kyneta/leveldb-store/server"
29
- *
30
- * const exchange = new Exchange({
31
- * stores: [createLevelDBStore("./data/exchange-db")],
32
- * })
33
- * ```
34
- */
35
- declare function createLevelDBStore(dbPath: string): Store;
36
-
37
- export { LevelDBStore, createLevelDBStore, decodeStoreEntry, encodeStoreEntry };
package/dist/server.js DELETED
@@ -1,188 +0,0 @@
1
- // src/server.ts
2
- import { ClassicLevel } from "classic-level";
3
- var SEP = "\0";
4
- var META_PREFIX = `meta${SEP}`;
5
- var ENTRY_PREFIX = `entry${SEP}`;
6
- var SEQ_PAD = 10;
7
- var encoder = new TextEncoder();
8
- var decoder = new TextDecoder();
9
- function encodeStoreEntry(entry) {
10
- const { payload, version } = entry;
11
- let flags = 0;
12
- if (payload.kind === "since") flags |= 1;
13
- if (payload.encoding === "binary") flags |= 2;
14
- const isDataBinary = payload.data instanceof Uint8Array;
15
- if (isDataBinary) flags |= 4;
16
- const versionBytes = encoder.encode(version);
17
- const dataBytes = isDataBinary ? payload.data : encoder.encode(payload.data);
18
- const buf = new Uint8Array(1 + 4 + versionBytes.length + dataBytes.length);
19
- const view = new DataView(buf.buffer);
20
- buf[0] = flags;
21
- view.setUint32(1, versionBytes.length, false);
22
- buf.set(versionBytes, 5);
23
- buf.set(dataBytes, 5 + versionBytes.length);
24
- return buf;
25
- }
26
- function decodeStoreEntry(bytes) {
27
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
28
- const flagByte = bytes[0];
29
- if (flagByte === void 0) throw new Error("empty store entry bytes");
30
- const flags = flagByte;
31
- const kind = (flags & 1) !== 0 ? "since" : "entirety";
32
- const encoding = (flags & 2) !== 0 ? "binary" : "json";
33
- const isDataBinary = (flags & 4) !== 0;
34
- const versionLen = view.getUint32(1, false);
35
- const version = decoder.decode(bytes.subarray(5, 5 + versionLen));
36
- const dataStart = 5 + versionLen;
37
- const rawData = bytes.subarray(dataStart);
38
- const data = isDataBinary ? new Uint8Array(rawData) : decoder.decode(rawData);
39
- return {
40
- payload: { kind, encoding, data },
41
- version
42
- };
43
- }
44
- function metaKey(docId) {
45
- return `${META_PREFIX}${docId}`;
46
- }
47
- function entryPrefix(docId) {
48
- return `${ENTRY_PREFIX}${docId}${SEP}`;
49
- }
50
- function entryKey(docId, seqNo) {
51
- return `${entryPrefix(docId)}${String(seqNo).padStart(SEQ_PAD, "0")}`;
52
- }
53
- function parseDocIdFromMetaKey(key) {
54
- return key.slice(META_PREFIX.length);
55
- }
56
- function parseSeqNoFromEntryKey(key, docId) {
57
- const prefix = entryPrefix(docId);
58
- return Number.parseInt(key.slice(prefix.length), 10);
59
- }
60
- var LevelDBStore = class {
61
- #db;
62
- #seqNos = /* @__PURE__ */ new Map();
63
- constructor(dbPath) {
64
- this.#db = new ClassicLevel(dbPath, {
65
- valueEncoding: "binary"
66
- });
67
- }
68
- // -------------------------------------------------------------------------
69
- // Private — seqNo management
70
- // -------------------------------------------------------------------------
71
- /**
72
- * Get the next seqNo for a doc. On first call after reboot, discovers
73
- * the max existing seqNo via a single reverse-iterator seek.
74
- */
75
- async #nextSeqNo(docId) {
76
- const cached = this.#seqNos.get(docId);
77
- if (cached !== void 0) {
78
- const next2 = cached + 1;
79
- this.#seqNos.set(docId, next2);
80
- return next2;
81
- }
82
- const prefix = entryPrefix(docId);
83
- let maxSeq = -1;
84
- for await (const key of this.#db.keys({
85
- gte: prefix,
86
- lt: `${prefix}\xFF`,
87
- reverse: true,
88
- limit: 1
89
- })) {
90
- maxSeq = parseSeqNoFromEntryKey(key, docId);
91
- }
92
- const next = maxSeq + 1;
93
- this.#seqNos.set(docId, next);
94
- return next;
95
- }
96
- // -------------------------------------------------------------------------
97
- // Store interface
98
- // -------------------------------------------------------------------------
99
- async lookup(docId) {
100
- try {
101
- const raw = await this.#db.get(metaKey(docId));
102
- return JSON.parse(decoder.decode(raw));
103
- } catch (error) {
104
- if (error.code === "LEVEL_NOT_FOUND") return null;
105
- throw error;
106
- }
107
- }
108
- async ensureDoc(docId, metadata) {
109
- try {
110
- await this.#db.get(metaKey(docId));
111
- } catch (error) {
112
- if (error.code === "LEVEL_NOT_FOUND") {
113
- await this.#db.put(
114
- metaKey(docId),
115
- encoder.encode(JSON.stringify(metadata))
116
- );
117
- return;
118
- }
119
- throw error;
120
- }
121
- }
122
- async append(docId, entry) {
123
- const seq = await this.#nextSeqNo(docId);
124
- await this.#db.put(entryKey(docId, seq), encodeStoreEntry(entry));
125
- }
126
- async *loadAll(docId) {
127
- const prefix = entryPrefix(docId);
128
- for await (const value of this.#db.values({
129
- gte: prefix,
130
- lt: `${prefix}\xFF`
131
- })) {
132
- yield decodeStoreEntry(value);
133
- }
134
- }
135
- async replace(docId, entry) {
136
- const prefix = entryPrefix(docId);
137
- const keysToDelete = [];
138
- for await (const key of this.#db.keys({
139
- gte: prefix,
140
- lt: `${prefix}\xFF`
141
- })) {
142
- keysToDelete.push(key);
143
- }
144
- const ops = keysToDelete.map((key) => ({ type: "del", key }));
145
- ops.push({
146
- type: "put",
147
- key: entryKey(docId, 0),
148
- value: encodeStoreEntry(entry)
149
- });
150
- await this.#db.batch(ops);
151
- this.#seqNos.set(docId, 0);
152
- }
153
- async delete(docId) {
154
- const prefix = entryPrefix(docId);
155
- const keysToDelete = [metaKey(docId)];
156
- for await (const key of this.#db.keys({
157
- gte: prefix,
158
- lt: `${prefix}\xFF`
159
- })) {
160
- keysToDelete.push(key);
161
- }
162
- await this.#db.batch(
163
- keysToDelete.map((key) => ({ type: "del", key }))
164
- );
165
- this.#seqNos.delete(docId);
166
- }
167
- async *listDocIds() {
168
- for await (const key of this.#db.keys({
169
- gte: META_PREFIX,
170
- lt: `${META_PREFIX}\xFF`
171
- })) {
172
- yield parseDocIdFromMetaKey(key);
173
- }
174
- }
175
- async close() {
176
- await this.#db.close();
177
- }
178
- };
179
- function createLevelDBStore(dbPath) {
180
- return new LevelDBStore(dbPath);
181
- }
182
- export {
183
- LevelDBStore,
184
- createLevelDBStore,
185
- decodeStoreEntry,
186
- encodeStoreEntry
187
- };
188
- //# sourceMappingURL=server.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/server.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 DocMetadata\n// entry\\x00{docId}\\x00{seqNo} → binary-encoded StoreEntry\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 10-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 type { Store, StoreEntry } from \"@kyneta/exchange/src/store/store.js\"\nimport type { DocId } from \"@kyneta/exchange/src/types.js\"\nimport type { DocMetadata } from \"@kyneta/schema\"\nimport { ClassicLevel } from \"classic-level\"\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst SEP = \"\\x00\"\nconst META_PREFIX = `meta${SEP}`\nconst ENTRY_PREFIX = `entry${SEP}`\nconst SEQ_PAD = 10\n\n// ---------------------------------------------------------------------------\n// Binary envelope — pure encode/decode for StoreEntry\n// ---------------------------------------------------------------------------\n\n// Flags byte layout:\n// bit 0: kind (0 = entirety, 1 = since)\n// bit 1: encoding (0 = json, 1 = binary)\n// bit 2: data type (0 = string, 1 = Uint8Array)\n//\n// Layout: [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 encodeStoreEntry(entry: StoreEntry): Uint8Array {\n const { payload, version } = entry\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 decodeStoreEntry(bytes: Uint8Array): StoreEntry {\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)\n\n const flagByte = bytes[0]\n if (flagByte === undefined) throw new Error(\"empty store entry bytes\")\n const flags = flagByte\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 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 entryPrefix(docId: DocId): string {\n return `${ENTRY_PREFIX}${docId}${SEP}`\n}\n\nfunction entryKey(docId: DocId, seqNo: number): string {\n return `${entryPrefix(docId)}${String(seqNo).padStart(SEQ_PAD, \"0\")}`\n}\n\nfunction parseDocIdFromMetaKey(key: string): DocId {\n return key.slice(META_PREFIX.length)\n}\n\nfunction parseSeqNoFromEntryKey(key: string, docId: DocId): number {\n const prefix = entryPrefix(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: Map<DocId, number> = new Map()\n\n constructor(dbPath: string) {\n this.#db = new ClassicLevel(dbPath, {\n valueEncoding: \"binary\",\n })\n }\n\n // -------------------------------------------------------------------------\n // Private — seqNo management\n // -------------------------------------------------------------------------\n\n /**\n * Get the next seqNo for a doc. On first call after reboot, discovers\n * the max existing seqNo via a single reverse-iterator seek.\n */\n async #nextSeqNo(docId: DocId): Promise<number> {\n const cached = this.#seqNos.get(docId)\n if (cached !== undefined) {\n const next = cached + 1\n this.#seqNos.set(docId, next)\n return next\n }\n\n // Lazy discovery: reverse iterate to find the highest existing seqNo\n const prefix = entryPrefix(docId)\n let maxSeq = -1\n for await (const key of this.#db.keys({\n gte: prefix,\n lt: `${prefix}\\xff`,\n reverse: true,\n limit: 1,\n })) {\n maxSeq = parseSeqNoFromEntryKey(key, docId)\n }\n\n const next = maxSeq + 1\n this.#seqNos.set(docId, next)\n return next\n }\n\n // -------------------------------------------------------------------------\n // Store interface\n // -------------------------------------------------------------------------\n\n async lookup(docId: DocId): Promise<DocMetadata | null> {\n try {\n const raw = await this.#db.get(metaKey(docId))\n return JSON.parse(decoder.decode(raw)) as DocMetadata\n } catch (error: any) {\n if (error.code === \"LEVEL_NOT_FOUND\") return null\n throw error\n }\n }\n\n async ensureDoc(docId: DocId, metadata: DocMetadata): Promise<void> {\n try {\n await this.#db.get(metaKey(docId))\n // Already exists — no-op (first call wins)\n } catch (error: any) {\n if (error.code === \"LEVEL_NOT_FOUND\") {\n await this.#db.put(\n metaKey(docId),\n encoder.encode(JSON.stringify(metadata)),\n )\n return\n }\n throw error\n }\n }\n\n async append(docId: DocId, entry: StoreEntry): Promise<void> {\n const seq = await this.#nextSeqNo(docId)\n await this.#db.put(entryKey(docId, seq), encodeStoreEntry(entry))\n }\n\n async *loadAll(docId: DocId): AsyncIterable<StoreEntry> {\n const prefix = entryPrefix(docId)\n for await (const value of this.#db.values({\n gte: prefix,\n lt: `${prefix}\\xff`,\n })) {\n yield decodeStoreEntry(value)\n }\n }\n\n async replace(docId: DocId, entry: StoreEntry): Promise<void> {\n const prefix = entryPrefix(docId)\n\n // Collect 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 entries + write the single replacement\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 ops.push({\n type: \"put\",\n key: entryKey(docId, 0),\n value: encodeStoreEntry(entry),\n })\n await this.#db.batch(ops)\n\n // Reset seqNo counter\n this.#seqNos.set(docId, 0)\n }\n\n async delete(docId: DocId): Promise<void> {\n const prefix = entryPrefix(docId)\n\n // Collect entry 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.delete(docId)\n }\n\n async *listDocIds(): AsyncIterable<DocId> {\n for await (const key of this.#db.keys({\n gte: META_PREFIX,\n lt: `${META_PREFIX}\\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/server\"\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":";AAkBA,SAAS,oBAAoB;AAM7B,IAAM,MAAM;AACZ,IAAM,cAAc,OAAO,GAAG;AAC9B,IAAM,eAAe,QAAQ,GAAG;AAChC,IAAM,UAAU;AAahB,IAAM,UAAU,IAAI,YAAY;AAChC,IAAM,UAAU,IAAI,YAAY;AAEzB,SAAS,iBAAiB,OAA+B;AAC9D,QAAM,EAAE,SAAS,QAAQ,IAAI;AAE7B,MAAI,QAAQ;AACZ,MAAI,QAAQ,SAAS,QAAS,UAAS;AACvC,MAAI,QAAQ,aAAa,SAAU,UAAS;AAE5C,QAAM,eAAe,QAAQ,gBAAgB;AAC7C,MAAI,aAAc,UAAS;AAE3B,QAAM,eAAe,QAAQ,OAAO,OAAO;AAC3C,QAAM,YAAY,eACb,QAAQ,OACT,QAAQ,OAAO,QAAQ,IAAc;AAEzC,QAAM,MAAM,IAAI,WAAW,IAAI,IAAI,aAAa,SAAS,UAAU,MAAM;AACzE,QAAM,OAAO,IAAI,SAAS,IAAI,MAAM;AAEpC,MAAI,CAAC,IAAI;AACT,OAAK,UAAU,GAAG,aAAa,QAAQ,KAAK;AAC5C,MAAI,IAAI,cAAc,CAAC;AACvB,MAAI,IAAI,WAAW,IAAI,aAAa,MAAM;AAE1C,SAAO;AACT;AAEO,SAAS,iBAAiB,OAA+B;AAC9D,QAAM,OAAO,IAAI,SAAS,MAAM,QAAQ,MAAM,YAAY,MAAM,UAAU;AAE1E,QAAM,WAAW,MAAM,CAAC;AACxB,MAAI,aAAa,OAAW,OAAM,IAAI,MAAM,yBAAyB;AACrE,QAAM,QAAQ;AACd,QAAM,QAAQ,QAAQ,OAAU,IAAI,UAAU;AAC9C,QAAM,YAAY,QAAQ,OAAU,IAAI,WAAW;AACnD,QAAM,gBAAgB,QAAQ,OAAU;AAExC,QAAM,aAAa,KAAK,UAAU,GAAG,KAAK;AAC1C,QAAM,UAAU,QAAQ,OAAO,MAAM,SAAS,GAAG,IAAI,UAAU,CAAC;AAEhE,QAAM,YAAY,IAAI;AACtB,QAAM,UAAU,MAAM,SAAS,SAAS;AAExC,QAAM,OAA4B,eAC9B,IAAI,WAAW,OAAO,IACtB,QAAQ,OAAO,OAAO;AAE1B,SAAO;AAAA,IACL,SAAS,EAAE,MAAM,UAAU,KAAK;AAAA,IAChC;AAAA,EACF;AACF;AAMA,SAAS,QAAQ,OAAsB;AACrC,SAAO,GAAG,WAAW,GAAG,KAAK;AAC/B;AAEA,SAAS,YAAY,OAAsB;AACzC,SAAO,GAAG,YAAY,GAAG,KAAK,GAAG,GAAG;AACtC;AAEA,SAAS,SAAS,OAAc,OAAuB;AACrD,SAAO,GAAG,YAAY,KAAK,CAAC,GAAG,OAAO,KAAK,EAAE,SAAS,SAAS,GAAG,CAAC;AACrE;AAEA,SAAS,sBAAsB,KAAoB;AACjD,SAAO,IAAI,MAAM,YAAY,MAAM;AACrC;AAEA,SAAS,uBAAuB,KAAa,OAAsB;AACjE,QAAM,SAAS,YAAY,KAAK;AAChC,SAAO,OAAO,SAAS,IAAI,MAAM,OAAO,MAAM,GAAG,EAAE;AACrD;AAMO,IAAM,eAAN,MAAoC;AAAA,EAChC;AAAA,EACA,UAA8B,oBAAI,IAAI;AAAA,EAE/C,YAAY,QAAgB;AAC1B,SAAK,MAAM,IAAI,aAAa,QAAQ;AAAA,MAClC,eAAe;AAAA,IACjB,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAM,WAAW,OAA+B;AAC9C,UAAM,SAAS,KAAK,QAAQ,IAAI,KAAK;AACrC,QAAI,WAAW,QAAW;AACxB,YAAMA,QAAO,SAAS;AACtB,WAAK,QAAQ,IAAI,OAAOA,KAAI;AAC5B,aAAOA;AAAA,IACT;AAGA,UAAM,SAAS,YAAY,KAAK;AAChC,QAAI,SAAS;AACb,qBAAiB,OAAO,KAAK,IAAI,KAAK;AAAA,MACpC,KAAK;AAAA,MACL,IAAI,GAAG,MAAM;AAAA,MACb,SAAS;AAAA,MACT,OAAO;AAAA,IACT,CAAC,GAAG;AACF,eAAS,uBAAuB,KAAK,KAAK;AAAA,IAC5C;AAEA,UAAM,OAAO,SAAS;AACtB,SAAK,QAAQ,IAAI,OAAO,IAAI;AAC5B,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,OAAO,OAA2C;AACtD,QAAI;AACF,YAAM,MAAM,MAAM,KAAK,IAAI,IAAI,QAAQ,KAAK,CAAC;AAC7C,aAAO,KAAK,MAAM,QAAQ,OAAO,GAAG,CAAC;AAAA,IACvC,SAAS,OAAY;AACnB,UAAI,MAAM,SAAS,kBAAmB,QAAO;AAC7C,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,MAAM,UAAU,OAAc,UAAsC;AAClE,QAAI;AACF,YAAM,KAAK,IAAI,IAAI,QAAQ,KAAK,CAAC;AAAA,IAEnC,SAAS,OAAY;AACnB,UAAI,MAAM,SAAS,mBAAmB;AACpC,cAAM,KAAK,IAAI;AAAA,UACb,QAAQ,KAAK;AAAA,UACb,QAAQ,OAAO,KAAK,UAAU,QAAQ,CAAC;AAAA,QACzC;AACA;AAAA,MACF;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,MAAM,OAAO,OAAc,OAAkC;AAC3D,UAAM,MAAM,MAAM,KAAK,WAAW,KAAK;AACvC,UAAM,KAAK,IAAI,IAAI,SAAS,OAAO,GAAG,GAAG,iBAAiB,KAAK,CAAC;AAAA,EAClE;AAAA,EAEA,OAAO,QAAQ,OAAyC;AACtD,UAAM,SAAS,YAAY,KAAK;AAChC,qBAAiB,SAAS,KAAK,IAAI,OAAO;AAAA,MACxC,KAAK;AAAA,MACL,IAAI,GAAG,MAAM;AAAA,IACf,CAAC,GAAG;AACF,YAAM,iBAAiB,KAAK;AAAA,IAC9B;AAAA,EACF;AAAA,EAEA,MAAM,QAAQ,OAAc,OAAkC;AAC5D,UAAM,SAAS,YAAY,KAAK;AAGhC,UAAM,eAAyB,CAAC;AAChC,qBAAiB,OAAO,KAAK,IAAI,KAAK;AAAA,MACpC,KAAK;AAAA,MACL,IAAI,GAAG,MAAM;AAAA,IACf,CAAC,GAAG;AACF,mBAAa,KAAK,GAAG;AAAA,IACvB;AAGA,UAAM,MAGF,aAAa,IAAI,UAAQ,EAAE,MAAM,OAAgB,IAAI,EAAE;AAC3D,QAAI,KAAK;AAAA,MACP,MAAM;AAAA,MACN,KAAK,SAAS,OAAO,CAAC;AAAA,MACtB,OAAO,iBAAiB,KAAK;AAAA,IAC/B,CAAC;AACD,UAAM,KAAK,IAAI,MAAM,GAAG;AAGxB,SAAK,QAAQ,IAAI,OAAO,CAAC;AAAA,EAC3B;AAAA,EAEA,MAAM,OAAO,OAA6B;AACxC,UAAM,SAAS,YAAY,KAAK;AAGhC,UAAM,eAAyB,CAAC,QAAQ,KAAK,CAAC;AAC9C,qBAAiB,OAAO,KAAK,IAAI,KAAK;AAAA,MACpC,KAAK;AAAA,MACL,IAAI,GAAG,MAAM;AAAA,IACf,CAAC,GAAG;AACF,mBAAa,KAAK,GAAG;AAAA,IACvB;AAEA,UAAM,KAAK,IAAI;AAAA,MACb,aAAa,IAAI,UAAQ,EAAE,MAAM,OAAgB,IAAI,EAAE;AAAA,IACzD;AAGA,SAAK,QAAQ,OAAO,KAAK;AAAA,EAC3B;AAAA,EAEA,OAAO,aAAmC;AACxC,qBAAiB,OAAO,KAAK,IAAI,KAAK;AAAA,MACpC,KAAK;AAAA,MACL,IAAI,GAAG,WAAW;AAAA,IACpB,CAAC,GAAG;AACF,YAAM,sBAAsB,GAAG;AAAA,IACjC;AAAA,EACF;AAAA,EAEA,MAAM,QAAuB;AAC3B,UAAM,KAAK,IAAI,MAAM;AAAA,EACvB;AACF;AAsBO,SAAS,mBAAmB,QAAuB;AACxD,SAAO,IAAI,aAAa,MAAM;AAChC;","names":["next"]}