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