@liorandb/core 1.0.13 → 1.0.14

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,9 +1,10 @@
1
1
  import { ClassicLevel } from "classic-level";
2
- import { matchDocument, applyUpdate } from "./query.js";
2
+ import { matchDocument, applyUpdate, extractIndexQuery } from "./query.js";
3
3
  import { v4 as uuid } from "uuid";
4
4
  import { encryptData, decryptData } from "../utils/encryption.js";
5
5
  import type { ZodSchema } from "zod";
6
6
  import { validateSchema } from "../utils/schema.js";
7
+ import { Index } from "./index.js";
7
8
 
8
9
  export interface UpdateOptions {
9
10
  upsert?: boolean;
@@ -14,6 +15,7 @@ export class Collection<T = any> {
14
15
  db: ClassicLevel<string, string>;
15
16
  private queue: Promise<any> = Promise.resolve();
16
17
  private schema?: ZodSchema<T>;
18
+ private indexes = new Map<string, Index>();
17
19
 
18
20
  constructor(dir: string, schema?: ZodSchema<T>) {
19
21
  this.dir = dir;
@@ -21,6 +23,18 @@ export class Collection<T = any> {
21
23
  this.schema = schema;
22
24
  }
23
25
 
26
+ /* ---------------------- INDEX MANAGEMENT ---------------------- */
27
+
28
+ registerIndex(index: Index) {
29
+ this.indexes.set(index.field, index);
30
+ }
31
+
32
+ getIndex(field: string) {
33
+ return this.indexes.get(field);
34
+ }
35
+
36
+ /* -------------------------- CORE -------------------------- */
37
+
24
38
  setSchema(schema: ZodSchema<T>) {
25
39
  this.schema = schema;
26
40
  }
@@ -35,6 +49,9 @@ export class Collection<T = any> {
35
49
  }
36
50
 
37
51
  async close(): Promise<void> {
52
+ for (const idx of this.indexes.values()) {
53
+ try { await idx.close(); } catch {}
54
+ }
38
55
  try { await this.db.close(); } catch {}
39
56
  }
40
57
 
@@ -53,6 +70,8 @@ export class Collection<T = any> {
53
70
  }
54
71
  }
55
72
 
73
+ /* --------------------- PUBLIC API --------------------- */
74
+
56
75
  insertOne(doc: T & { _id?: string }) {
57
76
  return this._enqueue(() => this._exec("insertOne", [doc]));
58
77
  }
@@ -99,12 +118,23 @@ export class Collection<T = any> {
99
118
  );
100
119
  }
101
120
 
121
+ /* ------------------ INDEX HOOK ------------------ */
122
+
123
+ private async _updateIndexes(oldDoc: any, newDoc: any) {
124
+ for (const index of this.indexes.values()) {
125
+ await index.update(oldDoc, newDoc);
126
+ }
127
+ }
128
+
102
129
  /* ---------------- Storage ---------------- */
103
130
 
104
131
  private async _insertOne(doc: any) {
105
132
  const _id = doc._id ?? uuid();
106
133
  const final = this.validate({ _id, ...doc });
134
+
107
135
  await this.db.put(String(_id), encryptData(final));
136
+ await this._updateIndexes(null, final);
137
+
108
138
  return final;
109
139
  }
110
140
 
@@ -115,11 +145,22 @@ export class Collection<T = any> {
115
145
  for (const d of docs) {
116
146
  const _id = d._id ?? uuid();
117
147
  const final = this.validate({ _id, ...d });
118
- batch.push({ type: "put", key: String(_id), value: encryptData(final) });
148
+
149
+ batch.push({
150
+ type: "put",
151
+ key: String(_id),
152
+ value: encryptData(final)
153
+ });
154
+
119
155
  out.push(final);
120
156
  }
121
157
 
122
158
  await this.db.batch(batch);
159
+
160
+ for (const doc of out) {
161
+ await this._updateIndexes(null, doc);
162
+ }
163
+
123
164
  return out;
124
165
  }
125
166
 
@@ -131,6 +172,19 @@ export class Collection<T = any> {
131
172
  } catch { return null; }
132
173
  }
