@liorandb/core 1.0.17 → 1.0.19
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 +118 -32
- package/dist/index.js +475 -207
- package/package.json +1 -1
- package/src/core/checkpoint.ts +111 -0
- package/src/core/collection.ts +150 -107
- package/src/core/compaction.ts +79 -32
- package/src/core/database.ts +98 -84
- package/src/core/wal.ts +213 -0
- package/src/types/index.ts +107 -34
package/src/core/compaction.ts
CHANGED
|
@@ -5,117 +5,164 @@ import { Collection } from "./collection.js";
|
|
|
5
5
|
import { Index } from "./index.js";
|
|
6
6
|
import { decryptData } from "../utils/encryption.js";
|
|
7
7
|
|
|
8
|
+
/* ---------------------------------------------------------
|
|
9
|
+
CONSTANTS
|
|
10
|
+
--------------------------------------------------------- */
|
|
11
|
+
|
|
8
12
|
const TMP_SUFFIX = "__compact_tmp";
|
|
9
|
-
const OLD_SUFFIX = "
|
|
13
|
+
const OLD_SUFFIX = "__compact_old";
|
|
14
|
+
const INDEX_DIR = "__indexes";
|
|
15
|
+
|
|
16
|
+
/* ---------------------------------------------------------
|
|
17
|
+
PUBLIC ENTRY
|
|
18
|
+
--------------------------------------------------------- */
|
|
10
19
|
|
|
11
20
|
/**
|
|
12
|
-
*
|
|
21
|
+
* Full safe compaction pipeline:
|
|
22
|
+
* 1. Crash recovery
|
|
23
|
+
* 2. Snapshot rebuild
|
|
24
|
+
* 3. Atomic directory swap
|
|
25
|
+
* 4. Index rebuild
|
|
13
26
|
*/
|
|
14
27
|
export async function compactCollectionEngine(col: Collection) {
|
|
15
|
-
await crashRecovery(col.dir);
|
|
16
|
-
|
|
17
28
|
const baseDir = col.dir;
|
|
18
29
|
const tmpDir = baseDir + TMP_SUFFIX;
|
|
19
30
|
const oldDir = baseDir + OLD_SUFFIX;
|
|
20
31
|
|
|
21
|
-
//
|
|
32
|
+
// Recover from any previous crash mid-compaction
|
|
33
|
+
await crashRecovery(baseDir);
|
|
34
|
+
|
|
35
|
+
// Clean leftovers (paranoia safety)
|
|
22
36
|
safeRemove(tmpDir);
|
|
23
37
|
safeRemove(oldDir);
|
|
24
38
|
|
|
25
|
-
//
|
|
39
|
+
// Step 1: rebuild snapshot
|
|
26
40
|
await snapshotRebuild(col, tmpDir);
|
|
27
41
|
|
|
28
|
-
//
|
|
42
|
+
// Step 2: atomic swap
|
|
29
43
|
atomicSwap(baseDir, tmpDir, oldDir);
|
|
30
44
|
|
|
31
|
-
// Cleanup
|
|
45
|
+
// Cleanup
|
|
32
46
|
safeRemove(oldDir);
|
|
33
47
|
}
|
|
34
48
|
|
|
49
|
+
/* ---------------------------------------------------------
|
|
50
|
+
SNAPSHOT REBUILD
|
|
51
|
+
--------------------------------------------------------- */
|
|
52
|
+
|
|
35
53
|
/**
|
|
36
|
-
*
|
|
54
|
+
* Rebuilds DB by copying only live keys
|
|
55
|
+
* WAL is assumed already checkpointed
|
|
37
56
|
*/
|
|
38
57
|
async function snapshotRebuild(col: Collection, tmpDir: string) {
|
|
39
58
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
40
59
|
|
|
41
|
-
const tmpDB = new ClassicLevel(tmpDir, {
|
|
60
|
+
const tmpDB = new ClassicLevel(tmpDir, {
|
|
61
|
+
valueEncoding: "utf8"
|
|
62
|
+
});
|
|
42
63
|
|
|
43
64
|
for await (const [key, val] of col.db.iterator()) {
|
|
44
|
-
|
|
65
|
+
if (val !== undefined) {
|
|
66
|
+
await tmpDB.put(key, val);
|
|
67
|
+
}
|
|
45
68
|
}
|
|
46
69
|
|
|
47
70
|
await tmpDB.close();
|
|
48
71
|
await col.db.close();
|
|
49
72
|
}
|
|
50
73
|
|
|
74
|
+
/* ---------------------------------------------------------
|
|
75
|
+
ATOMIC SWAP
|
|
76
|
+
--------------------------------------------------------- */
|
|
77
|
+
|
|
51
78
|
/**
|
|
52
|
-
* Atomic directory
|
|
79
|
+
* Atomic directory replacement (POSIX safe)
|
|
53
80
|
*/
|
|
54
81
|
function atomicSwap(base: string, tmp: string, old: string) {
|
|
55
82
|
fs.renameSync(base, old);
|
|
56
83
|
fs.renameSync(tmp, base);
|
|
57
84
|
}
|
|
58
85
|
|
|
86
|
+
/* ---------------------------------------------------------
|
|
87
|
+
CRASH RECOVERY
|
|
88
|
+
--------------------------------------------------------- */
|
|
89
|
+
|
|
59
90
|
/**
|
|
60
|
-
*
|
|
91
|
+
* Handles all partial-compaction states
|
|
61
92
|
*/
|
|
62
93
|
export async function crashRecovery(baseDir: string) {
|
|
63
94
|
const tmp = baseDir + TMP_SUFFIX;
|
|
64
95
|
const old = baseDir + OLD_SUFFIX;
|
|
65
96
|
|
|
66
|
-
|
|
67
|
-
|
|
97
|
+
const baseExists = fs.existsSync(baseDir);
|
|
98
|
+
const tmpExists = fs.existsSync(tmp);
|
|
99
|
+
const oldExists = fs.existsSync(old);
|
|
100
|
+
|
|
101
|
+
// Case 1: swap interrupted → tmp is valid snapshot
|
|
102
|
+
if (tmpExists && oldExists) {
|
|
68
103
|
safeRemove(baseDir);
|
|
69
104
|
fs.renameSync(tmp, baseDir);
|
|
70
105
|
safeRemove(old);
|
|
106
|
+
return;
|
|
71
107
|
}
|
|
72
108
|
|
|
73
|
-
//
|
|
74
|
-
if (
|
|
109
|
+
// Case 2: rename(base → old) happened, but tmp missing
|
|
110
|
+
if (!baseExists && oldExists) {
|
|
75
111
|
fs.renameSync(old, baseDir);
|
|
112
|
+
return;
|
|
76
113
|
}
|
|
77
114
|
|
|
78
|
-
//
|
|
79
|
-
if (
|
|
115
|
+
// Case 3: rebuild interrupted
|
|
116
|
+
if (tmpExists && !oldExists) {
|
|
80
117
|
safeRemove(tmp);
|
|
81
118
|
}
|
|
82
119
|
}
|
|
83
120
|
|
|
121
|
+
/* ---------------------------------------------------------
|
|
122
|
+
INDEX REBUILD
|
|
123
|
+
--------------------------------------------------------- */
|
|
124
|
+
|
|
84
125
|
/**
|
|
85
|
-
*
|
|
126
|
+
* Rebuilds all indexes from compacted DB
|
|
127
|
+
* Guarantees index consistency
|
|
86
128
|
*/
|
|
87
129
|
export async function rebuildIndexes(col: Collection) {
|
|
88
|
-
const indexRoot = path.join(col.dir,
|
|
89
|
-
|
|
90
|
-
// Destroy existing indexes
|
|
91
|
-
safeRemove(indexRoot);
|
|
92
|
-
fs.mkdirSync(indexRoot, { recursive: true });
|
|
130
|
+
const indexRoot = path.join(col.dir, INDEX_DIR);
|
|
93
131
|
|
|
132
|
+
// Close existing index handles
|
|
94
133
|
for (const idx of col["indexes"].values()) {
|
|
95
|
-
try {
|
|
134
|
+
try {
|
|
135
|
+
await idx.close();
|
|
136
|
+
} catch {}
|
|
96
137
|
}
|
|
97
138
|
|
|
139
|
+
// Destroy index directory
|
|
140
|
+
safeRemove(indexRoot);
|
|
141
|
+
fs.mkdirSync(indexRoot, { recursive: true });
|
|
142
|
+
|
|
98
143
|
const newIndexes = new Map<string, Index>();
|
|
99
144
|
|
|
100
145
|
for (const idx of col["indexes"].values()) {
|
|
101
|
-
const
|
|
146
|
+
const rebuilt = new Index(col.dir, idx.field, {
|
|
102
147
|
unique: idx.unique
|
|
103
148
|
});
|
|
104
149
|
|
|
105
150
|
for await (const [, enc] of col.db.iterator()) {
|
|
151
|
+
if (!enc) continue;
|
|
106
152
|
const doc = decryptData(enc);
|
|
107
|
-
await
|
|
153
|
+
await rebuilt.insert(doc);
|
|
108
154
|
}
|
|
109
155
|
|
|
110
|
-
newIndexes.set(idx.field,
|
|
156
|
+
newIndexes.set(idx.field, rebuilt);
|
|
111
157
|
}
|
|
112
158
|
|
|
113
159
|
col["indexes"] = newIndexes;
|
|
114
160
|
}
|
|
115
161
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
*/
|
|
162
|
+
/* ---------------------------------------------------------
|
|
163
|
+
UTIL
|
|
164
|
+
--------------------------------------------------------- */
|
|
165
|
+
|
|
119
166
|
function safeRemove(p: string) {
|
|
120
167
|
if (fs.existsSync(p)) {
|
|
121
168
|
fs.rmSync(p, { recursive: true, force: true });
|
package/src/core/database.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import fs from "fs";
|
|
3
|
-
import { execFile } from "child_process";
|
|
4
|
-
import { promisify } from "util";
|
|
5
3
|
import { Collection } from "./collection.js";
|
|
6
4
|
import { Index, IndexOptions } from "./index.js";
|
|
7
5
|
import { MigrationEngine } from "./migration.js";
|
|
8
6
|
import type { LioranManager } from "../LioranManager.js";
|
|
9
7
|
import type { ZodSchema } from "zod";
|
|
8
|
+
import { decryptData } from "../utils/encryption.js";
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
import { WALManager } from "./wal.js";
|
|
11
|
+
import { CheckpointManager } from "./checkpoint.js";
|
|
12
12
|
|
|
13
13
|
/* ----------------------------- TYPES ----------------------------- */
|
|
14
14
|
|
|
@@ -29,7 +29,7 @@ type DBMeta = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
const META_FILE = "__db_meta.json";
|
|
32
|
-
const META_VERSION =
|
|
32
|
+
const META_VERSION = 2;
|
|
33
33
|
const DEFAULT_SCHEMA_VERSION = "v1";
|
|
34
34
|
|
|
35
35
|
/* ---------------------- TRANSACTION CONTEXT ---------------------- */
|
|
@@ -40,7 +40,7 @@ class DBTransactionContext {
|
|
|
40
40
|
constructor(
|
|
41
41
|
private db: LioranDB,
|
|
42
42
|
public readonly txId: number
|
|
43
|
-
) {
|
|
43
|
+
) {}
|
|
44
44
|
|
|
45
45
|
collection(name: string) {
|
|
46
46
|
return new Proxy({}, {
|
|
@@ -58,11 +58,30 @@ class DBTransactionContext {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
async commit() {
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
for (const op of this.ops) {
|
|
62
|
+
const recordOp: any = {
|
|
63
|
+
tx: this.txId,
|
|
64
|
+
type: "op",
|
|
65
|
+
payload: op
|
|
66
|
+
};
|
|
67
|
+
await this.db.wal.append(recordOp);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const commitRecord: any = {
|
|
71
|
+
tx: this.txId,
|
|
72
|
+
type: "commit"
|
|
73
|
+
};
|
|
74
|
+
await this.db.wal.append(commitRecord);
|
|
75
|
+
|
|
63
76
|
await this.db.applyTransaction(this.ops);
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
|
|
78
|
+
const appliedRecord: any = {
|
|
79
|
+
tx: this.txId,
|
|
80
|
+
type: "applied"
|
|
81
|
+
};
|
|
82
|
+
await this.db.wal.append(appliedRecord);
|
|
83
|
+
|
|
84
|
+
await this.db.postCommitMaintenance();
|
|
66
85
|
}
|
|
67
86
|
}
|
|
68
87
|
|
|
@@ -74,29 +93,68 @@ export class LioranDB {
|
|
|
74
93
|
manager: LioranManager;
|
|
75
94
|
collections: Map<string, Collection>;
|
|
76
95
|
|
|
77
|
-
private walPath: string;
|
|
78
96
|
private metaPath: string;
|
|
79
97
|
private meta!: DBMeta;
|
|
80
98
|
|
|
81
99
|
private migrator: MigrationEngine;
|
|
82
|
-
|
|
83
100
|
private static TX_SEQ = 0;
|
|
84
101
|
|
|
102
|
+
public wal: WALManager;
|
|
103
|
+
private checkpoint: CheckpointManager;
|
|
104
|
+
|
|
85
105
|
constructor(basePath: string, dbName: string, manager: LioranManager) {
|
|
86
106
|
this.basePath = basePath;
|
|
87
107
|
this.dbName = dbName;
|
|
88
108
|
this.manager = manager;
|
|
89
109
|
this.collections = new Map();
|
|
90
110
|
|
|
91
|
-
this.walPath = path.join(basePath, "__tx_wal.log");
|
|
92
111
|
this.metaPath = path.join(basePath, META_FILE);
|
|
93
112
|
|
|
94
113
|
fs.mkdirSync(basePath, { recursive: true });
|
|
95
114
|
|
|
96
115
|
this.loadMeta();
|
|
116
|
+
|
|
117
|
+
this.wal = new WALManager(basePath);
|
|
118
|
+
this.checkpoint = new CheckpointManager(basePath);
|
|
119
|
+
|
|
97
120
|
this.migrator = new MigrationEngine(this);
|
|
98
121
|
|
|
99
|
-
this.
|
|
122
|
+
this.initialize().catch(console.error);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ------------------------- INIT & RECOVERY ------------------------- */
|
|
126
|
+
|
|
127
|
+
private async initialize() {
|
|
128
|
+
await this.recoverFromWAL();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async recoverFromWAL() {
|
|
132
|
+
const checkpointData = this.checkpoint.get();
|
|
133
|
+
const fromLSN = checkpointData.lsn;
|
|
134
|
+
|
|
135
|
+
const committed = new Set<number>();
|
|
136
|
+
const applied = new Set<number>();
|
|
137
|
+
const ops = new Map<number, TXOp[]>();
|
|
138
|
+
|
|
139
|
+
await this.wal.replay(fromLSN, async (record) => {
|
|
140
|
+
if (record.type === "commit") {
|
|
141
|
+
committed.add(record.tx);
|
|
142
|
+
} else if (record.type === "applied") {
|
|
143
|
+
applied.add(record.tx);
|
|
144
|
+
} else if (record.type === "op") {
|
|
145
|
+
if (!ops.has(record.tx)) ops.set(record.tx, []);
|
|
146
|
+
ops.get(record.tx)!.push(record.payload);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
for (const tx of committed) {
|
|
151
|
+
if (applied.has(tx)) continue;
|
|
152
|
+
|
|
153
|
+
const txOps = ops.get(tx);
|
|
154
|
+
if (txOps) {
|
|
155
|
+
await this.applyTransaction(txOps);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
100
158
|
}
|
|
101
159
|
|
|
102
160
|
/* ------------------------- META ------------------------- */
|
|
@@ -133,7 +191,7 @@ export class LioranDB {
|
|
|
133
191
|
this.saveMeta();
|
|
134
192
|
}
|
|
135
193
|
|
|
136
|
-
/* -------------------------
|
|
194
|
+
/* ------------------------- DB MIGRATIONS ------------------------- */
|
|
137
195
|
|
|
138
196
|
migrate(from: string, to: string, fn: (db: LioranDB) => Promise<void>) {
|
|
139
197
|
this.migrator.register(from, to, async db => {
|
|
@@ -146,51 +204,7 @@ export class LioranDB {
|
|
|
146
204
|
await this.migrator.upgradeToLatest();
|
|
147
205
|
}
|
|
148
206
|
|
|
149
|
-
/* -------------------------
|
|
150
|
-
|
|
151
|
-
async writeWAL(entries: WALEntry[]) {
|
|
152
|
-
const fd = await fs.promises.open(this.walPath, "a");
|
|
153
|
-
for (const e of entries) {
|
|
154
|
-
await fd.write(JSON.stringify(e) + "\n");
|
|
155
|
-
}
|
|
156
|
-
await fd.sync();
|
|
157
|
-
await fd.close();
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async clearWAL() {
|
|
161
|
-
try { await fs.promises.unlink(this.walPath); } catch { }
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
private async recoverFromWAL() {
|
|
165
|
-
if (!fs.existsSync(this.walPath)) return;
|
|
166
|
-
|
|
167
|
-
const raw = await fs.promises.readFile(this.walPath, "utf8");
|
|
168
|
-
|
|
169
|
-
const committed = new Set<number>();
|
|
170
|
-
const applied = new Set<number>();
|
|
171
|
-
const ops = new Map<number, TXOp[]>();
|
|
172
|
-
|
|
173
|
-
for (const line of raw.split("\n")) {
|
|
174
|
-
if (!line.trim()) continue;
|
|
175
|
-
|
|
176
|
-
const entry: WALEntry = JSON.parse(line);
|
|
177
|
-
|
|
178
|
-
if ("commit" in entry) committed.add(entry.tx);
|
|
179
|
-
else if ("applied" in entry) applied.add(entry.tx);
|
|
180
|
-
else {
|
|
181
|
-
if (!ops.has(entry.tx)) ops.set(entry.tx, []);
|
|
182
|
-
ops.get(entry.tx)!.push(entry);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
for (const tx of committed) {
|
|
187
|
-
if (applied.has(tx)) continue;
|
|
188
|
-
const txOps = ops.get(tx);
|
|
189
|
-
if (txOps) await this.applyTransaction(txOps);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
await this.clearWAL();
|
|
193
|
-
}
|
|
207
|
+
/* ------------------------- TX APPLY ------------------------- */
|
|
194
208
|
|
|
195
209
|
async applyTransaction(ops: TXOp[]) {
|
|
196
210
|
for (const { col, op, args } of ops) {
|
|
@@ -201,17 +215,27 @@ export class LioranDB {
|
|
|
201
215
|
|
|
202
216
|
/* ------------------------- COLLECTION ------------------------- */
|
|
203
217
|
|
|
204
|
-
collection<T = any>(
|
|
218
|
+
collection<T = any>(
|
|
219
|
+
name: string,
|
|
220
|
+
schema?: ZodSchema<T>,
|
|
221
|
+
schemaVersion?: number
|
|
222
|
+
): Collection<T> {
|
|
205
223
|
if (this.collections.has(name)) {
|
|
206
224
|
const col = this.collections.get(name)!;
|
|
207
|
-
if (schema)
|
|
225
|
+
if (schema && schemaVersion !== undefined) {
|
|
226
|
+
col.setSchema(schema, schemaVersion);
|
|
227
|
+
}
|
|
208
228
|
return col as Collection<T>;
|
|
209
229
|
}
|
|
210
230
|
|
|
211
231
|
const colPath = path.join(this.basePath, name);
|
|
212
232
|
fs.mkdirSync(colPath, { recursive: true });
|
|
213
233
|
|
|
214
|
-
const col = new Collection<T>(
|
|
234
|
+
const col = new Collection<T>(
|
|
235
|
+
colPath,
|
|
236
|
+
schema,
|
|
237
|
+
schemaVersion ?? 1
|
|
238
|
+
);
|
|
215
239
|
|
|
216
240
|
const metas = this.meta.indexes[name] ?? [];
|
|
217
241
|
for (const m of metas) {
|
|
@@ -236,25 +260,14 @@ export class LioranDB {
|
|
|
236
260
|
|
|
237
261
|
const index = new Index(col.dir, field, options);
|
|
238
262
|
|
|
239
|
-
// for await (const [, enc] of col.db.iterator()) {
|
|
240
|
-
// // const doc = JSON.parse(
|
|
241
|
-
// // Buffer.from(enc, "base64").subarray(32).toString("utf8")
|
|
242
|
-
// // );
|
|
243
|
-
// const payload = Buffer.from(enc, "utf8").subarray(32);
|
|
244
|
-
// const doc = JSON.parse(payload.toString("utf8"));
|
|
245
|
-
// await index.insert(doc);
|
|
246
|
-
// }
|
|
247
|
-
|
|
248
263
|
for await (const [key, enc] of col.db.iterator()) {
|
|
249
264
|
if (!enc) continue;
|
|
250
|
-
|
|
251
265
|
try {
|
|
252
|
-
const doc = decryptData(enc);
|
|
266
|
+
const doc = decryptData(enc);
|
|
253
267
|
await index.insert(doc);
|
|
254
268
|
} catch (err) {
|
|
255
|
-
const
|
|
256
|
-
console.warn(`
|
|
257
|
-
// You can continue, or collect bad keys for later inspection
|
|
269
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
270
|
+
console.warn(`Index build skipped doc ${key}: ${msg}`);
|
|
258
271
|
}
|
|
259
272
|
}
|
|
260
273
|
|
|
@@ -271,13 +284,11 @@ export class LioranDB {
|
|
|
271
284
|
/* ------------------------- COMPACTION ------------------------- */
|
|
272
285
|
|
|
273
286
|
async compactCollection(name: string) {
|
|
274
|
-
await this.clearWAL();
|
|
275
287
|
const col = this.collection(name);
|
|
276
288
|
await col.compact();
|
|
277
289
|
}
|
|
278
290
|
|
|
279
291
|
async compactAll() {
|
|
280
|
-
await this.clearWAL();
|
|
281
292
|
for (const name of this.collections.keys()) {
|
|
282
293
|
await this.compactCollection(name);
|
|
283
294
|
}
|
|
@@ -293,16 +304,19 @@ export class LioranDB {
|
|
|
293
304
|
return result;
|
|
294
305
|
}
|
|
295
306
|
|
|
307
|
+
/* ------------------------- POST COMMIT ------------------------- */
|
|
308
|
+
|
|
309
|
+
public async postCommitMaintenance() {
|
|
310
|
+
// Custom maintenance can be added here
|
|
311
|
+
}
|
|
312
|
+
|
|
296
313
|
/* ------------------------- SHUTDOWN ------------------------- */
|
|
297
314
|
|
|
298
315
|
async close(): Promise<void> {
|
|
299
316
|
for (const col of this.collections.values()) {
|
|
300
|
-
try { await col.close(); } catch {
|
|
317
|
+
try { await col.close(); } catch {}
|
|
301
318
|
}
|
|
319
|
+
|
|
302
320
|
this.collections.clear();
|
|
303
321
|
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function decryptData(enc: string) {
|
|
307
|
-
throw new Error("Function not implemented.");
|
|
308
|
-
}
|
|
322
|
+
}
|
package/src/core/wal.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/* =========================
|
|
5
|
+
WAL RECORD TYPES
|
|
6
|
+
========================= */
|
|
7
|
+
|
|
8
|
+
export type WALRecord =
|
|
9
|
+
| { lsn: number; tx: number; type: "op"; payload: any }
|
|
10
|
+
| { lsn: number; tx: number; type: "commit" }
|
|
11
|
+
| { lsn: number; tx: number; type: "applied" };
|
|
12
|
+
|
|
13
|
+
type StoredRecord = WALRecord & { crc: number };
|
|
14
|
+
|
|
15
|
+
/* =========================
|
|
16
|
+
CONSTANTS
|
|
17
|
+
========================= */
|
|
18
|
+
|
|
19
|
+
const MAX_WAL_SIZE = 16 * 1024 * 1024; // 16 MB
|
|
20
|
+
const WAL_DIR = "__wal";
|
|
21
|
+
|
|
22
|
+
/* =========================
|
|
23
|
+
CRC32 IMPLEMENTATION
|
|
24
|
+
(no dependencies)
|
|
25
|
+
========================= */
|
|
26
|
+
|
|
27
|
+
const CRC32_TABLE = (() => {
|
|
28
|
+
const table = new Uint32Array(256);
|
|
29
|
+
for (let i = 0; i < 256; i++) {
|
|
30
|
+
let c = i;
|
|
31
|
+
for (let k = 0; k < 8; k++) {
|
|
32
|
+
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
|
33
|
+
}
|
|
34
|
+
table[i] = c >>> 0;
|
|
35
|
+
}
|
|
36
|
+
return table;
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
function crc32(input: string): number {
|
|
40
|
+
let crc = 0xFFFFFFFF;
|
|
41
|
+
for (let i = 0; i < input.length; i++) {
|
|
42
|
+
const byte = input.charCodeAt(i);
|
|
43
|
+
crc = CRC32_TABLE[(crc ^ byte) & 0xFF] ^ (crc >>> 8);
|
|
44
|
+
}
|
|
45
|
+
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* =========================
|
|
49
|
+
WAL MANAGER
|
|
50
|
+
========================= */
|
|
51
|
+
|
|
52
|
+
export class WALManager {
|
|
53
|
+
private walDir: string;
|
|
54
|
+
private currentGen = 1;
|
|
55
|
+
private lsn = 0;
|
|
56
|
+
private fd: fs.promises.FileHandle | null = null;
|
|
57
|
+
|
|
58
|
+
constructor(baseDir: string) {
|
|
59
|
+
this.walDir = path.join(baseDir, WAL_DIR);
|
|
60
|
+
fs.mkdirSync(this.walDir, { recursive: true });
|
|
61
|
+
this.currentGen = this.detectLastGeneration();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* -------------------------
|
|
65
|
+
INTERNAL HELPERS
|
|
66
|
+
------------------------- */
|
|
67
|
+
|
|
68
|
+
private walPath(gen = this.currentGen) {
|
|
69
|
+
return path.join(
|
|
70
|
+
this.walDir,
|
|
71
|
+
`wal-${String(gen).padStart(6, "0")}.log`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private detectLastGeneration(): number {
|
|
76
|
+
if (!fs.existsSync(this.walDir)) return 1;
|
|
77
|
+
|
|
78
|
+
const files = fs.readdirSync(this.walDir);
|
|
79
|
+
let max = 0;
|
|
80
|
+
|
|
81
|
+
for (const f of files) {
|
|
82
|
+
const m = f.match(/^wal-(\d+)\.log$/);
|
|
83
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return max || 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async open() {
|
|
90
|
+
if (!this.fd) {
|
|
91
|
+
this.fd = await fs.promises.open(this.walPath(), "a");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async rotate() {
|
|
96
|
+
if (this.fd) {
|
|
97
|
+
await this.fd.close();
|
|
98
|
+
this.fd = null;
|
|
99
|
+
}
|
|
100
|
+
this.currentGen++;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* -------------------------
|
|
104
|
+
APPEND
|
|
105
|
+
------------------------- */
|
|
106
|
+
|
|
107
|
+
async append(record: Omit<WALRecord, "lsn">): Promise<number> {
|
|
108
|
+
await this.open();
|
|
109
|
+
|
|
110
|
+
const full: WALRecord = {
|
|
111
|
+
...(record as any),
|
|
112
|
+
lsn: ++this.lsn
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const body = JSON.stringify(full);
|
|
116
|
+
const stored: StoredRecord = {
|
|
117
|
+
...full,
|
|
118
|
+
crc: crc32(body)
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
await this.fd!.write(JSON.stringify(stored) + "\n");
|
|
122
|
+
await this.fd!.sync();
|
|
123
|
+
|
|
124
|
+
const stat = await this.fd!.stat();
|
|
125
|
+
if (stat.size >= MAX_WAL_SIZE) {
|
|
126
|
+
await this.rotate();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return full.lsn;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* -------------------------
|
|
133
|
+
REPLAY
|
|
134
|
+
------------------------- */
|
|
135
|
+
|
|
136
|
+
async replay(
|
|
137
|
+
fromLSN: number,
|
|
138
|
+
apply: (r: WALRecord) => Promise<void>
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
if (!fs.existsSync(this.walDir)) return;
|
|
141
|
+
|
|
142
|
+
const files = fs
|
|
143
|
+
.readdirSync(this.walDir)
|
|
144
|
+
.filter(f => f.startsWith("wal-"))
|
|
145
|
+
.sort();
|
|
146
|
+
|
|
147
|
+
for (const file of files) {
|
|
148
|
+
const filePath = path.join(this.walDir, file);
|
|
149
|
+
const data = fs.readFileSync(filePath, "utf8");
|
|
150
|
+
const lines = data.split("\n");
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < lines.length; i++) {
|
|
153
|
+
const line = lines[i];
|
|
154
|
+
if (!line.trim()) continue;
|
|
155
|
+
|
|
156
|
+
let parsed: StoredRecord;
|
|
157
|
+
try {
|
|
158
|
+
parsed = JSON.parse(line);
|
|
159
|
+
} catch {
|
|
160
|
+
console.error("WAL parse error, stopping replay");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const { crc, ...record } = parsed;
|
|
165
|
+
const expected = crc32(JSON.stringify(record));
|
|
166
|
+
|
|
167
|
+
if (expected !== crc) {
|
|
168
|
+
console.error(
|
|
169
|
+
"WAL checksum mismatch, stopping replay",
|
|
170
|
+
{ file, line: i + 1 }
|
|
171
|
+
);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (record.lsn <= fromLSN) continue;
|
|
176
|
+
|
|
177
|
+
this.lsn = Math.max(this.lsn, record.lsn);
|
|
178
|
+
await apply(record);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* -------------------------
|
|
184
|
+
CLEANUP
|
|
185
|
+
------------------------- */
|
|
186
|
+
|
|
187
|
+
async cleanup(beforeGen: number) {
|
|
188
|
+
if (!fs.existsSync(this.walDir)) return;
|
|
189
|
+
|
|
190
|
+
const files = fs.readdirSync(this.walDir);
|
|
191
|
+
for (const f of files) {
|
|
192
|
+
const m = f.match(/^wal-(\d+)\.log$/);
|
|
193
|
+
if (!m) continue;
|
|
194
|
+
|
|
195
|
+
const gen = Number(m[1]);
|
|
196
|
+
if (gen < beforeGen) {
|
|
197
|
+
fs.unlinkSync(path.join(this.walDir, f));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* -------------------------
|
|
203
|
+
GETTERS
|
|
204
|
+
------------------------- */
|
|
205
|
+
|
|
206
|
+
getCurrentLSN() {
|
|
207
|
+
return this.lsn;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
getCurrentGen() {
|
|
211
|
+
return this.currentGen;
|
|
212
|
+
}
|
|
213
|
+
}
|