@liorandb/core 1.1.0 → 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.1.0",
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,
@@ -23,6 +23,10 @@ export interface UpdateOptions {
23
23
  upsert?: boolean;
24
24
  }
25
25
 
26
+ export interface CollectionOptions {
27
+ readonly?: boolean;
28
+ }
29
+
26
30
  export class Collection<T = any> {
27
31
  dir: string;
28
32
  db: ClassicLevel<string, string>;
@@ -33,16 +37,31 @@ export class Collection<T = any> {
33
37
  private migrations: Migration<T>[] = [];
34
38
 
35
39
  private indexes = new Map<string, Index>();
40
+ private readonlyMode: boolean;
36
41
 
37
42
  constructor(
38
43
  dir: string,
39
44
  schema?: ZodSchema<T>,
40
- schemaVersion: number = 1
45
+ schemaVersion: number = 1,
46
+ options?: CollectionOptions
41
47
  ) {
42
48
  this.dir = dir;
43
- this.db = new ClassicLevel(dir, { valueEncoding: "utf8" });
44
49
  this.schema = schema;
45
50
  this.schemaVersion = schemaVersion;
51
+ this.readonlyMode = options?.readonly ?? false;
52
+
53
+ this.db = new ClassicLevel(dir, {
54
+ valueEncoding: "utf8",
55
+ readOnly: this.readonlyMode
56
+ } as any);
57
+ }
58
+
59
+ /* ===================== INTERNAL ===================== */
60
+
61
+ private assertWritable() {
62
+ if (this.readonlyMode) {
63
+ throw new Error("Collection is in readonly replica mode");
64
+ }
46
65
  }
47
66
 
48
67
  /* ===================== SCHEMA ===================== */
@@ -106,6 +125,7 @@ export class Collection<T = any> {
106
125
  }
107
126
 
108
127
  private async _updateIndexes(oldDoc: any, newDoc: any) {
128
+ if (this.readonlyMode) return;
109
129
  for (const index of this.indexes.values()) {
110
130
  await index.update(oldDoc, newDoc);
111
131
  }
@@ -114,6 +134,8 @@ export class Collection<T = any> {
114
134
  /* ===================== COMPACTION ===================== */
115
135
 
116
136
  async compact(): Promise<void> {
137
+ this.assertWritable();
138
+
117
139
  return this._enqueue(async () => {
118
140
  try { await this.db.close(); } catch {}
119
141
 
@@ -145,6 +167,8 @@ export class Collection<T = any> {
145
167
  /* ===================== STORAGE ===================== */
146
168
 
147
169
  private async _insertOne(doc: any) {
170
+ this.assertWritable();
171
+
148
172
  const _id = doc._id ?? uuid();
149
173
  const final = this.validate({
150
174
  _id,
@@ -159,6 +183,8 @@ export class Collection<T = any> {
159
183
  }
160
184
 
161
185
  private async _insertMany(docs: any[]) {
186
+ this.assertWritable();
187
+
162
188
  const batch: any[] = [];
163
189
  const out = [];
164
190
 
@@ -214,14 +240,13 @@ export class Collection<T = any> {
214
240
  }
215
241
 
216
242
  private async _readAndMigrate(id: string) {
217
- const enc = await this.db.get(id);
243
+ const enc = await this.db.get(id).catch(() => null);
218
244
  if (!enc) return null;
219
245
 
220
246
  const raw = decryptData(enc);
221
247
  const migrated = this.migrateIfNeeded(raw);
222
248
 
223
- // Lazy write-back if migrated
224
- if (raw.__v !== this.schemaVersion) {
249
+ if (!this.readonlyMode && raw.__v !== this.schemaVersion) {
225
250
  await this.db.put(id, encryptData(migrated));
226
251
  await this._updateIndexes(raw, migrated);
227
252
  }
@@ -247,9 +272,7 @@ export class Collection<T = any> {
247
272
 
248
273
  private async _findOne(query: any) {
249
274
  if (query?._id) {
250
- try {
251
- return await this._readAndMigrate(String(query._id));
252
- } catch { return null; }
275
+ return this._readAndMigrate(String(query._id));
253
276
  }
254
277
 
255
278
  const ids = await this._getCandidateIds(query);
@@ -285,6 +308,8 @@ export class Collection<T = any> {
285
308
  /* ===================== UPDATE ===================== */
286
309
 
287
310
  private async _updateOne(filter: any, update: any, options: UpdateOptions) {
311
+ this.assertWritable();
312
+
288
313
  const ids = await this._getCandidateIds(filter);
289
314
 
290
315
  for (const id of ids) {
@@ -313,6 +338,8 @@ export class Collection<T = any> {
313
338
  }
314
339
 
315
340
  private async _updateMany(filter: any, update: any) {
341
+ this.assertWritable();
342
+
316
343
  const ids = await this._getCandidateIds(filter);
317
344
  const out = [];
318
345
 
@@ -340,6 +367,8 @@ export class Collection<T = any> {
340
367
  /* ===================== DELETE ===================== */
341
368
 
342
369
  private async _deleteOne(filter: any) {
370
+ this.assertWritable();
371
+
343
372
  const ids = await this._getCandidateIds(filter);
344
373
 
345
374
  for (const id of ids) {
@@ -357,6 +386,8 @@ export class Collection<T = any> {
357
386
  }
358
387
 
359
388
  private async _deleteMany(filter: any) {
389
+ this.assertWritable();
390
+
360
391
  const ids = await this._getCandidateIds(filter);
361
392
  let count = 0;
362
393