@liorandb/core 1.0.12 → 1.0.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liorandb/core",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
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",
@@ -1,27 +1,34 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
+ import process from "process";
3
4
  import { LioranDB } from "./core/database.js";
4
5
  import { setEncryptionKey } from "./utils/encryption.js";
5
6
  import { getDefaultRootPath } from "./utils/rootpath.js";
6
7
  import { dbQueue } from "./ipc/queue.js";
8
+ import { IPCServer } from "./ipc/server.js";
9
+
10
+ enum ProcessMode {
11
+ PRIMARY = "primary",
12
+ CLIENT = "client"
13
+ }
7
14
 
8
15
  export interface LioranManagerOptions {
9
16
  rootPath?: string;
10
17
  encryptionKey?: string | Buffer;
11
- ipc?: boolean;
12
18
  }
13
19
 
14
20
  export class LioranManager {
15
21
  rootPath: string;
16
22
  openDBs: Map<string, LioranDB>;
17
23
  private closed = false;
18
- private ipc: boolean;
24
+ private mode: ProcessMode;
25
+ private lockFd?: number;
26
+ private ipcServer?: IPCServer;
19
27
 
20
28
  constructor(options: LioranManagerOptions = {}) {
21
- const { rootPath, encryptionKey, ipc } = options;
29
+ const { rootPath, encryptionKey } = options;
22
30
 
23
31
  this.rootPath = rootPath || getDefaultRootPath();
24
- this.ipc = ipc ?? process.env.LIORANDB_IPC === "1";
25
32
 
26
33
  if (!fs.existsSync(this.rootPath)) {
27
34
  fs.mkdirSync(this.rootPath, { recursive: true });
@@ -33,33 +40,56 @@ export class LioranManager {
33
40
 
34
41
  this.openDBs = new Map();
35
42
 
36
- if (!this.ipc) {
43
+ this.mode = this.tryAcquireLock()
44
+ ? ProcessMode.PRIMARY
45
+ : ProcessMode.CLIENT;
46
+
47
+ if (this.mode === ProcessMode.PRIMARY) {
48
+ this.ipcServer = new IPCServer(this, this.rootPath);
49
+ this.ipcServer.start();
37
50
  this._registerShutdownHooks();
38
51
  }
39
52
  }
40
53
 
41
- /* -------------------------------- CORE -------------------------------- */
42
-
43
- async db(name: string): Promise<LioranDB> {
44
- if (this.ipc) {
45
- await dbQueue.exec("db", { db: name });
46
- return new IPCDatabase(name) as any;
54
+ private isProcessAlive(pid: number): boolean {
55
+ try {
56
+ process.kill(pid, 0);
57
+ return true;
58
+ } catch {
59
+ return false;
47
60
  }
48
-
49
- return this.openDatabase(name);
50
61
  }
51
62
 
52
- async createDatabase(name: string): Promise<LioranDB> {
53
- this._assertOpen();
63
+ private tryAcquireLock(): boolean {
64
+ const lockPath = path.join(this.rootPath, ".lioran.lock");
54
65
 
55
- const dbPath = path.join(this.rootPath, name);
66
+ try {
67
+ this.lockFd = fs.openSync(lockPath, "wx");
68
+ fs.writeSync(this.lockFd, String(process.pid));
69
+ return true;
70
+ } catch {
71
+ // Possible stale lock → validate PID
72
+ try {
73
+ const pid = Number(fs.readFileSync(lockPath, "utf8"));
74
+ if (!this.isProcessAlive(pid)) {
75
+ fs.unlinkSync(lockPath);
76
+ this.lockFd = fs.openSync(lockPath, "wx");
77
+ fs.writeSync(this.lockFd, String(process.pid));
78
+ return true;
79
+ }
80
+ } catch {}
56
81
 
57
- if (fs.existsSync(dbPath)) {
58
- throw new Error(`Database "${name}" already exists`);
82
+ return false;
59
83
  }
84
+ }
60
85
 
61
- await fs.promises.mkdir(dbPath, { recursive: true });
62
- return this.db(name);
86
+ async db(name: string): Promise<LioranDB> {
87
+ if (this.mode === ProcessMode.CLIENT) {
88
+ await dbQueue.exec("db", { db: name });
89
+ return new IPCDatabase(name) as any;
90
+ }
91
+
92
+ return this.openDatabase(name);
63
93
  }
64
94
 
65
95
  async openDatabase(name: string): Promise<LioranDB> {
@@ -70,54 +100,36 @@ export class LioranManager {
70
100
  }
71
101
 
72
102
  const dbPath = path.join(this.rootPath, name);
73
-
74
- if (!fs.existsSync(dbPath)) {
75
- await fs.promises.mkdir(dbPath, { recursive: true });
76
- }
103
+ await fs.promises.mkdir(dbPath, { recursive: true });
77
104
 
78
105
  const db = new LioranDB(dbPath, name, this);
79
106
  this.openDBs.set(name, db);
80
107
  return db;
81
108
  }
82
109
 
83
- /* -------------------------------- LIFECYCLE -------------------------------- */
84
-
85
- async closeDatabase(name: string): Promise<void> {
86
- if (this.ipc) return;
87
-
88
- if (!this.openDBs.has(name)) return;
89
-
90
- const db = this.openDBs.get(name)!;
91
- await db.close();
92
- this.openDBs.delete(name);
93
- }
94
-
95
- /**
96
- * Gracefully shuts down everything.
97
- * - Closes all databases
98
- * - Terminates IPC worker if running
99
- */
100
110
  async closeAll(): Promise<void> {
101
111
  if (this.closed) return;
102
112
  this.closed = true;
103
113
 
104
- if (this.ipc) {
114
+ if (this.mode === ProcessMode.CLIENT) {
105
115
  await dbQueue.shutdown();
106
116
  return;
107
117
  }
108
118
 
109
119
  for (const db of this.openDBs.values()) {
110
- try {
111
- await db.close();
112
- } catch {}
120
+ try { await db.close(); } catch {}
113
121
  }
114
122
 
115
123
  this.openDBs.clear();
124
+
125
+ try {
126
+ if (this.lockFd) fs.closeSync(this.lockFd);
127
+ fs.unlinkSync(path.join(this.rootPath, ".lioran.lock"));
128
+ } catch {}
129
+
130
+ await this.ipcServer?.close();
116
131
  }
117
132
 
118
- /**
119
- * Alias for closeAll() (clean public API)
120
- */
121
133
  async close(): Promise<void> {
122
134
  return this.closeAll();
123
135
  }
@@ -137,73 +149,9 @@ export class LioranManager {
137
149
  throw new Error("LioranManager is closed");
138
150
  }
139
151
  }
140
-
141
- /* -------------------------------- MANAGEMENT -------------------------------- */
142
-
143
- async renameDatabase(oldName: string, newName: string): Promise<boolean> {
144
- if (this.ipc) {
145
- return (await dbQueue.exec("renameDatabase", { oldName, newName })) as boolean;
146
- }
147
-
148
- const oldPath = path.join(this.rootPath, oldName);
149
- const newPath = path.join(this.rootPath, newName);
150
-
151
- if (!fs.existsSync(oldPath)) {
152
- throw new Error(`Database "${oldName}" not found`);
153
- }
154
-
155
- if (fs.existsSync(newPath)) {
156
- throw new Error(`Database "${newName}" already exists`);
157
- }
158
-
159
- await this.closeDatabase(oldName);
160
- await fs.promises.rename(oldPath, newPath);
161
- return true;
162
- }
163
-
164
- async deleteDatabase(name: string): Promise<boolean> {
165
- return this.dropDatabase(name);
166
- }
167
-
168
- async dropDatabase(name: string): Promise<boolean> {
169
- if (this.ipc) {
170
- return (await dbQueue.exec("dropDatabase", { name })) as boolean;
171
- }
172
-
173
- const dbPath = path.join(this.rootPath, name);
174
-
175
- if (!fs.existsSync(dbPath)) return false;
176
-
177
- await this.closeDatabase(name);
178
- await fs.promises.rm(dbPath, { recursive: true, force: true });
179
- return true;
180
- }
181
-
182
- async listDatabases(): Promise<string[]> {
183
- if (this.ipc) {
184
- return (await dbQueue.exec("listDatabases", {})) as string[];
185
- }
186
-
187
- const items = await fs.promises.readdir(this.rootPath, {
188
- withFileTypes: true
189
- });
190
-
191
- return items.filter(i => i.isDirectory()).map(i => i.name);
192
- }
193
-
194
- /* -------------------------------- DEBUG -------------------------------- */
195
-
196
- getStats() {
197
- return {
198
- rootPath: this.rootPath,
199
- openDatabases: this.ipc ? ["<ipc>"] : [...this.openDBs.keys()],
200
- ipc: this.ipc,
201
- closed: this.closed
202
- };
203
- }
204
152
  }
205
153
 
206
- /* -------------------------------- IPC PROXY DB -------------------------------- */
154
+ /* ---------------- IPC PROXY DB ---------------- */
207
155
 
208
156
  class IPCDatabase {
209
157
  constructor(private name: string) {}
@@ -240,4 +188,4 @@ class IPCCollection {
240
188
  deleteMany = (filter: any) => this.call("deleteMany", [filter]);
241
189
  countDocuments = (filter?: any) =>
242
190
  this.call("countDocuments", [filter]);
243
- }
191
+ }
@@ -6,7 +6,8 @@ import type { ZodSchema } from "zod";
6
6
 
7
7
  type TXOp = { tx: number; col: string; op: string; args: any[] };
8
8
  type TXCommit = { tx: number; commit: true };
9
- type WALEntry = TXOp | TXCommit;
9
+ type TXApplied = { tx: number; applied: true };
10
+ type WALEntry = TXOp | TXCommit | TXApplied;
10
11
 
11
12
  class DBTransactionContext {
12
13
  private ops: TXOp[] = [];
@@ -35,6 +36,7 @@ class DBTransactionContext {
35
36
  await this.db.writeWAL(this.ops);
36
37
  await this.db.writeWAL([{ tx: this.txId, commit: true }]);
37
38
  await this.db.applyTransaction(this.ops);
39
+ await this.db.writeWAL([{ tx: this.txId, applied: true }]);
38
40
  await this.db.clearWAL();
39
41
  }
40
42
  }
@@ -76,25 +78,33 @@ export class LioranDB {
76
78
  private async recoverFromWAL() {
77
79
  if (!fs.existsSync(this.walPath)) return;
78
80
 
79
- const lines = (await fs.promises.readFile(this.walPath, "utf8"))
80
- .split("\n")
81
- .filter(Boolean);
81
+ const raw = await fs.promises.readFile(this.walPath, "utf8");
82
82
 
83
83
  const committed = new Set<number>();
84
+ const applied = new Set<number>();
84
85
  const ops = new Map<number, TXOp[]>();
85
86
 
86
- for (const line of lines) {
87
- const entry: WALEntry = JSON.parse(line);
88
-
89
- if ("commit" in entry) {
90
- committed.add(entry.tx);
91
- } else {
92
- if (!ops.has(entry.tx)) ops.set(entry.tx, []);
93
- ops.get(entry.tx)!.push(entry);
87
+ for (const line of raw.split("\n")) {
88
+ if (!line.trim()) continue;
89
+
90
+ try {
91
+ const entry: WALEntry = JSON.parse(line);
92
+
93
+ if ("commit" in entry) committed.add(entry.tx);
94
+ else if ("applied" in entry) applied.add(entry.tx);
95
+ else {
96
+ if (!ops.has(entry.tx)) ops.set(entry.tx, []);
97
+ ops.get(entry.tx)!.push(entry);
98
+ }
99
+ } catch {
100
+ // Ignore corrupted WAL tail
101
+ break;
94
102
  }
95
103
  }
96
104
 
97
105
  for (const tx of committed) {
106
+ if (applied.has(tx)) continue;
107
+
98
108
  const txOps = ops.get(tx);
99
109
  if (txOps) await this.applyTransaction(txOps);
100
110
  }
@@ -0,0 +1,85 @@
1
+ import net from "net";
2
+ import { getIPCSocketPath } from "./socketPath.js";
3
+
4
+ function delay(ms: number) {
5
+ return new Promise(r => setTimeout(r, ms));
6
+ }
7
+
8
+ async function connectWithRetry(path: string): Promise<net.Socket> {
9
+ let attempt = 0;
10
+
11
+ while (true) {
12
+ try {
13
+ return await new Promise((resolve, reject) => {
14
+ const socket = net.connect(path, () => resolve(socket));
15
+ socket.once("error", reject);
16
+ });
17
+ } catch (err: any) {
18
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
19
+ if (attempt++ > 80) {
20
+ throw new Error("IPC server not reachable");
21
+ }
22
+ await delay(50);
23
+ continue;
24
+ }
25
+ throw err;
26
+ }
27
+ }
28
+ }
29
+
30
+ export class IPCClient {
31
+ private socket!: net.Socket;
32
+ private buffer = "";
33
+ private seq = 0;
34
+ private pending = new Map<number, (v: any) => void>();
35
+ private ready: Promise<void>;
36
+
37
+ constructor(rootPath: string) {
38
+ const socketPath = getIPCSocketPath(rootPath);
39
+ this.ready = this.init(socketPath);
40
+ }
41
+
42
+ private async init(socketPath: string) {
43
+ this.socket = await connectWithRetry(socketPath);
44
+
45
+ this.socket.on("data", data => {
46
+ this.buffer += data.toString();
47
+
48
+ while (this.buffer.includes("\n")) {
49
+ const idx = this.buffer.indexOf("\n");
50
+ const raw = this.buffer.slice(0, idx);
51
+ this.buffer = this.buffer.slice(idx + 1);
52
+
53
+ const msg = JSON.parse(raw);
54
+ const cb = this.pending.get(msg.id);
55
+
56
+ if (cb) {
57
+ this.pending.delete(msg.id);
58
+ cb(msg);
59
+ }
60
+ }
61
+ });
62
+
63
+ this.socket.on("error", err => {
64
+ console.error("IPC socket error:", err);
65
+ });
66
+ }
67
+
68
+ async exec(action: string, args: any) {
69
+ await this.ready; // 🔥 HARD BARRIER — guarantees socket exists
70
+
71
+ return new Promise((resolve, reject) => {
72
+ const id = ++this.seq;
73
+
74
+ this.pending.set(id, msg => {
75
+ msg.ok ? resolve(msg.result) : reject(new Error(msg.error));
76
+ });
77
+
78
+ this.socket.write(JSON.stringify({ id, action, args }) + "\n");
79
+ });
80
+ }
81
+
82
+ close() {
83
+ try { this.socket.end(); } catch {}
84
+ }
85
+ }
package/src/ipc/queue.ts CHANGED
@@ -1,137 +1,22 @@
1
- import { fork, ChildProcess } from "child_process";
2
- import path from "path";
3
- import { fileURLToPath } from "url";
4
-
5
- const __filename = fileURLToPath(import.meta.url);
6
- const __dirname = path.dirname(__filename);
7
-
8
- function resolveWorkerPath() {
9
- return path.resolve(__dirname, "../dist/worker/dbWorker.js");
10
- }
1
+ import { IPCClient } from "./client.js";
2
+ import { getDefaultRootPath } from "../utils/rootpath.js";
11
3
 
12
4
  export class DBQueue {
13
- private worker!: ChildProcess;
14
- private seq = 0;
15
- private pending = new Map<number, (r: any) => void>();
16
- private isShutdown = false;
17
- private restarting = false;
18
- private workerAlive = false;
19
-
20
- constructor() {
21
- this.spawnWorker();
5
+ private client: IPCClient;
22
6
 
23
- process.once("exit", () => this.shutdown());
24
- process.once("SIGINT", () => this.shutdown());
25
- process.once("SIGTERM", () => this.shutdown());
7
+ constructor(rootPath = getDefaultRootPath()) {
8
+ this.client = new IPCClient(rootPath);
26
9
  }
27
10
 
28
- /* ---------------- Worker Control ---------------- */
29
-
30
- private spawnWorker() {
31
- const workerPath = resolveWorkerPath();
32
-
33
- this.worker = fork(workerPath, [], {
34
- stdio: ["inherit", "inherit", "inherit", "ipc"],
35
- });
36
-
37
- this.workerAlive = true;
38
-
39
- this.worker.on("message", (msg: any) => {
40
- const cb = this.pending.get(msg.id);
41
- if (cb) {
42
- this.pending.delete(msg.id);
43
- cb(msg);
44
- }
45
- });
46
-
47
- this.worker.once("exit", (code, signal) => {
48
- this.workerAlive = false;
49
-
50
- if (this.isShutdown) return;
51
-
52
- console.error("DB Worker crashed, restarting...", { code, signal });
53
-
54
- this.restartWorker();
55
- });
11
+ exec(action: string, args: any) {
12
+ return this.client.exec(action, args);
56
13
  }
57
14
 
58
- private restartWorker() {
59
- if (this.restarting || this.isShutdown) return;
60
-
61
- this.restarting = true;
62
-
63
- setTimeout(() => {
64
- if (this.isShutdown) return;
65
-
66
- for (const [, cb] of this.pending) {
67
- cb({ ok: false, error: "IPC worker crashed" });
68
- }
69
- this.pending.clear();
70
- this.seq = 0;
71
-
72
- this.spawnWorker();
73
- this.restarting = false;
74
- }, 500);
75
- }
76
-
77
- /* ---------------- IPC Exec ---------------- */
78
-
79
- exec(action: string, args: any, timeout = 15000) {
80
- if (this.isShutdown) {
81
- return Promise.reject(new Error("DBQueue is shutdown"));
82
- }
83
-
84
- if (!this.workerAlive) {
85
- return Promise.reject(new Error("IPC worker not running"));
86
- }
87
-
88
- return new Promise((resolve, reject) => {
89
- const id = ++this.seq;
90
-
91
- const timer = setTimeout(() => {
92
- this.pending.delete(id);
93
- reject(new Error(`IPC timeout: ${action}`));
94
- }, timeout);
95
-
96
- this.pending.set(id, (msg) => {
97
- clearTimeout(timer);
98
- this.pending.delete(id);
99
- msg.ok ? resolve(msg.result) : reject(new Error(msg.error));
100
- });
101
-
102
- try {
103
- this.worker.send({ id, action, args });
104
- } catch (err) {
105
- clearTimeout(timer);
106
- this.pending.delete(id);
107
- reject(err);
108
- }
109
- });
110
- }
111
-
112
- /* ---------------- Clean Shutdown ---------------- */
113
-
114
15
  async shutdown() {
115
- if (this.isShutdown) return;
116
- this.isShutdown = true;
117
-
118
- if (this.workerAlive) {
119
- try {
120
- this.worker.send({ action: "shutdown" });
121
- } catch {}
122
- }
123
-
124
- await new Promise(resolve => {
125
- if (!this.workerAlive) return resolve(null);
126
- this.worker.once("exit", resolve);
127
- setTimeout(resolve, 250);
128
- });
129
-
130
- for (const [, cb] of this.pending) {
131
- cb({ ok: false, error: "IPC shutdown" });
132
- }
133
-
134
- this.pending.clear();
16
+ try {
17
+ await this.exec("shutdown", {});
18
+ } catch {}
19
+ this.client.close();
135
20
  }
136
21
  }
137
22
 
@@ -0,0 +1,84 @@
1
+ import net from "net";
2
+ import fs from "fs";
3
+ import { LioranManager } from "../LioranManager.js";
4
+ import { getIPCSocketPath } from "./socketPath.js";
5
+
6
+ export class IPCServer {
7
+ private server!: net.Server;
8
+ private manager: LioranManager;
9
+ private socketPath: string;
10
+
11
+ constructor(manager: LioranManager, rootPath: string) {
12
+ this.manager = manager;
13
+ this.socketPath = getIPCSocketPath(rootPath);
14
+ }
15
+
16
+ start() {
17
+ if (!this.socketPath.startsWith("\\\\.\\")) {
18
+ if (fs.existsSync(this.socketPath)) fs.unlinkSync(this.socketPath);
19
+ }
20
+
21
+ this.server = net.createServer(socket => {
22
+ let buffer = "";
23
+
24
+ socket.on("data", async data => {
25
+ buffer += data.toString();
26
+
27
+ while (buffer.includes("\n")) {
28
+ const idx = buffer.indexOf("\n");
29
+ const raw = buffer.slice(0, idx);
30
+ buffer = buffer.slice(idx + 1);
31
+
32
+ const msg = JSON.parse(raw);
33
+ this.handleMessage(socket, msg).catch(console.error);
34
+ }
35
+ });
36
+ });
37
+
38
+ this.server.listen(this.socketPath, () => {
39
+ console.log("[IPC] Server listening:", this.socketPath);
40
+ });
41
+ }
42
+
43
+ private async handleMessage(socket: net.Socket, msg: any) {
44
+ const { id, action, args } = msg;
45
+
46
+ try {
47
+ let result;
48
+
49
+ switch (action) {
50
+ case "db":
51
+ await this.manager.db(args.db);
52
+ result = true;
53
+ break;
54
+
55
+ case "op": {
56
+ const { db, col, method, params } = args;
57
+ const collection = (await this.manager.db(db)).collection(col);
58
+ result = await (collection as any)[method](...params);
59
+ break;
60
+ }
61
+
62
+ case "shutdown":
63
+ await this.manager.closeAll();
64
+ result = true;
65
+ break;
66
+
67
+ default:
68
+ throw new Error("Unknown IPC action");
69
+ }
70
+
71
+ socket.write(JSON.stringify({ id, ok: true, result }) + "\n");
72
+ } catch (err: any) {
73
+ socket.write(JSON.stringify({ id, ok: false, error: err.message }) + "\n");
74
+ }
75
+ }
76
+
77
+ async close() {
78
+ if (this.server) this.server.close();
79
+
80
+ if (!this.socketPath.startsWith("\\\\.\\")) {
81
+ try { fs.unlinkSync(this.socketPath); } catch {}
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,10 @@
1
+ import os from "os";
2
+ import path from "path";
3
+
4
+ export function getIPCSocketPath(rootPath: string) {
5
+ if (os.platform() === "win32") {
6
+ return `\\\\.\\pipe\\liorandb_${rootPath.replace(/[:\\\/]/g, "_")}`;
7
+ }
8
+
9
+ return path.join(rootPath, ".lioran.sock");
10
+ }
@@ -1,43 +0,0 @@
1
- import { LioranManager } from "../LioranManager.js";
2
-
3
- const manager = new LioranManager({ ipc: false });
4
-
5
- process.on("message", async (msg: any) => {
6
- const { id, action, args } = msg;
7
-
8
- try {
9
- let result;
10
-
11
- switch (action) {
12
- case "shutdown":
13
- await manager.closeAll();
14
- result = true;
15
- break;
16
-
17
- case "db":
18
- await manager.db(args.db);
19
- result = true;
20
- break;
21
-
22
- case "op": {
23
- const { db, col, method, params } = args;
24
- const collection = (await manager.db(db)).collection(col);
25
- result = await (collection as any)[method](...params);
26
- break;
27
- }
28
-
29
- case "tx": {
30
- const db = await manager.db(args.db);
31
- result = await db.transaction(args.fn);
32
- break;
33
- }
34
-
35
- default:
36
- throw new Error("Unknown IPC action");
37
- }
38
-
39
- process.send?.({ id, ok: true, result });
40
- } catch (err: any) {
41
- process.send?.({ id, ok: false, error: err.message });
42
- }
43
- });