@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.
@@ -3,25 +3,59 @@
3
3
  import { QueryAST, QueryInput } from '@objectstack/spec/data';
4
4
  import { DriverOptions } from '@objectstack/spec/data';
5
5
  import { DriverInterface, Logger, createLogger } from '@objectstack/core';
6
- import { match, getValueByPath } from './memory-matcher.js';
6
+ import { Query, Aggregator } from 'mingo';
7
+ import { getValueByPath } from './memory-matcher.js';
7
8
 
8
9
  /**
9
- * Example: In-Memory Driver
10
+ * Configuration options for the InMemory driver.
11
+ * Aligned with @objectstack/spec MemoryConfigSchema.
12
+ */
13
+ export interface InMemoryDriverConfig {
14
+ /** Optional: Initial data to populate the store */
15
+ initialData?: Record<string, Record<string, unknown>[]>;
16
+ /** Optional: Enable strict mode (throw on missing records) */
17
+ strictMode?: boolean;
18
+ /** Optional: Logger instance */
19
+ logger?: Logger;
20
+ }
21
+
22
+ /**
23
+ * Snapshot for in-memory transactions.
24
+ */
25
+ interface MemoryTransaction {
26
+ id: string;
27
+ snapshot: Record<string, any[]>;
28
+ }
29
+
30
+ /**
31
+ * In-Memory Driver for ObjectStack
32
+ *
33
+ * A production-ready implementation of the ObjectStack Driver Protocol
34
+ * powered by Mingo — a MongoDB-compatible query and aggregation engine.
10
35
  *
11
- * A minimal reference implementation of the ObjectStack Driver Protocol.
12
- * This driver stores data in a simple JavaScript object (Heap).
36
+ * Features:
37
+ * - MongoDB-compatible query engine (Mingo) for filtering, projection, aggregation
38
+ * - Full CRUD and bulk operations
39
+ * - Aggregation pipeline support ($match, $group, $sort, $project, $unwind, etc.)
40
+ * - Snapshot-based transactions (begin/commit/rollback)
41
+ * - Field projection and distinct values
42
+ * - Strict mode and initial data loading
43
+ *
44
+ * Reference: objectql/packages/drivers/memory
13
45
  */