133
174
 
175
+ const iq = extractIndexQuery(query);
176
+ if (iq && this.indexes.has(iq.field)) {
177
+ const ids = await this.indexes.get(iq.field)!.find(iq.value);
178
+ if (!ids.length) return null;
179
+
180
+ try {
181
+ const enc = await this.db.get(ids[0]);
182
+ return enc ? decryptData(enc) : null;
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
134
188
  for await (const [, enc] of this.db.iterator()) {
135
189
  const v = decryptData(enc);
136
190
  if (matchDocument(v, query)) return v;
@@ -142,17 +196,27 @@ export class Collection<T = any> {
142
196
  private async _updateOne(filter: any, update: any, options: UpdateOptions) {
143
197
  for await (const [key, enc] of this.db.iterator()) {
144
198
  const value = decryptData(enc);
199
+
145
200
  if (matchDocument(value, filter)) {
146
201
  const updated = this.validate(applyUpdate(value, update)) as any;
147
202
  updated._id = value._id;
203
+
148
204
  await this.db.put(key, encryptData(updated));
205
+ await this._updateIndexes(value, updated);
206
+
149
207
  return updated;
150
208
  }
151
209
  }
152
210
 
153
211
  if (options?.upsert) {
154
- const doc = this.validate({ _id: uuid(), ...applyUpdate({}, update) }) as any;
212
+ const doc = this.validate({
213
+ _id: uuid(),
214
+ ...applyUpdate({}, update)
215
+ }) as any;
216
+
155
217
  await this.db.put(String(doc._id), encryptData(doc));
218
+ await this._updateIndexes(null, doc);
219
+
156
220
  return doc;
157
221
  }
158
222
 
@@ -164,10 +228,14 @@ export class Collection<T = any> {
164
228
 
165
229
  for await (const [key, enc] of this.db.iterator()) {
166
230
  const value = decryptData(enc);
231
+
167
232
  if (matchDocument(value, filter)) {
168
233
  const updated = this.validate(applyUpdate(value, update)) as any;
169
234
  updated._id = value._id;
235
+
170
236
  await this.db.put(key, encryptData(updated));
237
+ await this._updateIndexes(value, updated);
238
+
171
239
  out.push(updated);
172
240
  }
173
241
  }
@@ -176,18 +244,38 @@ export class Collection<T = any> {
176
244
  }
177
245
 
178
246
  private async _find(query: any) {
247
+ const iq = extractIndexQuery(query);
248
+
249
+ if (iq && this.indexes.has(iq.field)) {
250
+ const ids = await this.indexes.get(iq.field)!.find(iq.value);
251
+ const out = [];
252
+
253
+ for (const id of ids) {
254
+ try {
255
+ const enc = await this.db.get(id);
256
+ if (enc) out.push(decryptData(enc));
257
+ } catch {}
258
+ }
259
+
260
+ return out;
261
+ }
262
+
179
263
  const out = [];
180
264
  for await (const [, enc] of this.db.iterator()) {
181
265
  const v = decryptData(enc);
182
266
  if (matchDocument(v, query)) out.push(v);
183
267
  }
268
+
184
269
  return out;
185
270
  }
186
271
 
187
272
  private async _deleteOne(filter: any) {
188
273
  for await (const [key, enc] of this.db.iterator()) {
189
- if (matchDocument(decryptData(enc), filter)) {
274
+ const value = decryptData(enc);
275
+
276
+ if (matchDocument(value, filter)) {
190
277
  await this.db.del(key);
278
+ await this._updateIndexes(value, null);
191
279
  return true;
192
280
  }
193
281
  }
@@ -196,20 +284,32 @@ export class Collection<T = any> {
196
284
 
197
285
  private async _deleteMany(filter: any) {
198
286
  let count = 0;
287
+
199
288
  for await (const [key, enc] of this.db.iterator()) {
200
- if (matchDocument(decryptData(enc), filter)) {
289
+ const value = decryptData(enc);
290
+
291
+ if (matchDocument(value, filter)) {
201
292
  await this.db.del(key);
293
+ await this._updateIndexes(value, null);
202
294
  count++;
203
295
  }
204
296
  }
297
+
205
298
  return count;
206
299
  }
207
300
 
208
301
  private async _countDocuments(filter: any) {
209
302
  let c = 0;
303
+
304
+ const iq = extractIndexQuery(filter);
305
+ if (iq && this.indexes.has(iq.field)) {
306
+ return (await this.indexes.get(iq.field)!.find(iq.value)).length;
307
+ }
308
+
210
309
  for await (const [, enc] of this.db.iterator()) {
211
310
  if (matchDocument(decryptData(enc), filter)) c++;
212
311
  }
312
+
213
313
  return c;
214
314
  }
215
315
  }
@@ -1,14 +1,32 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
3
  import { Collection } from "./collection.js";
4
+ import { Index, IndexOptions } from "./index.js";
4
5
  import type { LioranManager } from "../LioranManager.js";
5
6
  import type { ZodSchema } from "zod";
6
7
 
8
+ /* ----------------------------- TYPES ----------------------------- */
9
+
7
10
  type TXOp = { tx: number; col: string; op: string; args: any[] };
8
11
  type TXCommit = { tx: number; commit: true };
9
12
  type TXApplied = { tx: number; applied: true };
10
13
  type WALEntry = TXOp | TXCommit | TXApplied;
11
14
 
15
+ type IndexMeta = {
16
+ field: string;
17
+ options: IndexOptions;
18
+ };
19
+
20
+ type DBMeta = {
21
+ version: number;
22
+ indexes: Record<string, IndexMeta[]>;
23
+ };
24
+
25
+ const META_FILE = "__db_meta.json";
26
+ const META_VERSION = 1;
27
+
28
+ /* ---------------------- TRANSACTION CONTEXT ---------------------- */
29
+
12
30
  class DBTransactionContext {
13
31
  private ops: TXOp[] = [];
14
32
 
@@ -41,12 +59,16 @@ class DBTransactionContext {
41
59
  }
42
60
  }
43
61
 
62
+ /* ----------------------------- DATABASE ----------------------------- */
63
+
44
64
  export class LioranDB {
45
65
  basePath: string;
46
66
  dbName: string;
47
67
  manager: LioranManager;
48
68
  collections: Map<string, Collection>;
49
69
  private walPath: string;
70
+ private metaPath: string;
71
+ private meta!: DBMeta;
50
72
 
51
73
  private static TX_SEQ = 0;
52
74
 
@@ -56,12 +78,36 @@ export class LioranDB {
56
78
  this.manager = manager;
57
79
  this.collections = new Map();
58
80
  this.walPath = path.join(basePath, "__tx_wal.log");
81
+ this.metaPath = path.join(basePath, META_FILE);
59
82
 
60
83
  fs.mkdirSync(basePath, { recursive: true });
61
84
 
85
+ this.loadMeta();
62
86
  this.recoverFromWAL().catch(console.error);
63
87
  }
64
88
 
89
+ /* ------------------------- META ------------------------- */
90
+
91
+ private loadMeta() {
92
+ if (!fs.existsSync(this.metaPath)) {
93
+ this.meta = { version: META_VERSION, indexes: {} };
94
+ this.saveMeta();
95
+ return;
96
+ }
97
+
98
+ try {
99
+ this.meta = JSON.parse(fs.readFileSync(this.metaPath, "utf8"));
100
+ } catch {
101
+ throw new Error("Database metadata corrupted");
102
+ }
103
+ }
104
+
105
+ private saveMeta() {
106
+ fs.writeFileSync(this.metaPath, JSON.stringify(this.meta, null, 2));
107
+ }
108
+
109
+ /* ------------------------- WAL ------------------------- */
110
+
65
111
  async writeWAL(entries: WALEntry[]) {
66
112
  const fd = await fs.promises.open(this.walPath, "a");
67
113
  for (const e of entries) {
@@ -97,14 +143,12 @@ export class LioranDB {
97
143
  ops.get(entry.tx)!.push(entry);
98
144
  }
99
145
  } catch {
100
- // Ignore corrupted WAL tail
101
146
  break;
102
147
  }
103
148
  }
104
149
 
105
150
  for (const tx of committed) {
106
151
  if (applied.has(tx)) continue;
107
-
108
152
  const txOps = ops.get(tx);
109
153
  if (txOps) await this.applyTransaction(txOps);
110
154
  }
@@ -119,6 +163,8 @@ export class LioranDB {
119
163
  }
120
164
  }
121
165
 
166
+ /* ------------------------- COLLECTION ------------------------- */
167
+
122
168
  collection<T = any>(name: string, schema?: ZodSchema<T>): Collection<T> {
123
169
  if (this.collections.has(name)) {
124
170
  const col = this.collections.get(name)!;
@@ -130,10 +176,49 @@ export class LioranDB {
130
176
  fs.mkdirSync(colPath, { recursive: true });
131
177
 
132
178
  const col = new Collection<T>(colPath, schema);
179
+
180
+ // 🔥 Auto-load indexes for this collection
181
+ const metas = this.meta.indexes[name] ?? [];
182
+ for (const m of metas) {
183
+ col.registerIndex(new Index(colPath, m.field, m.options));
184
+ }
185
+
133
186
  this.collections.set(name, col);
134
187
  return col;
135
188
  }
136
189
 
190
+ /* ------------------------- INDEX API ------------------------- */
191
+
192
+ async createIndex(
193
+ collection: string,
194
+ field: string,
195
+ options: IndexOptions = {}
196
+ ) {
197
+ const col = this.collection(collection);
198
+
199
+ const existing = this.meta.indexes[collection]?.find(i => i.field === field);
200
+ if (existing) return;
201
+
202
+ const index = new Index(col.dir, field, options);
203
+
204
+ // 🔁 Build index from existing documents
205
+ for await (const [, enc] of col.db.iterator()) {
206
+ const doc = JSON.parse(Buffer.from(enc, "base64").subarray(32).toString("utf8"));
207
+ await index.insert(doc);
208
+ }
209
+
210
+ col.registerIndex(index);
211
+
212
+ if (!this.meta.indexes[collection]) {
213
+ this.meta.indexes[collection] = [];
214
+ }
215
+
216
+ this.meta.indexes[collection].push({ field, options });
217
+ this.saveMeta();
218
+ }
219
+
220
+ /* ------------------------- TX API ------------------------- */
221
+
137
222
  async transaction<T>(fn: (tx: DBTransactionContext) => Promise<T>): Promise<T> {
138
223
  const txId = ++LioranDB.TX_SEQ;
139
224
  const tx = new DBTransactionContext(this, txId);
@@ -144,6 +229,8 @@ export class LioranDB {
144
229
  return result;
145
230
  }
146
231
 
232
+ /* ------------------------- SHUTDOWN ------------------------- */
233
+
147
234
  async close(): Promise<void> {
148
235
  for (const col of this.collections.values()) {
149
236
  try { await col.close(); } catch {}
@@ -0,0 +1,140 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import { ClassicLevel } from "classic-level";
4
+
5
+ /* ----------------------------- TYPES ----------------------------- */
6
+
7
+ export interface IndexOptions {
8
+ unique?: boolean;
9
+ }
10
+
11
+ type IndexValue = string | string[];
12
+
13
+ /* ----------------------------- INDEX ----------------------------- */
14
+
15
+ export class Index {
16
+ readonly field: string;
17
+ readonly unique: boolean;
18
+ readonly dir: string;
19
+ readonly db: ClassicLevel<string, string>;
20
+
21
+ constructor(baseDir: string, field: string, options: IndexOptions = {}) {
22
+ this.field = field;
23
+ this.unique = !!options.unique;
24
+
25
+ this.dir = path.join(baseDir, "__indexes", field + ".idx");
26
+ fs.mkdirSync(this.dir, { recursive: true });
27
+
28
+ this.db = new ClassicLevel(this.dir, { valueEncoding: "utf8" });
29
+ }
30
+
31
+ /* ------------------------- INTERNAL ------------------------- */
32
+
33
+ private normalizeKey(value: any): string {
34
+ if (value === null || value === undefined) return "__null__";
35
+
36
+ if (typeof value === "object") {
37
+ return JSON.stringify(value);
38
+ }
39
+
40
+ return String(value);
41
+ }
42
+
43
+ private async getRaw(key: string): Promise<IndexValue | null> {
44
+ try {
45
+ const v = await this.db.get(key);
46
+ if (v === undefined) return null;
47
+ return JSON.parse(v);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ private async setRaw(key: string, value: IndexValue) {
54
+ await this.db.put(key, JSON.stringify(value));
55
+ }
56
+
57
+ private async delRaw(key: string) {
58
+ try { await this.db.del(key); } catch {}
59
+ }
60
+
61
+ /* --------------------------- API --------------------------- */
62
+
63
+ async insert(doc: any) {
64
+ const val = doc[this.field];
65
+ if (val === undefined) return;
66
+
67
+ const key = this.normalizeKey(val);
68
+
69
+ if (this.unique) {
70
+ const existing = await this.getRaw(key);
71
+ if (existing) {
72
+ throw new Error(
73
+ `Unique index violation on "${this.field}" = ${val}`
74
+ );
75
+ }
76
+
77
+ await this.setRaw(key, doc._id);
78
+ return;
79
+ }
80
+
81
+ const arr = (await this.getRaw(key)) as string[] | null;
82
+
83
+ if (!arr) {
84
+ await this.setRaw(key, [doc._id]);
85
+ } else {
86
+ if (!arr.includes(doc._id)) {
87
+ arr.push(doc._id);
88
+ await this.setRaw(key, arr);
89
+ }
90
+ }
91
+ }
92
+
93
+ async delete(doc: any) {
94
+ const val = doc[this.field];
95
+ if (val === undefined) return;
96
+
97
+ const key = this.normalizeKey(val);
98
+
99
+ if (this.unique) {
100
+ await this.delRaw(key);
101
+ return;
102
+ }
103
+
104
+ const arr = (await this.getRaw(key)) as string[] | null;
105
+ if (!arr) return;
106
+
107
+ const next = arr.filter(id => id !== doc._id);
108
+
109
+ if (next.length === 0) {
110
+ await this.delRaw(key);
111
+ } else {
112
+ await this.setRaw(key, next);
113
+ }
114
+ }
115
+
116
+ async update(oldDoc: any, newDoc: any) {
117
+ const oldVal = oldDoc?.[this.field];
118
+ const newVal = newDoc?.[this.field];
119
+
120
+ if (oldVal === newVal) return;
121
+
122
+ if (oldDoc) await this.delete(oldDoc);
123
+ if (newDoc) await this.insert(newDoc);
124
+ }
125
+
126
+ async find(value: any): Promise<string[]> {
127
+ const key = this.normalizeKey(value);
128
+
129
+ const raw = await this.getRaw(key);
130
+ if (!raw) return [];
131
+
132
+ if (this.unique) return [raw as string];
133
+
134
+ return raw as string[];
135
+ }
136
+
137
+ async close() {
138
+ try { await this.db.close(); } catch {}
139
+ }
140
+ }
package/src/core/query.ts CHANGED
@@ -2,6 +2,8 @@ function getByPath(obj: any, path: string): any {
2
2
  return path.split(".").reduce((o, p) => (o ? o[p] : undefined), obj);
3
3
  }
4
4
 
5
+ /* ----------------------------- MATCH ENGINE ----------------------------- */
6
+
5
7
  export function matchDocument(doc: any, query: any): boolean {
6
8
  for (const key of Object.keys(query)) {
7
9
  const cond = query[key];
@@ -16,8 +18,7 @@ export function matchDocument(doc: any, query: any): boolean {
16
18
  if (op === "$lte" && !(val <= v)) return false;
17
19
  if (op === "$ne" && val === v) return false;
18
20
  if (op === "$eq" && val !== v) return false;
19
- if (op === "$in" && (!Array.isArray(v) || !v.includes(val)))
20
- return false;
21
+ if (op === "$in" && (!Array.isArray(v) || !v.includes(val))) return false;
21
22
  }
22
23
  } else {
23
24
  if (val !== cond) return false;
@@ -26,6 +27,8 @@ export function matchDocument(doc: any, query: any): boolean {
26
27
  return true;
27
28
  }
28
29
 
30
+ /* ------------------------------ UPDATE ENGINE ------------------------------ */
31
+
29
32
  export function applyUpdate(oldDoc: any, update: any): any {
30
33
  const doc = structuredClone(oldDoc);
31
34
 
@@ -61,3 +64,103 @@ export function applyUpdate(oldDoc: any, update: any): any {
61
64
 
62
65
  return doc;
63
66
  }
67
+
68
+ /* ------------------------------ INDEX ROUTER ------------------------------ */
69
+
70
+ export function extractIndexQuery(query: any): { field: string; value: any } | null {
71
+ for (const key of Object.keys(query)) {
72
+ const cond = query[key];
73
+
74
+ // Skip _id as it's handled separately
75
+ if (key === "_id") continue;
76
+
77
+ // Simple equality: { field: value }
78
+ if (!cond || typeof cond !== "object" || Array.isArray(cond)) {
79
+ return { field: key, value: cond };
80
+ }
81
+
82
+ // $eq operator: { field: { $eq: value } }
83
+ if ("$eq" in cond) {
84
+ return { field: key, value: cond.$eq };
85
+ }
86
+ }
87
+
88
+ return null;
89
+ }
90
+
91
+ export interface IndexProvider {
92
+ findByIndex(
93
+ field: string,
94
+ value: any
95
+ ): Promise<Set<string> | null>;
96
+
97
+ rangeByIndex?(
98
+ field: string,
99
+ cond: any
100
+ ): Promise<Set<string> | null>;
101
+ }
102
+
103
+ /**
104
+ * Selects best possible index from query.
105
+ */
106
+ export function selectIndex(query: any, indexes: Set<string>) {
107
+ for (const key of Object.keys(query)) {
108
+ if (!indexes.has(key)) continue;
109
+
110
+ const cond = query[key];
111
+
112
+ if (cond && typeof cond === "object" && !Array.isArray(cond)) {
113
+ return { field: key, cond };
114
+ }
115
+
116
+ return { field: key, cond: { $eq: cond } };
117
+ }
118
+
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Executes indexed query or fallback full scan.
124
+ */
125
+ export async function runIndexedQuery(
126
+ query: any,
127
+ indexProvider: IndexProvider,
128
+ allDocIds: () => Promise<string[]>
129
+ ): Promise<Set<string>> {
130
+ const indexes = (indexProvider as any).indexes as Set<string>;
131
+
132
+ if (!indexes?.size) {
133
+ return new Set(await allDocIds());
134
+ }
135
+
136
+ const sel = selectIndex(query, indexes);
137
+ if (!sel) {
138
+ return new Set(await allDocIds());
139
+ }
140
+
141
+ const { field, cond } = sel;
142
+
143
+ if ("$eq" in cond) {
144
+ return (await indexProvider.findByIndex(field, cond.$eq)) ??
145
+ new Set(await allDocIds());
146
+ }
147
+
148
+ if ("$in" in cond) {
149
+ const out = new Set<string>();
150
+ for (const v of cond.$in) {
151
+ const r = await indexProvider.findByIndex(field, v);
152
+ if (r) for (const id of r) out.add(id);
153
+ }
154
+ return out;
155
+ }
156
+
157
+ if (
158
+ indexProvider.rangeByIndex &&
159
+ ("$gt" in cond || "$gte" in cond || "$lt" in cond || "$lte" in cond)
160
+ ) {
161
+ return (await indexProvider.rangeByIndex(field, cond)) ??
162
+ new Set(await allDocIds());
163
+ }
164
+
165
+ return new Set(await allDocIds());
166
+ }