@kyneta/leveldb-store 1.3.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +1 -0
- package/package.json +15 -10
- package/src/__tests__/leveldb-storage.test.ts +154 -63
- package/src/{server.ts → index.ts} +121 -95
- package/dist/server.d.ts +0 -36
- package/dist/server.js +0 -188
- package/dist/server.js.map +0 -1
package/dist/index.d.ts
ADDED
|
@@ -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":";;;iBAuDgB,iBAAA,CAAkB,MAAA,EAAQ,WAAA,GAAc,UAAA;AAAA,iBAmCxC,iBAAA,CAAkB,KAAA,EAAO,UAAA,GAAa,WAAA;AAAA,cAsEzC,YAAA,YAAwB,KAAA;EAAA;cAIvB,MAAA;EAUN,WAAA,CAAY,KAAA,EAAO,KAAA,GAAQ,OAAA,CAAQ,SAAA;EAUnC,MAAA,CAAO,KAAA,EAAO,KAAA,EAAO,MAAA,EAAQ,WAAA,GAAc,OAAA;EA0B1C,OAAA,CAAQ,KAAA,EAAO,KAAA,GAAQ,aAAA,CAAc,WAAA;EAUtC,OAAA,CAAQ,KAAA,EAAO,KAAA,EAAO,OAAA,EAAS,WAAA,KAAgB,OAAA;EA2C/C,MAAA,CAAO,KAAA,EAAO,KAAA,GAAQ,OAAA;EAoBrB,UAAA,CAAW,MAAA,YAAkB,aAAA,CAAc,KAAA;EAW5C,KAAA,CAAA,GAAS,OAAA;AAAA;;;;;;;;AAtIjB;;;;;;;;;iBA+JgB,kBAAA,CAAmB,MAAA,WAAiB,KAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { SeqNoTracker, resolveMetaFromBatch, validateAppend } 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 = new SeqNoTracker();
|
|
83
|
+
constructor(dbPath) {
|
|
84
|
+
this.#db = new ClassicLevel(dbPath, { valueEncoding: "binary" });
|
|
85
|
+
}
|
|
86
|
+
async currentMeta(docId) {
|
|
87
|
+
try {
|
|
88
|
+
const raw = await this.#db.get(metaKey(docId));
|
|
89
|
+
return JSON.parse(decoder.decode(raw));
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error.code === "LEVEL_NOT_FOUND") return null;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async append(docId, record) {
|
|
96
|
+
const resolved = validateAppend(docId, record, await this.currentMeta(docId));
|
|
97
|
+
if (resolved !== null) await this.#db.put(metaKey(docId), encoder.encode(JSON.stringify(resolved)));
|
|
98
|
+
const seq = await this.#seqNos.next(docId, async () => {
|
|
99
|
+
const prefix = recordPrefix(docId);
|
|
100
|
+
for await (const key of this.#db.keys({
|
|
101
|
+
gte: prefix,
|
|
102
|
+
lt: `${prefix}\xff`,
|
|
103
|
+
reverse: true,
|
|
104
|
+
limit: 1
|
|
105
|
+
})) return parseSeqNoFromRecordKey(key, docId);
|
|
106
|
+
return null;
|
|
107
|
+
});
|
|
108
|
+
await this.#db.put(recordKey(docId, seq), encodeStoreRecord(record));
|
|
109
|
+
}
|
|
110
|
+
async *loadAll(docId) {
|
|
111
|
+
const prefix = recordPrefix(docId);
|
|
112
|
+
for await (const value of this.#db.values({
|
|
113
|
+
gte: prefix,
|
|
114
|
+
lt: `${prefix}\xff`
|
|
115
|
+
})) yield decodeStoreRecord(value);
|
|
116
|
+
}
|
|
117
|
+
async replace(docId, records) {
|
|
118
|
+
const resolved = resolveMetaFromBatch(records, await this.currentMeta(docId));
|
|
119
|
+
const prefix = recordPrefix(docId);
|
|
120
|
+
const keysToDelete = [];
|
|
121
|
+
for await (const key of this.#db.keys({
|
|
122
|
+
gte: prefix,
|
|
123
|
+
lt: `${prefix}\xff`
|
|
124
|
+
})) keysToDelete.push(key);
|
|
125
|
+
const ops = keysToDelete.map((key) => ({
|
|
126
|
+
type: "del",
|
|
127
|
+
key
|
|
128
|
+
}));
|
|
129
|
+
for (let i = 0; i < records.length; i++) ops.push({
|
|
130
|
+
type: "put",
|
|
131
|
+
key: recordKey(docId, i),
|
|
132
|
+
value: encodeStoreRecord(records[i])
|
|
133
|
+
});
|
|
134
|
+
ops.push({
|
|
135
|
+
type: "put",
|
|
136
|
+
key: metaKey(docId),
|
|
137
|
+
value: encoder.encode(JSON.stringify(resolved))
|
|
138
|
+
});
|
|
139
|
+
await this.#db.batch(ops);
|
|
140
|
+
this.#seqNos.reset(docId, records.length - 1);
|
|
141
|
+
}
|
|
142
|
+
async delete(docId) {
|
|
143
|
+
const prefix = recordPrefix(docId);
|
|
144
|
+
const keysToDelete = [metaKey(docId)];
|
|
145
|
+
for await (const key of this.#db.keys({
|
|
146
|
+
gte: prefix,
|
|
147
|
+
lt: `${prefix}\xff`
|
|
148
|
+
})) keysToDelete.push(key);
|
|
149
|
+
await this.#db.batch(keysToDelete.map((key) => ({
|
|
150
|
+
type: "del",
|
|
151
|
+
key
|
|
152
|
+
})));
|
|
153
|
+
this.#seqNos.remove(docId);
|
|
154
|
+
}
|
|
155
|
+
async *listDocIds(prefix) {
|
|
156
|
+
const rangePrefix = prefix !== void 0 ? `${META_PREFIX}${prefix}` : META_PREFIX;
|
|
157
|
+
for await (const key of this.#db.keys({
|
|
158
|
+
gte: rangePrefix,
|
|
159
|
+
lt: `${rangePrefix}\xff`
|
|
160
|
+
})) yield parseDocIdFromMetaKey(key);
|
|
161
|
+
}
|
|
162
|
+
async close() {
|
|
163
|
+
await this.#db.close();
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Create a LevelDB storage backend for server-side persistence.
|
|
168
|
+
*
|
|
169
|
+
* Returns a `Store` — pass directly to `Exchange({ stores: [...] })`.
|
|
170
|
+
*
|
|
171
|
+
* @param dbPath - Directory path where LevelDB stores its files
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* ```typescript
|
|
175
|
+
* import { createLevelDBStore } from "@kyneta/leveldb-store"
|
|
176
|
+
*
|
|
177
|
+
* const exchange = new Exchange({
|
|
178
|
+
* stores: [createLevelDBStore("./data/exchange-db")],
|
|
179
|
+
* })
|
|
180
|
+
* ```
|
|
181
|
+
*/
|
|
182
|
+
function createLevelDBStore(dbPath) {
|
|
183
|
+
return new LevelDBStore(dbPath);
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
export { LevelDBStore, createLevelDBStore, decodeStoreRecord, encodeStoreRecord };
|
|
187
|
+
|
|
188
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["#db","#seqNos"],"sources":["../src/index.ts"],"sourcesContent":["// server — LevelDB storage backend for @kyneta/exchange.\n//\n// Implements the Store interface using classic-level.\n//\n// Key-space design (FoundationDB convention — \\x00 null-byte separator):\n// meta\\x00{docId} → JSON-encoded StoreMeta (materialized index)\n// record\\x00{docId}\\x00{seqNo} → binary-encoded StoreRecord (unified stream)\n//\n// The \\x00 separator cannot appear in valid UTF-8 strings, so no docId\n// validation is needed — the key-space imposes zero constraints on callers.\n//\n// SeqNo is a zero-padded 16-digit monotonic counter per doc, tracked in\n// memory. On reboot, the max seqNo for a doc is lazily discovered via a\n// single reverse-iterator seek on first append.\n\nimport {\n type DocId,\n resolveMetaFromBatch,\n SeqNoTracker,\n type Store,\n type StoreMeta,\n type StoreRecord,\n validateAppend,\n} from \"@kyneta/exchange\"\nimport { ClassicLevel } from \"classic-level\"\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst SEP = \"\\x00\"\nconst META_PREFIX = `meta${SEP}`\nconst RECORD_PREFIX = `record${SEP}`\nconst SEQ_PAD = 16\n\n// ---------------------------------------------------------------------------\n// Binary envelope v2 — pure encode/decode for StoreRecord\n// ---------------------------------------------------------------------------\n\n// Flags byte layout:\n// bit 0: payload kind (0 = entirety, 1 = since) — entry records only\n// bit 1: encoding (0 = json, 1 = binary) — entry records only\n// bit 2: data type (0 = string, 1 = Uint8Array) — entry records only\n// bit 3: record kind (0 = entry, 1 = meta)\n// bit 7: future-format (0 = current format, reserved)\n//\n// Meta records (bit 3 = 1):\n// [1 byte flags] [remaining: JSON-encoded StoreMeta]\n//\n// Entry records (bit 3 = 0):\n// [1 byte flags] [4 bytes version length BE] [N bytes version UTF-8] [remaining: payload data]\n\nconst encoder = new TextEncoder()\nconst decoder = new TextDecoder()\n\nexport function encodeStoreRecord(record: StoreRecord): Uint8Array {\n if (record.kind === \"meta\") {\n const metaBytes = encoder.encode(JSON.stringify(record.meta))\n const buf = new Uint8Array(1 + metaBytes.length)\n buf[0] = 0x08 // bit 3 set (meta)\n buf.set(metaBytes, 1)\n return buf\n }\n\n // Entry record\n const { payload, version } = record\n\n let flags = 0\n if (payload.kind === \"since\") flags |= 0x01\n if (payload.encoding === \"binary\") flags |= 0x02\n\n const isDataBinary = payload.data instanceof Uint8Array\n if (isDataBinary) flags |= 0x04\n\n const versionBytes = encoder.encode(version)\n const dataBytes = isDataBinary\n ? (payload.data as Uint8Array)\n : encoder.encode(payload.data as string)\n\n const buf = new Uint8Array(1 + 4 + versionBytes.length + dataBytes.length)\n const view = new DataView(buf.buffer)\n\n buf[0] = flags\n view.setUint32(1, versionBytes.length, false) // big-endian\n buf.set(versionBytes, 5)\n buf.set(dataBytes, 5 + versionBytes.length)\n\n return buf\n}\n\nexport function decodeStoreRecord(bytes: Uint8Array): StoreRecord {\n const flagByte = bytes[0]\n if (flagByte === undefined) throw new Error(\"empty store record bytes\")\n const flags = flagByte\n\n // Check future-format flag (bit 7)\n if ((flags & 0x80) !== 0) {\n throw new Error(\"unknown store record format (future-format bit set)\")\n }\n\n const isMeta = (flags & 0x08) !== 0\n\n if (isMeta) {\n const metaJson = decoder.decode(bytes.subarray(1))\n return { kind: \"meta\", meta: JSON.parse(metaJson) as StoreMeta }\n }\n\n // Entry record\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)\n\n const kind = (flags & 0x01) !== 0 ? \"since\" : \"entirety\"\n const encoding = (flags & 0x02) !== 0 ? \"binary\" : \"json\"\n const isDataBinary = (flags & 0x04) !== 0\n\n const versionLen = view.getUint32(1, false)\n const version = decoder.decode(bytes.subarray(5, 5 + versionLen))\n\n const dataStart = 5 + versionLen\n const rawData = bytes.subarray(dataStart)\n\n const data: string | Uint8Array = isDataBinary\n ? new Uint8Array(rawData)\n : decoder.decode(rawData)\n\n return {\n kind: \"entry\",\n payload: { kind, encoding, data },\n version,\n }\n}\n\n// ---------------------------------------------------------------------------\n// Key helpers\n// ---------------------------------------------------------------------------\n\nfunction metaKey(docId: DocId): string {\n return `${META_PREFIX}${docId}`\n}\n\nfunction recordPrefix(docId: DocId): string {\n return `${RECORD_PREFIX}${docId}${SEP}`\n}\n\nfunction recordKey(docId: DocId, seqNo: number): string {\n return `${recordPrefix(docId)}${String(seqNo).padStart(SEQ_PAD, \"0\")}`\n}\n\nfunction parseDocIdFromMetaKey(key: string): DocId {\n return key.slice(META_PREFIX.length)\n}\n\nfunction parseSeqNoFromRecordKey(key: string, docId: DocId): number {\n const prefix = recordPrefix(docId)\n return Number.parseInt(key.slice(prefix.length), 10)\n}\n\n// ---------------------------------------------------------------------------\n// LevelDBStore\n// ---------------------------------------------------------------------------\n\nexport class LevelDBStore implements Store {\n readonly #db: ClassicLevel<string, Uint8Array>\n readonly #seqNos = new SeqNoTracker()\n\n constructor(dbPath: string) {\n this.#db = new ClassicLevel(dbPath, {\n valueEncoding: \"binary\",\n })\n }\n\n // -------------------------------------------------------------------------\n // Store interface\n // -------------------------------------------------------------------------\n\n async currentMeta(docId: DocId): Promise<StoreMeta | null> {\n try {\n const raw = await this.#db.get(metaKey(docId))\n return JSON.parse(decoder.decode(raw)) as StoreMeta\n } catch (error: any) {\n if (error.code === \"LEVEL_NOT_FOUND\") return null\n throw error\n }\n }\n\n async append(docId: DocId, record: StoreRecord): Promise<void> {\n const existingMeta = await this.currentMeta(docId)\n const resolved = validateAppend(docId, record, existingMeta)\n\n if (resolved !== null) {\n await this.#db.put(\n metaKey(docId),\n encoder.encode(JSON.stringify(resolved)),\n )\n }\n\n const seq = await this.#seqNos.next(docId, async () => {\n const prefix = recordPrefix(docId)\n for await (const key of this.#db.keys({\n gte: prefix,\n lt: `${prefix}\\xff`,\n reverse: true,\n limit: 1,\n })) {\n return parseSeqNoFromRecordKey(key, docId)\n }\n return null\n })\n await this.#db.put(recordKey(docId, seq), encodeStoreRecord(record))\n }\n\n async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {\n const prefix = recordPrefix(docId)\n for await (const value of this.#db.values({\n gte: prefix,\n lt: `${prefix}\\xff`,\n })) {\n yield decodeStoreRecord(value)\n }\n }\n\n async replace(docId: DocId, records: StoreRecord[]): Promise<void> {\n const existingMeta = await this.currentMeta(docId)\n\n // Resolve validates: at least one meta present, immutable fields match.\n const resolved = resolveMetaFromBatch(records, existingMeta)\n\n const prefix = recordPrefix(docId)\n\n // Collect existing record keys to delete\n const keysToDelete: string[] = []\n for await (const key of this.#db.keys({\n gte: prefix,\n lt: `${prefix}\\xff`,\n })) {\n keysToDelete.push(key)\n }\n\n // Atomic batch: delete all existing records, write replacements, upsert meta\n const ops: Array<\n | { type: \"del\"; key: string }\n | { type: \"put\"; key: string; value: Uint8Array }\n > = keysToDelete.map(key => ({ type: \"del\" as const, key }))\n\n for (let i = 0; i < records.length; i++) {\n ops.push({\n type: \"put\",\n key: recordKey(docId, i),\n value: encodeStoreRecord(records[i]!),\n })\n }\n\n ops.push({\n type: \"put\",\n key: metaKey(docId),\n value: encoder.encode(JSON.stringify(resolved)),\n })\n\n await this.#db.batch(ops)\n\n // Reset seqNo counter to the last written index\n this.#seqNos.reset(docId, records.length - 1)\n }\n\n async delete(docId: DocId): Promise<void> {\n const prefix = recordPrefix(docId)\n\n // Collect record keys to delete\n const keysToDelete: string[] = [metaKey(docId)]\n for await (const key of this.#db.keys({\n gte: prefix,\n lt: `${prefix}\\xff`,\n })) {\n keysToDelete.push(key)\n }\n\n await this.#db.batch(\n keysToDelete.map(key => ({ type: \"del\" as const, key })),\n )\n\n // Remove from in-memory seqNo tracker\n this.#seqNos.remove(docId)\n }\n\n async *listDocIds(prefix?: string): AsyncIterable<DocId> {\n const rangePrefix =\n prefix !== undefined ? `${META_PREFIX}${prefix}` : META_PREFIX\n for await (const key of this.#db.keys({\n gte: rangePrefix,\n lt: `${rangePrefix}\\xff`,\n })) {\n yield parseDocIdFromMetaKey(key)\n }\n }\n\n async close(): Promise<void> {\n await this.#db.close()\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory function\n// ---------------------------------------------------------------------------\n\n/**\n * Create a LevelDB storage backend for server-side persistence.\n *\n * Returns a `Store` — pass directly to `Exchange({ stores: [...] })`.\n *\n * @param dbPath - Directory path where LevelDB stores its files\n *\n * @example\n * ```typescript\n * import { createLevelDBStore } from \"@kyneta/leveldb-store\"\n *\n * const exchange = new Exchange({\n * stores: [createLevelDBStore(\"./data/exchange-db\")],\n * })\n * ```\n */\nexport function createLevelDBStore(dbPath: string): Store {\n return new LevelDBStore(dbPath)\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kyneta/leveldb-store",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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
|
-
"
|
|
22
|
-
"types": "./dist/
|
|
23
|
-
"import": "./dist/
|
|
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.
|
|
29
|
-
"@kyneta/schema": "^1.
|
|
33
|
+
"@kyneta/exchange": "^1.5.0",
|
|
34
|
+
"@kyneta/schema": "^1.5.0"
|
|
30
35
|
},
|
|
31
36
|
"dependencies": {
|
|
32
37
|
"classic-level": "^1.4.1"
|
|
33
38
|
},
|
|
34
39
|
"devDependencies": {
|
|
35
40
|
"@types/node": "^22",
|
|
36
|
-
"
|
|
41
|
+
"tsdown": "^0.21.9",
|
|
37
42
|
"typescript": "^5.9.2",
|
|
38
43
|
"vitest": "^4.0.17",
|
|
39
|
-
"@kyneta/exchange": "^1.
|
|
40
|
-
"@kyneta/schema": "^1.
|
|
44
|
+
"@kyneta/exchange": "^1.5.0",
|
|
45
|
+
"@kyneta/schema": "^1.5.0"
|
|
41
46
|
},
|
|
42
47
|
"scripts": {
|
|
43
|
-
"build": "
|
|
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 {
|
|
10
|
+
import type { StoreRecord } from "@kyneta/exchange"
|
|
11
11
|
import {
|
|
12
12
|
collectAll,
|
|
13
13
|
describeStore,
|
|
14
|
-
|
|
15
|
-
|
|
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 {
|
|
20
|
+
import { decodeStoreRecord, encodeStoreRecord, LevelDBStore } from "../index.js"
|
|
19
21
|
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
21
23
|
// Temp directory management
|
|
@@ -43,13 +45,11 @@ afterAll(() => {
|
|
|
43
45
|
// Conformance suite — validates the full Store contract
|
|
44
46
|
// ---------------------------------------------------------------------------
|
|
45
47
|
|
|
46
|
-
describeStore(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
async backend => {
|
|
50
|
-
if (backend.close) await backend.close()
|
|
48
|
+
describeStore("LevelDBStore", () => new LevelDBStore(makeTmpDir()), {
|
|
49
|
+
cleanup: async backend => {
|
|
50
|
+
await backend.close()
|
|
51
51
|
},
|
|
52
|
-
)
|
|
52
|
+
})
|
|
53
53
|
|
|
54
54
|
// ---------------------------------------------------------------------------
|
|
55
55
|
// LevelDB-specific tests
|
|
@@ -61,19 +61,26 @@ describe("LevelDBStore — close + reopen", () => {
|
|
|
61
61
|
|
|
62
62
|
// Phase 1: write data then close
|
|
63
63
|
const backend1 = new LevelDBStore(dir)
|
|
64
|
-
await backend1.
|
|
65
|
-
await backend1.append("doc-1",
|
|
66
|
-
await backend1.append("doc-1",
|
|
64
|
+
await backend1.append("doc-1", makeMetaRecord())
|
|
65
|
+
await backend1.append("doc-1", makeEntryRecord("entirety", "v1"))
|
|
66
|
+
await backend1.append("doc-1", makeEntryRecord("since", "v2"))
|
|
67
67
|
await backend1.close()
|
|
68
68
|
|
|
69
69
|
// Phase 2: reopen and verify
|
|
70
70
|
const backend2 = new LevelDBStore(dir)
|
|
71
|
-
expect(await backend2.
|
|
71
|
+
expect(await backend2.currentMeta("doc-1")).toEqual(plainMeta)
|
|
72
72
|
|
|
73
|
-
const
|
|
73
|
+
const records = await collectAll(backend2.loadAll("doc-1"))
|
|
74
|
+
expect(records).toHaveLength(3)
|
|
75
|
+
expect(records[0]).toEqual(makeMetaRecord())
|
|
76
|
+
const entries = records.filter(r => r.kind === "entry")
|
|
74
77
|
expect(entries).toHaveLength(2)
|
|
75
|
-
expect(entries[0]
|
|
76
|
-
|
|
78
|
+
expect((entries[0] as { kind: "entry"; version: string }).version).toBe(
|
|
79
|
+
"v1",
|
|
80
|
+
)
|
|
81
|
+
expect((entries[1] as { kind: "entry"; version: string }).version).toBe(
|
|
82
|
+
"v2",
|
|
83
|
+
)
|
|
77
84
|
await backend2.close()
|
|
78
85
|
})
|
|
79
86
|
|
|
@@ -81,37 +88,51 @@ describe("LevelDBStore — close + reopen", () => {
|
|
|
81
88
|
const dir = makeTmpDir()
|
|
82
89
|
|
|
83
90
|
const backend1 = new LevelDBStore(dir)
|
|
84
|
-
await backend1.
|
|
85
|
-
await backend1.append("doc-1",
|
|
86
|
-
await backend1.append("doc-1",
|
|
91
|
+
await backend1.append("doc-1", makeMetaRecord())
|
|
92
|
+
await backend1.append("doc-1", makeEntryRecord("entirety", "v1"))
|
|
93
|
+
await backend1.append("doc-1", makeEntryRecord("since", "v2"))
|
|
87
94
|
await backend1.close()
|
|
88
95
|
|
|
89
96
|
// Reopen and append more
|
|
90
97
|
const backend2 = new LevelDBStore(dir)
|
|
91
|
-
await backend2.append("doc-1",
|
|
98
|
+
await backend2.append("doc-1", makeEntryRecord("since", "v3"))
|
|
92
99
|
|
|
93
|
-
const
|
|
100
|
+
const records = await collectAll(backend2.loadAll("doc-1"))
|
|
101
|
+
const entries = records.filter(r => r.kind === "entry")
|
|
94
102
|
expect(entries).toHaveLength(3)
|
|
95
|
-
expect(entries[0]
|
|
96
|
-
|
|
97
|
-
|
|
103
|
+
expect((entries[0] as { kind: "entry"; version: string }).version).toBe(
|
|
104
|
+
"v1",
|
|
105
|
+
)
|
|
106
|
+
expect((entries[1] as { kind: "entry"; version: string }).version).toBe(
|
|
107
|
+
"v2",
|
|
108
|
+
)
|
|
109
|
+
expect((entries[2] as { kind: "entry"; version: string }).version).toBe(
|
|
110
|
+
"v3",
|
|
111
|
+
)
|
|
98
112
|
await backend2.close()
|
|
99
113
|
})
|
|
100
114
|
|
|
101
|
-
it("replace then reopen preserves the
|
|
115
|
+
it("replace then reopen preserves the replacement records", async () => {
|
|
102
116
|
const dir = makeTmpDir()
|
|
103
117
|
|
|
104
118
|
const backend1 = new LevelDBStore(dir)
|
|
105
|
-
await backend1.
|
|
106
|
-
await backend1.append("doc-1",
|
|
107
|
-
await backend1.append("doc-1",
|
|
108
|
-
await backend1.replace("doc-1",
|
|
119
|
+
await backend1.append("doc-1", makeMetaRecord())
|
|
120
|
+
await backend1.append("doc-1", makeEntryRecord("since", "v1"))
|
|
121
|
+
await backend1.append("doc-1", makeEntryRecord("since", "v2"))
|
|
122
|
+
await backend1.replace("doc-1", [
|
|
123
|
+
makeMetaRecord(),
|
|
124
|
+
makeEntryRecord("entirety", "v3"),
|
|
125
|
+
])
|
|
109
126
|
await backend1.close()
|
|
110
127
|
|
|
111
128
|
const backend2 = new LevelDBStore(dir)
|
|
112
|
-
const
|
|
113
|
-
expect(
|
|
114
|
-
expect(
|
|
129
|
+
const records = await collectAll(backend2.loadAll("doc-1"))
|
|
130
|
+
expect(records).toHaveLength(2)
|
|
131
|
+
expect(records[0]?.kind).toBe("meta")
|
|
132
|
+
expect(records[1]?.kind).toBe("entry")
|
|
133
|
+
expect((records[1] as { kind: "entry"; version: string }).version).toBe(
|
|
134
|
+
"v3",
|
|
135
|
+
)
|
|
115
136
|
await backend2.close()
|
|
116
137
|
})
|
|
117
138
|
|
|
@@ -119,9 +140,9 @@ describe("LevelDBStore — close + reopen", () => {
|
|
|
119
140
|
const dir = makeTmpDir()
|
|
120
141
|
|
|
121
142
|
const backend1 = new LevelDBStore(dir)
|
|
122
|
-
await backend1.
|
|
123
|
-
await backend1.
|
|
124
|
-
await backend1.
|
|
143
|
+
await backend1.append("alpha", makeMetaRecord())
|
|
144
|
+
await backend1.append("beta", makeMetaRecord())
|
|
145
|
+
await backend1.append("gamma", makeMetaRecord())
|
|
125
146
|
await backend1.close()
|
|
126
147
|
|
|
127
148
|
const backend2 = new LevelDBStore(dir)
|
|
@@ -135,9 +156,26 @@ describe("LevelDBStore — close + reopen", () => {
|
|
|
135
156
|
// encode/decode round-trip — pure function unit tests
|
|
136
157
|
// ---------------------------------------------------------------------------
|
|
137
158
|
|
|
138
|
-
describe("
|
|
139
|
-
it("round-trips a
|
|
140
|
-
const
|
|
159
|
+
describe("encodeStoreRecord / decodeStoreRecord", () => {
|
|
160
|
+
it("round-trips a meta record", () => {
|
|
161
|
+
const record: StoreRecord = makeMetaRecord()
|
|
162
|
+
const decoded = decodeStoreRecord(encodeStoreRecord(record))
|
|
163
|
+
expect(decoded).toEqual(record)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("round-trips a meta record with custom schemaHash", () => {
|
|
167
|
+
const record: StoreRecord = makeMetaRecord({ schemaHash: "00custom" })
|
|
168
|
+
const decoded = decodeStoreRecord(encodeStoreRecord(record))
|
|
169
|
+
expect(decoded).toEqual(record)
|
|
170
|
+
expect(decoded.kind).toBe("meta")
|
|
171
|
+
if (decoded.kind === "meta") {
|
|
172
|
+
expect(decoded.meta.schemaHash).toBe("00custom")
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("round-trips a JSON string payload entry (entirety)", () => {
|
|
177
|
+
const record: StoreRecord = {
|
|
178
|
+
kind: "entry",
|
|
141
179
|
payload: {
|
|
142
180
|
kind: "entirety",
|
|
143
181
|
encoding: "json",
|
|
@@ -145,51 +183,75 @@ describe("encodeStoreEntry / decodeStoreEntry", () => {
|
|
|
145
183
|
},
|
|
146
184
|
version: "42",
|
|
147
185
|
}
|
|
148
|
-
const decoded =
|
|
149
|
-
expect(decoded).toEqual(
|
|
186
|
+
const decoded = decodeStoreRecord(encodeStoreRecord(record))
|
|
187
|
+
expect(decoded).toEqual(record)
|
|
150
188
|
})
|
|
151
189
|
|
|
152
|
-
it("round-trips a binary Uint8Array payload (since)", () => {
|
|
190
|
+
it("round-trips a binary Uint8Array payload entry (since)", () => {
|
|
153
191
|
const bytes = new Uint8Array([0x00, 0x01, 0x02, 0xff, 0xfe])
|
|
154
|
-
const
|
|
192
|
+
const record: StoreRecord = {
|
|
193
|
+
kind: "entry",
|
|
155
194
|
payload: { kind: "since", encoding: "binary", data: bytes },
|
|
156
195
|
version: "v7",
|
|
157
196
|
}
|
|
158
|
-
const decoded =
|
|
159
|
-
expect(decoded.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
197
|
+
const decoded = decodeStoreRecord(encodeStoreRecord(record))
|
|
198
|
+
expect(decoded.kind).toBe("entry")
|
|
199
|
+
if (decoded.kind === "entry") {
|
|
200
|
+
expect(decoded.version).toBe("v7")
|
|
201
|
+
expect(decoded.payload.kind).toBe("since")
|
|
202
|
+
expect(decoded.payload.encoding).toBe("binary")
|
|
203
|
+
expect(decoded.payload.data).toBeInstanceOf(Uint8Array)
|
|
204
|
+
expect(decoded.payload.data).toEqual(bytes)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("round-trips a binary entry record via makeBinaryEntryRecord", () => {
|
|
209
|
+
const bytes = new Uint8Array([0xde, 0xad, 0xbe, 0xef])
|
|
210
|
+
const record: StoreRecord = makeBinaryEntryRecord(
|
|
211
|
+
"entirety",
|
|
212
|
+
"bin-v1",
|
|
213
|
+
bytes,
|
|
214
|
+
)
|
|
215
|
+
const decoded = decodeStoreRecord(encodeStoreRecord(record))
|
|
216
|
+
expect(decoded).toEqual(record)
|
|
164
217
|
})
|
|
165
218
|
|
|
166
219
|
it("handles empty string data", () => {
|
|
167
|
-
const
|
|
220
|
+
const record: StoreRecord = {
|
|
221
|
+
kind: "entry",
|
|
168
222
|
payload: { kind: "entirety", encoding: "json", data: "" },
|
|
169
223
|
version: "v0",
|
|
170
224
|
}
|
|
171
|
-
const decoded =
|
|
172
|
-
expect(decoded).toEqual(
|
|
225
|
+
const decoded = decodeStoreRecord(encodeStoreRecord(record))
|
|
226
|
+
expect(decoded).toEqual(record)
|
|
173
227
|
})
|
|
174
228
|
|
|
175
229
|
it("handles empty Uint8Array data", () => {
|
|
176
|
-
const
|
|
230
|
+
const record: StoreRecord = {
|
|
231
|
+
kind: "entry",
|
|
177
232
|
payload: { kind: "since", encoding: "binary", data: new Uint8Array(0) },
|
|
178
233
|
version: "v0",
|
|
179
234
|
}
|
|
180
|
-
const decoded =
|
|
181
|
-
expect(decoded.
|
|
182
|
-
|
|
235
|
+
const decoded = decodeStoreRecord(encodeStoreRecord(record))
|
|
236
|
+
expect(decoded.kind).toBe("entry")
|
|
237
|
+
if (decoded.kind === "entry") {
|
|
238
|
+
expect(decoded.payload.data).toBeInstanceOf(Uint8Array)
|
|
239
|
+
expect((decoded.payload.data as Uint8Array).length).toBe(0)
|
|
240
|
+
}
|
|
183
241
|
})
|
|
184
242
|
|
|
185
243
|
it("handles empty version string", () => {
|
|
186
|
-
const
|
|
244
|
+
const record: StoreRecord = {
|
|
245
|
+
kind: "entry",
|
|
187
246
|
payload: { kind: "entirety", encoding: "json", data: "{}" },
|
|
188
247
|
version: "",
|
|
189
248
|
}
|
|
190
|
-
const decoded =
|
|
191
|
-
expect(decoded.
|
|
192
|
-
|
|
249
|
+
const decoded = decodeStoreRecord(encodeStoreRecord(record))
|
|
250
|
+
expect(decoded.kind).toBe("entry")
|
|
251
|
+
if (decoded.kind === "entry") {
|
|
252
|
+
expect(decoded.version).toBe("")
|
|
253
|
+
expect(decoded.payload.data).toBe("{}")
|
|
254
|
+
}
|
|
193
255
|
})
|
|
194
256
|
|
|
195
257
|
it("handles large binary payload", () => {
|
|
@@ -197,11 +259,40 @@ describe("encodeStoreEntry / decodeStoreEntry", () => {
|
|
|
197
259
|
for (let i = 0; i < largeData.length; i++) {
|
|
198
260
|
largeData[i] = i % 256
|
|
199
261
|
}
|
|
200
|
-
const
|
|
262
|
+
const record: StoreRecord = {
|
|
263
|
+
kind: "entry",
|
|
201
264
|
payload: { kind: "entirety", encoding: "binary", data: largeData },
|
|
202
265
|
version: "large-v1",
|
|
203
266
|
}
|
|
204
|
-
const decoded =
|
|
205
|
-
expect(decoded.
|
|
267
|
+
const decoded = decodeStoreRecord(encodeStoreRecord(record))
|
|
268
|
+
expect(decoded.kind).toBe("entry")
|
|
269
|
+
if (decoded.kind === "entry") {
|
|
270
|
+
expect(decoded.payload.data).toEqual(largeData)
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it("meta record flags byte has bit 3 set", () => {
|
|
275
|
+
const record: StoreRecord = makeMetaRecord()
|
|
276
|
+
const encoded = encodeStoreRecord(record)
|
|
277
|
+
expect(encoded[0]! & 0x08).toBe(0x08) // bit 3 set
|
|
278
|
+
expect(encoded[0]! & 0x80).toBe(0x00) // bit 7 clear (current format)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it("entry record flags byte has bit 3 clear", () => {
|
|
282
|
+
const record: StoreRecord = makeEntryRecord("entirety", "v1")
|
|
283
|
+
const encoded = encodeStoreRecord(record)
|
|
284
|
+
expect(encoded[0]! & 0x08).toBe(0x00) // bit 3 clear
|
|
285
|
+
expect(encoded[0]! & 0x80).toBe(0x00) // bit 7 clear (current format)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it("rejects bytes with future-format bit set", () => {
|
|
289
|
+
const record: StoreRecord = makeEntryRecord("entirety", "v1")
|
|
290
|
+
const encoded = encodeStoreRecord(record)
|
|
291
|
+
encoded[0] = encoded[0]! | 0x80 // set bit 7
|
|
292
|
+
expect(() => decodeStoreRecord(encoded)).toThrow(/future-format/)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it("throws on empty bytes", () => {
|
|
296
|
+
expect(() => decodeStoreRecord(new Uint8Array(0))).toThrow()
|
|
206
297
|
})
|
|
207
298
|
})
|
|
@@ -3,18 +3,25 @@
|
|
|
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}
|
|
7
|
-
//
|
|
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
|
|
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
|
|
17
|
-
|
|
16
|
+
import {
|
|
17
|
+
type DocId,
|
|
18
|
+
resolveMetaFromBatch,
|
|
19
|
+
SeqNoTracker,
|
|
20
|
+
type Store,
|
|
21
|
+
type StoreMeta,
|
|
22
|
+
type StoreRecord,
|
|
23
|
+
validateAppend,
|
|
24
|
+
} from "@kyneta/exchange"
|
|
18
25
|
import { ClassicLevel } from "classic-level"
|
|
19
26
|
|
|
20
27
|
// ---------------------------------------------------------------------------
|
|
@@ -23,25 +30,40 @@ import { ClassicLevel } from "classic-level"
|
|
|
23
30
|
|
|
24
31
|
const SEP = "\x00"
|
|
25
32
|
const META_PREFIX = `meta${SEP}`
|
|
26
|
-
const
|
|
27
|
-
const SEQ_PAD =
|
|
33
|
+
const RECORD_PREFIX = `record${SEP}`
|
|
34
|
+
const SEQ_PAD = 16
|
|
28
35
|
|
|
29
36
|
// ---------------------------------------------------------------------------
|
|
30
|
-
// Binary envelope — pure encode/decode for
|
|
37
|
+
// Binary envelope v2 — pure encode/decode for StoreRecord
|
|
31
38
|
// ---------------------------------------------------------------------------
|
|
32
39
|
|
|
33
40
|
// Flags byte layout:
|
|
34
|
-
// bit 0: kind
|
|
35
|
-
// bit 1: encoding
|
|
36
|
-
// bit 2: data type
|
|
41
|
+
// bit 0: payload kind (0 = entirety, 1 = since) — entry records only
|
|
42
|
+
// bit 1: encoding (0 = json, 1 = binary) — entry records only
|
|
43
|
+
// bit 2: data type (0 = string, 1 = Uint8Array) — entry records only
|
|
44
|
+
// bit 3: record kind (0 = entry, 1 = meta)
|
|
45
|
+
// bit 7: future-format (0 = current format, reserved)
|
|
37
46
|
//
|
|
38
|
-
//
|
|
47
|
+
// Meta records (bit 3 = 1):
|
|
48
|
+
// [1 byte flags] [remaining: JSON-encoded StoreMeta]
|
|
49
|
+
//
|
|
50
|
+
// Entry records (bit 3 = 0):
|
|
51
|
+
// [1 byte flags] [4 bytes version length BE] [N bytes version UTF-8] [remaining: payload data]
|
|
39
52
|
|
|
40
53
|
const encoder = new TextEncoder()
|
|
41
54
|
const decoder = new TextDecoder()
|
|
42
55
|
|
|
43
|
-
export function
|
|
44
|
-
|
|
56
|
+
export function encodeStoreRecord(record: StoreRecord): Uint8Array {
|
|
57
|
+
if (record.kind === "meta") {
|
|
58
|
+
const metaBytes = encoder.encode(JSON.stringify(record.meta))
|
|
59
|
+
const buf = new Uint8Array(1 + metaBytes.length)
|
|
60
|
+
buf[0] = 0x08 // bit 3 set (meta)
|
|
61
|
+
buf.set(metaBytes, 1)
|
|
62
|
+
return buf
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Entry record
|
|
66
|
+
const { payload, version } = record
|
|
45
67
|
|
|
46
68
|
let flags = 0
|
|
47
69
|
if (payload.kind === "since") flags |= 0x01
|
|
@@ -66,12 +88,26 @@ export function encodeStoreEntry(entry: StoreEntry): Uint8Array {
|
|
|
66
88
|
return buf
|
|
67
89
|
}
|
|
68
90
|
|
|
69
|
-
export function
|
|
70
|
-
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
71
|
-
|
|
91
|
+
export function decodeStoreRecord(bytes: Uint8Array): StoreRecord {
|
|
72
92
|
const flagByte = bytes[0]
|
|
73
|
-
if (flagByte === undefined) throw new Error("empty store
|
|
93
|
+
if (flagByte === undefined) throw new Error("empty store record bytes")
|
|
74
94
|
const flags = flagByte
|
|
95
|
+
|
|
96
|
+
// Check future-format flag (bit 7)
|
|
97
|
+
if ((flags & 0x80) !== 0) {
|
|
98
|
+
throw new Error("unknown store record format (future-format bit set)")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const isMeta = (flags & 0x08) !== 0
|
|
102
|
+
|
|
103
|
+
if (isMeta) {
|
|
104
|
+
const metaJson = decoder.decode(bytes.subarray(1))
|
|
105
|
+
return { kind: "meta", meta: JSON.parse(metaJson) as StoreMeta }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Entry record
|
|
109
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
110
|
+
|
|
75
111
|
const kind = (flags & 0x01) !== 0 ? "since" : "entirety"
|
|
76
112
|
const encoding = (flags & 0x02) !== 0 ? "binary" : "json"
|
|
77
113
|
const isDataBinary = (flags & 0x04) !== 0
|
|
@@ -87,6 +123,7 @@ export function decodeStoreEntry(bytes: Uint8Array): StoreEntry {
|
|
|
87
123
|
: decoder.decode(rawData)
|
|
88
124
|
|
|
89
125
|
return {
|
|
126
|
+
kind: "entry",
|
|
90
127
|
payload: { kind, encoding, data },
|
|
91
128
|
version,
|
|
92
129
|
}
|
|
@@ -100,20 +137,20 @@ function metaKey(docId: DocId): string {
|
|
|
100
137
|
return `${META_PREFIX}${docId}`
|
|
101
138
|
}
|
|
102
139
|
|
|
103
|
-
function
|
|
104
|
-
return `${
|
|
140
|
+
function recordPrefix(docId: DocId): string {
|
|
141
|
+
return `${RECORD_PREFIX}${docId}${SEP}`
|
|
105
142
|
}
|
|
106
143
|
|
|
107
|
-
function
|
|
108
|
-
return `${
|
|
144
|
+
function recordKey(docId: DocId, seqNo: number): string {
|
|
145
|
+
return `${recordPrefix(docId)}${String(seqNo).padStart(SEQ_PAD, "0")}`
|
|
109
146
|
}
|
|
110
147
|
|
|
111
148
|
function parseDocIdFromMetaKey(key: string): DocId {
|
|
112
149
|
return key.slice(META_PREFIX.length)
|
|
113
150
|
}
|
|
114
151
|
|
|
115
|
-
function
|
|
116
|
-
const prefix =
|
|
152
|
+
function parseSeqNoFromRecordKey(key: string, docId: DocId): number {
|
|
153
|
+
const prefix = recordPrefix(docId)
|
|
117
154
|
return Number.parseInt(key.slice(prefix.length), 10)
|
|
118
155
|
}
|
|
119
156
|
|
|
@@ -123,7 +160,7 @@ function parseSeqNoFromEntryKey(key: string, docId: DocId): number {
|
|
|
123
160
|
|
|
124
161
|
export class LevelDBStore implements Store {
|
|
125
162
|
readonly #db: ClassicLevel<string, Uint8Array>
|
|
126
|
-
readonly #seqNos
|
|
163
|
+
readonly #seqNos = new SeqNoTracker()
|
|
127
164
|
|
|
128
165
|
constructor(dbPath: string) {
|
|
129
166
|
this.#db = new ClassicLevel(dbPath, {
|
|
@@ -131,88 +168,65 @@ export class LevelDBStore implements Store {
|
|
|
131
168
|
})
|
|
132
169
|
}
|
|
133
170
|
|
|
134
|
-
// -------------------------------------------------------------------------
|
|
135
|
-
// Private — seqNo management
|
|
136
|
-
// -------------------------------------------------------------------------
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Get the next seqNo for a doc. On first call after reboot, discovers
|
|
140
|
-
* the max existing seqNo via a single reverse-iterator seek.
|
|
141
|
-
*/
|
|
142
|
-
async #nextSeqNo(docId: DocId): Promise<number> {
|
|
143
|
-
const cached = this.#seqNos.get(docId)
|
|
144
|
-
if (cached !== undefined) {
|
|
145
|
-
const next = cached + 1
|
|
146
|
-
this.#seqNos.set(docId, next)
|
|
147
|
-
return next
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Lazy discovery: reverse iterate to find the highest existing seqNo
|
|
151
|
-
const prefix = entryPrefix(docId)
|
|
152
|
-
let maxSeq = -1
|
|
153
|
-
for await (const key of this.#db.keys({
|
|
154
|
-
gte: prefix,
|
|
155
|
-
lt: `${prefix}\xff`,
|
|
156
|
-
reverse: true,
|
|
157
|
-
limit: 1,
|
|
158
|
-
})) {
|
|
159
|
-
maxSeq = parseSeqNoFromEntryKey(key, docId)
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const next = maxSeq + 1
|
|
163
|
-
this.#seqNos.set(docId, next)
|
|
164
|
-
return next
|
|
165
|
-
}
|
|
166
|
-
|
|
167
171
|
// -------------------------------------------------------------------------
|
|
168
172
|
// Store interface
|
|
169
173
|
// -------------------------------------------------------------------------
|
|
170
174
|
|
|
171
|
-
async
|
|
175
|
+
async currentMeta(docId: DocId): Promise<StoreMeta | null> {
|
|
172
176
|
try {
|
|
173
177
|
const raw = await this.#db.get(metaKey(docId))
|
|
174
|
-
return JSON.parse(decoder.decode(raw)) as
|
|
178
|
+
return JSON.parse(decoder.decode(raw)) as StoreMeta
|
|
175
179
|
} catch (error: any) {
|
|
176
180
|
if (error.code === "LEVEL_NOT_FOUND") return null
|
|
177
181
|
throw error
|
|
178
182
|
}
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
async
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
)
|
|
191
|
-
return
|
|
192
|
-
}
|
|
193
|
-
throw error
|
|
185
|
+
async append(docId: DocId, record: StoreRecord): Promise<void> {
|
|
186
|
+
const existingMeta = await this.currentMeta(docId)
|
|
187
|
+
const resolved = validateAppend(docId, record, existingMeta)
|
|
188
|
+
|
|
189
|
+
if (resolved !== null) {
|
|
190
|
+
await this.#db.put(
|
|
191
|
+
metaKey(docId),
|
|
192
|
+
encoder.encode(JSON.stringify(resolved)),
|
|
193
|
+
)
|
|
194
194
|
}
|
|
195
|
-
}
|
|
196
195
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
196
|
+
const seq = await this.#seqNos.next(docId, async () => {
|
|
197
|
+
const prefix = recordPrefix(docId)
|
|
198
|
+
for await (const key of this.#db.keys({
|
|
199
|
+
gte: prefix,
|
|
200
|
+
lt: `${prefix}\xff`,
|
|
201
|
+
reverse: true,
|
|
202
|
+
limit: 1,
|
|
203
|
+
})) {
|
|
204
|
+
return parseSeqNoFromRecordKey(key, docId)
|
|
205
|
+
}
|
|
206
|
+
return null
|
|
207
|
+
})
|
|
208
|
+
await this.#db.put(recordKey(docId, seq), encodeStoreRecord(record))
|
|
200
209
|
}
|
|
201
210
|
|
|
202
|
-
async *loadAll(docId: DocId): AsyncIterable<
|
|
203
|
-
const prefix =
|
|
211
|
+
async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {
|
|
212
|
+
const prefix = recordPrefix(docId)
|
|
204
213
|
for await (const value of this.#db.values({
|
|
205
214
|
gte: prefix,
|
|
206
215
|
lt: `${prefix}\xff`,
|
|
207
216
|
})) {
|
|
208
|
-
yield
|
|
217
|
+
yield decodeStoreRecord(value)
|
|
209
218
|
}
|
|
210
219
|
}
|
|
211
220
|
|
|
212
|
-
async replace(docId: DocId,
|
|
213
|
-
const
|
|
221
|
+
async replace(docId: DocId, records: StoreRecord[]): Promise<void> {
|
|
222
|
+
const existingMeta = await this.currentMeta(docId)
|
|
223
|
+
|
|
224
|
+
// Resolve validates: at least one meta present, immutable fields match.
|
|
225
|
+
const resolved = resolveMetaFromBatch(records, existingMeta)
|
|
214
226
|
|
|
215
|
-
|
|
227
|
+
const prefix = recordPrefix(docId)
|
|
228
|
+
|
|
229
|
+
// Collect existing record keys to delete
|
|
216
230
|
const keysToDelete: string[] = []
|
|
217
231
|
for await (const key of this.#db.keys({
|
|
218
232
|
gte: prefix,
|
|
@@ -221,26 +235,36 @@ export class LevelDBStore implements Store {
|
|
|
221
235
|
keysToDelete.push(key)
|
|
222
236
|
}
|
|
223
237
|
|
|
224
|
-
// Atomic batch: delete all existing
|
|
238
|
+
// Atomic batch: delete all existing records, write replacements, upsert meta
|
|
225
239
|
const ops: Array<
|
|
226
240
|
| { type: "del"; key: string }
|
|
227
241
|
| { type: "put"; key: string; value: Uint8Array }
|
|
228
242
|
> = keysToDelete.map(key => ({ type: "del" as const, key }))
|
|
243
|
+
|
|
244
|
+
for (let i = 0; i < records.length; i++) {
|
|
245
|
+
ops.push({
|
|
246
|
+
type: "put",
|
|
247
|
+
key: recordKey(docId, i),
|
|
248
|
+
value: encodeStoreRecord(records[i]!),
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
229
252
|
ops.push({
|
|
230
253
|
type: "put",
|
|
231
|
-
key:
|
|
232
|
-
value:
|
|
254
|
+
key: metaKey(docId),
|
|
255
|
+
value: encoder.encode(JSON.stringify(resolved)),
|
|
233
256
|
})
|
|
257
|
+
|
|
234
258
|
await this.#db.batch(ops)
|
|
235
259
|
|
|
236
|
-
// Reset seqNo counter
|
|
237
|
-
this.#seqNos.
|
|
260
|
+
// Reset seqNo counter to the last written index
|
|
261
|
+
this.#seqNos.reset(docId, records.length - 1)
|
|
238
262
|
}
|
|
239
263
|
|
|
240
264
|
async delete(docId: DocId): Promise<void> {
|
|
241
|
-
const prefix =
|
|
265
|
+
const prefix = recordPrefix(docId)
|
|
242
266
|
|
|
243
|
-
// Collect
|
|
267
|
+
// Collect record keys to delete
|
|
244
268
|
const keysToDelete: string[] = [metaKey(docId)]
|
|
245
269
|
for await (const key of this.#db.keys({
|
|
246
270
|
gte: prefix,
|
|
@@ -254,13 +278,15 @@ export class LevelDBStore implements Store {
|
|
|
254
278
|
)
|
|
255
279
|
|
|
256
280
|
// Remove from in-memory seqNo tracker
|
|
257
|
-
this.#seqNos.
|
|
281
|
+
this.#seqNos.remove(docId)
|
|
258
282
|
}
|
|
259
283
|
|
|
260
|
-
async *listDocIds(): AsyncIterable<DocId> {
|
|
284
|
+
async *listDocIds(prefix?: string): AsyncIterable<DocId> {
|
|
285
|
+
const rangePrefix =
|
|
286
|
+
prefix !== undefined ? `${META_PREFIX}${prefix}` : META_PREFIX
|
|
261
287
|
for await (const key of this.#db.keys({
|
|
262
|
-
gte:
|
|
263
|
-
lt: `${
|
|
288
|
+
gte: rangePrefix,
|
|
289
|
+
lt: `${rangePrefix}\xff`,
|
|
264
290
|
})) {
|
|
265
291
|
yield parseDocIdFromMetaKey(key)
|
|
266
292
|
}
|
|
@@ -284,7 +310,7 @@ export class LevelDBStore implements Store {
|
|
|
284
310
|
*
|
|
285
311
|
* @example
|
|
286
312
|
* ```typescript
|
|
287
|
-
* import { createLevelDBStore } from "@kyneta/leveldb-store
|
|
313
|
+
* import { createLevelDBStore } from "@kyneta/leveldb-store"
|
|
288
314
|
*
|
|
289
315
|
* const exchange = new Exchange({
|
|
290
316
|
* 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
|
package/dist/server.js.map
DELETED
|
@@ -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"]}
|