@liorandb/core 1.0.19 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,179 @@
1
+ import {
2
+ LioranManager,
3
+ getDefaultRootPath
4
+ } from "./chunk-2FSI7HX7.js";
5
+
6
+ // src/ipc/pool.ts
7
+ import { Worker } from "worker_threads";
8
+ import os from "os";
9
+ import path from "path";
10
+ import { fileURLToPath } from "url";
11
+ var IPCWorkerPool = class {
12
+ workers = [];
13
+ workerCount;
14
+ shuttingDown = false;
15
+ rrIndex = 0;
16
+ constructor() {
17
+ this.workerCount = Math.max(2, os.cpus().length);
18
+ }
19
+ /* -------------------------------------------------- */
20
+ /* START POOL */
21
+ /* -------------------------------------------------- */
22
+ start() {
23
+ for (let i = 0; i < this.workerCount; i++) {
24
+ this.spawnWorker();
25
+ }
26
+ console.log(
27
+ `[WorkerPool] Started ${this.workerCount} worker threads`
28
+ );
29
+ }
30
+ /* -------------------------------------------------- */
31
+ /* SPAWN WORKER */
32
+ /* -------------------------------------------------- */
33
+ spawnWorker() {
34
+ const __filename = fileURLToPath(import.meta.url);
35
+ const __dirname = path.dirname(__filename);
36
+ const workerFile = path.join(__dirname, "worker.js");
37
+ const worker = new Worker(workerFile);
38
+ worker.on("exit", (code) => {
39
+ if (this.shuttingDown) return;
40
+ console.error(
41
+ `[WorkerPool] Worker exited (code=${code}). Restarting...`
42
+ );
43
+ this.workers = this.workers.filter((w) => w !== worker);
44
+ setTimeout(() => {
45
+ this.spawnWorker();
46
+ }, 500);
47
+ });
48
+ worker.on("error", (err) => {
49
+ console.error("[WorkerPool] Worker error:", err);
50
+ });
51
+ this.workers.push(worker);
52
+ }
53
+ /* -------------------------------------------------- */
54
+ /* EXECUTE TASK */
55
+ /* -------------------------------------------------- */
56
+ exec(task) {
57
+ if (this.workers.length === 0) {
58
+ throw new Error("No workers available");
59
+ }
60
+ const worker = this.workers[this.rrIndex];
61
+ this.rrIndex = (this.rrIndex + 1) % this.workers.length;
62
+ return new Promise((resolve, reject) => {
63
+ const id = Date.now() + Math.random();
64
+ const messageHandler = (msg) => {
65
+ if (msg.id !== id) return;
66
+ worker.off("message", messageHandler);
67
+ if (msg.ok) resolve(msg.result);
68
+ else reject(new Error(msg.error));
69
+ };
70
+ worker.on("message", messageHandler);
71
+ worker.postMessage({
72
+ id,
73
+ task
74
+ });
75
+ });
76
+ }
77
+ /* -------------------------------------------------- */
78
+ /* SHUTDOWN */
79
+ /* -------------------------------------------------- */
80
+ async shutdown() {
81
+ this.shuttingDown = true;
82
+ console.log("[WorkerPool] Shutting down worker threads...");
83
+ for (const worker of this.workers) {
84
+ try {
85
+ await worker.terminate();
86
+ } catch (err) {
87
+ console.error("[WorkerPool] Worker terminate error:", err);
88
+ }
89
+ }
90
+ this.workers = [];
91
+ }
92
+ /* -------------------------------------------------- */
93
+ /* INFO */
94
+ /* -------------------------------------------------- */
95
+ get size() {
96
+ return this.workerCount;
97
+ }
98
+ };
99
+
100
+ // src/ipc/queue.ts
101
+ var DBQueue = class {
102
+ constructor(rootPath = getDefaultRootPath()) {
103
+ this.rootPath = rootPath;
104
+ this.manager = new LioranManager({ rootPath });
105
+ this.pool = new IPCWorkerPool();
106
+ this.pool.start();
107
+ }
108
+ manager;
109
+ pool;
110
+ destroyed = false;
111
+ /* -------------------------------- EXEC -------------------------------- */
112
+ async exec(action, args) {
113
+ if (this.destroyed) {
114
+ throw new Error("DBQueue already shutdown");
115
+ }
116
+ switch (action) {
117
+ /* ---------------- DB ---------------- */
118
+ case "db":
119
+ await this.manager.db(args.db);
120
+ return true;
121
+ /* ---------------- CRUD OPS ---------------- */
122
+ case "op": {
123
+ const { db, col, method, params } = args;
124
+ const collection = (await this.manager.db(db)).collection(col);
125
+ return await collection[method](...params);
126
+ }
127
+ /* ---------------- INDEX OPS ---------------- */
128
+ case "index": {
129
+ const { db, col, method, params } = args;
130
+ const collection = (await this.manager.db(db)).collection(col);
131
+ return await collection[method](...params);
132
+ }
133
+ /* ---------------- COMPACTION ---------------- */
134
+ case "compact:collection": {
135
+ const { db, col } = args;
136
+ const collection = (await this.manager.db(db)).collection(col);
137
+ await collection.compact();
138
+ return true;
139
+ }
140
+ case "compact:db": {
141
+ const { db } = args;
142
+ const database = await this.manager.db(db);
143
+ await database.compactAll();
144
+ return true;
145
+ }
146
+ case "compact:all": {
147
+ for (const db of this.manager.openDBs.values()) {
148
+ await db.compactAll();
149
+ }
150
+ return true;
151
+ }
152
+ /* ---------------- SNAPSHOT ---------------- */
153
+ case "snapshot":
154
+ await this.manager.snapshot(args.path);
155
+ return true;
156
+ case "restore":
157
+ await this.manager.restore(args.path);
158
+ return true;
159
+ /* ---------------- CONTROL ---------------- */
160
+ case "shutdown":
161
+ await this.shutdown();
162
+ return true;
163
+ default:
164
+ throw new Error(`Unknown action: ${action}`);
165
+ }
166
+ }
167
+ /* ------------------------------ SHUTDOWN ------------------------------ */
168
+ async shutdown() {
169
+ if (this.destroyed) return;
170
+ this.destroyed = true;
171
+ await this.manager.closeAll();
172
+ await this.pool.shutdown();
173
+ }
174
+ };
175
+ var dbQueue = new DBQueue();
176
+ export {
177
+ DBQueue,
178
+ dbQueue
179
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liorandb/core",
3
- "version": "1.0.19",
3
+ "version": "1.1.1",
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",
@@ -4,29 +4,34 @@ import process from "process";
4
4
  import { LioranDB } from "./core/database.js";
5
5
  import { setEncryptionKey } from "./utils/encryption.js";
6
6
  import { getDefaultRootPath } from "./utils/rootpath.js";
7
- import { dbQueue } from "./ipc/queue.js";
8
- import { IPCServer } from "./ipc/server.js";
7
+
8
+ /* ---------------- PROCESS MODE ---------------- */
9
9
 
10
10
  enum ProcessMode {
11
11
  PRIMARY = "primary",
12
- CLIENT = "client"
12
+ CLIENT = "client",
13
+ READONLY = "readonly"
13
14
  }
14
15
 
16
+ /* ---------------- OPTIONS ---------------- */
17
+
15
18
  export interface LioranManagerOptions {
16
19
  rootPath?: string;
17
20
  encryptionKey?: string | Buffer;
21
+ ipc?: "primary" | "client" | "readonly";
18
22
  }
19
23
 
24
+ /* ---------------- MANAGER ---------------- */
25
+
20
26
  export class LioranManager {
21
27
  rootPath: string;
22
28
  openDBs: Map<string, LioranDB>;
23
29
  private closed = false;
24
30
  private mode: ProcessMode;
25
31
  private lockFd?: number;
26
- private ipcServer?: IPCServer;
27
32
 
28
33
  constructor(options: LioranManagerOptions = {}) {
29
- const { rootPath, encryptionKey } = options;
34
+ const { rootPath, encryptionKey, ipc } = options;
30
35
 
31
36
  this.rootPath = rootPath || getDefaultRootPath();
32
37
 
@@ -40,17 +45,48 @@ export class LioranManager {
40
45
 
41
46
  this.openDBs = new Map();
42
47
 
43
- this.mode = this.tryAcquireLock()
44
- ? ProcessMode.PRIMARY
45
- : ProcessMode.CLIENT;
48
+ /* ---------------- MODE RESOLUTION ---------------- */
49
+
50
+ if (ipc === "readonly") {
51
+ this.mode = ProcessMode.READONLY;
52
+ } else if (ipc === "client") {
53
+ this.mode = ProcessMode.CLIENT;
54
+ } else if (ipc === "primary") {
55
+ this.mode = ProcessMode.PRIMARY;
56
+ this.tryAcquireLock();
57
+ } else {
58
+ // auto-detect (default behavior)
59
+ this.mode = this.tryAcquireLock()
60
+ ? ProcessMode.PRIMARY
61
+ : ProcessMode.CLIENT;
62
+ }
46
63
 
47
64
  if (this.mode === ProcessMode.PRIMARY) {
48
- this.ipcServer = new IPCServer(this, this.rootPath);
49
- this.ipcServer.start();
50
65
  this._registerShutdownHooks();
51
66
  }
52
67
  }
53
68
 
69
+ /* ---------------- MODE HELPERS ---------------- */
70
+
71
+ isPrimary() {
72
+ return this.mode === ProcessMode.PRIMARY;
73
+ }
74
+
75
+ isClient() {
76
+ return this.mode === ProcessMode.CLIENT;
77
+ }
78
+
79
+ isReadOnly() {
80
+ return this.mode === ProcessMode.READONLY;
81
+ }
82
+
83
+ /* ---------------- QUEUE HELPER ---------------- */
84
+
85
+ private async getQueue() {
86
+ const { dbQueue } = await import("./ipc/queue.js");
87
+ return dbQueue;
88
+ }
89
+
54
90
  /* ---------------- LOCK MANAGEMENT ---------------- */
55
91
 
56
92
  private isProcessAlive(pid: number): boolean {
@@ -78,7 +114,7 @@ export class LioranManager {
78
114
  fs.writeSync(this.lockFd, String(process.pid));
79
115
  return true;
80
116
  }
81
- } catch { }
117
+ } catch {}
82
118
  return false;
83
119
  }
84
120
  }
