@objectstack/driver-memory 2.0.6 → 2.0.7

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.mjs CHANGED
@@ -1,34 +1,8 @@
1
1
  // src/memory-driver.ts
2
2
  import { createLogger } from "@objectstack/core";
3
+ import { Query, Aggregator } from "mingo";
3
4
 
4
5
  // src/memory-matcher.ts
5
- function match(record, filter) {
6
- if (!filter || Object.keys(filter).length === 0) return true;
7
- if (Array.isArray(filter.$and)) {
8
- if (!filter.$and.every((f) => match(record, f))) {
9
- return false;
10
- }
11
- }
12
- if (Array.isArray(filter.$or)) {
13
- if (!filter.$or.some((f) => match(record, f))) {
14
- return false;
15
- }
16
- }
17
- if (filter.$not) {
18
- if (match(record, filter.$not)) {
19
- return false;
20
- }
21
- }
22
- for (const key of Object.keys(filter)) {
23
- if (key.startsWith("$")) continue;
24
- const condition = filter[key];
25
- const value = getValueByPath(record, key);
26
- if (!checkCondition(value, condition)) {
27
- return false;
28
- }
29
- }
30
- return true;
31
- }
32
6
  function getValueByPath(obj, path) {
33
7
  if (path === "_id" && obj._id === void 0 && obj.id !== void 0) {
34
8
  return obj.id;
@@ -36,89 +10,19 @@ function getValueByPath(obj, path) {
36
10
  if (!path.includes(".")) return obj[path];
37
11
  return path.split(".").reduce((o, i) => o ? o[i] : void 0, obj);
38
12
  }
39
- function checkCondition(value, condition) {
40
- if (typeof condition !== "object" || condition === null || condition instanceof Date || Array.isArray(condition)) {
41
- return value == condition;
42
- }
43
- const keys = Object.keys(condition);
44
- const isOperatorObject = keys.some((k) => k.startsWith("$"));
45
- if (!isOperatorObject) {
46
- return JSON.stringify(value) === JSON.stringify(condition);
47
- }
48
- for (const op of keys) {
49
- const target = condition[op];
50
- if (value === void 0 && op !== "$exists" && op !== "$ne") {
51
- return false;
52
- }
53
- switch (op) {
54
- case "$eq":
55
- if (value != target) return false;
56
- break;
57
- case "$ne":
58
- if (value == target) return false;
59
- break;
60
- // Numeric / Date
61
- case "$gt":
62
- if (!(value > target)) return false;
63
- break;
64
- case "$gte":
65
- if (!(value >= target)) return false;
66
- break;
67
- case "$lt":
68
- if (!(value < target)) return false;
69
- break;
70
- case "$lte":
71
- if (!(value <= target)) return false;
72
- break;
73
- case "$between":
74
- if (Array.isArray(target) && (value < target[0] || value > target[1])) return false;
75
- break;
76
- // Sets
77
- case "$in":
78
- if (!Array.isArray(target) || !target.includes(value)) return false;
79
- break;
80
- case "$nin":
81
- if (Array.isArray(target) && target.includes(value)) return false;
82
- break;
83
- // Existence
84
- case "$exists":
85
- const exists = value !== void 0 && value !== null;
86
- if (exists !== !!target) return false;
87
- break;
88
- // Strings
89
- case "$contains":
90
- if (typeof value !== "string" || !value.includes(target)) return false;
91
- break;
92
- case "$startsWith":
93
- if (typeof value !== "string" || !value.startsWith(target)) return false;
94
- break;
95
- case "$endsWith":
96
- if (typeof value !== "string" || !value.endsWith(target)) return false;
97
- break;
98
- case "$regex":
99
- try {
100
- const re = new RegExp(target, condition.$options || "");
101
- if (!re.test(String(value))) return false;
102
- } catch (e) {
103
- return false;
104
- }
105
- break;
106
- default:
107
- break;
108
- }
109
- }
110
- return true;
111
- }
112
13
 
113
14
  // src/memory-driver.ts
114
15
  var InMemoryDriver = class {
115
16
  constructor(config) {
116
17
  this.name = "com.objectstack.driver.memory";
117
18
  this.type = "driver";
118
- this.version = "0.0.1";
19
+ this.version = "1.0.0";
20
+ this.idCounters = /* @__PURE__ */ new Map();
21
+ this.transactions = /* @__PURE__ */ new Map();
119
22
  this.supports = {
120
23
  // Transaction & Connection Management
121
- transactions: false,
24
+ transactions: true,
25
+ // Snapshot-based transactions
122
26
  // Query Operations
123
27
  queryFilters: true,
124
28
  // Implemented via memory-matcher
@@ -152,7 +56,7 @@ var InMemoryDriver = class {
152
56
  this.db = {};
153
57
  this.config = config || {};
154
58
  this.logger = config?.logger || createLogger({ level: "info", format: "pretty" });
155
- this.logger.debug("InMemory driver instance created", { config: this.config });
59
+ this.logger.debug("InMemory driver instance created");
156
60
  }
157
61
  // Duck-typed RuntimePlugin hook
158
62
  install(ctx) {
@@ -168,7 +72,20 @@ var InMemoryDriver = class {
168
72
  // Lifecycle
169
73
  // ===================================
170
74
  async connect() {
171
- this.logger.info("InMemory Database Connected (Virtual)");
75
+ if (this.config.initialData) {
76
+ for (const [objectName, records] of Object.entries(this.config.initialData)) {
77
+ const table = this.getTable(objectName);
78
+ for (const record of records) {
79
+ const id = record.id || this.generateId(objectName);
80
+ table.push({ ...record, id });
81
+ }
82
+ }
83
+ this.logger.info("InMemory Database Connected with initial data", {
84
+ tables: Object.keys(this.config.initialData).length
85
+ });
86
+ } else {
87
+ this.logger.info("InMemory Database Connected (Virtual)");
88
+ }
172
89
  }
173
90
  async disconnect() {
174
91
  const tableCount = Object.keys(this.db).length;
@@ -199,25 +116,20 @@ var InMemoryDriver = class {
199
116
  async find(object, query, options) {
200
117
  this.logger.debug("Find operation", { object, query });
201
118
  const table = this.getTable(object);
202
- let results = table;
119
+ let results = [...table];
203
120
  if (query.where) {
204
- results = results.filter((record) => match(record, query.where));
121
+ const mongoQuery = this.convertToMongoQuery(query.where);
122
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
123
+ const mingoQuery = new Query(mongoQuery);
124
+ results = mingoQuery.find(results).all();
125
+ }
205
126
  }
206
127
  if (query.groupBy || query.aggregations && query.aggregations.length > 0) {
207
128
  results = this.performAggregation(results, query);
208
129
  }
209
130
  if (query.orderBy) {
210
131
  const sortFields = Array.isArray(query.orderBy) ? query.orderBy : [query.orderBy];
211
- results.sort((a, b) => {
212
- for (const { field, order } of sortFields) {
213
- const valA = getValueByPath(a, field);
214
- const valB = getValueByPath(b, field);
215
- if (valA === valB) continue;
216
- const comparison = valA > valB ? 1 : -1;
217
- return order === "desc" ? -comparison : comparison;
218
- }
219
- return 0;
220
- });
132
+ results = this.applySort(results, sortFields);
221
133
  }
222
134
  if (query.offset) {
223
135
  results = results.slice(query.offset);
@@ -225,6 +137,9 @@ var InMemoryDriver = class {
225
137
  if (query.limit) {
226
138
  results = results.slice(0, query.limit);
227
139
  }
140
+ if (query.fields && Array.isArray(query.fields) && query.fields.length > 0) {
141
+ results = results.map((record) => this.projectFields(record, query.fields));
142
+ }
228
143
  this.logger.debug("Find completed", { object, resultCount: results.length });
229
144
  return results;
230
145
  }
@@ -246,31 +161,38 @@ var InMemoryDriver = class {
246
161
  this.logger.debug("Create operation", { object, hasData: !!data });
247
162
  const table = this.getTable(object);
248
163
  const newRecord = {
249
- id: data.id || this.generateId(),
164
+ id: data.id || this.generateId(object),
250
165
  ...data,
251
- created_at: data.created_at || /* @__PURE__ */ new Date(),
252
- updated_at: data.updated_at || /* @__PURE__ */ new Date()
166
+ created_at: data.created_at || (/* @__PURE__ */ new Date()).toISOString(),
167
+ updated_at: data.updated_at || (/* @__PURE__ */ new Date()).toISOString()
253
168
  };
254
169
  table.push(newRecord);
255
170
  this.logger.debug("Record created", { object, id: newRecord.id, tableSize: table.length });
256
- return newRecord;
171
+ return { ...newRecord };
257
172
  }
258
173
  async update(object, id, data, options) {
259
174
  this.logger.debug("Update operation", { object, id });
260
175
  const table = this.getTable(object);
261
176
  const index = table.findIndex((r) => r.id == id);
262
177
  if (index === -1) {
263
- this.logger.warn("Record not found for update", { object, id });
264
- throw new Error(`Record with ID ${id} not found in ${object}`);
178
+ if (this.config.strictMode) {
179
+ this.logger.warn("Record not found for update", { object, id });
180
+ throw new Error(`Record with ID ${id} not found in ${object}`);
181
+ }
182
+ return null;
265
183
  }
266
184
  const updatedRecord = {
267
185
  ...table[index],
268
186
  ...data,
269
- updated_at: /* @__PURE__ */ new Date()
187
+ id: table[index].id,
188
+ // Preserve original ID
189
+ created_at: table[index].created_at,
190
+ // Preserve created_at
191
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
270
192
  };
271
193
  table[index] = updatedRecord;
272
194
  this.logger.debug("Record updated", { object, id });
273
- return updatedRecord;
195
+ return { ...updatedRecord };
274
196
  }
275
197
  async upsert(object, data, conflictKeys, options) {
276
198
  this.logger.debug("Upsert operation", { object, conflictKeys });
@@ -294,6 +216,9 @@ var InMemoryDriver = class {
294
216
  const table = this.getTable(object);
295
217
  const index = table.findIndex((r) => r.id == id);
296
218
  if (index === -1) {
219
+ if (this.config.strictMode) {
220
+ throw new Error(`Record with ID ${id} not found in ${object}`);
221
+ }
297
222
  this.logger.warn("Record not found for deletion", { object, id });
298
223
  return false;
299
224
  }
@@ -302,11 +227,15 @@ var InMemoryDriver = class {
302
227
  return true;
303
228
  }
304
229
  async count(object, query, options) {
305
- let results = this.getTable(object);
230
+ let records = this.getTable(object);
306
231
  if (query?.where) {
307
- results = results.filter((record) => match(record, query.where));
232
+ const mongoQuery = this.convertToMongoQuery(query.where);
233
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
234
+ const mingoQuery = new Query(mongoQuery);
235
+ records = mingoQuery.find(records).all();
236
+ }
308
237
  }
309
- const count = results.length;
238
+ const count = records.length;
310
239
  this.logger.debug("Count operation", { object, count });
311
240
  return count;
312
241
  }
@@ -324,7 +253,11 @@ var InMemoryDriver = class {
324
253
  const table = this.getTable(object);
325
254
  let targetRecords = table;
326
255
  if (query && query.where) {
327
- targetRecords = targetRecords.filter((r) => match(r, query.where));
256
+ const mongoQuery = this.convertToMongoQuery(query.where);
257
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
258
+ const mingoQuery = new Query(mongoQuery);
259
+ targetRecords = mingoQuery.find(targetRecords).all();
260
+ }
328
261
  }
329
262
  const count = targetRecords.length;
330
263
  for (const record of targetRecords) {
@@ -333,7 +266,7 @@ var InMemoryDriver = class {
333
266
  const updated = {
334
267
  ...table[index],
335
268
  ...data,
336
- updated_at: /* @__PURE__ */ new Date()
269
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
337
270
  };
338
271
  table[index] = updated;
339
272
  }
@@ -345,13 +278,20 @@ var InMemoryDriver = class {
345
278
  this.logger.debug("DeleteMany operation", { object, query });
346
279
  const table = this.getTable(object);
347
280
  const initialLength = table.length;
348
- const remaining = table.filter((r) => {
349
- if (!query || !query.where) return false;
350
- const matches = match(r, query.where);
351
- return !matches;
352
- });
353
- this.db[object] = remaining;
354
- const count = initialLength - remaining.length;
281
+ if (query && query.where) {
282
+ const mongoQuery = this.convertToMongoQuery(query.where);
283
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
284
+ const mingoQuery = new Query(mongoQuery);
285
+ const matched = mingoQuery.find(table).all();
286
+ const matchedIds = new Set(matched.map((r) => r.id));
287
+ this.db[object] = table.filter((r) => !matchedIds.has(r.id));
288
+ } else {
289
+ this.db[object] = [];
290
+ }
291
+ } else {
292
+ this.db[object] = [];
293
+ }
294
+ const count = initialLength - this.db[object].length;
355
295
  this.logger.debug("DeleteMany completed", { object, count });
356
296
  return { count };
357
297
  }
@@ -368,27 +308,213 @@ var InMemoryDriver = class {
368
308
  this.logger.debug("BulkDelete completed", { object, count: ids.length });
369
309
  }
370
310
  // ===================================
371
- // Schema & Transactions
311
+ // Transaction Management
372
312
  // ===================================
373
- async syncSchema(object, schema, options) {
374
- if (!this.db[object]) {
375
- this.db[object] = [];
376
- this.logger.info("Created in-memory table", { object });
313
+ async beginTransaction() {
314
+ const txId = `tx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
315
+ const snapshot = {};
316
+ for (const [table, records] of Object.entries(this.db)) {
317
+ snapshot[table] = records.map((r) => ({ ...r }));
377
318
  }
378
- }
379
- async dropTable(object, options) {
380
- if (this.db[object]) {
381
- const recordCount = this.db[object].length;
382
- delete this.db[object];
383
- this.logger.info("Dropped in-memory table", { object, recordCount });
319
+ const transaction = { id: txId, snapshot };
320
+ this.transactions.set(txId, transaction);
321
+ this.logger.debug("Transaction started", { txId });
322
+ return { id: txId };
323
+ }
324
+ async commit(txHandle) {
325
+ const txId = txHandle?.id;
326
+ if (!txId || !this.transactions.has(txId)) {
327
+ this.logger.warn("Commit called with unknown transaction");
328
+ return;
329
+ }
330
+ this.transactions.delete(txId);
331
+ this.logger.debug("Transaction committed", { txId });
332
+ }
333
+ async rollback(txHandle) {
334
+ const txId = txHandle?.id;
335
+ if (!txId || !this.transactions.has(txId)) {
336
+ this.logger.warn("Rollback called with unknown transaction");
337
+ return;
384
338
  }
339
+ const tx = this.transactions.get(txId);
340
+ this.db = tx.snapshot;
341
+ this.transactions.delete(txId);
342
+ this.logger.debug("Transaction rolled back", { txId });
385
343
  }
386
- async beginTransaction() {
387
- throw new Error("Transactions not supported in InMemoryDriver");
344
+ // ===================================
345
+ // Utility Methods
346
+ // ===================================
347
+ /**
348
+ * Remove all data from the store.
349
+ */
350
+ async clear() {
351
+ this.db = {};
352
+ this.idCounters.clear();
353
+ this.logger.debug("All data cleared");
354
+ }
355
+ /**
356
+ * Get total number of records across all tables.
357
+ */
358
+ getSize() {
359
+ return Object.values(this.db).reduce((sum, table) => sum + table.length, 0);
360
+ }
361
+ /**
362
+ * Get distinct values for a field, optionally filtered.
363
+ */
364
+ async distinct(object, field, query) {
365
+ let records = this.getTable(object);
366
+ if (query?.where) {
367
+ const mongoQuery = this.convertToMongoQuery(query.where);
368
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
369
+ const mingoQuery = new Query(mongoQuery);
370
+ records = mingoQuery.find(records).all();
371
+ }
372
+ }
373
+ const values = /* @__PURE__ */ new Set();
374
+ for (const record of records) {
375
+ const value = getValueByPath(record, field);
376
+ if (value !== void 0 && value !== null) {
377
+ values.add(value);
378
+ }
379
+ }
380
+ return Array.from(values);
381
+ }
382
+ /**
383
+ * Execute a MongoDB-style aggregation pipeline using Mingo.
384
+ *
385
+ * Supports all standard MongoDB pipeline stages:
386
+ * - $match, $group, $sort, $project, $unwind, $limit, $skip
387
+ * - $addFields, $replaceRoot, $lookup (limited), $count
388
+ * - Accumulator operators: $sum, $avg, $min, $max, $first, $last, $push, $addToSet
389
+ *
390
+ * @example
391
+ * // Group by status and count
392
+ * const results = await driver.aggregate('orders', [
393
+ * { $match: { status: 'completed' } },
394
+ * { $group: { _id: '$customer', totalAmount: { $sum: '$amount' } } }
395
+ * ]);
396
+ *
397
+ * @example
398
+ * // Calculate average with filter
399
+ * const results = await driver.aggregate('products', [
400
+ * { $match: { category: 'electronics' } },
401
+ * { $group: { _id: null, avgPrice: { $avg: '$price' } } }
402
+ * ]);
403
+ */
404
+ async aggregate(object, pipeline, options) {
405
+ this.logger.debug("Aggregate operation", { object, stageCount: pipeline.length });
406
+ const records = this.getTable(object).map((r) => ({ ...r }));
407
+ const aggregator = new Aggregator(pipeline);
408
+ const results = aggregator.run(records);
409
+ this.logger.debug("Aggregate completed", { object, resultCount: results.length });
410
+ return results;
388
411
  }
389
- async commit() {
412
+ // ===================================
413
+ // Query Conversion (ObjectQL → MongoDB)
414
+ // ===================================
415
+ /**
416
+ * Convert ObjectQL filter format to MongoDB query format for Mingo.
417
+ *
418
+ * Supports:
419
+ * 1. AST Comparison Node: { type: 'comparison', field, operator, value }
420
+ * 2. AST Logical Node: { type: 'logical', operator: 'and'|'or', conditions: [...] }
421
+ * 3. Legacy Array Format: [['field', 'op', value], 'and', ['field2', 'op', value2]]
422
+ * 4. MongoDB Format: { field: value } or { field: { $eq: value } } (passthrough)
423
+ */
424
+ convertToMongoQuery(filters) {
425
+ if (!filters) return {};
426
+ if (!Array.isArray(filters) && typeof filters === "object") {
427
+ if (filters.type === "comparison") {
428
+ return this.convertConditionToMongo(filters.field, filters.operator, filters.value) || {};
429
+ }
430
+ if (filters.type === "logical") {
431
+ const conditions = filters.conditions?.map((c) => this.convertToMongoQuery(c)) || [];
432
+ if (conditions.length === 0) return {};
433
+ if (conditions.length === 1) return conditions[0];
434
+ const op = filters.operator === "or" ? "$or" : "$and";
435
+ return { [op]: conditions };
436
+ }
437
+ return filters;
438
+ }
439
+ if (!Array.isArray(filters) || filters.length === 0) return {};
440
+ const logicGroups = [
441
+ { logic: "and", conditions: [] }
442
+ ];
443
+ let currentLogic = "and";
444
+ for (const item of filters) {
445
+ if (typeof item === "string") {
446
+ const newLogic = item.toLowerCase();
447
+ if (newLogic !== currentLogic) {
448
+ currentLogic = newLogic;
449
+ logicGroups.push({ logic: currentLogic, conditions: [] });
450
+ }
451
+ } else if (Array.isArray(item)) {
452
+ const [field, operator, value] = item;
453
+ const cond = this.convertConditionToMongo(field, operator, value);
454
+ if (cond) logicGroups[logicGroups.length - 1].conditions.push(cond);
455
+ }
456
+ }
457
+ const allConditions = [];
458
+ for (const group of logicGroups) {
459
+ if (group.conditions.length === 0) continue;
460
+ if (group.conditions.length === 1) {
461
+ allConditions.push(group.conditions[0]);
462
+ } else {
463
+ const op = group.logic === "or" ? "$or" : "$and";
464
+ allConditions.push({ [op]: group.conditions });
465
+ }
466
+ }
467
+ if (allConditions.length === 0) return {};
468
+ if (allConditions.length === 1) return allConditions[0];
469
+ return { $and: allConditions };
470
+ }
471
+ /**
472
+ * Convert a single ObjectQL condition to MongoDB operator format.
473
+ */
474
+ convertConditionToMongo(field, operator, value) {
475
+ switch (operator) {
476
+ case "=":
477
+ case "==":
478
+ return { [field]: value };
479
+ case "!=":
480
+ case "<>":
481
+ return { [field]: { $ne: value } };
482
+ case ">":
483
+ return { [field]: { $gt: value } };
484
+ case ">=":
485
+ return { [field]: { $gte: value } };
486
+ case "<":
487
+ return { [field]: { $lt: value } };
488
+ case "<=":
489
+ return { [field]: { $lte: value } };
490
+ case "in":
491
+ return { [field]: { $in: value } };
492
+ case "nin":
493
+ case "not in":
494
+ return { [field]: { $nin: value } };
495
+ case "contains":
496
+ case "like":
497
+ return { [field]: { $regex: new RegExp(this.escapeRegex(value), "i") } };
498
+ case "startswith":
499
+ case "starts_with":
500
+ return { [field]: { $regex: new RegExp(`^${this.escapeRegex(value)}`, "i") } };
501
+ case "endswith":
502
+ case "ends_with":
503
+ return { [field]: { $regex: new RegExp(`${this.escapeRegex(value)}$`, "i") } };
504
+ case "between":
505
+ if (Array.isArray(value) && value.length === 2) {
506
+ return { [field]: { $gte: value[0], $lte: value[1] } };
507
+ }
508
+ return null;
509
+ default:
510
+ return null;
511
+ }
390
512
  }
391
- async rollback() {
513
+ /**
514
+ * Escape special regex characters for safe literal matching.
515
+ */
516
+ escapeRegex(str) {
517
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
392
518
  }
393
519
  // ===================================
394
520
  // Aggregation Logic
@@ -409,14 +535,10 @@ var InMemoryDriver = class {
409
535
  groups.get(key).push(record);
410
536
  }
411
537
  } else {
412
- if (aggregations && aggregations.length > 0) {
413
- groups.set("all", records);
414
- } else {
415
- groups.set("all", records);
416
- }
538
+ groups.set("all", records);
417
539
  }
418
540
  const resultRows = [];
419
- for (const [key, groupRecords] of groups.entries()) {
541
+ for (const [_key, groupRecords] of groups.entries()) {
420
542
  const row = {};
421
543
  if (groupBy && groupBy.length > 0) {
422
544
  if (groupRecords.length > 0) {
@@ -475,16 +597,82 @@ var InMemoryDriver = class {
475
597
  current[parts[parts.length - 1]] = value;
476
598
  }
477
599
  // ===================================
600
+ // Schema Management
601
+ // ===================================
602
+ async syncSchema(object, schema, options) {
603
+ if (!this.db[object]) {
604
+ this.db[object] = [];
605
+ this.logger.info("Created in-memory table", { object });
606
+ }
607
+ }
608
+ async dropTable(object, options) {
609
+ if (this.db[object]) {
610
+ const recordCount = this.db[object].length;
611
+ delete this.db[object];
612
+ this.logger.info("Dropped in-memory table", { object, recordCount });
613
+ }
614
+ }
615
+ // ===================================
478
616
  // Helpers
479
617
  // ===================================
618
+ /**
619
+ * Apply manual sorting (Mingo sort has CJS build issues).
620
+ */
621
+ applySort(records, sortFields) {
622
+ const sorted = [...records];
623
+ for (let i = sortFields.length - 1; i >= 0; i--) {
624
+ const sortItem = sortFields[i];
625
+ let field;
626
+ let direction;
627
+ if (typeof sortItem === "object" && !Array.isArray(sortItem)) {
628
+ field = sortItem.field;
629
+ direction = sortItem.order || sortItem.direction || "asc";
630
+ } else if (Array.isArray(sortItem)) {
631
+ [field, direction] = sortItem;
632
+ } else {
633
+ continue;
634
+ }
635
+ sorted.sort((a, b) => {
636
+ const aVal = getValueByPath(a, field);
637
+ const bVal = getValueByPath(b, field);
638
+ if (aVal == null && bVal == null) return 0;
639
+ if (aVal == null) return 1;
640
+ if (bVal == null) return -1;
641
+ if (aVal < bVal) return direction === "desc" ? 1 : -1;
642
+ if (aVal > bVal) return direction === "desc" ? -1 : 1;
643
+ return 0;
644
+ });
645
+ }
646
+ return sorted;
647
+ }
648
+ /**
649
+ * Project specific fields from a record.
650
+ */
651
+ projectFields(record, fields) {
652
+ const result = {};
653
+ for (const field of fields) {
654
+ const value = getValueByPath(record, field);
655
+ if (value !== void 0) {
656
+ result[field] = value;
657
+ }
658
+ }
659
+ if (!fields.includes("id") && record.id !== void 0) {
660
+ result.id = record.id;
661
+ }
662
+ return result;
663
+ }
480
664
  getTable(name) {
481
665
  if (!this.db[name]) {
482
666
  this.db[name] = [];
483
667
  }
484
668
  return this.db[name];
485
669
  }
486
- generateId() {
487
- return Math.random().toString(36).substring(2, 15);
670
+ generateId(objectName) {
671
+ const key = objectName || "_global";
672
+ const counter = (this.idCounters.get(key) || 0) + 1;
673
+ this.idCounters.set(key, counter);
674
+ const timestamp = Date.now();
675
+ return `${key}-${timestamp}-${counter}`;
488
676
  }
489
677
  };
490
678