@liorandb/core 1.0.19 → 1.1.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 CHANGED
@@ -93,6 +93,8 @@ declare class WALManager {
93
93
  constructor(baseDir: string);
94
94
  private walPath;
95
95
  private detectLastGeneration;
96
+ private recoverLSNFromExistingLogs;
97
+ private getSortedWalFiles;
96
98
  private open;
97
99
  private rotate;
98
100
  append(record: Omit<WALRecord, "lsn">): Promise<number>;
@@ -130,6 +132,7 @@ declare class LioranDB {
130
132
  constructor(basePath: string, dbName: string, manager: LioranManager);
131
133
  private initialize;
132
134
  private recoverFromWAL;
135
+ advanceCheckpoint(lsn: number): void;
133
136
  private loadMeta;
134
137
  private saveMeta;
135
138
  getSchemaVersion(): string;
package/dist/index.js CHANGED
@@ -303,8 +303,10 @@ async function compactCollectionEngine(col) {
303
303
  safeRemove(tmpDir);
304
304
  safeRemove(oldDir);
305
305
  await snapshotRebuild(col, tmpDir);
306
- atomicSwap(baseDir, tmpDir, oldDir);
306
+ await atomicSwap(baseDir, tmpDir, oldDir);
307
307
  safeRemove(oldDir);
308
+ await reopenCollectionDB(col);
309
+ await rebuildIndexes(col);
308
310
  }
309
311
  async function snapshotRebuild(col, tmpDir) {
310
312
  fs2.mkdirSync(tmpDir, { recursive: true });
@@ -319,9 +321,16 @@ async function snapshotRebuild(col, tmpDir) {
319
321
  await tmpDB.close();
320
322
  await col.db.close();
321
323
  }
322
- function atomicSwap(base, tmp, old) {
324
+ async function atomicSwap(base, tmp, old) {
323
325
  fs2.renameSync(base, old);
324
- fs2.renameSync(tmp, base);
326
+ try {
327
+ fs2.renameSync(tmp, base);
328
+ } catch (err) {
329
+ if (fs2.existsSync(old)) {
330
+ fs2.renameSync(old, base);
331
+ }
332
+ throw err;
333
+ }
325
334
  }
326
335
  async function crashRecovery(baseDir) {
327
336
  const tmp = baseDir + TMP_SUFFIX;
@@ -343,9 +352,15 @@ async function crashRecovery(baseDir) {
343
352
  safeRemove(tmp);
344
353
  }
345
354
  }
355
+ async function reopenCollectionDB(col) {
356
+ col.db = new ClassicLevel2(col.dir, {
357
+ valueEncoding: "utf8"
358
+ });
359
+ }
346
360
  async function rebuildIndexes(col) {
347
361
  const indexRoot = path2.join(col.dir, INDEX_DIR);
348
- for (const idx of col["indexes"].values()) {
362
+ const oldIndexes = new Map(col["indexes"]);
363
+ for (const idx of oldIndexes.values()) {
349
364
  try {
350
365
  await idx.close();
351
366
  } catch {
@@ -353,19 +368,22 @@ async function rebuildIndexes(col) {
353
368
  }
354
369
  safeRemove(indexRoot);
355
370
  fs2.mkdirSync(indexRoot, { recursive: true });
356
- const newIndexes = /* @__PURE__ */ new Map();
357
- for (const idx of col["indexes"].values()) {
371
+ const rebuiltIndexes = /* @__PURE__ */ new Map();
372
+ for (const idx of oldIndexes.values()) {
358
373
  const rebuilt = new Index(col.dir, idx.field, {
359
374
  unique: idx.unique
360
375
  });
361
376
  for await (const [, enc] of col.db.iterator()) {
362
377
  if (!enc) continue;
363
- const doc = decryptData(enc);
364
- await rebuilt.insert(doc);
378
+ try {
379
+ const doc = decryptData(enc);
380
+ await rebuilt.insert(doc);
381
+ } catch {
382
+ }
365
383
  }
366
- newIndexes.set(idx.field, rebuilt);
384
+ rebuiltIndexes.set(idx.field, rebuilt);
367
385
  }
368
- col["indexes"] = newIndexes;
386
+ col["indexes"] = rebuiltIndexes;
369
387
  }
370
388
  function safeRemove(p) {
371
389
  if (fs2.existsSync(p)) {
@@ -848,8 +866,7 @@ var CRC32_TABLE = (() => {
848
866
  function crc32(input) {
849
867
  let crc = 4294967295;
850
868
  for (let i = 0; i < input.length; i++) {
851
- const byte = input.charCodeAt(i);
852
- crc = CRC32_TABLE[(crc ^ byte) & 255] ^ crc >>> 8;
869
+ crc = CRC32_TABLE[(crc ^ input.charCodeAt(i)) & 255] ^ crc >>> 8;
853
870
  }
854
871
  return (crc ^ 4294967295) >>> 0;
855
872
  }
@@ -862,6 +879,7 @@ var WALManager = class {
862
879
  this.walDir = path4.join(baseDir, WAL_DIR);
863
880
  fs4.mkdirSync(this.walDir, { recursive: true });
864
881
  this.currentGen = this.detectLastGeneration();
882
+ this.recoverLSNFromExistingLogs();
865
883
  }
866
884
  /* -------------------------
867
885
  INTERNAL HELPERS
@@ -878,10 +896,40 @@ var WALManager = class {
878
896
  let max = 0;
879
897
  for (const f of files) {
880
898
  const m = f.match(/^wal-(\d+)\.log$/);
881
- if (m) max = Math.max(max, Number(m[1]));
899
+ if (m) {
900
+ const gen = Number(m[1]);
901
+ if (!Number.isNaN(gen)) {
902
+ max = Math.max(max, gen);
903
+ }
904
+ }
882
905
  }
883
906
  return max || 1;
884
907
  }
908
+ recoverLSNFromExistingLogs() {
909
+ const files = this.getSortedWalFiles();
910
+ for (const file of files) {
911
+ const filePath = path4.join(this.walDir, file);
912
+ const lines = fs4.readFileSync(filePath, "utf8").split("\n");
913
+ for (const line of lines) {
914
+ if (!line.trim()) continue;
915
+ try {
916
+ const parsed = JSON.parse(line);
917
+ const { crc, ...record } = parsed;
918
+ if (crc32(JSON.stringify(record)) !== crc) break;
919
+ this.lsn = Math.max(this.lsn, record.lsn);
920
+ } catch {
921
+ break;
922
+ }
923
+ }
924
+ }
925
+ }
926
+ getSortedWalFiles() {
927
+ return fs4.readdirSync(this.walDir).filter((f) => /^wal-\d+\.log$/.test(f)).sort((a, b) => {
928
+ const ga = Number(a.match(/^wal-(\d+)\.log$/)[1]);
929
+ const gb = Number(b.match(/^wal-(\d+)\.log$/)[1]);
930
+ return ga - gb;
931
+ });
932
+ }
885
933
  async open() {
886
934
  if (!this.fd) {
887
935
  this.fd = await fs4.promises.open(this.walPath(), "a");
@@ -889,13 +937,14 @@ var WALManager = class {
889
937
  }
890
938
  async rotate() {
891
939
  if (this.fd) {
940
+ await this.fd.sync();
892
941
  await this.fd.close();
893
942
  this.fd = null;
894
943
  }
895
944
  this.currentGen++;
896
945
  }
897
946
  /* -------------------------
898
- APPEND
947
+ APPEND (Crash-safe)
899
948
  ------------------------- */
900
949
  async append(record) {
901
950
  await this.open();
@@ -908,7 +957,8 @@ var WALManager = class {
908
957
  ...full,
909
958
  crc: crc32(body)
910
959
  };
911
- await this.fd.write(JSON.stringify(stored) + "\n");
960
+ const line = JSON.stringify(stored) + "\n";
961
+ await this.fd.write(line);
912
962
  await this.fd.sync();
913
963
  const stat = await this.fd.stat();
914
964
  if (stat.size >= MAX_WAL_SIZE) {
@@ -917,38 +967,44 @@ var WALManager = class {
917
967
  return full.lsn;
918
968
  }
919
969
  /* -------------------------
920
- REPLAY
970
+ REPLAY (Auto-heal tail)
921
971
  ------------------------- */
922
972
  async replay(fromLSN, apply) {
923
973
  if (!fs4.existsSync(this.walDir)) return;
924
- const files = fs4.readdirSync(this.walDir).filter((f) => f.startsWith("wal-")).sort();
974
+ const files = this.getSortedWalFiles();
925
975
  for (const file of files) {
926
976
  const filePath = path4.join(this.walDir, file);
927
- const data = fs4.readFileSync(filePath, "utf8");
928
- const lines = data.split("\n");
977
+ const fd = fs4.openSync(filePath, "r+");
978
+ const content = fs4.readFileSync(filePath, "utf8");
979
+ const lines = content.split("\n");
980
+ let validOffset = 0;
929
981
  for (let i = 0; i < lines.length; i++) {
930
982
  const line = lines[i];
931
- if (!line.trim()) continue;
983
+ if (!line.trim()) {
984
+ validOffset += line.length + 1;
985
+ continue;
986
+ }
932
987
  let parsed;
933
988
  try {
934
989
  parsed = JSON.parse(line);
935
990
  } catch {
936
- console.error("WAL parse error, stopping replay");
937
- return;
991
+ break;
938
992
  }
939
993
  const { crc, ...record } = parsed;
940
994
  const expected = crc32(JSON.stringify(record));
941
995
  if (expected !== crc) {
942
- console.error(
943
- "WAL checksum mismatch, stopping replay",
944
- { file, line: i + 1 }
945
- );
946
- return;
996
+ break;
947
997
  }
998
+ validOffset += line.length + 1;
948
999
  if (record.lsn <= fromLSN) continue;
949
1000
  this.lsn = Math.max(this.lsn, record.lsn);
950
1001
  await apply(record);
951
1002
  }
1003
+ const stat = fs4.fstatSync(fd);
1004
+ if (validOffset < stat.size) {
1005
+ fs4.ftruncateSync(fd, validOffset);
1006
+ }
1007
+ fs4.closeSync(fd);
952
1008
  }
953
1009
  }
954
1010
  /* -------------------------
@@ -980,14 +1036,32 @@ var WALManager = class {
980
1036
  // src/core/checkpoint.ts
981
1037
  import fs5 from "fs";
982
1038
  import path5 from "path";
983
- var CHECKPOINT_FILE = "__checkpoint.json";
984
- var TMP_SUFFIX2 = ".tmp";
1039
+ var CHECKPOINT_A = "__checkpoint_A.json";
1040
+ var CHECKPOINT_B = "__checkpoint_B.json";
985
1041
  var FORMAT_VERSION = 1;
1042
+ var CRC32_TABLE2 = (() => {
1043
+ const table = new Uint32Array(256);
1044
+ for (let i = 0; i < 256; i++) {
1045
+ let c = i;
1046
+ for (let k = 0; k < 8; k++) {
1047
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
1048
+ }
1049
+ table[i] = c >>> 0;
1050
+ }
1051
+ return table;
1052
+ })();
1053
+ function crc322(input) {
1054
+ let crc = 4294967295;
1055
+ for (let i = 0; i < input.length; i++) {
1056
+ crc = CRC32_TABLE2[(crc ^ input.charCodeAt(i)) & 255] ^ crc >>> 8;
1057
+ }
1058
+ return (crc ^ 4294967295) >>> 0;
1059
+ }
986
1060
  var CheckpointManager = class {
987
- filePath;
1061
+ baseDir;
988
1062
  data;
989
1063
  constructor(baseDir) {
990
- this.filePath = path5.join(baseDir, CHECKPOINT_FILE);
1064
+ this.baseDir = baseDir;
991
1065
  this.data = {
992
1066
  lsn: 0,
993
1067
  walGen: 1,
@@ -997,47 +1071,66 @@ var CheckpointManager = class {
997
1071
  this.load();
998
1072
  }
999
1073
  /* -------------------------
1000
- LOAD (Crash-safe)
1074
+ LOAD (CRC + FALLBACK)
1001
1075
  ------------------------- */
1002
1076
  load() {
1003
- if (!fs5.existsSync(this.filePath)) {
1077
+ const a = this.readCheckpoint(CHECKPOINT_A);
1078
+ const b = this.readCheckpoint(CHECKPOINT_B);
1079
+ if (a && b) {
1080
+ this.data = a.data.lsn >= b.data.lsn ? a.data : b.data;
1081
+ return;
1082
+ }
1083
+ if (a) {
1084
+ this.data = a.data;
1085
+ return;
1086
+ }
1087
+ if (b) {
1088
+ this.data = b.data;
1004
1089
  return;
1005
1090
  }
1091
+ console.warn("No valid checkpoint found, starting from zero");
1092
+ }
1093
+ readCheckpoint(file) {
1094
+ const filePath = path5.join(this.baseDir, file);
1095
+ if (!fs5.existsSync(filePath)) return null;
1006
1096
  try {
1007
- const raw = fs5.readFileSync(this.filePath, "utf8");
1097
+ const raw = fs5.readFileSync(filePath, "utf8");
1008
1098
  const parsed = JSON.parse(raw);
1009
- if (typeof parsed.lsn === "number" && typeof parsed.walGen === "number") {
1010
- this.data = parsed;
1099
+ if (!parsed?.data || typeof parsed.crc !== "number") {
1100
+ return null;
1101
+ }
1102
+ const expected = crc322(JSON.stringify(parsed.data));
1103
+ if (expected !== parsed.crc) {
1104
+ console.error(`Checkpoint CRC mismatch: ${file}`);
1105
+ return null;
1011
1106
  }
1107
+ return parsed;
1012
1108
  } catch {
1013
- console.error("Checkpoint corrupted, starting from zero");
1014
- this.data = {
1015
- lsn: 0,
1016
- walGen: 1,
1017
- time: 0,
1018
- version: FORMAT_VERSION
1019
- };
1109
+ return null;
1020
1110
  }
1021
1111
  }
1022
1112
  /* -------------------------
1023
- SAVE (Atomic Write)
1113
+ SAVE (DUAL WRITE)
1024
1114
  ------------------------- */
1025
1115
  save(lsn, walGen) {
1026
- const newData = {
1116
+ const data = {
1027
1117
  lsn,
1028
1118
  walGen,
1029
1119
  time: Date.now(),
1030
1120
  version: FORMAT_VERSION
1031
1121
  };
1032
- const tmpPath = this.filePath + TMP_SUFFIX2;
1122
+ const stored = {
1123
+ data,
1124
+ crc: crc322(JSON.stringify(data))
1125
+ };
1126
+ const target = lsn % 2 === 0 ? CHECKPOINT_A : CHECKPOINT_B;
1033
1127
  try {
1034
1128
  fs5.writeFileSync(
1035
- tmpPath,
1036
- JSON.stringify(newData, null, 2),
1037
- { encoding: "utf8" }
1129
+ path5.join(this.baseDir, target),
1130
+ JSON.stringify(stored, null, 2),
1131
+ "utf8"
1038
1132
  );
1039
- fs5.renameSync(tmpPath, this.filePath);
1040
- this.data = newData;
1133
+ this.data = data;
1041
1134
  } catch (err) {
1042
1135
  console.error("Failed to write checkpoint:", err);
1043
1136
  }
@@ -1076,24 +1169,22 @@ var DBTransactionContext = class {
1076
1169
  }
1077
1170
  async commit() {
1078
1171
  for (const op of this.ops) {
1079
- const recordOp = {
1172
+ await this.db.wal.append({
1080
1173
  tx: this.txId,
1081
1174
  type: "op",
1082
1175
  payload: op
1083
- };
1084
- await this.db.wal.append(recordOp);
1176
+ });
1085
1177
  }
1086
- const commitRecord = {
1178
+ const commitLSN = await this.db.wal.append({
1087
1179
  tx: this.txId,
1088
1180
  type: "commit"
1089
- };
1090
- await this.db.wal.append(commitRecord);
1181
+ });
1091
1182
  await this.db.applyTransaction(this.ops);
1092
- const appliedRecord = {
1183
+ const appliedLSN = await this.db.wal.append({
1093
1184
  tx: this.txId,
1094
1185
  type: "applied"
1095
- };
1096
- await this.db.wal.append(appliedRecord);
1186
+ });
1187
+ this.db.advanceCheckpoint(appliedLSN);
1097
1188
  await this.db.postCommitMaintenance();
1098
1189
  }
1099
1190
  };
@@ -1141,13 +1232,25 @@ var LioranDB = class _LioranDB {
1141
1232
  ops.get(record.tx).push(record.payload);
1142
1233
  }
1143
1234
  });
1235
+ let highestAppliedLSN = fromLSN;
1144
1236
  for (const tx of committed) {
1145
1237
  if (applied.has(tx)) continue;
1146
1238
  const txOps = ops.get(tx);
1147
1239
  if (txOps) {
1148
1240
  await this.applyTransaction(txOps);
1241
+ highestAppliedLSN = this.wal.getCurrentLSN();
1149
1242
  }
1150
1243
  }
1244
+ this.advanceCheckpoint(highestAppliedLSN);
1245
+ }
1246
+ /* ------------------------- CHECKPOINT ADVANCE ------------------------- */
1247
+ advanceCheckpoint(lsn) {
1248
+ const current = this.checkpoint.get();
1249
+ if (lsn > current.lsn) {
1250
+ this.checkpoint.save(lsn, this.wal.getCurrentGen());
1251
+ this.wal.cleanup(this.wal.getCurrentGen() - 1).catch(() => {
1252
+ });
1253
+ }
1151
1254
  }
1152
1255
  /* ------------------------- META ------------------------- */
1153
1256
  loadMeta() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liorandb/core",
3
- "version": "1.0.19",
3
+ "version": "1.1.0",
4
4
  "description": "LioranDB Core Module – Lightweight, local-first, peer-to-peer database management for Node.js.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,27 +9,56 @@ export interface CheckpointData {
9
9
  lsn: number; // Last durable LSN
10
10
  walGen: number; // WAL generation at checkpoint
11
11
  time: number; // Timestamp (ms)
12
- version: number; // For future format upgrades
12
+ version: number; // Format version
13
+ }
14
+
15
+ interface StoredCheckpoint {
16
+ data: CheckpointData;
17
+ crc: number;
13
18
  }
14
19
 
15
20
  /* =========================
16
21
  CONSTANTS
17
22
  ========================= */
18
23
 
19
- const CHECKPOINT_FILE = "__checkpoint.json";
20
- const TMP_SUFFIX = ".tmp";
24
+ const CHECKPOINT_A = "__checkpoint_A.json";
25
+ const CHECKPOINT_B = "__checkpoint_B.json";
21
26
  const FORMAT_VERSION = 1;
22
27
 
28
+ /* =========================
29
+ CRC32 (no deps)
30
+ ========================= */
31
+
32
+ const CRC32_TABLE = (() => {
33
+ const table = new Uint32Array(256);
34
+ for (let i = 0; i < 256; i++) {
35
+ let c = i;
36
+ for (let k = 0; k < 8; k++) {
37
+ c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
38
+ }
39
+ table[i] = c >>> 0;
40
+ }
41
+ return table;
42
+ })();
43
+
44
+ function crc32(input: string): number {
45
+ let crc = 0xFFFFFFFF;
46
+ for (let i = 0; i < input.length; i++) {
47
+ crc = CRC32_TABLE[(crc ^ input.charCodeAt(i)) & 0xFF] ^ (crc >>> 8);
48
+ }
49
+ return (crc ^ 0xFFFFFFFF) >>> 0;
50
+ }
51
+
23
52
  /* =========================
24
53
  CHECKPOINT MANAGER
25
54
  ========================= */
26
55
 
27
56
  export class CheckpointManager {
28
- private filePath: string;
57
+ private baseDir: string;
29
58
  private data: CheckpointData;
30
59
 
31
60
  constructor(baseDir: string) {
32
- this.filePath = path.join(baseDir, CHECKPOINT_FILE);
61
+ this.baseDir = baseDir;
33
62
  this.data = {
34
63
  lsn: 0,
35
64
  walGen: 1,
@@ -41,61 +70,84 @@ export class CheckpointManager {
41
70
  }
42
71
 
43
72
  /* -------------------------
44
- LOAD (Crash-safe)
73
+ LOAD (CRC + FALLBACK)
45
74
  ------------------------- */
46
75
 
47
76
  private load() {
48
- if (!fs.existsSync(this.filePath)) {
77
+ const a = this.readCheckpoint(CHECKPOINT_A);
78
+ const b = this.readCheckpoint(CHECKPOINT_B);
79
+
80
+ if (a && b) {
81
+ // pick newest valid checkpoint
82
+ this.data = a.data.lsn >= b.data.lsn ? a.data : b.data;
83
+ return;
84
+ }
85
+
86
+ if (a) {
87
+ this.data = a.data;
49
88
  return;
50
89
  }
51
90
 
91
+ if (b) {
92
+ this.data = b.data;
93
+ return;
94
+ }
95
+
96
+ console.warn("No valid checkpoint found, starting from zero");
97
+ }
98
+
99
+ private readCheckpoint(file: string): StoredCheckpoint | null {
100
+ const filePath = path.join(this.baseDir, file);
101
+ if (!fs.existsSync(filePath)) return null;
102
+
52
103
  try {
53
- const raw = fs.readFileSync(this.filePath, "utf8");
54
- const parsed = JSON.parse(raw) as CheckpointData;
55
-
56
- if (
57
- typeof parsed.lsn === "number" &&
58
- typeof parsed.walGen === "number"
59
- ) {
60
- this.data = parsed;
104
+ const raw = fs.readFileSync(filePath, "utf8");
105
+ const parsed = JSON.parse(raw) as StoredCheckpoint;
106
+
107
+ if (!parsed?.data || typeof parsed.crc !== "number") {
108
+ return null;
61
109
  }
110
+
111
+ const expected = crc32(JSON.stringify(parsed.data));
112
+ if (expected !== parsed.crc) {
113
+ console.error(`Checkpoint CRC mismatch: ${file}`);
114
+ return null;
115
+ }
116
+
117
+ return parsed;
62
118
  } catch {
63
- console.error("Checkpoint corrupted, starting from zero");
64
- this.data = {
65
- lsn: 0,
66
- walGen: 1,
67
- time: 0,
68
- version: FORMAT_VERSION
69
- };
119
+ return null;
70
120
  }
71
121
  }
72
122
 
73
123
  /* -------------------------
74
- SAVE (Atomic Write)
124
+ SAVE (DUAL WRITE)
75
125
  ------------------------- */
76
126
 
77
127
  save(lsn: number, walGen: number) {
78
- const newData: CheckpointData = {
128
+ const data: CheckpointData = {
79
129
  lsn,
80
130
  walGen,
81
131
  time: Date.now(),
82
132
  version: FORMAT_VERSION
83
133
  };
84
134
 
85
- const tmpPath = this.filePath + TMP_SUFFIX;
135
+ const stored: StoredCheckpoint = {
136
+ data,
137
+ crc: crc32(JSON.stringify(data))
138
+ };
139
+
140
+ // alternate between A/B for crash safety
141
+ const target =
142
+ lsn % 2 === 0 ? CHECKPOINT_A : CHECKPOINT_B;
86
143
 
87
144
  try {
88
- // Write to temp file first
89
145
  fs.writeFileSync(
90
- tmpPath,
91
- JSON.stringify(newData, null, 2),
92
- { encoding: "utf8" }
146
+ path.join(this.baseDir, target),
147
+ JSON.stringify(stored, null, 2),
148
+ "utf8"
93
149
  );
94
-
95
- // Atomic rename
96
- fs.renameSync(tmpPath, this.filePath);
97
-
98
- this.data = newData;
150
+ this.data = data;
99
151
  } catch (err) {
100
152
  console.error("Failed to write checkpoint:", err);
101
153
  }
@@ -18,42 +18,40 @@ const INDEX_DIR = "__indexes";
18
18
  --------------------------------------------------------- */
19
19
 
20
20
  /**
21
- * Full safe compaction pipeline:
21
+ * Full production-safe compaction:
22
22
  * 1. Crash recovery
23
23
  * 2. Snapshot rebuild
24
- * 3. Atomic directory swap
25
- * 4. Index rebuild
24
+ * 3. Atomic swap
25
+ * 4. Reopen DB
26
+ * 5. Rebuild indexes
26
27
  */
27
28
  export async function compactCollectionEngine(col: Collection) {
28
29
  const baseDir = col.dir;
29
30
  const tmpDir = baseDir + TMP_SUFFIX;
30
31
  const oldDir = baseDir + OLD_SUFFIX;
31
32
 
32
- // Recover from any previous crash mid-compaction
33
33
  await crashRecovery(baseDir);
34
34
 
35
- // Clean leftovers (paranoia safety)
36
35
  safeRemove(tmpDir);
37
36
  safeRemove(oldDir);
38
37
 
39
- // Step 1: rebuild snapshot
40
38
  await snapshotRebuild(col, tmpDir);
41
39
 
42
- // Step 2: atomic swap
43
- atomicSwap(baseDir, tmpDir, oldDir);
40
+ await atomicSwap(baseDir, tmpDir, oldDir);
44
41
 
45
- // Cleanup
46
42
  safeRemove(oldDir);
43
+
44
+ // Reopen DB after swap
45
+ await reopenCollectionDB(col);
46
+
47
+ // Rebuild indexes after compaction
48
+ await rebuildIndexes(col);
47
49
  }
48
50
 
49
51
  /* ---------------------------------------------------------
50
52
  SNAPSHOT REBUILD
51
53
  --------------------------------------------------------- */
52
54
 
53
- /**
54
- * Rebuilds DB by copying only live keys
55
- * WAL is assumed already checkpointed
56
- */
57
55
  async function snapshotRebuild(col: Collection, tmpDir: string) {
58
56
  fs.mkdirSync(tmpDir, { recursive: true });
59
57
 
@@ -68,28 +66,33 @@ async function snapshotRebuild(col: Collection, tmpDir: string) {
68
66
  }
69
67
 
70
68
  await tmpDB.close();
71
- await col.db.close();
69
+ await col.db.close(); // important: close before swap
72
70
  }
73
71
 
74
72
  /* ---------------------------------------------------------
75
- ATOMIC SWAP
73
+ ATOMIC SWAP (HARDENED)
76
74
  --------------------------------------------------------- */
77
75
 
78
- /**
79
- * Atomic directory replacement (POSIX safe)
80
- */
81
- function atomicSwap(base: string, tmp: string, old: string) {
76
+ async function atomicSwap(base: string, tmp: string, old: string) {
77
+ // Phase 1: rename base old
82
78
  fs.renameSync(base, old);
83
- fs.renameSync(tmp, base);
79
+
80
+ try {
81
+ // Phase 2: rename tmp → base
82
+ fs.renameSync(tmp, base);
83
+ } catch (err) {
84
+ // Rollback if tmp rename fails
85
+ if (fs.existsSync(old)) {
86
+ fs.renameSync(old, base);
87
+ }
88
+ throw err;
89
+ }
84
90
  }
85
91
 
86
92
  /* ---------------------------------------------------------
87
93
  CRASH RECOVERY
88
94
  --------------------------------------------------------- */
89
95
 
90
- /**
91
- * Handles all partial-compaction states
92
- */
93
96
  export async function crashRecovery(baseDir: string) {
94
97
  const tmp = baseDir + TMP_SUFFIX;
95
98
  const old = baseDir + OLD_SUFFIX;
@@ -106,7 +109,7 @@ export async function crashRecovery(baseDir: string) {
106
109
  return;
107
110
  }
108
111
 
109
- // Case 2: rename(base old) happened, but tmp missing
112
+ // Case 2: base→old happened but tmp missing
110
113
  if (!baseExists && oldExists) {
111
114
  fs.renameSync(old, baseDir);
112
115
  return;
@@ -119,44 +122,56 @@ export async function crashRecovery(baseDir: string) {
119
122
  }
120
123
 
121
124
  /* ---------------------------------------------------------
122
- INDEX REBUILD
125
+ REOPEN DB
126
+ --------------------------------------------------------- */
127
+
128
+ async function reopenCollectionDB(col: Collection) {
129
+ col.db = new ClassicLevel(col.dir, {
130
+ valueEncoding: "utf8"
131
+ });
132
+ }
133
+
134
+ /* ---------------------------------------------------------
135
+ INDEX REBUILD (SAFE)
123
136
  --------------------------------------------------------- */
124
137
 
125
- /**
126
- * Rebuilds all indexes from compacted DB
127
- * Guarantees index consistency
128
- */
129
138
  export async function rebuildIndexes(col: Collection) {
130
139
  const indexRoot = path.join(col.dir, INDEX_DIR);
131
140
 
132
- // Close existing index handles
133
- for (const idx of col["indexes"].values()) {
141
+ const oldIndexes = new Map(col["indexes"]);
142
+
143
+ // Close old index handles
144
+ for (const idx of oldIndexes.values()) {
134
145
  try {
135
146
  await idx.close();
136
147
  } catch {}
137
148
  }
138
149
 
139
- // Destroy index directory
140
150
  safeRemove(indexRoot);
141
151
  fs.mkdirSync(indexRoot, { recursive: true });
142
152
 
143
- const newIndexes = new Map<string, Index>();
153
+ const rebuiltIndexes = new Map<string, Index>();
144
154
 
145
- for (const idx of col["indexes"].values()) {
155
+ for (const idx of oldIndexes.values()) {
146
156
  const rebuilt = new Index(col.dir, idx.field, {
147
157
  unique: idx.unique
148
158
  });
149
159
 
150
160
  for await (const [, enc] of col.db.iterator()) {
151
161
  if (!enc) continue;
152
- const doc = decryptData(enc);
153
- await rebuilt.insert(doc);
162
+
163
+ try {
164
+ const doc = decryptData(enc);
165
+ await rebuilt.insert(doc);
166
+ } catch {
167
+ // Skip corrupted doc safely
168
+ }
154
169
  }
155
170
 
156
- newIndexes.set(idx.field, rebuilt);
171
+ rebuiltIndexes.set(idx.field, rebuilt);
157
172
  }
158
173
 
159
- col["indexes"] = newIndexes;
174
+ col["indexes"] = rebuiltIndexes;
160
175
  }
161
176
 
162
177
  /* ---------------------------------------------------------
@@ -13,9 +13,6 @@ import { CheckpointManager } from "./checkpoint.js";
13
13
  /* ----------------------------- TYPES ----------------------------- */
14
14
 
15
15
  type TXOp = { tx: number; col: string; op: string; args: any[] };
16
- type TXCommit = { tx: number; commit: true };
17
- type TXApplied = { tx: number; applied: true };
18
- type WALEntry = TXOp | TXCommit | TXApplied;
19
16
 
20
17
  type IndexMeta = {
21
18
  field: string;
@@ -58,28 +55,32 @@ class DBTransactionContext {
58
55
  }
59
56
 
60
57
  async commit() {
58
+ // 1️⃣ Write all operations
61
59
  for (const op of this.ops) {
62
- const recordOp: any = {
60
+ await this.db.wal.append({
63
61
  tx: this.txId,
64
62
  type: "op",
65
63
  payload: op
66
- };
67
- await this.db.wal.append(recordOp);
64
+ } as any);
68
65
  }
69
66
 
70
- const commitRecord: any = {
67
+ // 2️⃣ Commit marker
68
+ const commitLSN = await this.db.wal.append({
71
69
  tx: this.txId,
72
70
  type: "commit"
73
- };
74
- await this.db.wal.append(commitRecord);
71
+ } as any);
75
72
 
73
+ // 3️⃣ Apply to storage
76
74
  await this.db.applyTransaction(this.ops);
77
75
 
78
- const appliedRecord: any = {
76
+ // 4️⃣ Applied marker
77
+ const appliedLSN = await this.db.wal.append({
79
78
  tx: this.txId,
80
79
  type: "applied"
81
- };
82
- await this.db.wal.append(appliedRecord);
80
+ } as any);
81
+
82
+ // 5️⃣ Advance checkpoint to durable applied LSN
83
+ this.db.advanceCheckpoint(appliedLSN);
83
84
 
84
85
  await this.db.postCommitMaintenance();
85
86
  }
@@ -143,18 +144,37 @@ export class LioranDB {
143
144
  applied.add(record.tx);
144
145
  } else if (record.type === "op") {
145
146
  if (!ops.has(record.tx)) ops.set(record.tx, []);
146
- ops.get(record.tx)!.push(record.payload);
147
+ ops.get(record.tx)!.push(record.payload as TXOp);
147
148
  }
148
149
  });
149
150
 
151
+ let highestAppliedLSN = fromLSN;
152
+
150
153
  for (const tx of committed) {
151
154
  if (applied.has(tx)) continue;
152
155
 
153
156
  const txOps = ops.get(tx);
154
157
  if (txOps) {
155
158
  await this.applyTransaction(txOps);
159
+ highestAppliedLSN = this.wal.getCurrentLSN();
156
160
  }
157
161
  }
162
+
163
+ // Advance checkpoint after recovery
164
+ this.advanceCheckpoint(highestAppliedLSN);
165
+ }
166
+
167
+ /* ------------------------- CHECKPOINT ADVANCE ------------------------- */
168
+
169
+ public advanceCheckpoint(lsn: number) {
170
+ const current = this.checkpoint.get();
171
+
172
+ if (lsn > current.lsn) {
173
+ this.checkpoint.save(lsn, this.wal.getCurrentGen());
174
+
175
+ // Optional WAL cleanup (safe because checkpoint advanced)
176
+ this.wal.cleanup(this.wal.getCurrentGen() - 1).catch(() => {});
177
+ }
158
178
  }
159
179
 
160
180
  /* ------------------------- META ------------------------- */
@@ -307,7 +327,7 @@ export class LioranDB {
307
327
  /* ------------------------- POST COMMIT ------------------------- */
308
328
 
309
329
  public async postCommitMaintenance() {
310
- // Custom maintenance can be added here
330
+ // Hook for background compaction, stats, etc.
311
331
  }
312
332
 
313
333
  /* ------------------------- SHUTDOWN ------------------------- */
package/src/core/wal.ts CHANGED
@@ -16,12 +16,11 @@ type StoredRecord = WALRecord & { crc: number };
16
16
  CONSTANTS
17
17
  ========================= */
18
18
 
19
- const MAX_WAL_SIZE = 16 * 1024 * 1024; // 16 MB
19
+ const MAX_WAL_SIZE = 16 * 1024 * 1024; // 16MB
20
20
  const WAL_DIR = "__wal";
21
21
 
22
22
  /* =========================
23
- CRC32 IMPLEMENTATION
24
- (no dependencies)
23
+ CRC32 (no deps)
25
24
  ========================= */
26
25
 
27
26
  const CRC32_TABLE = (() => {
@@ -37,12 +36,11 @@ const CRC32_TABLE = (() => {
37
36
  })();
38
37
 
39
38
  function crc32(input: string): number {
40
- let crc = 0xFFFFFFFF;
39
+ let crc = 0xffffffff;
41
40
  for (let i = 0; i < input.length; i++) {
42
- const byte = input.charCodeAt(i);
43
- crc = CRC32_TABLE[(crc ^ byte) & 0xFF] ^ (crc >>> 8);
41
+ crc = CRC32_TABLE[(crc ^ input.charCodeAt(i)) & 0xff] ^ (crc >>> 8);
44
42
  }
45
- return (crc ^ 0xFFFFFFFF) >>> 0;
43
+ return (crc ^ 0xffffffff) >>> 0;
46
44
  }
47
45
 
48
46
  /* =========================
@@ -58,7 +56,9 @@ export class WALManager {
58
56
  constructor(baseDir: string) {
59
57
  this.walDir = path.join(baseDir, WAL_DIR);
60
58
  fs.mkdirSync(this.walDir, { recursive: true });
59
+
61
60
  this.currentGen = this.detectLastGeneration();
61
+ this.recoverLSNFromExistingLogs();
62
62
  }
63
63
 
64
64
  /* -------------------------
@@ -80,12 +80,52 @@ export class WALManager {
80
80
 
81
81
  for (const f of files) {
82
82
  const m = f.match(/^wal-(\d+)\.log$/);
83
- if (m) max = Math.max(max, Number(m[1]));
83
+ if (m) {
84
+ const gen = Number(m[1]);
85
+ if (!Number.isNaN(gen)) {
86
+ max = Math.max(max, gen);
87
+ }
88
+ }
84
89
  }
85
90
 
86
91
  return max || 1;
87
92
  }
88
93
 
94
+ private recoverLSNFromExistingLogs() {
95
+ const files = this.getSortedWalFiles();
96
+
97
+ for (const file of files) {
98
+ const filePath = path.join(this.walDir, file);
99
+ const lines = fs.readFileSync(filePath, "utf8").split("\n");
100
+
101
+ for (const line of lines) {
102
+ if (!line.trim()) continue;
103
+
104
+ try {
105
+ const parsed: StoredRecord = JSON.parse(line);
106
+ const { crc, ...record } = parsed;
107
+
108
+ if (crc32(JSON.stringify(record)) !== crc) break;
109
+
110
+ this.lsn = Math.max(this.lsn, record.lsn);
111
+ } catch {
112
+ break; // stop on corruption
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ private getSortedWalFiles(): string[] {
119
+ return fs
120
+ .readdirSync(this.walDir)
121
+ .filter(f => /^wal-\d+\.log$/.test(f))
122
+ .sort((a, b) => {
123
+ const ga = Number(a.match(/^wal-(\d+)\.log$/)![1]);
124
+ const gb = Number(b.match(/^wal-(\d+)\.log$/)![1]);
125
+ return ga - gb;
126
+ });
127
+ }
128
+
89
129
  private async open() {
90
130
  if (!this.fd) {
91
131
  this.fd = await fs.promises.open(this.walPath(), "a");
@@ -94,6 +134,7 @@ export class WALManager {
94
134
 
95
135
  private async rotate() {
96
136
  if (this.fd) {
137
+ await this.fd.sync();
97
138
  await this.fd.close();
98
139
  this.fd = null;
99
140
  }
@@ -101,7 +142,7 @@ export class WALManager {
101
142
  }
102
143
 
103
144
  /* -------------------------
104
- APPEND
145
+ APPEND (Crash-safe)
105
146
  ------------------------- */
106
147
 
107
148
  async append(record: Omit<WALRecord, "lsn">): Promise<number> {
@@ -113,12 +154,15 @@ export class WALManager {
113
154
  };
114
155
 
115
156
  const body = JSON.stringify(full);
157
+
116
158
  const stored: StoredRecord = {
117
159
  ...full,
118
160
  crc: crc32(body)
119
161
  };
120
162
 
121
- await this.fd!.write(JSON.stringify(stored) + "\n");
163
+ const line = JSON.stringify(stored) + "\n";
164
+
165
+ await this.fd!.write(line);
122
166
  await this.fd!.sync();
123
167
 
124
168
  const stat = await this.fd!.stat();
@@ -130,7 +174,7 @@ export class WALManager {
130
174
  }
131
175
 
132
176
  /* -------------------------
133
- REPLAY
177
+ REPLAY (Auto-heal tail)
134
178
  ------------------------- */
135
179
 
136
180
  async replay(
@@ -139,44 +183,54 @@ export class WALManager {
139
183
  ): Promise<void> {
140
184
  if (!fs.existsSync(this.walDir)) return;
141
185
 
142
- const files = fs
143
- .readdirSync(this.walDir)
144
- .filter(f => f.startsWith("wal-"))
145
- .sort();
186
+ const files = this.getSortedWalFiles();
146
187
 
147
188
  for (const file of files) {
148
189
  const filePath = path.join(this.walDir, file);
149
- const data = fs.readFileSync(filePath, "utf8");
150
- const lines = data.split("\n");
190
+
191
+ const fd = fs.openSync(filePath, "r+");
192
+ const content = fs.readFileSync(filePath, "utf8");
193
+ const lines = content.split("\n");
194
+
195
+ let validOffset = 0;
151
196
 
152
197
  for (let i = 0; i < lines.length; i++) {
153
198
  const line = lines[i];
154
- if (!line.trim()) continue;
199
+ if (!line.trim()) {
200
+ validOffset += line.length + 1;
201
+ continue;
202
+ }
155
203
 
156
204
  let parsed: StoredRecord;
205
+
157
206
  try {
158
207
  parsed = JSON.parse(line);
159
208
  } catch {
160
- console.error("WAL parse error, stopping replay");
161
- return;
209
+ break;
162
210
  }
163
211
 
164
212
  const { crc, ...record } = parsed;
165
213
  const expected = crc32(JSON.stringify(record));
166
214
 
167
215
  if (expected !== crc) {
168
- console.error(
169
- "WAL checksum mismatch, stopping replay",
170
- { file, line: i + 1 }
171
- );
172
- return;
216
+ break;
173
217
  }
174
218
 
219
+ validOffset += line.length + 1;
220
+
175
221
  if (record.lsn <= fromLSN) continue;
176
222
 
177
223
  this.lsn = Math.max(this.lsn, record.lsn);
178
224
  await apply(record);
179
225
  }
226
+
227
+ // Truncate corrupted tail (auto-heal)
228
+ const stat = fs.fstatSync(fd);
229
+ if (validOffset < stat.size) {
230
+ fs.ftruncateSync(fd, validOffset);
231
+ }
232
+
233
+ fs.closeSync(fd);
180
234
  }
181
235
  }
182
236
 
@@ -188,6 +242,7 @@ export class WALManager {
188
242
  if (!fs.existsSync(this.walDir)) return;
189
243
 
190
244
  const files = fs.readdirSync(this.walDir);
245
+
191
246
  for (const f of files) {
192
247
  const m = f.match(/^wal-(\d+)\.log$/);
193
248
  if (!m) continue;