@liorandb/core 1.0.11 → 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.
@@ -1,5 +1,3 @@
1
- import fs from "fs";
2
- import path from "path";
3
1
  import { ClassicLevel } from "classic-level";
4
2
  import { matchDocument, applyUpdate } from "./query.js";
5
3
  import { v4 as uuid } from "uuid";
@@ -14,19 +12,13 @@ export interface UpdateOptions {
14
12
  export class Collection<T = any> {
15
13
  dir: string;
16
14
  db: ClassicLevel<string, string>;
17
- private queue: Promise<any>;
18
- private walPath: string;
15
+ private queue: Promise<any> = Promise.resolve();
19
16
  private schema?: ZodSchema<T>;
20
- private walCounter = 0;
21
17
 
22
18
  constructor(dir: string, schema?: ZodSchema<T>) {
23
19
  this.dir = dir;
24
- this.db = new ClassicLevel(dir);
25
- this.queue = Promise.resolve();
26
- this.walPath = path.join(dir, "__wal.log");
20
+ this.db = new ClassicLevel(dir, { valueEncoding: "utf8" });
27
21
  this.schema = schema;
28
-
29
- this.recoverFromWAL().catch(console.error);
30
22
  }
31
23
 
32
24
  setSchema(schema: ZodSchema<T>) {
@@ -37,349 +29,187 @@ export class Collection<T = any> {
37
29
  return this.schema ? validateSchema(this.schema, doc) : doc;
38
30
  }
39
31
 
40
- /* ---------------- WAL ---------------- */
41
-
42
- private async writeWAL(entry: any) {
43
- await fs.promises.appendFile(
44
- this.walPath,
45
- JSON.stringify(entry) + "\n"
46
- );
47
- }
48
-
49
- private async clearWAL() {
50
- if (fs.existsSync(this.walPath)) {
51
- await fs.promises.unlink(this.walPath);
52
- }
53
- }
54
-
55
- private async maybeCheckpoint() {
56
- if (++this.walCounter >= 100) {
57
- this.walCounter = 0;
58
- await this.clearWAL();
59
- }
60
- }
61
-
62
- private async recoverFromWAL() {
63
- if (!fs.existsSync(this.walPath)) return;
64
-
65
- const lines = (await fs.promises.readFile(this.walPath, "utf8"))
66
- .split("\n")
67
- .filter(Boolean);
68
-
69
- for (const line of lines) {
70
- try {
71
- const { op, args } = JSON.parse(line);
72
- await this._exec(op, args, false);
73
- } catch (err) {
74
- console.error("WAL recovery failed:", err);
75
- }
76
- }
77
-
78
- await this.clearWAL();
79
- }
80
-
81
- /* ---------------- Queue ---------------- */
82
-
83
32
  private _enqueue<R>(task: () => Promise<R>): Promise<R> {
84
33
  this.queue = this.queue.then(task).catch(console.error);
85
34
  return this.queue;
86
35
  }
87
36
 
88
- /* ---------------- Core Executor ---------------- */
89
-
90
- private async _exec(op: string, args: any[], log = true) {
91
- if (log) await this.writeWAL({ op, args });
92
-
93
- let result: any;
37
+ async close(): Promise<void> {
38
+ try { await this.db.close(); } catch {}
39
+ }
94
40
 
41
+ async _exec(op: string, args: any[]) {
95
42
  switch (op) {
96
- case "insertOne":
97
- result = await this._insertOne(args[0]);
98
- break;
99
-
100
- case "insertMany":
101
- result = await this._insertMany(args[0]);
102
- break;
103
-
104
- case "find":
105
- result = await this._find(args[0]);
106
- break;
107
-
108
- case "findOne":
109
- result = await this._findOne(args[0]);
110
- break;
111
-
112
- case "updateOne":
113
- result = await this._updateOne(args[0], args[1], args[2]);
114
- break;
115
-
116
- case "updateMany":
117
- result = await this._updateMany(args[0], args[1]);
118
- break;
119
-
120
- case "deleteOne":
121
- result = await this._deleteOne(args[0]);
122
- break;
123
-
124
- case "deleteMany":
125
- result = await this._deleteMany(args[0]);
126
- break;
127
-
128
- case "countDocuments":
129
- result = await this._countDocuments(args[0]);
130
- break;
131
-
132
- default:
133
- throw new Error(`Unknown operation: ${op}`);
43
+ case "insertOne": return this._insertOne(args[0]);
44
+ case "insertMany": return this._insertMany(args[0]);
45
+ case "find": return this._find(args[0]);
46
+ case "findOne": return this._findOne(args[0]);
47
+ case "updateOne": return this._updateOne(args[0], args[1], args[2]);
48
+ case "updateMany": return this._updateMany(args[0], args[1]);
49
+ case "deleteOne": return this._deleteOne(args[0]);
50
+ case "deleteMany": return this._deleteMany(args[0]);
51
+ case "countDocuments": return this._countDocuments(args[0]);
52
+ default: throw new Error(`Unknown operation: ${op}`);
134
53
  }
135
-
136
- if (log) await this.maybeCheckpoint();
137
- return result;
138
- }
139
-
140
- /* ---------------- Public API ---------------- */
141
-
142
- async close(): Promise<void> {
143
- try {
144
- await this.db.close();
145
- } catch {}
146
54
  }
147
55
 
148
- insertOne(doc: T & { _id?: string }): Promise<T> {
56
+ insertOne(doc: T & { _id?: string }) {
149
57
  return this._enqueue(() => this._exec("insertOne", [doc]));
150
58
  }
151
59
 
152
- insertMany(docs: (T & { _id?: string })[] = []): Promise<T[]> {
60
+ insertMany(docs: (T & { _id?: string })[] = []) {
153
61
  return this._enqueue(() => this._exec("insertMany", [docs]));
154
62
  }
155
63
 
156
- find(query: any = {}): Promise<T[]> {
64
+ find(query: any = {}) {
157
65
  return this._enqueue(() => this._exec("find", [query]));
158
66
  }
159
67
 
160
- findOne(query: any = {}): Promise<T | null> {
68
+ findOne(query: any = {}) {
161
69
  return this._enqueue(() => this._exec("findOne", [query]));
162
70
  }
163
71
 
164
- updateOne(
165
- filter: any = {},
166
- update: any = {},
167
- options: UpdateOptions = { upsert: false }
168
- ): Promise<T | null> {
72
+ updateOne(filter: any, update: any, options: UpdateOptions = {}) {
169
73
  return this._enqueue(() =>
170
74
  this._exec("updateOne", [filter, update, options])
171
75
  );
172
76
  }
173
77
 
174
- updateMany(filter: any = {}, update: any = {}): Promise<T[]> {
78
+ updateMany(filter: any, update: any) {
175
79
  return this._enqueue(() =>
176
80
  this._exec("updateMany", [filter, update])
177
81
  );
178
82
  }
179
83
 
180
- deleteOne(filter: any = {}): Promise<boolean> {
181
- return this._enqueue(() => this._exec("deleteOne", [filter]));
84
+ deleteOne(filter: any) {
85
+ return this._enqueue(() =>
86
+ this._exec("deleteOne", [filter])
87
+ );
182
88
  }
183
89
 
184
- deleteMany(filter: any = {}): Promise<number> {
185
- return this._enqueue(() => this._exec("deleteMany", [filter]));
90
+ deleteMany(filter: any) {
91
+ return this._enqueue(() =>
92
+ this._exec("deleteMany", [filter])
93
+ );
186
94
  }
187
95
 
188
- countDocuments(filter: any = {}): Promise<number> {
96
+ countDocuments(filter: any = {}) {
189
97
  return this._enqueue(() =>
190
98
  this._exec("countDocuments", [filter])
191
99
  );
192
100
  }
193
101
 
194
- /* ---------------- Internal Ops ---------------- */
102
+ /* ---------------- Storage ---------------- */
195
103
 
196
- private async _insertOne(doc: T & { _id?: string }): Promise<T> {
104
+ private async _insertOne(doc: any) {
197
105
  const _id = doc._id ?? uuid();
198
106
  const final = this.validate({ _id, ...doc });
199
-
200
107
  await this.db.put(String(_id), encryptData(final));
201
108
  return final;
202
109
  }
203
110
 
204
- private async _insertMany(
205
- docs: (T & { _id?: string })[]
206
- ): Promise<T[]> {
207
- const ops: Array<{ type: "put"; key: string; value: string }> = [];
208
- const out: T[] = [];
111
+ private async _insertMany(docs: any[]) {
112
+ const batch: Array<{ type: "put"; key: string; value: string }> = [];
113
+ const out = [];
209
114
 
210
115
  for (const d of docs) {
211
116
  const _id = d._id ?? uuid();
212
117
  const final = this.validate({ _id, ...d });
213
-
214
- ops.push({
215
- type: "put",
216
- key: String(_id),
217
- value: encryptData(final)
218
- });
219
-
118
+ batch.push({ type: "put", key: String(_id), value: encryptData(final) });
220
119
  out.push(final);
221
120
  }
222
121
 
223
- await this.db.batch(ops);
122
+ await this.db.batch(batch);
224
123
  return out;
225
124
  }
226
125
 
227
- /* ---------- FAST PATH (_id INDEX) ---------- */
228
-
229
- private async _findOne(query: any): Promise<T | null> {
126
+ private async _findOne(query: any) {
230
127
  if (query?._id) {
231
128
  try {
232
129
  const enc = await this.db.get(String(query._id));
233
- if (!enc) return null;
234
- return decryptData(enc);
235
- } catch {
236
- return null;
237
- }
130
+ return enc ? decryptData(enc) : null;
131
+ } catch { return null; }
238
132
  }
239
133
 
240
134
  for await (const [, enc] of this.db.iterator()) {
241
- if (!enc) continue;
242
- const value = decryptData(enc);
243
- if (matchDocument(value, query)) return value;
135
+ const v = decryptData(enc);
136
+ if (matchDocument(v, query)) return v;
244
137
  }
245
138
 
246
139
  return null;
247
140
  }
248
141
 
249
- private async _updateOne(
250
- filter: any,
251
- update: any,
252
- options: UpdateOptions
253
- ): Promise<T | null> {
254
- if (filter?._id) {
255
- try {
256
- const enc = await this.db.get(String(filter._id));
257
- if (!enc) throw new Error("Not found");
258
- const value = decryptData(enc);
259
-
260
- const updated = applyUpdate(value, update);
261
- updated._id = value._id;
262
-
263
- const validated = this.validate(updated);
264
- await this.db.put(String(updated._id), encryptData(validated));
265
-
266
- return validated;
267
- } catch {
268
- if (!options?.upsert) return null;
269
- }
270
- }
271
-
142
+ private async _updateOne(filter: any, update: any, options: UpdateOptions) {
272
143
  for await (const [key, enc] of this.db.iterator()) {
273
- if (!enc) continue;
274
144
  const value = decryptData(enc);
275
-
276
145
  if (matchDocument(value, filter)) {
277
- const updated = applyUpdate(value, update);
146
+ const updated = this.validate(applyUpdate(value, update)) as any;
278
147
  updated._id = value._id;
279
-
280
- const validated = this.validate(updated);
281
- await this.db.put(key, encryptData(validated));
282
-
283
- return validated;
148
+ await this.db.put(key, encryptData(updated));
149
+ return updated;
284
150
  }
285
151
  }
286
152
 
287
153
  if (options?.upsert) {
288
- const doc = applyUpdate(filter, update);
289
- doc._id ??= uuid();
290
-
291
- const validated = this.validate(doc);
292
- await this.db.put(String(doc._id), encryptData(validated));
293
-
294
- return validated;
154
+ const doc = this.validate({ _id: uuid(), ...applyUpdate({}, update) }) as any;
155
+ await this.db.put(String(doc._id), encryptData(doc));
156
+ return doc;
295
157
  }
296
158
 
297
159
  return null;
298
160
  }
299
161
 
300
- private async _deleteOne(filter: any): Promise<boolean> {
301
- if (filter?._id) {
302
- try {
303
- await this.db.del(String(filter._id));
304
- return true;
305
- } catch {
306
- return false;
307
- }
308
- }
162
+ private async _updateMany(filter: any, update: any) {
163
+ const out = [];
309
164
 
310
165
  for await (const [key, enc] of this.db.iterator()) {
311
- if (!enc) continue;
312
166
  const value = decryptData(enc);
313
-
314
167
  if (matchDocument(value, filter)) {
315
- await this.db.del(key);
316
- return true;
168
+ const updated = this.validate(applyUpdate(value, update)) as any;
169
+ updated._id = value._id;
170
+ await this.db.put(key, encryptData(updated));
171
+ out.push(updated);
317
172
  }
318
173
  }
319
174
 
320
- return false;
175
+ return out;
321
176
  }
322
177
 
323
- /* ---------- NORMAL OPS ---------- */
324
-
325
- private async _updateMany(filter: any, update: any): Promise<T[]> {
326
- const updated: T[] = [];
327
-
328
- for await (const [key, enc] of this.db.iterator()) {
329
- if (!enc) continue;
330
- const value = decryptData(enc);
331
-
332
- if (matchDocument(value, filter)) {
333
- const doc = applyUpdate(value, update);
334
- doc._id = value._id;
335
-
336
- const validated = this.validate(doc);
337
- await this.db.put(key, encryptData(validated));
338
-
339
- updated.push(validated);
340
- }
178
+ private async _find(query: any) {
179
+ const out = [];
180
+ for await (const [, enc] of this.db.iterator()) {
181
+ const v = decryptData(enc);
182
+ if (matchDocument(v, query)) out.push(v);
341
183
  }
342
-
343
- return updated;
184
+ return out;
344
185
  }
345
186
 
346
- private async _find(query: any): Promise<T[]> {
347
- const out: T[] = [];
348
-
349
- for await (const [, enc] of this.db.iterator()) {
350
- if (!enc) continue;
351
- const value = decryptData(enc);
352
- if (matchDocument(value, query)) out.push(value);
187
+ private async _deleteOne(filter: any) {
188
+ for await (const [key, enc] of this.db.iterator()) {
189
+ if (matchDocument(decryptData(enc), filter)) {
190
+ await this.db.del(key);
191
+ return true;
192
+ }
353
193
  }
354
-
355
- return out;
194
+ return false;
356
195
  }
357
196
 
358
- private async _deleteMany(filter: any): Promise<number> {
197
+ private async _deleteMany(filter: any) {
359
198
  let count = 0;
360
-
361
199
  for await (const [key, enc] of this.db.iterator()) {
362
- if (!enc) continue;
363
- const value = decryptData(enc);
364
-
365
- if (matchDocument(value, filter)) {
200
+ if (matchDocument(decryptData(enc), filter)) {
366
201
  await this.db.del(key);
367
202
  count++;
368
203
  }
369
204
  }
370
-
371
205
  return count;
372
206
  }
373
207
 
374
- private async _countDocuments(filter: any): Promise<number> {
208
+ private async _countDocuments(filter: any) {
375
209
  let c = 0;
376
-
377
210
  for await (const [, enc] of this.db.iterator()) {
378
- if (!enc) continue;
379
- const value = decryptData(enc);
380
- if (matchDocument(value, filter)) c++;
211
+ if (matchDocument(decryptData(enc), filter)) c++;
381
212
  }
382
-
383
213
  return c;
384
214
  }
385
215
  }
@@ -4,133 +4,150 @@ import { Collection } from "./collection.js";
4
4
  import type { LioranManager } from "../LioranManager.js";
5
5
  import type { ZodSchema } from "zod";
6
6
 
7
+ type TXOp = { tx: number; col: string; op: string; args: any[] };
8
+ type TXCommit = { tx: number; commit: true };
9
+ type TXApplied = { tx: number; applied: true };
10
+ type WALEntry = TXOp | TXCommit | TXApplied;
11
+
12
+ class DBTransactionContext {
13
+ private ops: TXOp[] = [];
14
+
15
+ constructor(
16
+ private db: LioranDB,
17
+ public readonly txId: number
18
+ ) {}
19
+
20
+ collection(name: string) {
21
+ return new Proxy({}, {
22
+ get: (_, prop: string) => {
23
+ return (...args: any[]) => {
24
+ this.ops.push({
25
+ tx: this.txId,
26
+ col: name,
27
+ op: prop,
28
+ args
29
+ });
30
+ };
31
+ }
32
+ });
33
+ }
34
+
35
+ async commit() {
36
+ await this.db.writeWAL(this.ops);
37
+ await this.db.writeWAL([{ tx: this.txId, commit: true }]);
38
+ await this.db.applyTransaction(this.ops);
39
+ await this.db.writeWAL([{ tx: this.txId, applied: true }]);
40
+ await this.db.clearWAL();
41
+ }
42
+ }
43
+
7
44
  export class LioranDB {
8
45
  basePath: string;
9
46
  dbName: string;
10
47
  manager: LioranManager;
11
48
  collections: Map<string, Collection>;
49
+ private walPath: string;
50
+
51
+ private static TX_SEQ = 0;
12
52
 
13
53
  constructor(basePath: string, dbName: string, manager: LioranManager) {
14
54
  this.basePath = basePath;
15
55
  this.dbName = dbName;
16
56
  this.manager = manager;
17
57
  this.collections = new Map();
58
+ this.walPath = path.join(basePath, "__tx_wal.log");
18
59
 
19
- if (!fs.existsSync(basePath)) {
20
- fs.mkdirSync(basePath, { recursive: true });
21
- }
22
- }
23
-
24
- /* -------------------------------- COLLECTION -------------------------------- */
60
+ fs.mkdirSync(basePath, { recursive: true });
25
61
 
26
- collection<T = any>(name: string, schema?: ZodSchema<T>): Collection<T> {
27
- if (this.collections.has(name)) {
28
- const col = this.collections.get(name)!;
29
- if (schema) col.setSchema(schema);
30
- return col as Collection<T>;
31
- }
32
-
33
- const colPath = path.join(this.basePath, name);
62
+ this.recoverFromWAL().catch(console.error);
63
+ }
34
64
 
35
- if (!fs.existsSync(colPath)) {
36
- fs.mkdirSync(colPath, { recursive: true });
65
+ async writeWAL(entries: WALEntry[]) {
66
+ const fd = await fs.promises.open(this.walPath, "a");
67
+ for (const e of entries) {
68
+ await fd.write(JSON.stringify(e) + "\n");
37
69
  }
38
-
39
- const col = new Collection<T>(colPath, schema);
40
- this.collections.set(name, col);
41
-
42
- return col;
70
+ await fd.sync();
71
+ await fd.close();
43
72
  }
44
73
 
45
- async createCollection<T = any>(
46
- name: string,
47
- schema?: ZodSchema<T>
48
- ): Promise<Collection<T>> {
49
- const colPath = path.join(this.basePath, name);
50
-
51
- if (fs.existsSync(colPath)) {
52
- throw new Error("Collection already exists");
53
- }
74
+ async clearWAL() {
75
+ try { await fs.promises.unlink(this.walPath); } catch {}
76
+ }
54
77
 
55
- await fs.promises.mkdir(colPath, { recursive: true });
78
+ private async recoverFromWAL() {
79
+ if (!fs.existsSync(this.walPath)) return;
56
80
 
57
- const col = new Collection<T>(colPath, schema);
58
- this.collections.set(name, col);
81
+ const raw = await fs.promises.readFile(this.walPath, "utf8");
59
82
 
60
- return col;
61
- }
83
+ const committed = new Set<number>();
84
+ const applied = new Set<number>();
85
+ const ops = new Map<number, TXOp[]>();
62
86
 
63
- async deleteCollection(name: string): Promise<boolean> {
64
- const colPath = path.join(this.basePath, name);
87
+ for (const line of raw.split("\n")) {
88
+ if (!line.trim()) continue;
65
89
 
66
- if (!fs.existsSync(colPath)) {
67
- throw new Error("Collection does not exist");
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;
102
+ }
68
103
  }
69
104
 
70
- if (this.collections.has(name)) {
71
- await this.collections.get(name)!.close();
72
- this.collections.delete(name);
105
+ for (const tx of committed) {
106
+ if (applied.has(tx)) continue;
107
+
108
+ const txOps = ops.get(tx);
109
+ if (txOps) await this.applyTransaction(txOps);
73
110
  }
74
111
 
75
- await fs.promises.rm(colPath, { recursive: true, force: true });
76
- return true;
112
+ await this.clearWAL();
77
113
  }
78
114
 
79
- async renameCollection(oldName: string, newName: string): Promise<boolean> {
80
- const oldPath = path.join(this.basePath, oldName);
81
- const newPath = path.join(this.basePath, newName);
82
-
83
- if (!fs.existsSync(oldPath)) {
84
- throw new Error("Collection does not exist");
85
- }
86
-
87
- if (fs.existsSync(newPath)) {
88
- throw new Error("New collection name already exists");
115
+ async applyTransaction(ops: TXOp[]) {
116
+ for (const { col, op, args } of ops) {
117
+ const collection = this.collection(col);
118
+ await (collection as any)._exec(op, args);
89
119
  }
120
+ }
90
121
 
91
- if (this.collections.has(oldName)) {
92
- await this.collections.get(oldName)!.close();
93
- this.collections.delete(oldName);
122
+ collection<T = any>(name: string, schema?: ZodSchema<T>): Collection<T> {
123
+ if (this.collections.has(name)) {
124
+ const col = this.collections.get(name)!;
125
+ if (schema) col.setSchema(schema);
126
+ return col as Collection<T>;
94
127
  }
95
128
 
96
- await fs.promises.rename(oldPath, newPath);
97
-
98
- const col = new Collection(newPath);
99
- this.collections.set(newName, col);
129
+ const colPath = path.join(this.basePath, name);
130
+ fs.mkdirSync(colPath, { recursive: true });
100
131
 
101
- return true;
132
+ const col = new Collection<T>(colPath, schema);
133
+ this.collections.set(name, col);
134
+ return col;
102
135
  }
103
136
 
104
- async dropCollection(name: string): Promise<boolean> {
105
- return this.deleteCollection(name);
106
- }
137
+ async transaction<T>(fn: (tx: DBTransactionContext) => Promise<T>): Promise<T> {
138
+ const txId = ++LioranDB.TX_SEQ;
139
+ const tx = new DBTransactionContext(this, txId);
107
140
 
108
- async listCollections(): Promise<string[]> {
109
- const dirs = await fs.promises.readdir(this.basePath, {
110
- withFileTypes: true
111
- });
141
+ const result = await fn(tx);
142
+ await tx.commit();
112
143
 
113
- return dirs.filter(d => d.isDirectory()).map(d => d.name);
144
+ return result;
114
145
  }
115
146
 
116
- /* -------------------------------- LIFECYCLE -------------------------------- */
117
-
118
147
  async close(): Promise<void> {
119
148
  for (const col of this.collections.values()) {
120
- try {
121
- await col.close();
122
- } catch {}
149
+ try { await col.close(); } catch {}
123
150
  }
124
151
  this.collections.clear();
125
152
  }
126
-
127
- /* -------------------------------- DEBUG -------------------------------- */
128
-
129
- getStats() {
130
- return {
131
- dbName: this.dbName,
132
- basePath: this.basePath,
133
- collections: [...this.collections.keys()]
134
- };
135
- }
136
- }
153
+ }