@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.
- package/dist/index.d.ts +13 -6
- package/dist/index.js +289 -71
- package/package.json +4 -1
- package/src/LioranManager.ts +67 -8
- package/src/core/database.ts +82 -39
- package/src/core/migration.store.ts +40 -0
- package/src/core/migration.ts +162 -0
- package/src/core/migration.types.ts +22 -0
- package/src/ipc/index.ts +20 -0
- package/src/ipc/queue.ts +17 -1
- package/src/ipc/server.ts +21 -3
- package/src/utils/tar.ts +89 -0
package/src/LioranManager.ts
CHANGED
|
@@ -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", {
|
package/src/core/database.ts
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
/*
|
|
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() {
|