@objectql/driver-memory 4.0.2 → 4.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectql/driver-memory",
3
- "version": "4.0.2",
3
+ "version": "4.0.3",
4
4
  "description": "In-memory driver for ObjectQL - Fast MongoDB-like query engine powered by Mingo with DriverInterface v4.0 compliance",
5
5
  "keywords": [
6
6
  "objectql",
@@ -16,9 +16,9 @@
16
16
  "main": "dist/index.js",
17
17
  "types": "dist/index.d.ts",
18
18
  "dependencies": {
19
- "@objectstack/spec": "^0.3.3",
19
+ "@objectstack/spec": "^0.8.0",
20
20
  "mingo": "^7.1.1",
21
- "@objectql/types": "4.0.2"
21
+ "@objectql/types": "4.0.3"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/jest": "^29.0.0",
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { Data, Driver as DriverSpec } from '@objectstack/spec';
1
+ import { Data, System as SystemSpec } from '@objectstack/spec';
2
2
  type QueryAST = Data.QueryAST;
3
3
  type SortNode = Data.SortNode;
4
- type DriverInterface = DriverSpec.DriverInterface;
4
+ type DriverInterface = Data.DriverInterface;
5
5
  /**
6
6
  * ObjectQL
7
7
  * Copyright (c) 2026-present ObjectStack Inc.
@@ -38,7 +38,7 @@ type DriverInterface = DriverSpec.DriverInterface;
38
38
  */
39
39
 
40
40
  import { Driver, ObjectQLError } from '@objectql/types';
41
- import { Query } from 'mingo';
41
+ import { Query, Aggregator } from 'mingo';
42
42
 
43
43
  /**
44
44
  * Command interface for executeCommand method
@@ -72,6 +72,24 @@ export interface MemoryDriverConfig {
72
72
  initialData?: Record<string, any[]>;
73
73
  /** Optional: Enable strict mode (throw on missing objects) */
74
74
  strictMode?: boolean;
75
+ /** Optional: Enable persistence to file system */
76
+ persistence?: {
77
+ /** File path to persist data */
78
+ filePath: string;
79
+ /** Auto-save interval in milliseconds (default: 5000) */
80
+ autoSaveInterval?: number;
81
+ };
82
+ /** Optional: Fields to index for faster queries */
83
+ indexes?: Record<string, string[]>; // objectName -> field names
84
+ }
85
+
86
+ /**
87
+ * In-memory transaction
88
+ */
89
+ interface MemoryTransaction {
90
+ id: string;
91
+ snapshot: Map<string, any>;
92
+ operations: Array<{ type: 'set' | 'delete'; key: string; value?: any }>;
75
93
  }
76
94
 
77
95
  /**
@@ -87,32 +105,47 @@ export class MemoryDriver implements Driver {
87
105
  public readonly name = 'MemoryDriver';
88
106
  public readonly version = '4.0.0';
89
107
  public readonly supports = {
90
- transactions: false,
108
+ transactions: true,
91
109
  joins: false,
92
110
  fullTextSearch: false,
93
111
  jsonFields: true,
94
112
  arrayFields: true,
95
113
  queryFilters: true,
96
- queryAggregations: false,
114
+ queryAggregations: true,
97
115
  querySorting: true,
98
116
  queryPagination: true,
99
117
  queryWindowFunctions: false,
100
118
  querySubqueries: false
101
119
  };
102
120
 
103
- private store: Map<string, any>;
104
- private config: MemoryDriverConfig;
105
- private idCounters: Map<string, number>;
121
+ protected store: Map<string, any>;
122
+ protected config: MemoryDriverConfig;
123
+ protected idCounters: Map<string, number>;
124
+ protected transactions: Map<string, MemoryTransaction>;
125
+ protected indexes: Map<string, Map<string, Set<string>>>; // objectName -> field -> Set<recordIds>
126
+ protected persistenceTimer?: NodeJS.Timeout;
106
127
 
107
128
  constructor(config: MemoryDriverConfig = {}) {
108
129
  this.config = config;
109
130
  this.store = new Map<string, any>();
110
131
  this.idCounters = new Map<string, number>();
132
+ this.transactions = new Map();
133
+ this.indexes = new Map();
111
134
 
112
135
  // Load initial data if provided
113
136
  if (config.initialData) {
114
137
  this.loadInitialData(config.initialData);
115
138
  }
139
+
140
+ // Set up persistence if configured
141
+ if (config.persistence) {
142
+ this.setupPersistence();
143
+ }
144
+
145
+ // Build indexes if configured
146
+ if (config.indexes) {
147
+ this.buildIndexes(config.indexes);
148
+ }
116
149
  }
117
150
 
118
151
  /**
@@ -134,7 +167,7 @@ export class MemoryDriver implements Driver {
134
167
  /**
135
168
  * Load initial data into the store.
136
169
  */
137
- private loadInitialData(data: Record<string, any[]>): void {
170
+ protected loadInitialData(data: Record<string, any[]>): void {
138
171
  for (const [objectName, records] of Object.entries(data)) {
139
172
  for (const record of records) {
140
173
  const id = record.id || this.generateId(objectName);
@@ -235,6 +268,10 @@ export class MemoryDriver implements Driver {
235
268
  };
236
269
 
237
270
  this.store.set(key, doc);
271
+
272
+ // Update indexes
273
+ this.updateIndex(objectName, id, doc);
274
+
238
275
  return { ...doc };
239
276
  }
240
277
 
@@ -265,6 +302,10 @@ export class MemoryDriver implements Driver {
265
302
  };
266
303
 
267
304
  this.store.set(key, doc);
305
+
306
+ // Update indexes
307
+ this.updateIndex(objectName, id.toString(), doc);
308
+
268
309
  return { ...doc };
269
310
  }
270
311
 
@@ -275,6 +316,11 @@ export class MemoryDriver implements Driver {
275
316
  const key = `${objectName}:${id}`;
276
317
  const deleted = this.store.delete(key);
277
318
 
319
+ // Remove from indexes
320
+ if (deleted) {
321
+ this.removeFromIndex(objectName, id.toString());
322
+ }
323
+
278
324
  if (!deleted && this.config.strictMode) {
279
325
  throw new ObjectQLError({
280
326
  code: 'RECORD_NOT_FOUND',
@@ -468,10 +514,259 @@ export class MemoryDriver implements Driver {
468
514
  }
469
515
 
470
516
  /**
471
- * Disconnect (no-op for memory driver).
517
+ * Disconnect and cleanup resources.
472
518
  */
473
519
  async disconnect(): Promise<void> {
474
- // No-op: Memory driver doesn't need cleanup
520
+ // Save data to disk if persistence is enabled
521
+ if (this.config.persistence) {
522
+ this.saveToDisk();
523
+
524
+ // Clear the auto-save timer
525
+ if (this.persistenceTimer) {
526
+ clearInterval(this.persistenceTimer);
527
+ this.persistenceTimer = undefined;
528
+ }
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Perform aggregation operations using Mingo
534
+ * @param objectName - The object type to aggregate
535
+ * @param pipeline - MongoDB-style aggregation pipeline
536
+ * @param options - Optional query options
537
+ * @returns Aggregation results
538
+ *
539
+ * @example
540
+ * // Group by status and count
541
+ * const results = await driver.aggregate('orders', [
542
+ * { $match: { status: 'completed' } },
543
+ * { $group: { _id: '$customer', totalAmount: { $sum: '$amount' } } }
544
+ * ]);
545
+ *
546
+ * @example
547
+ * // Calculate average with filter
548
+ * const results = await driver.aggregate('products', [
549
+ * { $match: { category: 'electronics' } },
550
+ * { $group: { _id: null, avgPrice: { $avg: '$price' } } }
551
+ * ]);
552
+ */
553
+ async aggregate(objectName: string, pipeline: any[], options?: any): Promise<any[]> {
554
+ const pattern = `${objectName}:`;
555
+
556
+ // Get all records for this object type
557
+ let records: any[] = [];
558
+ for (const [key, value] of this.store.entries()) {
559
+ if (key.startsWith(pattern)) {
560
+ records.push({ ...value });
561
+ }
562
+ }
563
+
564
+ // Use Mingo to execute the aggregation pipeline
565
+ const aggregator = new Aggregator(pipeline);
566
+ const results = aggregator.run(records);
567
+
568
+ return results;
569
+ }
570
+
571
+ /**
572
+ * Begin a new transaction
573
+ * @returns Transaction object that can be passed to other methods
574
+ */
575
+ async beginTransaction(): Promise<any> {
576
+ const txId = `tx_${Date.now()}_${Math.random().toString(36).substring(7)}`;
577
+
578
+ // Create a deep snapshot of the current store to ensure complete transaction isolation
579
+ // This prevents issues with nested objects and arrays being modified during the transaction
580
+ const snapshot = new Map<string, any>();
581
+ for (const [key, value] of this.store.entries()) {
582
+ snapshot.set(key, JSON.parse(JSON.stringify(value)));
583
+ }
584
+
585
+ const transaction: MemoryTransaction = {
586
+ id: txId,
587
+ snapshot,
588
+ operations: []
589
+ };
590
+
591
+ this.transactions.set(txId, transaction);
592
+
593
+ return { id: txId };
594
+ }
595
+
596
+ /**
597
+ * Commit a transaction
598
+ * @param transaction - Transaction object returned by beginTransaction()
599
+ */
600
+ async commitTransaction(transaction: any): Promise<void> {
601
+ const txId = transaction?.id;
602
+ if (!txId) {
603
+ throw new ObjectQLError({
604
+ code: 'INVALID_TRANSACTION',
605
+ message: 'Invalid transaction object'
606
+ });
607
+ }
608
+
609
+ const tx = this.transactions.get(txId);
610
+ if (!tx) {
611
+ throw new ObjectQLError({
612
+ code: 'TRANSACTION_NOT_FOUND',
613
+ message: `Transaction ${txId} not found`
614
+ });
615
+ }
616
+
617
+ // Operations are already applied to the store during the transaction
618
+ // Just clean up the transaction record
619
+ this.transactions.delete(txId);
620
+ }
621
+
622
+ /**
623
+ * Rollback a transaction
624
+ * @param transaction - Transaction object returned by beginTransaction()
625
+ */
626
+ async rollbackTransaction(transaction: any): Promise<void> {
627
+ const txId = transaction?.id;
628
+ if (!txId) {
629
+ throw new ObjectQLError({
630
+ code: 'INVALID_TRANSACTION',
631
+ message: 'Invalid transaction object'
632
+ });
633
+ }
634
+
635
+ const tx = this.transactions.get(txId);
636
+ if (!tx) {
637
+ throw new ObjectQLError({
638
+ code: 'TRANSACTION_NOT_FOUND',
639
+ message: `Transaction ${txId} not found`
640
+ });
641
+ }
642
+
643
+ // Restore the snapshot
644
+ this.store = new Map(tx.snapshot);
645
+
646
+ // Clean up the transaction
647
+ this.transactions.delete(txId);
648
+ }
649
+
650
+ /**
651
+ * Set up persistence to file system
652
+ * @private
653
+ */
654
+ protected setupPersistence(): void {
655
+ if (!this.config.persistence) return;
656
+
657
+ const { filePath, autoSaveInterval = 5000 } = this.config.persistence;
658
+
659
+ // Try to load existing data from file
660
+ try {
661
+ const fs = require('fs');
662
+ if (fs.existsSync(filePath)) {
663
+ const fileData = fs.readFileSync(filePath, 'utf8');
664
+ const data = JSON.parse(fileData);
665
+
666
+ // Load data into store
667
+ for (const [key, value] of Object.entries(data.store || {})) {
668
+ this.store.set(key, value);
669
+ }
670
+
671
+ // Load ID counters
672
+ if (data.idCounters) {
673
+ for (const [key, value] of Object.entries(data.idCounters)) {
674
+ this.idCounters.set(key, value as number);
675
+ }
676
+ }
677
+ }
678
+ } catch (error) {
679
+ console.warn('[MemoryDriver] Failed to load persisted data:', error);
680
+ }
681
+
682
+ // Set up auto-save timer
683
+ // Use unref() to allow Node.js process to exit gracefully when idle
684
+ this.persistenceTimer = setInterval(() => {
685
+ this.saveToDisk();
686
+ }, autoSaveInterval);
687
+ this.persistenceTimer.unref();
688
+ }
689
+
690
+ /**
691
+ * Save current state to disk
692
+ * @private
693
+ */
694
+ protected saveToDisk(): void {
695
+ if (!this.config.persistence) return;
696
+
697
+ try {
698
+ const fs = require('fs');
699
+ const data = {
700
+ store: Object.fromEntries(this.store),
701
+ idCounters: Object.fromEntries(this.idCounters),
702
+ timestamp: new Date().toISOString()
703
+ };
704
+
705
+ fs.writeFileSync(this.config.persistence.filePath, JSON.stringify(data, null, 2), 'utf8');
706
+ } catch (error) {
707
+ console.error('[MemoryDriver] Failed to save data to disk:', error);
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Build indexes for faster queries
713
+ * @private
714
+ */
715
+ protected buildIndexes(indexConfig: Record<string, string[]>): void {
716
+ for (const [objectName, fields] of Object.entries(indexConfig)) {
717
+ const objectIndexes = new Map<string, Set<string>>();
718
+
719
+ for (const field of fields) {
720
+ const fieldIndex = new Set<string>();
721
+
722
+ // Scan all records of this object type
723
+ const pattern = `${objectName}:`;
724
+ for (const [key, record] of this.store.entries()) {
725
+ if (key.startsWith(pattern)) {
726
+ const fieldValue = record[field];
727
+ if (fieldValue !== undefined && fieldValue !== null) {
728
+ // Store the record ID in the index
729
+ fieldIndex.add(record.id);
730
+ }
731
+ }
732
+ }
733
+
734
+ objectIndexes.set(field, fieldIndex);
735
+ }
736
+
737
+ this.indexes.set(objectName, objectIndexes);
738
+ }
739
+ }
740
+
741
+ /**
742
+ * Update index when a record is created or updated
743
+ * @private
744
+ */
745
+ protected updateIndex(objectName: string, recordId: string, record: any): void {
746
+ const objectIndexes = this.indexes.get(objectName);
747
+ if (!objectIndexes) return;
748
+
749
+ for (const [field, indexSet] of objectIndexes.entries()) {
750
+ const fieldValue = record[field];
751
+ if (fieldValue !== undefined && fieldValue !== null) {
752
+ indexSet.add(recordId);
753
+ } else {
754
+ indexSet.delete(recordId);
755
+ }
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Remove record from indexes
761
+ * @private
762
+ */
763
+ protected removeFromIndex(objectName: string, recordId: string): void {
764
+ const objectIndexes = this.indexes.get(objectName);
765
+ if (!objectIndexes) return;
766
+
767
+ for (const indexSet of objectIndexes.values()) {
768
+ indexSet.delete(recordId);
769
+ }
475
770
  }
476
771
 
477
772
  // ========== Helper Methods ==========
@@ -495,13 +790,46 @@ export class MemoryDriver implements Driver {
495
790
  * Converts to MongoDB query format:
496
791
  * { $or: [{ field: { $operator: value }}, { field2: { $operator: value2 }}] }
497
792
  */
498
- private convertToMongoQuery(filters?: any[] | Record<string, any>): Record<string, any> {
793
+ /**
794
+ * Convert ObjectQL filter format to MongoDB query format.
795
+ * Supports three input formats:
796
+ *
797
+ * 1. AST Comparison Node: { type: 'comparison', field: string, operator: string, value: any }
798
+ * 2. AST Logical Node: { type: 'logical', operator: 'and' | 'or', conditions: Node[] }
799
+ * 3. Legacy Array Format: [field, operator, value, 'and', field2, operator2, value2]
800
+ * 4. MongoDB Format: { field: value } or { field: { $eq: value } } (passthrough)
801
+ *
802
+ * @param filters - Filter in any supported format
803
+ * @returns MongoDB query object
804
+ */
805
+ protected convertToMongoQuery(filters?: any[] | Record<string, any>): Record<string, any> {
499
806
  if (!filters) {
500
807
  return {};
501
808
  }
502
809
 
503
- // If filters is already an object (FilterCondition format), return it directly
504
- if (!Array.isArray(filters)) {
810
+ // Handle AST node format (ObjectQL QueryAST)
811
+ if (!Array.isArray(filters) && typeof filters === 'object') {
812
+ // AST Comparison Node: { type: 'comparison', field, operator, value }
813
+ if (filters.type === 'comparison') {
814
+ const mongoCondition = this.convertConditionToMongo(filters.field, filters.operator, filters.value);
815
+ return mongoCondition || {};
816
+ }
817
+
818
+ // AST Logical Node: { type: 'logical', operator: 'and' | 'or', conditions: [...] }
819
+ else if (filters.type === 'logical') {
820
+ const conditions = filters.conditions?.map((cond: any) => this.convertToMongoQuery(cond)) || [];
821
+ if (conditions.length === 0) {
822
+ return {};
823
+ }
824
+ if (conditions.length === 1) {
825
+ return conditions[0];
826
+ }
827
+ const operator = filters.operator === 'or' ? '$or' : '$and';
828
+ return { [operator]: conditions };
829
+ }
830
+
831
+ // MongoDB format (passthrough): { field: value } or { field: { $eq: value } }
832
+ // This handles cases where filters is already a proper MongoDB query
505
833
  return filters;
506
834
  }
507
835
 
@@ -585,7 +913,7 @@ export class MemoryDriver implements Driver {
585
913
  /**
586
914
  * Convert a single ObjectQL condition to MongoDB operator format.
587
915
  */
588
- private convertConditionToMongo(field: string, operator: string, value: any): Record<string, any> | null {
916
+ protected convertConditionToMongo(field: string, operator: string, value: any): Record<string, any> | null {
589
917
  switch (operator) {
590
918
  case '=':
591
919
  case '==':
@@ -646,7 +974,7 @@ export class MemoryDriver implements Driver {
646
974
  * Escape special regex characters to prevent ReDoS and ensure literal matching.
647
975
  * This is crucial for security when using user input in regex patterns.
648
976
  */
649
- private escapeRegex(str: string): string {
977
+ protected escapeRegex(str: string): string {
650
978
  return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
651
979
  }
652
980
 
@@ -654,7 +982,7 @@ export class MemoryDriver implements Driver {
654
982
  * Apply manual sorting to an array of records.
655
983
  * This is used instead of Mingo's sort to avoid CJS build issues.
656
984
  */
657
- private applyManualSort(records: any[], sort: any[]): any[] {
985
+ protected applyManualSort(records: any[], sort: any[]): any[] {
658
986
  const sorted = [...records];
659
987
 
660
988
  // Apply sorts in reverse order for correct multi-field precedence
@@ -695,7 +1023,7 @@ export class MemoryDriver implements Driver {
695
1023
  /**
696
1024
  * Project specific fields from a document.
697
1025
  */
698
- private projectFields(doc: any, fields: string[]): any {
1026
+ protected projectFields(doc: any, fields: string[]): any {
699
1027
  const result: any = {};
700
1028
  for (const field of fields) {
701
1029
  if (doc[field] !== undefined) {
@@ -708,7 +1036,7 @@ export class MemoryDriver implements Driver {
708
1036
  /**
709
1037
  * Generate a unique ID for a record.
710
1038
  */
711
- private generateId(objectName: string): string {
1039
+ protected generateId(objectName: string): string {
712
1040
  const counter = (this.idCounters.get(objectName) || 0) + 1;
713
1041
  this.idCounters.set(objectName, counter);
714
1042