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