@kyneta/leveldb-store 1.3.1 → 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.1",
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.1",
29
- "@kyneta/schema": "^1.3.1"
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.1",
40
- "@kyneta/schema": "^1.3.1"
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"
10
+ import type { StoreRecord } from "@kyneta/exchange"
11
11
  import {
12
12
  collectAll,
13
13
  describeStore,
14
- makeEntry,
15
- plainMetadata,
14
+ makeBinaryEntryRecord,
15
+ makeEntryRecord,
16
+ makeMetaRecord,
17
+ plainMeta,
16
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,18 +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 { DocId, Store, StoreEntry } from "@kyneta/exchange"
17
- 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"
18
23
  import { ClassicLevel } from "classic-level"
19
24
 
20
25
  // ---------------------------------------------------------------------------
@@ -23,25 +28,40 @@ import { ClassicLevel } from "classic-level"
23
28
 
24
29
  const SEP = "\x00"
25
30
  const META_PREFIX = `meta${SEP}`
26
- const ENTRY_PREFIX = `entry${SEP}`
27
- const SEQ_PAD = 10
31
+ const RECORD_PREFIX = `record${SEP}`
32
+ const SEQ_PAD = 16
28
33
 
29
34
  // ---------------------------------------------------------------------------
30
- // Binary envelope — pure encode/decode for StoreEntry
35
+ // Binary envelope v2 — pure encode/decode for StoreRecord
31
36
  // ---------------------------------------------------------------------------
32
37
 
33
38
  // Flags byte layout:
34
- // bit 0: kind (0 = entirety, 1 = since)
35
- // bit 1: encoding (0 = json, 1 = binary)
36
- // 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)
37
44
  //
38
- // 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]
39
50
 
40
51
  const encoder = new TextEncoder()
41
52
  const decoder = new TextDecoder()
42
53
 
43
- export function encodeStoreEntry(entry: StoreEntry): Uint8Array {
44
- 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
45
65
 
46
66
  let flags = 0
47
67
  if (payload.kind === "since") flags |= 0x01
@@ -66,12 +86,26 @@ export function encodeStoreEntry(entry: StoreEntry): Uint8Array {
66
86
  return buf
67
87
  }
68
88
 
69
- export function decodeStoreEntry(bytes: Uint8Array): StoreEntry {
70
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
71
-
89
+ export function decodeStoreRecord(bytes: Uint8Array): StoreRecord {
72
90
  const flagByte = bytes[0]
73
- if (flagByte === undefined) throw new Error("empty store entry bytes")
91
+ if (flagByte === undefined) throw new Error("empty store record bytes")
74
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
+
75
109
  const kind = (flags & 0x01) !== 0 ? "since" : "entirety"
76
110
  const encoding = (flags & 0x02) !== 0 ? "binary" : "json"
77
111
  const isDataBinary = (flags & 0x04) !== 0
@@ -87,6 +121,7 @@ export function decodeStoreEntry(bytes: Uint8Array): StoreEntry {
87
121
  : decoder.decode(rawData)
88
122
 
89
123
  return {
124
+ kind: "entry",
90
125
  payload: { kind, encoding, data },
91
126
  version,
92
127
  }
@@ -100,20 +135,20 @@ function metaKey(docId: DocId): string {
100
135
  return `${META_PREFIX}${docId}`
101
136
  }
102
137
 
103
- function entryPrefix(docId: DocId): string {
104
- return `${ENTRY_PREFIX}${docId}${SEP}`
138
+ function recordPrefix(docId: DocId): string {
139
+ return `${RECORD_PREFIX}${docId}${SEP}`
105
140
  }
106
141
 
107
- function entryKey(docId: DocId, seqNo: number): string {
108
- 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")}`
109
144
  }
110
145
 
111
146
  function parseDocIdFromMetaKey(key: string): DocId {
112
147
  return key.slice(META_PREFIX.length)
113
148
  }
114
149
 
115
- function parseSeqNoFromEntryKey(key: string, docId: DocId): number {
116
- const prefix = entryPrefix(docId)
150
+ function parseSeqNoFromRecordKey(key: string, docId: DocId): number {
151
+ const prefix = recordPrefix(docId)
117
152
  return Number.parseInt(key.slice(prefix.length), 10)
118
153
  }
119
154
 
@@ -148,7 +183,7 @@ export class LevelDBStore implements Store {
148
183
  }
149
184
 
150
185
  // Lazy discovery: reverse iterate to find the highest existing seqNo
151
- const prefix = entryPrefix(docId)
186
+ const prefix = recordPrefix(docId)
152
187
  let maxSeq = -1
153
188
  for await (const key of this.#db.keys({
154
189
  gte: prefix,
@@ -156,7 +191,7 @@ export class LevelDBStore implements Store {
156
191
  reverse: true,
157
192
  limit: 1,
158
193
  })) {
159
- maxSeq = parseSeqNoFromEntryKey(key, docId)
194
+ maxSeq = parseSeqNoFromRecordKey(key, docId)
160
195
  }
161
196
 
162
197
  const next = maxSeq + 1
@@ -168,51 +203,57 @@ export class LevelDBStore implements Store {
168
203
  // Store interface
169
204
  // -------------------------------------------------------------------------
170
205
 
171
- async lookup(docId: DocId): Promise<DocMetadata | null> {
206
+ async currentMeta(docId: DocId): Promise<StoreMeta | null> {
172
207
  try {
173
208
  const raw = await this.#db.get(metaKey(docId))
174
- return JSON.parse(decoder.decode(raw)) as DocMetadata
209
+ return JSON.parse(decoder.decode(raw)) as StoreMeta
175
210
  } catch (error: any) {
176
211
  if (error.code === "LEVEL_NOT_FOUND") return null
177
212
  throw error
178
213
  }
179
214
  }
180
215
 
181
- async ensureDoc(docId: DocId, metadata: DocMetadata): Promise<void> {
182
- try {
183
- await this.#db.get(metaKey(docId))
184
- // Already exists — no-op (first call wins)
185
- } catch (error: any) {
186
- if (error.code === "LEVEL_NOT_FOUND") {
187
- await this.#db.put(
188
- metaKey(docId),
189
- 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`,
190
223
  )
191
- return
192
224
  }
193
- 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
+ )
194
232
  }
195
- }
196
233
 
197
- async append(docId: DocId, entry: StoreEntry): Promise<void> {
198
234
  const seq = await this.#nextSeqNo(docId)
199
- await this.#db.put(entryKey(docId, seq), encodeStoreEntry(entry))
235
+ await this.#db.put(recordKey(docId, seq), encodeStoreRecord(record))
200
236
  }
201
237
 
202
- async *loadAll(docId: DocId): AsyncIterable<StoreEntry> {
203
- const prefix = entryPrefix(docId)
238
+ async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {
239
+ const prefix = recordPrefix(docId)
204
240
  for await (const value of this.#db.values({
205
241
  gte: prefix,
206
242
  lt: `${prefix}\xff`,
207
243
  })) {
208
- yield decodeStoreEntry(value)
244
+ yield decodeStoreRecord(value)
209
245
  }
210
246
  }
211
247
 
212
- async replace(docId: DocId, entry: StoreEntry): Promise<void> {
213
- const prefix = entryPrefix(docId)
248
+ async replace(docId: DocId, records: StoreRecord[]): Promise<void> {
249
+ const existingMeta = await this.currentMeta(docId)
214
250
 
215
- // 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
216
257
  const keysToDelete: string[] = []
217
258
  for await (const key of this.#db.keys({
218
259
  gte: prefix,
@@ -221,26 +262,36 @@ export class LevelDBStore implements Store {
221
262
  keysToDelete.push(key)
222
263
  }
223
264
 
224
- // Atomic batch: delete all existing entries + write the single replacement
265
+ // Atomic batch: delete all existing records, write replacements, upsert meta
225
266
  const ops: Array<
226
267
  | { type: "del"; key: string }
227
268
  | { type: "put"; key: string; value: Uint8Array }
228
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
+
229
279
  ops.push({
230
280
  type: "put",
231
- key: entryKey(docId, 0),
232
- value: encodeStoreEntry(entry),
281
+ key: metaKey(docId),
282
+ value: encoder.encode(JSON.stringify(resolved)),
233
283
  })
284
+
234
285
  await this.#db.batch(ops)
235
286
 
236
- // Reset seqNo counter
237
- this.#seqNos.set(docId, 0)
287
+ // Reset seqNo counter to the last written index
288
+ this.#seqNos.set(docId, records.length - 1)
238
289
  }
239
290
 
240
291
  async delete(docId: DocId): Promise<void> {
241
- const prefix = entryPrefix(docId)
292
+ const prefix = recordPrefix(docId)
242
293
 
243
- // Collect entry keys to delete
294
+ // Collect record keys to delete
244
295
  const keysToDelete: string[] = [metaKey(docId)]
245
296
  for await (const key of this.#db.keys({
246
297
  gte: prefix,
@@ -257,10 +308,12 @@ export class LevelDBStore implements Store {
257
308
  this.#seqNos.delete(docId)
258
309
  }
259
310
 
260
- async *listDocIds(): AsyncIterable<DocId> {
311
+ async *listDocIds(prefix?: string): AsyncIterable<DocId> {
312
+ const rangePrefix =
313
+ prefix !== undefined ? `${META_PREFIX}${prefix}` : META_PREFIX
261
314
  for await (const key of this.#db.keys({
262
- gte: META_PREFIX,
263
- lt: `${META_PREFIX}\xff`,
315
+ gte: rangePrefix,
316
+ lt: `${rangePrefix}\xff`,
264
317
  })) {
265
318
  yield parseDocIdFromMetaKey(key)
266
319
  }
@@ -284,7 +337,7 @@ export class LevelDBStore implements Store {
284
337
  *
285
338
  * @example
286
339
  * ```typescript
287
- * import { createLevelDBStore } from "@kyneta/leveldb-store/server"
340
+ * import { createLevelDBStore } from "@kyneta/leveldb-store"
288
341
  *
289
342
  * const exchange = new Exchange({
290
343
  * stores: [createLevelDBStore("./data/exchange-db")],
package/dist/server.d.ts DELETED
@@ -1,36 +0,0 @@
1
- import { Store, DocId, StoreEntry } from '@kyneta/exchange';
2
- import { DocMetadata } from '@kyneta/schema';
3
-
4
- declare function encodeStoreEntry(entry: StoreEntry): Uint8Array;
5
- declare function decodeStoreEntry(bytes: Uint8Array): StoreEntry;
6
- declare class LevelDBStore implements Store {
7
- #private;
8
- constructor(dbPath: string);
9
- lookup(docId: DocId): Promise<DocMetadata | null>;
10
- ensureDoc(docId: DocId, metadata: DocMetadata): Promise<void>;
11
- append(docId: DocId, entry: StoreEntry): Promise<void>;
12
- loadAll(docId: DocId): AsyncIterable<StoreEntry>;
13
- replace(docId: DocId, entry: StoreEntry): Promise<void>;
14
- delete(docId: DocId): Promise<void>;
15
- listDocIds(): AsyncIterable<DocId>;
16
- close(): Promise<void>;
17
- }
18
- /**
19
- * Create a LevelDB storage backend for server-side persistence.
20
- *
21
- * Returns a `Store` — pass directly to `Exchange({ stores: [...] })`.
22
- *
23
- * @param dbPath - Directory path where LevelDB stores its files
24
- *
25
- * @example
26
- * ```typescript
27
- * import { createLevelDBStore } from "@kyneta/leveldb-store/server"
28
- *
29
- * const exchange = new Exchange({
30
- * stores: [createLevelDBStore("./data/exchange-db")],
31
- * })
32
- * ```
33
- */
34
- declare function createLevelDBStore(dbPath: string): Store;
35
-
36
- 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 { DocId, Store, StoreEntry } from \"@kyneta/exchange\"\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":"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","names":["next"]}