@liorandb/core 1.0.15 → 1.0.17

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.
@@ -51,6 +51,8 @@ export class LioranManager {
51
51
  }
52
52
  }
53
53
 
54
+ /* ---------------- LOCK MANAGEMENT ---------------- */
55
+
54
56
  private isProcessAlive(pid: number): boolean {
55
57
  try {
56
58
  process.kill(pid, 0);
@@ -68,7 +70,6 @@ export class LioranManager {
68
70
  fs.writeSync(this.lockFd, String(process.pid));
69
71
  return true;
70
72
  } catch {
71
- // Possible stale lock → validate PID
72
73
  try {
73
74
  const pid = Number(fs.readFileSync(lockPath, "utf8"));
74
75
  if (!this.isProcessAlive(pid)) {
@@ -77,18 +78,18 @@ export class LioranManager {
77
78
  fs.writeSync(this.lockFd, String(process.pid));
78
79
  return true;
79
80
  }
80
- } catch {}
81
-
81
+ } catch { }
82
82
  return false;
83
83
  }
84
84
  }
85
85
 
86
+ /* ---------------- DB OPEN ---------------- */
87
+
86
88
  async db(name: string): Promise<LioranDB> {
87
89
  if (this.mode === ProcessMode.CLIENT) {
88
90
  await dbQueue.exec("db", { db: name });
89
91
  return new IPCDatabase(name) as any;
90
92
  }
91
-
92
93
  return this.openDatabase(name);
93
94
  }
94
95
 
@@ -107,6 +108,64 @@ export class LioranManager {
107
108
  return db;
108
109
  }
109
110
 
111
+ /* ---------------- SNAPSHOT ORCHESTRATION ---------------- */
112
+
113
+ /**
114
+ * Create TAR snapshot of full DB directory
115
+ */
116
+ async snapshot(snapshotPath: string) {
117
+ if (this.mode === ProcessMode.CLIENT) {
118
+ return dbQueue.exec("snapshot", { path: snapshotPath });
119
+ }
120
+
121
+ // Flush all DBs safely
122
+ for (const db of this.openDBs.values()) {
123
+ for (const col of db.collections.values()) {
124
+ try { await col.db.close(); } catch { }
125
+ }
126
+ }
127
+
128
+ // Ensure backup directory exists
129
+ fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
130
+
131
+ const tar = await import("tar");
132
+
133
+ await tar.c({
134
+ gzip: true,
135
+ file: snapshotPath,
136
+ cwd: this.rootPath,
137
+ portable: true
138
+ }, ["./"]);
139
+
140
+ return true;
141
+ }
142
+
143
+ /**
144
+ * Restore TAR snapshot safely
145
+ */
146
+ async restore(snapshotPath: string) {
147
+ if (this.mode === ProcessMode.CLIENT) {
148
+ return dbQueue.exec("restore", { path: snapshotPath });
149
+ }
150
+
151
+ await this.closeAll();
152
+
153
+ fs.rmSync(this.rootPath, { recursive: true, force: true });
154
+ fs.mkdirSync(this.rootPath, { recursive: true });
155
+
156
+ const tar = await import("tar");
157
+
158
+ await tar.x({
159
+ file: snapshotPath,
160
+ cwd: this.rootPath
161
+ });
162
+
163
+ console.log("Restore completed. Restart required.");
164
+ process.exit(0);
165
+ }
166
+
167
+ /* ---------------- SHUTDOWN ---------------- */
168
+
110
169
  async closeAll(): Promise<void> {
111
170
  if (this.closed) return;
112
171
  this.closed = true;
@@ -117,7 +176,7 @@ export class LioranManager {
117
176
  }
118
177
 
119
178
  for (const db of this.openDBs.values()) {
120
- try { await db.close(); } catch {}
179
+ try { await db.close(); } catch { }
121
180
  }
122
181
 
123
182
  this.openDBs.clear();
@@ -125,7 +184,7 @@ export class LioranManager {
125
184
  try {
126
185
  if (this.lockFd) fs.closeSync(this.lockFd);
127
186
  fs.unlinkSync(path.join(this.rootPath, ".lioran.lock"));
128
- } catch {}
187
+ } catch { }
129
188
 
130
189
  await this.ipcServer?.close();
131
190
  }
@@ -154,7 +213,7 @@ export class LioranManager {
154
213
  /* ---------------- IPC PROXY DB ---------------- */
155
214
 
156
215
  class IPCDatabase {
157
- constructor(private name: string) {}
216
+ constructor(private name: string) { }
158
217
 
159
218
  collection(name: string) {
160
219
  return new IPCCollection(this.name, name);
@@ -165,7 +224,7 @@ class IPCCollection {
165
224
  constructor(
166
225
  private db: string,
167
226
  private col: string
168
- ) {}
227
+ ) { }
169
228
 
170
229
  private call(method: string, params: any[]) {
171
230
  return dbQueue.exec("op", {
@@ -1,10 +1,15 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
+ import { execFile } from "child_process";
4
+ import { promisify } from "util";
3
5
  import { Collection } from "./collection.js";
4
6
  import { Index, IndexOptions } from "./index.js";
7
+ import { MigrationEngine } from "./migration.js";
5
8
  import type { LioranManager } from "../LioranManager.js";
6
9
  import type { ZodSchema } from "zod";
7
10
 
11
+ const exec = promisify(execFile);
12
+
8
13
  /* ----------------------------- TYPES ----------------------------- */
9
14
 
10
15
  type TXOp = { tx: number; col: string; op: string; args: any[] };
@@ -20,10 +25,12 @@ type IndexMeta = {
20
25
  type DBMeta = {
21
26
  version: number;
22
27
  indexes: Record<string, IndexMeta[]>;
28
+ schemaVersion: string;
23
29
  };
24
30
 
25
31
  const META_FILE = "__db_meta.json";
26
32
  const META_VERSION = 1;
33
+ const DEFAULT_SCHEMA_VERSION = "v1";
27
34
 
28
35
  /* ---------------------- TRANSACTION CONTEXT ---------------------- */
29
36
 
@@ -33,7 +40,7 @@ class DBTransactionContext {
33
40
  constructor(
34
41
  private db: LioranDB,
35
42
  public readonly txId: number
36
- ) {}
43
+ ) { }
37
44
 
38
45
  collection(name: string) {
39
46
  return new Proxy({}, {
@@ -66,10 +73,13 @@ export class LioranDB {
66
73
  dbName: string;
67
74
  manager: LioranManager;
68
75
  collections: Map<string, Collection>;
76
+
69
77
  private walPath: string;
70
78
  private metaPath: string;
71
79
  private meta!: DBMeta;
72
80
 
81
+ private migrator: MigrationEngine;
82
+
73
83
  private static TX_SEQ = 0;
74
84
 
75
85
  constructor(basePath: string, dbName: string, manager: LioranManager) {
@@ -77,12 +87,15 @@ export class LioranDB {
77
87
  this.dbName = dbName;
78
88
  this.manager = manager;
79
89
  this.collections = new Map();
90
+
80
91
  this.walPath = path.join(basePath, "__tx_wal.log");
81
92
  this.metaPath = path.join(basePath, META_FILE);
82
93
 
83
94
  fs.mkdirSync(basePath, { recursive: true });
84
95
 
85
96
  this.loadMeta();
97
+ this.migrator = new MigrationEngine(this);
98
+
86
99
  this.recoverFromWAL().catch(console.error);
87
100
  }
88
101
 
@@ -90,15 +103,20 @@ export class LioranDB {
90
103
 
91
104
  private loadMeta() {
92
105
  if (!fs.existsSync(this.metaPath)) {
93
- this.meta = { version: META_VERSION, indexes: {} };
106
+ this.meta = {
107
+ version: META_VERSION,
108
+ indexes: {},
109
+ schemaVersion: DEFAULT_SCHEMA_VERSION
110
+ };
94
111
  this.saveMeta();
95
112
  return;
96
113
  }
97
114
 
98
- try {
99
- this.meta = JSON.parse(fs.readFileSync(this.metaPath, "utf8"));
100
- } catch {
101
- throw new Error("Database metadata corrupted");
115
+ this.meta = JSON.parse(fs.readFileSync(this.metaPath, "utf8"));
116
+
117
+ if (!this.meta.schemaVersion) {
118
+ this.meta.schemaVersion = DEFAULT_SCHEMA_VERSION;
119
+ this.saveMeta();
102
120
  }
103
121
  }
104
122
 
@@ -106,6 +124,28 @@ export class LioranDB {
106
124
  fs.writeFileSync(this.metaPath, JSON.stringify(this.meta, null, 2));
107
125
  }
108
126
 
127
+ getSchemaVersion(): string {
128
+ return this.meta.schemaVersion;
129
+ }
130
+
131
+ setSchemaVersion(v: string) {
132
+ this.meta.schemaVersion = v;
133
+ this.saveMeta();
134
+ }
135
+
136
+ /* ------------------------- MIGRATION API ------------------------- */
137
+
138
+ migrate(from: string, to: string, fn: (db: LioranDB) => Promise<void>) {
139
+ this.migrator.register(from, to, async db => {
140
+ await fn(db);
141
+ db.setSchemaVersion(to);
142
+ });
143
+ }
144
+
145
+ async applyMigrations(targetVersion: string) {
146
+ await this.migrator.upgradeToLatest();
147
+ }
148
+
109
149
  /* ------------------------- WAL ------------------------- */
110
150
 
111
151
  async writeWAL(entries: WALEntry[]) {
@@ -118,7 +158,7 @@ export class LioranDB {
118
158
  }
119
159
 
120
160
  async clearWAL() {
121
- try { await fs.promises.unlink(this.walPath); } catch {}
161
+ try { await fs.promises.unlink(this.walPath); } catch { }
122
162
  }
123
163
 
124
164
  private async recoverFromWAL() {
@@ -133,17 +173,13 @@ export class LioranDB {
133
173
  for (const line of raw.split("\n")) {
134
174
  if (!line.trim()) continue;
135
175
 
136
- try {
137
- const entry: WALEntry = JSON.parse(line);
138
-
139
- if ("commit" in entry) committed.add(entry.tx);
140
- else if ("applied" in entry) applied.add(entry.tx);
141
- else {
142
- if (!ops.has(entry.tx)) ops.set(entry.tx, []);
143
- ops.get(entry.tx)!.push(entry);
144
- }
145
- } catch {
146
- break;
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);
147
183
  }
148
184
  }
149
185
 
@@ -177,7 +213,6 @@ export class LioranDB {
177
213
 
178
214
  const col = new Collection<T>(colPath, schema);
179
215
 
180
- // Auto-load indexes
181
216
  const metas = this.meta.indexes[name] ?? [];
182
217
  for (const m of metas) {
183
218
  col.registerIndex(new Index(colPath, m.field, m.options));
@@ -201,9 +236,26 @@ export class LioranDB {
201
236
 
202
237
  const index = new Index(col.dir, field, options);
203
238
 
204
- for await (const [, enc] of col.db.iterator()) {
205
- const doc = JSON.parse(Buffer.from(enc, "base64").subarray(32).toString("utf8"));
206
- await index.insert(doc);
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
+ for await (const [key, enc] of col.db.iterator()) {
249
+ if (!enc) continue;
250
+
251
+ try {
252
+ const doc = decryptData(enc); // ← this does base64 → AES-GCM → JSON
253
+ await index.insert(doc);
254
+ } catch (err) {
255
+ const errorMessage = err instanceof Error ? err.message : String(err);
256
+ console.warn(`Could not decrypt document ${key} during index build: ${errorMessage}`);
257
+ // You can continue, or collect bad keys for later inspection
258
+ }
207
259
  }
208
260
 
209
261
  col.registerIndex(index);
@@ -216,27 +268,16 @@ export class LioranDB {
216
268
  this.saveMeta();
217
269
  }
218
270
 
219
- /* ---------------------- COMPACTION ORCHESTRATOR ---------------------- */
271
+ /* ------------------------- COMPACTION ------------------------- */
220
272
 
221
- /**
222
- * Compact single collection safely (WAL + TX safe)
223
- */
224
273
  async compactCollection(name: string) {
225
- const col = this.collection(name);
226
-
227
- // Ensure WAL is fully flushed
228
274
  await this.clearWAL();
229
-
230
- // Perform compaction inside write barrier
275
+ const col = this.collection(name);
231
276
  await col.compact();
232
277
  }
233
278
 
234
- /**
235
- * Compact entire database safely
236
- */
237
279
  async compactAll() {
238
280
  await this.clearWAL();
239
-
240
281
  for (const name of this.collections.keys()) {
241
282
  await this.compactCollection(name);
242
283
  }
@@ -247,10 +288,8 @@ export class LioranDB {
247
288
  async transaction<T>(fn: (tx: DBTransactionContext) => Promise<T>): Promise<T> {
248
289
  const txId = ++LioranDB.TX_SEQ;
249
290
  const tx = new DBTransactionContext(this, txId);
250
-
251
291
  const result = await fn(tx);
252
292
  await tx.commit();
253
-
254
293
  return result;
255
294
  }
256
295
 
@@ -258,8 +297,12 @@ export class LioranDB {
258
297
 
259
298
  async close(): Promise<void> {
260
299
  for (const col of this.collections.values()) {
261
- try { await col.close(); } catch {}
300
+ try { await col.close(); } catch { }
262
301
  }
263
302
  this.collections.clear();
264
303
  }
265
- }
304
+ }
305
+
306
+ function decryptData(enc: string) {
307
+ throw new Error("Function not implemented.");
308
+ }
@@ -0,0 +1,40 @@
1
+ import { MigrationMeta } from "./migration.types";
2
+ import type { LioranDB } from "./database.js";
3
+
4
+ const MIGRATION_COLLECTION = "__migrations__";
5
+ const MIGRATION_KEY = "__migration_meta__";
6
+
7
+ export class MigrationStore {
8
+ constructor(private db: LioranDB) {}
9
+
10
+ async get(): Promise<MigrationMeta> {
11
+ const col = this.db.collection<MigrationMeta>(MIGRATION_COLLECTION);
12
+ const meta = await col.findOne((d: MigrationMeta) => d.id === MIGRATION_KEY);
13
+ return (
14
+ meta ?? {
15
+ currentVersion: "v1",
16
+ history: []
17
+ }
18
+ );
19
+ }
20
+
21
+ async set(meta: MigrationMeta): Promise<void> {
22
+ const col = this.db.collection<MigrationMeta>(MIGRATION_COLLECTION);
23
+ const existing = await col.findOne((d: MigrationMeta) => d.id === MIGRATION_KEY);
24
+ if (existing) {
25
+ await col.updateOne(existing._id, { ...meta, id: MIGRATION_KEY });
26
+ } else {
27
+ await col.insertOne({ ...meta, id: MIGRATION_KEY });
28
+ }
29
+ }
30
+
31
+ async updateVersion(version: string): Promise<void> {
32
+ const meta = await this.get();
33
+ meta.currentVersion = version;
34
+ meta.history.push({
35
+ version,
36
+ appliedAt: Date.now()
37
+ });
38
+ await this.set(meta);
39
+ }
40
+ }
@@ -0,0 +1,162 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+ import type { LioranDB } from "./database.js";
5
+
6
+ export type MigrationFn = (db: LioranDB) => Promise<void>;
7
+
8
+ type MigrationRecord = {
9
+ from: string;
10
+ to: string;
11
+ checksum: string;
12
+ appliedAt: number;
13
+ };
14
+
15
+ const LOCK_FILE = "__migration.lock";
16
+ const HISTORY_FILE = "__migration_history.json";
17
+
18
+ export class MigrationEngine {
19
+ private migrations = new Map<string, MigrationFn>();
20
+
21
+ constructor(private db: LioranDB) {}
22
+
23
+ /* ------------------------------------------------------------ */
24
+ /* Public API */
25
+ /* ------------------------------------------------------------ */
26
+
27
+ register(from: string, to: string, fn: MigrationFn) {
28
+ const key = `${from}→${to}`;
29
+
30
+ if (this.migrations.has(key)) {
31
+ throw new Error(`Duplicate migration: ${key}`);
32
+ }
33
+
34
+ this.migrations.set(key, fn);
35
+ }
36
+
37
+ async migrate(from: string, to: string, fn: MigrationFn) {
38
+ this.register(from, to, fn);
39
+ await this.execute();
40
+ }
41
+
42
+ async upgradeToLatest() {
43
+ await this.execute();
44
+ }
45
+
46
+ /* ------------------------------------------------------------ */
47
+ /* Core Execution Logic */
48
+ /* ------------------------------------------------------------ */
49
+
50
+ private async execute() {
51
+ let current = this.db.getSchemaVersion();
52
+
53
+ while (true) {
54
+ const next = this.findNext(current);
55
+ if (!next) break;
56
+
57
+ const fn = this.migrations.get(`${current}→${next}`)!;
58
+ await this.runMigration(current, next, fn);
59
+ current = next;
60
+ }
61
+ }
62
+
63
+ private findNext(current: string): string | null {
64
+ for (const key of this.migrations.keys()) {
65
+ const [from, to] = key.split("→");
66
+ if (from === current) return to;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /* ------------------------------------------------------------ */
72
+ /* Atomic Migration Execution */
73
+ /* ------------------------------------------------------------ */
74
+
75
+ private async runMigration(from: string, to: string, fn: MigrationFn) {
76
+ const current = this.db.getSchemaVersion();
77
+ if (current !== from) {
78
+ throw new Error(
79
+ `Schema mismatch: DB=${current}, expected=${from}`
80
+ );
81
+ }
82
+
83
+ const lockPath = path.join(this.db.basePath, LOCK_FILE);
84
+
85
+ if (fs.existsSync(lockPath)) {
86
+ throw new Error(
87
+ "Previous migration interrupted. Resolve manually before continuing."
88
+ );
89
+ }
90
+
91
+ this.acquireLock(lockPath);
92
+
93
+ try {
94
+ await this.db.transaction(async () => {
95
+ await fn(this.db);
96
+ this.writeHistory(from, to, fn);
97
+ this.db.setSchemaVersion(to);
98
+ });
99
+ } finally {
100
+ this.releaseLock(lockPath);
101
+ }
102
+ }
103
+
104
+ /* ------------------------------------------------------------ */
105
+ /* Locking */
106
+ /* ------------------------------------------------------------ */
107
+
108
+ private acquireLock(file: string) {
109
+ const token = crypto.randomBytes(16).toString("hex");
110
+
111
+ fs.writeFileSync(
112
+ file,
113
+ JSON.stringify({
114
+ pid: process.pid,
115
+ token,
116
+ time: Date.now(),
117
+ })
118
+ );
119
+ }
120
+
121
+ private releaseLock(file: string) {
122
+ if (fs.existsSync(file)) fs.unlinkSync(file);
123
+ }
124
+
125
+ /* ------------------------------------------------------------ */
126
+ /* Migration History */
127
+ /* ------------------------------------------------------------ */
128
+
129
+ private historyPath() {
130
+ return path.join(this.db.basePath, HISTORY_FILE);
131
+ }
132
+
133
+ private readHistory(): MigrationRecord[] {
134
+ if (!fs.existsSync(this.historyPath())) return [];
135
+ return JSON.parse(fs.readFileSync(this.historyPath(), "utf8"));
136
+ }
137
+
138
+ private writeHistory(from: string, to: string, fn: MigrationFn) {
139
+ const history = this.readHistory();
140
+
141
+ history.push({
142
+ from,
143
+ to,
144
+ checksum: this.hash(fn.toString()),
145
+ appliedAt: Date.now(),
146
+ });
147
+
148
+ fs.writeFileSync(this.historyPath(), JSON.stringify(history, null, 2));
149
+ }
150
+
151
+ private hash(data: string) {
152
+ return crypto.createHash("sha256").update(data).digest("hex");
153
+ }
154
+
155
+ /* ------------------------------------------------------------ */
156
+ /* Diagnostics */
157
+ /* ------------------------------------------------------------ */
158
+
159
+ getHistory(): MigrationRecord[] {
160
+ return this.readHistory();
161
+ }
162
+ }
@@ -0,0 +1,22 @@
1
+ import type { LioranDB } from "./database.js";
2
+
3
+ export type MigrationVersion = string;
4
+
5
+ export type MigrationFn = (db: LioranDB) => Promise<void>;
6
+
7
+ export interface Migration {
8
+ from: MigrationVersion;
9
+ to: MigrationVersion;
10
+ run: MigrationFn;
11
+ }
12
+
13
+ export interface MigrationRecord {
14
+ version: MigrationVersion;
15
+ appliedAt: number;
16
+ }
17
+
18
+ export interface MigrationMeta {
19
+ id?: string;
20
+ currentVersion: MigrationVersion;
21
+ history: MigrationRecord[];
22
+ }
package/src/ipc/index.ts CHANGED
@@ -72,6 +72,14 @@ class DBProxy {
72
72
  compact() {
73
73
  return dbQueue.exec("compact:db", { db: this.dbName });
74
74
  }
75
+
76
+ snapshot(path: string) {
77
+ return dbQueue.exec("snapshot", { path });
78
+ }
79
+
80
+ restore(path: string) {
81
+ return dbQueue.exec("restore", { path });
82
+ }
75
83
  }
76
84
 
77
85
  /* -------------------------------- MANAGER PROXY -------------------------------- */
@@ -85,6 +93,18 @@ class LioranManagerIPC {
85
93
  compactAll() {
86
94
  return dbQueue.exec("compact:all", {});
87
95
  }
96
+
97
+ snapshot(path: string) {
98
+ return dbQueue.exec("snapshot", { path });
99
+ }
100
+
101
+ restore(path: string) {
102
+ return dbQueue.exec("restore", { path });
103
+ }
104
+
105
+ shutdown() {
106
+ return dbQueue.shutdown();
107
+ }
88
108
  }
89
109
 
90
110
  export const manager = new LioranManagerIPC();
package/src/ipc/queue.ts CHANGED
@@ -10,7 +10,13 @@ export type IPCAction =
10
10
  | "compact:collection"
11
11
  | "compact:db"
12
12
  | "compact:all"
13
- | "shutdown";
13
+ | "shutdown"
14
+ | "open"
15
+ | "close"
16
+ | "minimize"
17
+ | "maximize"
18
+ | "restore"
19
+ | "snapshot";
14
20
 
15
21
  /* -------------------------------- DB QUEUE -------------------------------- */
16
22
 
@@ -39,6 +45,16 @@ export class DBQueue {
39
45
  return this.exec("compact:all", {});
40
46
  }
41
47
 
48
+ /* ----------------------------- SNAPSHOT API ----------------------------- */
49
+
50
+ snapshot(path: string) {
51
+ return this.exec("snapshot", { path });
52
+ }
53
+
54
+ restore(path: string) {
55
+ return this.exec("restore", { path });
56
+ }
57
+
42
58
  /* ------------------------------ SHUTDOWN ------------------------------ */
43
59
 
44
60
  async shutdown() {