14
46
  export class InMemoryDriver implements DriverInterface {
15
47
  name = 'com.objectstack.driver.memory';
16
48
  type = 'driver';
17
- version = '0.0.1';
18
- private config: any;
49
+ version = '1.0.0';
50
+ private config: InMemoryDriverConfig;
19
51
  private logger: Logger;
52
+ private idCounters: Map<string, number> = new Map();
53
+ private transactions: Map<string, MemoryTransaction> = new Map();
20
54
 
21
- constructor(config?: any) {
55
+ constructor(config?: InMemoryDriverConfig) {
22
56
  this.config = config || {};
23
57
  this.logger = config?.logger || createLogger({ level: 'info', format: 'pretty' });
24
- this.logger.debug('InMemory driver instance created', { config: this.config });
58
+ this.logger.debug('InMemory driver instance created');
25
59
  }
26
60
 
27
61
  // Duck-typed RuntimePlugin hook
@@ -37,11 +71,11 @@ export class InMemoryDriver implements DriverInterface {
37
71
 
38
72
  supports = {
39
73
  // Transaction & Connection Management
40
- transactions: false,
74
+ transactions: true, // Snapshot-based transactions
41
75
 
42
76
  // Query Operations
43
77
  queryFilters: true, // Implemented via memory-matcher
44
- queryAggregations: true, // Implemented
78
+ queryAggregations: true, // Implemented
45
79
  querySorting: true, // Implemented via JS sort
46
80
  queryPagination: true, // Implemented
47
81
  queryWindowFunctions: false, // @planned: Window functions (ROW_NUMBER, RANK, etc.)
@@ -66,7 +100,21 @@ export class InMemoryDriver implements DriverInterface {
66
100
  // ===================================
67
101
 
68
102
  async connect() {
69
- this.logger.info('InMemory Database Connected (Virtual)');
103
+ // Load initial data if provided
104
+ if (this.config.initialData) {
105
+ for (const [objectName, records] of Object.entries(this.config.initialData)) {
106
+ const table = this.getTable(objectName);
107
+ for (const record of records) {
108
+ const id = (record as any).id || this.generateId(objectName);
109
+ table.push({ ...record, id });
110
+ }
111
+ }
112
+ this.logger.info('InMemory Database Connected with initial data', {
113
+ tables: Object.keys(this.config.initialData).length,
114
+ });
115
+ } else {
116
+ this.logger.info('InMemory Database Connected (Virtual)');
117
+ }
70
118
  }
71
119
 
72
120
  async disconnect() {
@@ -105,11 +153,15 @@ export class InMemoryDriver implements DriverInterface {
105
153
  this.logger.debug('Find operation', { object, query });
106
154
 
107
155
  const table = this.getTable(object);
108
- let results = table;
156
+ let results = [...table]; // Work on copy
109
157
 
110
- // 1. Filter
158
+ // 1. Filter using Mingo
111
159
  if (query.where) {
112
- results = results.filter(record => match(record, query.where));
160
+ const mongoQuery = this.convertToMongoQuery(query.where);
161
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
162
+ const mingoQuery = new Query(mongoQuery);
163
+ results = mingoQuery.find(results).all();
164
+ }
113
165
  }
114
166
 
115
167
  // 1.5 Aggregation & Grouping
@@ -119,21 +171,8 @@ export class InMemoryDriver implements DriverInterface {
119
171
 
120
172
  // 2. Sort
121
173
  if (query.orderBy) {
122
- // Normalize sort to array
123
174
  const sortFields = Array.isArray(query.orderBy) ? query.orderBy : [query.orderBy];
124
-
125
- results.sort((a, b) => {
126
- for (const { field, order } of sortFields) {
127
- const valA = getValueByPath(a, field);
128
- const valB = getValueByPath(b, field);
129
-
130
- if (valA === valB) continue;
131
-
132
- const comparison = valA > valB ? 1 : -1;
133
- return order === 'desc' ? -comparison : comparison;
134
- }
135
- return 0;
136
- });
175
+ results = this.applySort(results, sortFields);
137
176
  }
138
177
 
139
178
  // 3. Pagination (Offset)
@@ -146,6 +185,11 @@ export class InMemoryDriver implements DriverInterface {
146
185
  results = results.slice(0, query.limit);
147
186
  }
148
187
 
188
+ // 5. Field Projection
189
+ if (query.fields && Array.isArray(query.fields) && query.fields.length > 0) {
190
+ results = results.map(record => this.projectFields(record, query.fields as string[]));
191
+ }
192
+
149
193
  this.logger.debug('Find completed', { object, resultCount: results.length });
150
194
  return results;
151
195
  }
@@ -174,17 +218,16 @@ export class InMemoryDriver implements DriverInterface {
174
218
 
175
219
  const table = this.getTable(object);
176
220
 
177
- // COMPATIBILITY: Driver must return 'id' as string
178
221
  const newRecord = {
179
- id: data.id || this.generateId(),
222
+ id: data.id || this.generateId(object),
180
223
  ...data,
181
- created_at: data.created_at || new Date(),
182
- updated_at: data.updated_at || new Date(),
224
+ created_at: data.created_at || new Date().toISOString(),
225
+ updated_at: data.updated_at || new Date().toISOString(),
183
226
  };
184
227
 
185
228
  table.push(newRecord);
186
229
  this.logger.debug('Record created', { object, id: newRecord.id, tableSize: table.length });
187
- return newRecord;
230
+ return { ...newRecord };
188
231
  }
189
232
 
190
233
  async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions) {
@@ -194,19 +237,24 @@ export class InMemoryDriver implements DriverInterface {
194
237
  const index = table.findIndex(r => r.id == id);
195
238
 
196
239
  if (index === -1) {
197
- this.logger.warn('Record not found for update', { object, id });
198
- throw new Error(`Record with ID ${id} not found in ${object}`);
240
+ if (this.config.strictMode) {
241
+ this.logger.warn('Record not found for update', { object, id });
242
+ throw new Error(`Record with ID ${id} not found in ${object}`);
243
+ }
244
+ return null;
199
245
  }
200
246
 
201
247
  const updatedRecord = {
202
248
  ...table[index],
203
249
  ...data,
204
- updated_at: new Date()
250
+ id: table[index].id, // Preserve original ID
251
+ created_at: table[index].created_at, // Preserve created_at
252
+ updated_at: new Date().toISOString(),
205
253
  };
206
254
 
207
255
  table[index] = updatedRecord;
208
256
  this.logger.debug('Record updated', { object, id });
209
- return updatedRecord;
257
+ return { ...updatedRecord };
210
258
  }
211
259
 
212
260
  async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions) {
@@ -237,6 +285,9 @@ export class InMemoryDriver implements DriverInterface {
237
285
  const index = table.findIndex(r => r.id == id);
238
286
 
239
287
  if (index === -1) {
288
+ if (this.config.strictMode) {
289
+ throw new Error(`Record with ID ${id} not found in ${object}`);
290
+ }
240
291
  this.logger.warn('Record not found for deletion', { object, id });
241
292
  return false;
242
293
  }
@@ -247,11 +298,15 @@ export class InMemoryDriver implements DriverInterface {
247
298
  }
248
299
 
249
300
  async count(object: string, query?: QueryInput, options?: DriverOptions) {
250
- let results = this.getTable(object);
301
+ let records = this.getTable(object);
251
302
  if (query?.where) {
252
- results = results.filter(record => match(record, query.where));
303
+ const mongoQuery = this.convertToMongoQuery(query.where);
304
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
305
+ const mingoQuery = new Query(mongoQuery);
306
+ records = mingoQuery.find(records).all();
307
+ }
253
308
  }
254
- const count = results.length;
309
+ const count = records.length;
255
310
  this.logger.debug('Count operation', { object, count });
256
311
  return count;
257
312
  }
@@ -274,20 +329,22 @@ export class InMemoryDriver implements DriverInterface {
274
329
  let targetRecords = table;
275
330
 
276
331
  if (query && query.where) {
277
- targetRecords = targetRecords.filter(r => match(r, query.where));
332
+ const mongoQuery = this.convertToMongoQuery(query.where);
333
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
334
+ const mingoQuery = new Query(mongoQuery);
335
+ targetRecords = mingoQuery.find(targetRecords).all();
336
+ }
278
337
  }
279
338
 
280
339
  const count = targetRecords.length;
281
340
 
282
- // Update each record
283
341
  for (const record of targetRecords) {
284
- // Find index in original table
285
342
  const index = table.findIndex(r => r.id === record.id);
286
343
  if (index !== -1) {
287
344
  const updated = {
288
345
  ...table[index],
289
346
  ...data,
290
- updated_at: new Date()
347
+ updated_at: new Date().toISOString()
291
348
  };
292
349
  table[index] = updated;
293
350
  }
@@ -303,20 +360,23 @@ export class InMemoryDriver implements DriverInterface {
303
360
  const table = this.getTable(object);
304
361
  const initialLength = table.length;
305
362
 
306
- // Filter IN PLACE or create new array?
307
- // Creating new array is safer for now.
308
-
309
- const remaining = table.filter(r => {
310
- if (!query || !query.where) return false; // Delete all? No, standard safety implies explicit empty filter for delete all.
311
- // Wait, normally deleteMany({}) deletes all.
312
- // Let's assume if query passed, use it.
313
- const matches = match(r, query.where);
314
- return !matches; // Keep if it DOES NOT match
315
- });
316
-
317
- this.db[object] = remaining;
318
- const count = initialLength - remaining.length;
363
+ if (query && query.where) {
364
+ const mongoQuery = this.convertToMongoQuery(query.where);
365
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
366
+ const mingoQuery = new Query(mongoQuery);
367
+ const matched = mingoQuery.find(table).all();
368
+ const matchedIds = new Set(matched.map((r: any) => r.id));
369
+ this.db[object] = table.filter(r => !matchedIds.has(r.id));
370
+ } else {
371
+ // Empty query = delete all
372
+ this.db[object] = [];
373
+ }
374
+ } else {
375
+ // No where clause = delete all
376
+ this.db[object] = [];
377
+ }
319
378
 
379
+ const count = initialLength - this.db[object].length;
320
380
  this.logger.debug('DeleteMany completed', { object, count });
321
381
  return { count };
322
382
  }
@@ -336,30 +396,236 @@ export class InMemoryDriver implements DriverInterface {
336
396
  }
337
397
 
338
398
  // ===================================
339
- // Schema & Transactions
399
+ // Transaction Management
340
400
  // ===================================
341
401
 
342
- async syncSchema(object: string, schema: any, options?: DriverOptions) {
343
- if (!this.db[object]) {
344
- this.db[object] = [];
345
- this.logger.info('Created in-memory table', { object });
402
+ async beginTransaction() {
403
+ const txId = `tx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
404
+
405
+ // Deep-clone current database state as a snapshot
406
+ const snapshot: Record<string, any[]> = {};
407
+ for (const [table, records] of Object.entries(this.db)) {
408
+ snapshot[table] = records.map(r => ({ ...r }));
346
409
  }
410
+
411
+ const transaction: MemoryTransaction = { id: txId, snapshot };
412
+ this.transactions.set(txId, transaction);
413
+ this.logger.debug('Transaction started', { txId });
414
+ return { id: txId };
347
415
  }
348
416
 
349
- async dropTable(object: string, options?: DriverOptions) {
350
- if (this.db[object]) {
351
- const recordCount = this.db[object].length;
352
- delete this.db[object];
353
- this.logger.info('Dropped in-memory table', { object, recordCount });
417
+ async commit(txHandle?: unknown) {
418
+ const txId = (txHandle as any)?.id;
419
+ if (!txId || !this.transactions.has(txId)) {
420
+ this.logger.warn('Commit called with unknown transaction');
421
+ return;
354
422
  }
423
+ // Data is already in the store; just remove the snapshot
424
+ this.transactions.delete(txId);
425
+ this.logger.debug('Transaction committed', { txId });
355
426
  }
356
427
 
357
- async beginTransaction() {
358
- throw new Error('Transactions not supported in InMemoryDriver');
428
+ async rollback(txHandle?: unknown) {
429
+ const txId = (txHandle as any)?.id;
430
+ if (!txId || !this.transactions.has(txId)) {
431
+ this.logger.warn('Rollback called with unknown transaction');
432
+ return;
433
+ }
434
+ const tx = this.transactions.get(txId)!;
435
+ // Restore the snapshot
436
+ this.db = tx.snapshot;
437
+ this.transactions.delete(txId);
438
+ this.logger.debug('Transaction rolled back', { txId });
439
+ }
440
+
441
+ // ===================================
442
+ // Utility Methods
443
+ // ===================================
444
+
445
+ /**
446
+ * Remove all data from the store.
447
+ */
448
+ async clear() {
449
+ this.db = {};
450
+ this.idCounters.clear();
451
+ this.logger.debug('All data cleared');
359
452
  }
360
453
 
361
- async commit() { /* No-op */ }
362
- async rollback() { /* No-op */ }
454
+ /**
455
+ * Get total number of records across all tables.
456
+ */
457
+ getSize(): number {
458
+ return Object.values(this.db).reduce((sum, table) => sum + table.length, 0);
459
+ }
460
+
461
+ /**
462
+ * Get distinct values for a field, optionally filtered.
463
+ */
464
+ async distinct(object: string, field: string, query?: QueryInput): Promise<any[]> {
465
+ let records = this.getTable(object);
466
+ if (query?.where) {
467
+ const mongoQuery = this.convertToMongoQuery(query.where);
468
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
469
+ const mingoQuery = new Query(mongoQuery);
470
+ records = mingoQuery.find(records).all();
471
+ }
472
+ }
473
+ const values = new Set<any>();
474
+ for (const record of records) {
475
+ const value = getValueByPath(record, field);
476
+ if (value !== undefined && value !== null) {
477
+ values.add(value);
478
+ }
479
+ }
480
+ return Array.from(values);
481
+ }
482
+
483
+ /**
484
+ * Execute a MongoDB-style aggregation pipeline using Mingo.
485
+ *
486
+ * Supports all standard MongoDB pipeline stages:
487
+ * - $match, $group, $sort, $project, $unwind, $limit, $skip
488
+ * - $addFields, $replaceRoot, $lookup (limited), $count
489
+ * - Accumulator operators: $sum, $avg, $min, $max, $first, $last, $push, $addToSet
490
+ *
491
+ * @example
492
+ * // Group by status and count
493
+ * const results = await driver.aggregate('orders', [
494
+ * { $match: { status: 'completed' } },
495
+ * { $group: { _id: '$customer', totalAmount: { $sum: '$amount' } } }
496
+ * ]);
497
+ *
498
+ * @example
499
+ * // Calculate average with filter
500
+ * const results = await driver.aggregate('products', [
501
+ * { $match: { category: 'electronics' } },
502
+ * { $group: { _id: null, avgPrice: { $avg: '$price' } } }
503
+ * ]);
504
+ */
505
+ async aggregate(object: string, pipeline: Record<string, any>[], options?: DriverOptions): Promise<any[]> {
506
+ this.logger.debug('Aggregate operation', { object, stageCount: pipeline.length });
507
+
508
+ const records = this.getTable(object).map(r => ({ ...r }));
509
+ const aggregator = new Aggregator(pipeline);
510
+ const results = aggregator.run(records);
511
+
512
+ this.logger.debug('Aggregate completed', { object, resultCount: results.length });
513
+ return results;
514
+ }
515
+
516
+ // ===================================
517
+ // Query Conversion (ObjectQL → MongoDB)
518
+ // ===================================
519
+
520
+ /**
521
+ * Convert ObjectQL filter format to MongoDB query format for Mingo.
522
+ *
523
+ * Supports:
524
+ * 1. AST Comparison Node: { type: 'comparison', field, operator, value }
525
+ * 2. AST Logical Node: { type: 'logical', operator: 'and'|'or', conditions: [...] }
526
+ * 3. Legacy Array Format: [['field', 'op', value], 'and', ['field2', 'op', value2]]
527
+ * 4. MongoDB Format: { field: value } or { field: { $eq: value } } (passthrough)
528
+ */
529
+ private convertToMongoQuery(filters?: any): Record<string, any> {
530
+ if (!filters) return {};
531
+
532
+ // AST node format (ObjectQL QueryAST)
533
+ if (!Array.isArray(filters) && typeof filters === 'object') {
534
+ if (filters.type === 'comparison') {
535
+ return this.convertConditionToMongo(filters.field, filters.operator, filters.value) || {};
536
+ }
537
+ if (filters.type === 'logical') {
538
+ const conditions = filters.conditions?.map((c: any) => this.convertToMongoQuery(c)) || [];
539
+ if (conditions.length === 0) return {};
540
+ if (conditions.length === 1) return conditions[0];
541
+ const op = filters.operator === 'or' ? '$or' : '$and';
542
+ return { [op]: conditions };
543
+ }
544
+ // MongoDB format passthrough: { field: value } or { field: { $eq: value } }
545
+ return filters;
546
+ }
547
+
548
+ // Legacy array format
549
+ if (!Array.isArray(filters) || filters.length === 0) return {};
550
+
551
+ const logicGroups: { logic: 'and' | 'or'; conditions: Record<string, any>[] }[] = [
552
+ { logic: 'and', conditions: [] },
553
+ ];
554
+ let currentLogic: 'and' | 'or' = 'and';
555
+
556
+ for (const item of filters) {
557
+ if (typeof item === 'string') {
558
+ const newLogic = item.toLowerCase() as 'and' | 'or';
559
+ if (newLogic !== currentLogic) {
560
+ currentLogic = newLogic;
561
+ logicGroups.push({ logic: currentLogic, conditions: [] });
562
+ }
563
+ } else if (Array.isArray(item)) {
564
+ const [field, operator, value] = item;
565
+ const cond = this.convertConditionToMongo(field, operator, value);
566
+ if (cond) logicGroups[logicGroups.length - 1].conditions.push(cond);
567
+ }
568
+ }
569
+
570
+ const allConditions: Record<string, any>[] = [];
571
+ for (const group of logicGroups) {
572
+ if (group.conditions.length === 0) continue;
573
+ if (group.conditions.length === 1) {
574
+ allConditions.push(group.conditions[0]);
575
+ } else {
576
+ const op = group.logic === 'or' ? '$or' : '$and';
577
+ allConditions.push({ [op]: group.conditions });
578
+ }
579
+ }
580
+
581
+ if (allConditions.length === 0) return {};
582
+ if (allConditions.length === 1) return allConditions[0];
583
+ return { $and: allConditions };
584
+ }
585
+
586
+ /**
587
+ * Convert a single ObjectQL condition to MongoDB operator format.
588
+ */
589
+ private convertConditionToMongo(field: string, operator: string, value: any): Record<string, any> | null {
590
+ switch (operator) {
591
+ case '=': case '==':
592
+ return { [field]: value };
593
+ case '!=': case '<>':
594
+ return { [field]: { $ne: value } };
595
+ case '>':
596
+ return { [field]: { $gt: value } };
597
+ case '>=':
598
+ return { [field]: { $gte: value } };
599
+ case '<':
600
+ return { [field]: { $lt: value } };
601
+ case '<=':
602
+ return { [field]: { $lte: value } };
603
+ case 'in':
604
+ return { [field]: { $in: value } };
605
+ case 'nin': case 'not in':
606
+ return { [field]: { $nin: value } };
607
+ case 'contains': case 'like':
608
+ return { [field]: { $regex: new RegExp(this.escapeRegex(value), 'i') } };
609
+ case 'startswith': case 'starts_with':
610
+ return { [field]: { $regex: new RegExp(`^${this.escapeRegex(value)}`, 'i') } };
611
+ case 'endswith': case 'ends_with':
612
+ return { [field]: { $regex: new RegExp(`${this.escapeRegex(value)}$`, 'i') } };
613
+ case 'between':
614
+ if (Array.isArray(value) && value.length === 2) {
615
+ return { [field]: { $gte: value[0], $lte: value[1] } };
616
+ }
617
+ return null;
618
+ default:
619
+ return null;
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Escape special regex characters for safe literal matching.
625
+ */
626
+ private escapeRegex(str: string): string {
627
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
628
+ }
363
629
 
364
630
  // ===================================
365
631
  // Aggregation Logic
@@ -385,20 +651,13 @@ export class InMemoryDriver implements DriverInterface {
385
651
  groups.get(key)!.push(record);
386
652
  }
387
653
  } else {
388
- // No grouping -> Single group containing all records
389
- // If aggregation is requested without group by, it runs on whole set (even if empty)
390
- if (aggregations && aggregations.length > 0) {
391
- groups.set('all', records);
392
- } else {
393
- // Should not be here if performAggregation called correctly
394
- groups.set('all', records);
395
- }
654
+ groups.set('all', records);
396
655
  }
397
656
 
398
657
  // 2. Compute aggregates for each group
399
658
  const resultRows: any[] = [];
400
659
 
401
- for (const [key, groupRecords] of groups.entries()) {
660
+ for (const [_key, groupRecords] of groups.entries()) {
402
661
  const row: any = {};
403
662
 
404
663
  // A. Add Group fields to row (if groupBy exists)
@@ -473,10 +732,78 @@ export class InMemoryDriver implements DriverInterface {
473
732
  current[parts[parts.length - 1]] = value;
474
733
  }
475
734
 
735
+ // ===================================
736
+ // Schema Management
737
+ // ===================================
738
+
739
+ async syncSchema(object: string, schema: any, options?: DriverOptions) {
740
+ if (!this.db[object]) {
741
+ this.db[object] = [];
742
+ this.logger.info('Created in-memory table', { object });
743
+ }
744
+ }
745
+
746
+ async dropTable(object: string, options?: DriverOptions) {
747
+ if (this.db[object]) {
748
+ const recordCount = this.db[object].length;
749
+ delete this.db[object];
750
+ this.logger.info('Dropped in-memory table', { object, recordCount });
751
+ }
752
+ }
753
+
476
754
  // ===================================
477
755
  // Helpers
478
756
  // ===================================
479
757
 
758
+ /**
759
+ * Apply manual sorting (Mingo sort has CJS build issues).
760
+ */
761
+ private applySort(records: any[], sortFields: any[]): any[] {
762
+ const sorted = [...records];
763
+ for (let i = sortFields.length - 1; i >= 0; i--) {
764
+ const sortItem = sortFields[i];
765
+ let field: string;
766
+ let direction: string;
767
+ if (typeof sortItem === 'object' && !Array.isArray(sortItem)) {
768
+ field = sortItem.field;
769
+ direction = sortItem.order || sortItem.direction || 'asc';
770
+ } else if (Array.isArray(sortItem)) {
771
+ [field, direction] = sortItem;
772
+ } else {
773
+ continue;
774
+ }
775
+ sorted.sort((a, b) => {
776
+ const aVal = getValueByPath(a, field);
777
+ const bVal = getValueByPath(b, field);
778
+ if (aVal == null && bVal == null) return 0;
779
+ if (aVal == null) return 1;
780
+ if (bVal == null) return -1;
781
+ if (aVal < bVal) return direction === 'desc' ? 1 : -1;
782
+ if (aVal > bVal) return direction === 'desc' ? -1 : 1;
783
+ return 0;
784
+ });
785
+ }
786
+ return sorted;
787
+ }
788
+
789
+ /**
790
+ * Project specific fields from a record.
791
+ */
792
+ private projectFields(record: any, fields: string[]): any {
793
+ const result: any = {};
794
+ for (const field of fields) {
795
+ const value = getValueByPath(record, field);
796
+ if (value !== undefined) {
797
+ result[field] = value;
798
+ }
799
+ }
800
+ // Always include id if not explicitly listed
801
+ if (!fields.includes('id') && record.id !== undefined) {
802
+ result.id = record.id;
803
+ }
804
+ return result;
805
+ }
806
+
480
807
  private getTable(name: string) {
481
808
  if (!this.db[name]) {
482
809
  this.db[name] = [];
@@ -484,7 +811,11 @@ export class InMemoryDriver implements DriverInterface {
484
811
  return this.db[name];
485
812
  }
486
813
 
487
- private generateId() {
488
- return Math.random().toString(36).substring(2, 15);
814
+ private generateId(objectName?: string) {
815
+ const key = objectName || '_global';
816
+ const counter = (this.idCounters.get(key) || 0) + 1;
817
+ this.idCounters.set(key, counter);
818
+ const timestamp = Date.now();
819
+ return `${key}-${timestamp}-${counter}`;
489
820
  }
490
821
  }