@@ -87,9 +123,11 @@ export class LioranManager {
87
123
 
88
124
  async db(name: string): Promise<LioranDB> {
89
125
  if (this.mode === ProcessMode.CLIENT) {
90
- await dbQueue.exec("db", { db: name });
126
+ const queue = await this.getQueue();
127
+ await queue.exec("db", { db: name });
91
128
  return new IPCDatabase(name) as any;
92
129
  }
130
+
93
131
  return this.openDatabase(name);
94
132
  }
95
133
 
@@ -108,44 +146,51 @@ export class LioranManager {
108
146
  return db;
109
147
  }
110
148
 
111
- /* ---------------- SNAPSHOT ORCHESTRATION ---------------- */
149
+ /* ---------------- SNAPSHOT ---------------- */
112
150
 
113
- /**
114
- * Create TAR snapshot of full DB directory
115
- */
116
151
  async snapshot(snapshotPath: string) {
117
152
  if (this.mode === ProcessMode.CLIENT) {
118
- return dbQueue.exec("snapshot", { path: snapshotPath });
153
+ const queue = await this.getQueue();
154
+ return queue.exec("snapshot", { path: snapshotPath });
155
+ }
156
+
157
+ if (this.mode === ProcessMode.READONLY) {
158
+ throw new Error("Snapshot not allowed in readonly mode");
119
159
  }
120
160
 
121
- // Flush all DBs safely
122
161
  for (const db of this.openDBs.values()) {
123
- for (const col of db.collections.values()) {
124
- try { await col.db.close(); } catch { }
125
- }
162
+ try {
163
+ await db.close();
164
+ } catch {}
126
165
  }
127
166
 
128
- // Ensure backup directory exists
129
167
  fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
130
168
 
131
169
  const tar = await import("tar");
132
170
 
133
- await tar.c({
134
- gzip: true,
135
- file: snapshotPath,
136
- cwd: this.rootPath,
137
- portable: true
138
- }, ["./"]);
171
+ await tar.c(
172
+ {
173
+ gzip: true,
174
+ file: snapshotPath,
175
+ cwd: this.rootPath,
176
+ portable: true
177
+ },
178
+ ["./"]
179
+ );
139
180
 
140
181
  return true;
141
182
  }
142
183
 
143
- /**
144
- * Restore TAR snapshot safely
145
- */
184
+ /* ---------------- RESTORE ---------------- */
185
+
146
186
  async restore(snapshotPath: string) {
147
187
  if (this.mode === ProcessMode.CLIENT) {
148
- return dbQueue.exec("restore", { path: snapshotPath });
188
+ const queue = await this.getQueue();
189
+ return queue.exec("restore", { path: snapshotPath });
190
+ }
191
+
192
+ if (this.mode === ProcessMode.READONLY) {
193
+ throw new Error("Restore not allowed in readonly mode");
149
194
  }
150
195
 
151
196
  await this.closeAll();
@@ -171,22 +216,26 @@ export class LioranManager {
171
216
  this.closed = true;
172
217
 
173
218
  if (this.mode === ProcessMode.CLIENT) {
174
- await dbQueue.shutdown();
219
+ const queue = await this.getQueue();
220
+ await queue.shutdown();
175
221
  return;
176
222
  }
177
223
 
178
224
  for (const db of this.openDBs.values()) {
179
- try { await db.close(); } catch { }
225
+ try {
226
+ await db.close();
227
+ } catch {}
180
228
  }
181
229
 
182
230
  this.openDBs.clear();
183
231
 
184
- try {
185
- if (this.lockFd) fs.closeSync(this.lockFd);
186
- fs.unlinkSync(path.join(this.rootPath, ".lioran.lock"));
187
- } catch { }
188
-
189
- await this.ipcServer?.close();
232
+ // Only primary owns lock
233
+ if (this.mode === ProcessMode.PRIMARY) {
234
+ try {
235
+ if (this.lockFd) fs.closeSync(this.lockFd);
236
+ fs.unlinkSync(path.join(this.rootPath, ".lioran.lock"));
237
+ } catch {}
238
+ }
190
239
  }
191
240
 
192
241
  async close(): Promise<void> {
@@ -213,7 +262,7 @@ export class LioranManager {
213
262
  /* ---------------- IPC PROXY DB ---------------- */
214
263
 
215
264
  class IPCDatabase {
216
- constructor(private name: string) { }
265
+ constructor(private name: string) {}
217
266
 
218
267
  collection(name: string) {
219
268
  return new IPCCollection(this.name, name);
@@ -224,10 +273,16 @@ class IPCCollection {
224
273
  constructor(
225
274
  private db: string,
226
275
  private col: string
227
- ) { }
276
+ ) {}
277
+
278
+ private async getQueue() {
279
+ const { dbQueue } = await import("./ipc/queue.js");
280
+ return dbQueue;
281
+ }
228
282
 
229
- private call(method: string, params: any[]) {
230
- return dbQueue.exec("op", {
283
+ private async call(method: string, params: any[]) {
284
+ const queue = await this.getQueue();
285
+ return queue.exec("op", {
231
286
  db: this.db,
232
287
  col: this.col,
233
288
  method,
@@ -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
  }