@objectstack/driver-memory 4.0.3 → 4.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1201 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { QueryAST, QueryInput, DriverOptions } from '@objectstack/spec/data';
4
- import type { IDataDriver } from '@objectstack/spec/contracts';
5
- import { Logger, createLogger } from '@objectstack/core';
6
- import { Query, Aggregator } from 'mingo';
7
- import { getValueByPath } from './memory-matcher.js';
8
-
9
- /**
10
- * Persistence adapter interface.
11
- * Matches the PersistenceAdapterSchema contract from @objectstack/spec.
12
- */
13
- export interface PersistenceAdapterInterface {
14
- load(): Promise<Record<string, any[]> | null>;
15
- save(db: Record<string, any[]>): Promise<void>;
16
- flush(): Promise<void>;
17
- /** Optional: Start periodic auto-save (used by FileSystemPersistenceAdapter). */
18
- startAutoSave?(): void;
19
- /** Optional: Stop auto-save timer and flush pending writes. */
20
- stopAutoSave?(): Promise<void>;
21
- }
22
-
23
- /**
24
- * Configuration options for the InMemory driver.
25
- * Aligned with @objectstack/spec MemoryConfigSchema.
26
- */
27
- export interface InMemoryDriverConfig {
28
- /** Optional: Initial data to populate the store */
29
- initialData?: Record<string, Record<string, unknown>[]>;
30
- /** Optional: Enable strict mode (throw on missing records) */
31
- strictMode?: boolean;
32
- /** Optional: Logger instance */
33
- logger?: Logger;
34
- /**
35
- * Persistence configuration. Defaults to `'auto'`.
36
- * - `'auto'` (default) — Auto-detect environment (browser → localStorage, Node.js → file, serverless → disabled)
37
- * - `'file'` — File-system persistence with defaults (Node.js only)
38
- * - `'local'` — localStorage persistence with defaults (Browser only)
39
- * - `{ type: 'file', path?: string, autoSaveInterval?: number }` — File-system with options
40
- * - `{ type: 'local', key?: string }` — localStorage with options
41
- * - `{ type: 'auto', path?: string, key?: string, autoSaveInterval?: number }` — Auto-detect with options
42
- * - `{ adapter: PersistenceAdapterInterface }` — Custom adapter
43
- * - `false` — Disable persistence (pure in-memory)
44
- *
45
- * ⚠️ In serverless environments (Vercel, AWS Lambda, Netlify, etc.),
46
- * auto mode disables file persistence to prevent silent data loss.
47
- * Use `persistence: false` or supply a custom adapter for serverless deployments.
48
- */
49
- persistence?: string | false | {
50
- type?: 'file' | 'local' | 'auto';
51
- path?: string;
52
- key?: string;
53
- autoSaveInterval?: number;
54
- adapter?: PersistenceAdapterInterface;
55
- };
56
- }
57
-
58
- /**
59
- * Snapshot for in-memory transactions.
60
- */
61
- interface MemoryTransaction {
62
- id: string;
63
- snapshot: Record<string, any[]>;
64
- }
65
-
66
- /**
67
- * In-Memory Driver for ObjectStack
68
- *
69
- * A production-ready implementation of the ObjectStack Driver Protocol
70
- * powered by Mingo — a MongoDB-compatible query and aggregation engine.
71
- *
72
- * Features:
73
- * - MongoDB-compatible query engine (Mingo) for filtering, projection, aggregation
74
- * - Full CRUD and bulk operations
75
- * - Aggregation pipeline support ($match, $group, $sort, $project, $unwind, etc.)
76
- * - Snapshot-based transactions (begin/commit/rollback)
77
- * - Field projection and distinct values
78
- * - Strict mode and initial data loading
79
- *
80
- * Reference: objectql/packages/drivers/memory
81
- */
82
- export class InMemoryDriver implements IDataDriver {
83
- readonly name = 'com.objectstack.driver.memory';
84
- type = 'driver';
85
- readonly version = '1.0.0';
86
- private config: InMemoryDriverConfig;
87
- private logger: Logger;
88
- private idCounters: Map<string, number> = new Map();
89
- private transactions: Map<string, MemoryTransaction> = new Map();
90
- private persistenceAdapter: PersistenceAdapterInterface | null = null;
91
-
92
- constructor(config?: InMemoryDriverConfig) {
93
- this.config = config || {};
94
- this.logger = config?.logger || createLogger({ level: 'info', format: 'pretty' });
95
- this.logger.debug('InMemory driver instance created');
96
- }
97
-
98
- // Duck-typed RuntimePlugin hook
99
- install(ctx: any) {
100
- this.logger.debug('Installing InMemory driver via plugin hook');
101
- if (ctx.engine && ctx.engine.ql && typeof ctx.engine.ql.registerDriver === 'function') {
102
- ctx.engine.ql.registerDriver(this);
103
- this.logger.info('InMemory driver registered with ObjectQL engine');
104
- } else {
105
- this.logger.warn('Could not register driver - ObjectQL engine not found in context');
106
- }
107
- }
108
-
109
- readonly supports = {
110
- // Basic CRUD Operations
111
- create: true,
112
- read: true,
113
- update: true,
114
- delete: true,
115
-
116
- // Bulk Operations
117
- bulkCreate: true,
118
- bulkUpdate: true,
119
- bulkDelete: true,
120
-
121
- // Transaction & Connection Management
122
- transactions: true, // Snapshot-based transactions
123
- savepoints: false,
124
-
125
- // Query Operations
126
- queryFilters: true, // Implemented via memory-matcher
127
- queryAggregations: true, // Implemented
128
- querySorting: true, // Implemented via JS sort
129
- queryPagination: true, // Implemented
130
- queryWindowFunctions: false, // @planned: Window functions (ROW_NUMBER, RANK, etc.)
131
- querySubqueries: false, // @planned: Subquery execution
132
- queryCTE: false,
133
- joins: false, // @planned: In-memory join operations
134
-
135
- // Advanced Features
136
- fullTextSearch: false, // @planned: Text tokenization + matching
137
- jsonQuery: false,
138
- geospatialQuery: false,
139
- streaming: true, // Implemented via findStream()
140
- jsonFields: true, // Native JS object support
141
- arrayFields: true, // Native JS array support
142
- vectorSearch: false, // @planned: Cosine similarity search
143
-
144
- // Schema Management
145
- schemaSync: true, // Implemented via syncSchema()
146
- batchSchemaSync: false,
147
- migrations: false,
148
- indexes: false,
149
-
150
- // Performance & Optimization
151
- connectionPooling: false,
152
- preparedStatements: false,
153
- queryCache: false,
154
- };
155
-
156
- /**
157
- * The "Database": A map of TableName -> Array of Records
158
- */
159
- private db: Record<string, any[]> = {};
160
-
161
- // ===================================
162
- // Lifecycle
163
- // ===================================
164
-
165
- async connect() {
166
- // Initialize persistence adapter if configured
167
- await this.initPersistence();
168
-
169
- // Load persisted data if available
170
- if (this.persistenceAdapter) {
171
- const persisted = await this.persistenceAdapter.load();
172
- if (persisted) {
173
- for (const [objectName, records] of Object.entries(persisted)) {
174
- this.db[objectName] = records;
175
- // Update ID counters based on persisted data
176
- for (const record of records) {
177
- if (record.id && typeof record.id === 'string') {
178
- // ID format: {objectName}-{timestamp}-{counter}
179
- const parts = record.id.split('-');
180
- const lastPart = parts[parts.length - 1];
181
- const counter = parseInt(lastPart, 10);
182
- if (!isNaN(counter)) {
183
- const current = this.idCounters.get(objectName) || 0;
184
- if (counter > current) {
185
- this.idCounters.set(objectName, counter);
186
- }
187
- }
188
- }
189
- }
190
- }
191
- this.logger.info('InMemory Database restored from persistence', {
192
- tables: Object.keys(persisted).length,
193
- });
194
- }
195
- }
196
-
197
- // Load initial data if provided
198
- if (this.config.initialData) {
199
- for (const [objectName, records] of Object.entries(this.config.initialData)) {
200
- const table = this.getTable(objectName);
201
- for (const record of records) {
202
- const id = (record as any).id || this.generateId(objectName);
203
- table.push({ ...record, id });
204
- }
205
- }
206
- this.logger.info('InMemory Database Connected with initial data', {
207
- tables: Object.keys(this.config.initialData).length,
208
- });
209
- } else {
210
- this.logger.info('InMemory Database Connected (Virtual)');
211
- }
212
-
213
- // Start auto-save if using file adapter
214
- if (this.persistenceAdapter?.startAutoSave) {
215
- this.persistenceAdapter.startAutoSave();
216
- }
217
- }
218
-
219
- async disconnect() {
220
- // Stop auto-save and flush pending writes
221
- if (this.persistenceAdapter) {
222
- if (this.persistenceAdapter.stopAutoSave) {
223
- await this.persistenceAdapter.stopAutoSave();
224
- }
225
- await this.persistenceAdapter.flush();
226
- }
227
-
228
- const tableCount = Object.keys(this.db).length;
229
- const recordCount = Object.values(this.db).reduce((sum, table) => sum + table.length, 0);
230
-
231
- this.db = {};
232
- this.logger.info('InMemory Database Disconnected & Cleared', {
233
- tableCount,
234
- recordCount
235
- });
236
- }
237
-
238
- async checkHealth() {
239
- this.logger.debug('Health check performed', {
240
- tableCount: Object.keys(this.db).length,
241
- status: 'healthy'
242
- });
243
- return true;
244
- }
245
-
246
- // ===================================
247
- // Execution
248
- // ===================================
249
-
250
- async execute(command: any, params?: any[]) {
251
- this.logger.warn('Raw execution not supported in InMemory driver', { command });
252
- return null;
253
- }
254
-
255
- // ===================================
256
- // CRUD
257
- // ===================================
258
-
259
- async find(object: string, query: QueryAST, options?: DriverOptions) {
260
- this.logger.debug('Find operation', { object, query });
261
-
262
- const table = this.getTable(object);
263
- let results = [...table]; // Work on copy
264
-
265
- // 1. Filter using Mingo
266
- if (query.where) {
267
- const mongoQuery = this.convertToMongoQuery(query.where);
268
- if (mongoQuery && Object.keys(mongoQuery).length > 0) {
269
- const mingoQuery = new Query(mongoQuery);
270
- results = mingoQuery.find(results).all();
271
- }
272
- }
273
-
274
- // 1.5 Aggregation & Grouping
275
- if (query.groupBy || (query.aggregations && query.aggregations.length > 0)) {
276
- results = this.performAggregation(results, query);
277
- }
278
-
279
- // 2. Sort
280
- if (query.orderBy) {
281
- const sortFields = Array.isArray(query.orderBy) ? query.orderBy : [query.orderBy];
282
- results = this.applySort(results, sortFields);
283
- }
284
-
285
- // 3. Pagination (Offset)
286
- if (query.offset) {
287
- results = results.slice(query.offset);
288
- }
289
-
290
- // 4. Pagination (Limit)
291
- if (query.limit) {
292
- results = results.slice(0, query.limit);
293
- }
294
-
295
- // 5. Field Projection
296
- if (query.fields && Array.isArray(query.fields) && query.fields.length > 0) {
297
- results = results.map(record => this.projectFields(record, query.fields as string[]));
298
- }
299
-
300
- this.logger.debug('Find completed', { object, resultCount: results.length });
301
- return results;
302
- }
303
-
304
- async *findStream(object: string, query: QueryAST, options?: DriverOptions) {
305
- this.logger.debug('FindStream operation', { object });
306
-
307
- const results = await this.find(object, query, options);
308
- for (const record of results) {
309
- yield record;
310
- }
311
- }
312
-
313
- async findOne(object: string, query: QueryAST, options?: DriverOptions) {
314
- this.logger.debug('FindOne operation', { object, query });
315
-
316
- const results = await this.find(object, { ...query, limit: 1 }, options);
317
- const result = results[0] || null;
318
-
319
- this.logger.debug('FindOne completed', { object, found: !!result });
320
- return result;
321
- }
322
-
323
- async create(object: string, data: Record<string, any>, options?: DriverOptions) {
324
- this.logger.debug('Create operation', { object, hasData: !!data });
325
-
326
- const table = this.getTable(object);
327
-
328
- const newRecord = {
329
- id: data.id || this.generateId(object),
330
- ...data,
331
- created_at: data.created_at || new Date().toISOString(),
332
- updated_at: data.updated_at || new Date().toISOString(),
333
- };
334
-
335
- table.push(newRecord);
336
- this.markDirty();
337
- this.logger.debug('Record created', { object, id: newRecord.id, tableSize: table.length });
338
- return { ...newRecord };
339
- }
340
-
341
- async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions) {
342
- this.logger.debug('Update operation', { object, id });
343
-
344
- const table = this.getTable(object);
345
- const index = table.findIndex(r => r.id == id);
346
-
347
- if (index === -1) {
348
- if (this.config.strictMode) {
349
- this.logger.warn('Record not found for update', { object, id });
350
- throw new Error(`Record with ID ${id} not found in ${object}`);
351
- }
352
- return null;
353
- }
354
-
355
- const updatedRecord = {
356
- ...table[index],
357
- ...data,
358
- id: table[index].id, // Preserve original ID
359
- created_at: table[index].created_at, // Preserve created_at
360
- updated_at: new Date().toISOString(),
361
- };
362
-
363
- table[index] = updatedRecord;
364
- this.markDirty();
365
- this.logger.debug('Record updated', { object, id });
366
- return { ...updatedRecord };
367
- }
368
-
369
- async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions) {
370
- this.logger.debug('Upsert operation', { object, conflictKeys });
371
-
372
- const table = this.getTable(object);
373
- let existingRecord: any = null;
374
-
375
- if (data.id) {
376
- existingRecord = table.find(r => r.id === data.id);
377
- } else if (conflictKeys && conflictKeys.length > 0) {
378
- existingRecord = table.find(r => conflictKeys.every(key => r[key] === data[key]));
379
- }
380
-
381
- if (existingRecord) {
382
- this.logger.debug('Record exists, updating', { object, id: existingRecord.id });
383
- return this.update(object, existingRecord.id, data, options);
384
- } else {
385
- this.logger.debug('Record does not exist, creating', { object });
386
- return this.create(object, data, options);
387
- }
388
- }
389
-
390
- async delete(object: string, id: string | number, options?: DriverOptions) {
391
- this.logger.debug('Delete operation', { object, id });
392
-
393
- const table = this.getTable(object);
394
- const index = table.findIndex(r => r.id == id);
395
-
396
- if (index === -1) {
397
- if (this.config.strictMode) {
398
- throw new Error(`Record with ID ${id} not found in ${object}`);
399
- }
400
- this.logger.warn('Record not found for deletion', { object, id });
401
- return false;
402
- }
403
-
404
- table.splice(index, 1);
405
- this.markDirty();
406
- this.logger.debug('Record deleted', { object, id, tableSize: table.length });
407
- return true;
408
- }
409
-
410
- async count(object: string, query?: QueryAST, options?: DriverOptions) {
411
- let records = this.getTable(object);
412
- if (query?.where) {
413
- const mongoQuery = this.convertToMongoQuery(query.where);
414
- if (mongoQuery && Object.keys(mongoQuery).length > 0) {
415
- const mingoQuery = new Query(mongoQuery);
416
- records = mingoQuery.find(records).all();
417
- }
418
- }
419
- const count = records.length;
420
- this.logger.debug('Count operation', { object, count });
421
- return count;
422
- }
423
-
424
- // ===================================
425
- // Bulk Operations
426
- // ===================================
427
-
428
- async bulkCreate(object: string, dataArray: Record<string, any>[], options?: DriverOptions) {
429
- this.logger.debug('BulkCreate operation', { object, count: dataArray.length });
430
- const results = await Promise.all(dataArray.map(data => this.create(object, data, options)));
431
- this.logger.debug('BulkCreate completed', { object, count: results.length });
432
- return results;
433
- }
434
-
435
- async updateMany(object: string, query: QueryAST, data: Record<string, any>, options?: DriverOptions): Promise<number> {
436
- this.logger.debug('UpdateMany operation', { object, query });
437
-
438
- const table = this.getTable(object);
439
- let targetRecords = table;
440
-
441
- if (query && query.where) {
442
- const mongoQuery = this.convertToMongoQuery(query.where);
443
- if (mongoQuery && Object.keys(mongoQuery).length > 0) {
444
- const mingoQuery = new Query(mongoQuery);
445
- targetRecords = mingoQuery.find(targetRecords).all();
446
- }
447
- }
448
-
449
- const count = targetRecords.length;
450
-
451
- for (const record of targetRecords) {
452
- const index = table.findIndex(r => r.id === record.id);
453
- if (index !== -1) {
454
- const updated = {
455
- ...table[index],
456
- ...data,
457
- updated_at: new Date().toISOString()
458
- };
459
- table[index] = updated;
460
- }
461
- }
462
-
463
- if (count > 0) this.markDirty();
464
- this.logger.debug('UpdateMany completed', { object, count });
465
- return count;
466
- }
467
-
468
- async deleteMany(object: string, query: QueryAST, options?: DriverOptions): Promise<number> {
469
- this.logger.debug('DeleteMany operation', { object, query });
470
-
471
- const table = this.getTable(object);
472
- const initialLength = table.length;
473
-
474
- if (query && query.where) {
475
- const mongoQuery = this.convertToMongoQuery(query.where);
476
- if (mongoQuery && Object.keys(mongoQuery).length > 0) {
477
- const mingoQuery = new Query(mongoQuery);
478
- const matched = mingoQuery.find(table).all();
479
- const matchedIds = new Set(matched.map((r: any) => r.id));
480
- this.db[object] = table.filter(r => !matchedIds.has(r.id));
481
- } else {
482
- // Empty query = delete all
483
- this.db[object] = [];
484
- }
485
- } else {
486
- // No where clause = delete all
487
- this.db[object] = [];
488
- }
489
-
490
- const count = initialLength - this.db[object].length;
491
- if (count > 0) this.markDirty();
492
- this.logger.debug('DeleteMany completed', { object, count });
493
- return count;
494
- }
495
-
496
- // Compatibility aliases
497
- async bulkUpdate(object: string, updates: { id: string | number, data: Record<string, any> }[], options?: DriverOptions) {
498
- this.logger.debug('BulkUpdate operation', { object, count: updates.length });
499
- const results = await Promise.all(updates.map(u => this.update(object, u.id, u.data, options)));
500
- this.logger.debug('BulkUpdate completed', { object, count: results.length });
501
- return results;
502
- }
503
-
504
- async bulkDelete(object: string, ids: (string | number)[], options?: DriverOptions) {
505
- this.logger.debug('BulkDelete operation', { object, count: ids.length });
506
- await Promise.all(ids.map(id => this.delete(object, id, options)));
507
- this.logger.debug('BulkDelete completed', { object, count: ids.length });
508
- }
509
-
510
- // ===================================
511
- // Transaction Management
512
- // ===================================
513
-
514
- async beginTransaction() {
515
- const txId = `tx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
516
-
517
- // Deep-clone current database state as a snapshot
518
- const snapshot: Record<string, any[]> = {};
519
- for (const [table, records] of Object.entries(this.db)) {
520
- snapshot[table] = records.map(r => ({ ...r }));
521
- }
522
-
523
- const transaction: MemoryTransaction = { id: txId, snapshot };
524
- this.transactions.set(txId, transaction);
525
- this.logger.debug('Transaction started', { txId });
526
- return { id: txId };
527
- }
528
-
529
- async commit(txHandle?: unknown) {
530
- const txId = (txHandle as any)?.id;
531
- if (!txId || !this.transactions.has(txId)) {
532
- this.logger.warn('Commit called with unknown transaction');
533
- return;
534
- }
535
- // Data is already in the store; just remove the snapshot
536
- this.transactions.delete(txId);
537
- this.logger.debug('Transaction committed', { txId });
538
- }
539
-
540
- async rollback(txHandle?: unknown) {
541
- const txId = (txHandle as any)?.id;
542
- if (!txId || !this.transactions.has(txId)) {
543
- this.logger.warn('Rollback called with unknown transaction');
544
- return;
545
- }
546
- const tx = this.transactions.get(txId)!;
547
- // Restore the snapshot
548
- this.db = tx.snapshot;
549
- this.transactions.delete(txId);
550
- this.markDirty();
551
- this.logger.debug('Transaction rolled back', { txId });
552
- }
553
-
554
- // ===================================
555
- // Utility Methods
556
- // ===================================
557
-
558
- /**
559
- * Remove all data from the store.
560
- */
561
- async clear() {
562
- this.db = {};
563
- this.idCounters.clear();
564
- this.markDirty();
565
- this.logger.debug('All data cleared');
566
- }
567
-
568
- /**
569
- * Get total number of records across all tables.
570
- */
571
- getSize(): number {
572
- return Object.values(this.db).reduce((sum, table) => sum + table.length, 0);
573
- }
574
-
575
- /**
576
- * Get distinct values for a field, optionally filtered.
577
- */
578
- async distinct(object: string, field: string, query?: QueryInput): Promise<any[]> {
579
- let records = this.getTable(object);
580
- if (query?.where) {
581
- const mongoQuery = this.convertToMongoQuery(query.where);
582
- if (mongoQuery && Object.keys(mongoQuery).length > 0) {
583
- const mingoQuery = new Query(mongoQuery);
584
- records = mingoQuery.find(records).all();
585
- }
586
- }
587
- const values = new Set<any>();
588
- for (const record of records) {
589
- const value = getValueByPath(record, field);
590
- if (value !== undefined && value !== null) {
591
- values.add(value);
592
- }
593
- }
594
- return Array.from(values);
595
- }
596
-
597
- /**
598
- * Execute a MongoDB-style aggregation pipeline using Mingo.
599
- *
600
- * Supports all standard MongoDB pipeline stages:
601
- * - $match, $group, $sort, $project, $unwind, $limit, $skip
602
- * - $addFields, $replaceRoot, $lookup (limited), $count
603
- * - Accumulator operators: $sum, $avg, $min, $max, $first, $last, $push, $addToSet
604
- *
605
- * @example
606
- * // Group by status and count
607
- * const results = await driver.aggregate('orders', [
608
- * { $match: { status: 'completed' } },
609
- * { $group: { _id: '$customer', totalAmount: { $sum: '$amount' } } }
610
- * ]);
611
- *
612
- * @example
613
- * // Calculate average with filter
614
- * const results = await driver.aggregate('products', [
615
- * { $match: { category: 'electronics' } },
616
- * { $group: { _id: null, avgPrice: { $avg: '$price' } } }
617
- * ]);
618
- */
619
- async aggregate(object: string, pipeline: Record<string, any>[], options?: DriverOptions): Promise<any[]> {
620
- this.logger.debug('Aggregate operation', { object, stageCount: pipeline.length });
621
-
622
- const records = this.getTable(object).map(r => ({ ...r }));
623
- const aggregator = new Aggregator(pipeline);
624
- const results = aggregator.run(records);
625
-
626
- this.logger.debug('Aggregate completed', { object, resultCount: results.length });
627
- return results;
628
- }
629
-
630
- // ===================================
631
- // Query Conversion (ObjectQL → MongoDB)
632
- // ===================================
633
-
634
- /**
635
- * Convert ObjectQL filter format to MongoDB query format for Mingo.
636
- *
637
- * Supports:
638
- * 1. AST Comparison Node: { type: 'comparison', field, operator, value }
639
- * 2. AST Logical Node: { type: 'logical', operator: 'and'|'or', conditions: [...] }
640
- * 3. Legacy Array Format: [['field', 'op', value], 'and', ['field2', 'op', value2]]
641
- * 4. MongoDB Format: { field: value } or { field: { $eq: value } } (passthrough)
642
- */
643
- private convertToMongoQuery(filters?: any): Record<string, any> {
644
- if (!filters) return {};
645
-
646
- // AST node format (ObjectQL QueryAST)
647
- if (!Array.isArray(filters) && typeof filters === 'object') {
648
- if (filters.type === 'comparison') {
649
- return this.convertConditionToMongo(filters.field, filters.operator, filters.value) || {};
650
- }
651
- if (filters.type === 'logical') {
652
- const conditions = filters.conditions?.map((c: any) => this.convertToMongoQuery(c)) || [];
653
- if (conditions.length === 0) return {};
654
- if (conditions.length === 1) return conditions[0];
655
- const op = filters.operator === 'or' ? '$or' : '$and';
656
- return { [op]: conditions };
657
- }
658
- // MongoDB/FilterCondition format: { field: value } or { field: { $op: value } }
659
- // Translate non-standard operators ($contains, $notContains, etc.) to Mingo-compatible format
660
- return this.normalizeFilterCondition(filters);
661
- }
662
-
663
- // Legacy array format
664
- if (!Array.isArray(filters) || filters.length === 0) return {};
665
-
666
- const logicGroups: { logic: 'and' | 'or'; conditions: Record<string, any>[] }[] = [
667
- { logic: 'and', conditions: [] },
668
- ];
669
- let currentLogic: 'and' | 'or' = 'and';
670
-
671
- for (const item of filters) {
672
- if (typeof item === 'string') {
673
- const newLogic = item.toLowerCase() as 'and' | 'or';
674
- if (newLogic !== currentLogic) {
675
- currentLogic = newLogic;
676
- logicGroups.push({ logic: currentLogic, conditions: [] });
677
- }
678
- } else if (Array.isArray(item)) {
679
- const [field, operator, value] = item;
680
- const cond = this.convertConditionToMongo(field, operator, value);
681
- if (cond) logicGroups[logicGroups.length - 1].conditions.push(cond);
682
- }
683
- }
684
-
685
- const allConditions: Record<string, any>[] = [];
686
- for (const group of logicGroups) {
687
- if (group.conditions.length === 0) continue;
688
- if (group.conditions.length === 1) {
689
- allConditions.push(group.conditions[0]);
690
- } else {
691
- const op = group.logic === 'or' ? '$or' : '$and';
692
- allConditions.push({ [op]: group.conditions });
693
- }
694
- }
695
-
696
- if (allConditions.length === 0) return {};
697
- if (allConditions.length === 1) return allConditions[0];
698
- return { $and: allConditions };
699
- }
700
-
701
- /**
702
- * Convert a single ObjectQL condition to MongoDB operator format.
703
- */
704
- private convertConditionToMongo(field: string, operator: string, value: any): Record<string, any> | null {
705
- switch (operator) {
706
- case '=': case '==':
707
- return { [field]: value };
708
- case '!=': case '<>':
709
- return { [field]: { $ne: value } };
710
- case '>':
711
- return { [field]: { $gt: value } };
712
- case '>=':
713
- return { [field]: { $gte: value } };
714
- case '<':
715
- return { [field]: { $lt: value } };
716
- case '<=':
717
- return { [field]: { $lte: value } };
718
- case 'in':
719
- return { [field]: { $in: value } };
720
- case 'nin': case 'not in':
721
- return { [field]: { $nin: value } };
722
- case 'contains': case 'like':
723
- return { [field]: { $regex: new RegExp(this.escapeRegex(value), 'i') } };
724
- case 'notcontains': case 'not_contains':
725
- return { [field]: { $not: { $regex: new RegExp(this.escapeRegex(value), 'i') } } };
726
- case 'startswith': case 'starts_with':
727
- return { [field]: { $regex: new RegExp(`^${this.escapeRegex(value)}`, 'i') } };
728
- case 'endswith': case 'ends_with':
729
- return { [field]: { $regex: new RegExp(`${this.escapeRegex(value)}$`, 'i') } };
730
- case 'between':
731
- if (Array.isArray(value) && value.length === 2) {
732
- return { [field]: { $gte: value[0], $lte: value[1] } };
733
- }
734
- return null;
735
- default:
736
- return null;
737
- }
738
- }
739
-
740
- /**
741
- * Normalize a FilterCondition object by converting non-standard $-prefixed
742
- * operators ($contains, $notContains, $startsWith, $endsWith, $between, $null)
743
- * to Mingo-compatible equivalents ($regex, $gte/$lte, null checks).
744
- */
745
- private normalizeFilterCondition(filter: Record<string, any>): Record<string, any> {
746
- const result: Record<string, any> = {};
747
- const extraAndConditions: Record<string, any>[] = [];
748
-
749
- for (const key of Object.keys(filter)) {
750
- const value = filter[key];
751
- // Recurse into logical operators
752
- if (key === '$and' || key === '$or') {
753
- result[key] = Array.isArray(value)
754
- ? value.map((child: any) => this.normalizeFilterCondition(child))
755
- : value;
756
- continue;
757
- }
758
- if (key === '$not') {
759
- result[key] = value && typeof value === 'object'
760
- ? this.normalizeFilterCondition(value)
761
- : value;
762
- continue;
763
- }
764
- // Skip $-prefixed keys that aren't field names (already handled or unknown)
765
- if (key.startsWith('$')) {
766
- result[key] = value;
767
- continue;
768
- }
769
- // Field-level: value may be primitive (implicit eq) or operator object
770
- if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof RegExp)) {
771
- const normalized = this.normalizeFieldOperators(value);
772
- // Handle multiple regex conditions on the same field (e.g. $startsWith + $endsWith)
773
- if (normalized._multiRegex) {
774
- const regexConditions: Record<string, any>[] = normalized._multiRegex;
775
- delete normalized._multiRegex;
776
- // Each regex becomes its own { field: { $regex: ... } } inside $and
777
- for (const rc of regexConditions) {
778
- extraAndConditions.push({ [key]: { ...normalized, ...rc } });
779
- }
780
- } else {
781
- result[key] = normalized;
782
- }
783
- } else {
784
- result[key] = value;
785
- }
786
- }
787
-
788
- // Merge extra $and conditions from multi-regex fields
789
- if (extraAndConditions.length > 0) {
790
- const existing = result.$and;
791
- const andArray = Array.isArray(existing) ? existing : [];
792
- // Include the rest of result as a condition too
793
- if (Object.keys(result).filter(k => k !== '$and').length > 0) {
794
- const rest = { ...result };
795
- delete rest.$and;
796
- andArray.push(rest);
797
- }
798
- andArray.push(...extraAndConditions);
799
- return { $and: andArray };
800
- }
801
-
802
- return result;
803
- }
804
-
805
- /**
806
- * Convert non-standard field operators to Mingo-compatible format.
807
- * When multiple regex-producing operators appear on the same field
808
- * (e.g. $startsWith + $endsWith), they are combined via $and.
809
- */
810
- private normalizeFieldOperators(ops: Record<string, any>): Record<string, any> {
811
- const result: Record<string, any> = {};
812
- const regexConditions: Record<string, any>[] = [];
813
-
814
- for (const op of Object.keys(ops)) {
815
- const val = ops[op];
816
- switch (op) {
817
- case '$contains':
818
- regexConditions.push({ $regex: new RegExp(this.escapeRegex(val), 'i') });
819
- break;
820
- case '$notContains':
821
- result.$not = { $regex: new RegExp(this.escapeRegex(val), 'i') };
822
- break;
823
- case '$startsWith':
824
- regexConditions.push({ $regex: new RegExp(`^${this.escapeRegex(val)}`, 'i') });
825
- break;
826
- case '$endsWith':
827
- regexConditions.push({ $regex: new RegExp(`${this.escapeRegex(val)}$`, 'i') });
828
- break;
829
- case '$between':
830
- if (Array.isArray(val) && val.length === 2) {
831
- result.$gte = val[0];
832
- result.$lte = val[1];
833
- }
834
- break;
835
- case '$null':
836
- // $null: true → field is null, $null: false → field is not null
837
- // Use $eq/$ne null for Mingo compatibility
838
- if (val === true) {
839
- result.$eq = null;
840
- } else {
841
- result.$ne = null;
842
- }
843
- break;
844
- default:
845
- result[op] = val;
846
- break;
847
- }
848
- }
849
-
850
- // Merge regex conditions: single → inline, multiple → wrap with $and
851
- if (regexConditions.length === 1) {
852
- Object.assign(result, regexConditions[0]);
853
- } else if (regexConditions.length > 1) {
854
- // Cannot have multiple $regex on one object; promote to top-level $and.
855
- // _multiRegex is an internal sentinel consumed by normalizeFilterCondition().
856
- result._multiRegex = regexConditions;
857
- }
858
-
859
- return result;
860
- }
861
-
862
- /**
863
- * Escape special regex characters for safe literal matching.
864
- */
865
- private escapeRegex(str: string): string {
866
- return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
867
- }
868
-
869
- // ===================================
870
- // Aggregation Logic
871
- // ===================================
872
-
873
- private performAggregation(records: any[], query: QueryInput): any[] {
874
- const { groupBy, aggregations } = query;
875
- const groups: Map<string, any[]> = new Map();
876
-
877
- // 1. Group records
878
- if (groupBy && groupBy.length > 0) {
879
- for (const record of records) {
880
- // Create a composite key from group values
881
- const keyParts = groupBy.map(field => {
882
- const val = getValueByPath(record, field);
883
- return val === undefined || val === null ? 'null' : String(val);
884
- });
885
- const key = JSON.stringify(keyParts);
886
-
887
- if (!groups.has(key)) {
888
- groups.set(key, []);
889
- }
890
- groups.get(key)!.push(record);
891
- }
892
- } else {
893
- groups.set('all', records);
894
- }
895
-
896
- // 2. Compute aggregates for each group
897
- const resultRows: any[] = [];
898
-
899
- for (const [_key, groupRecords] of groups.entries()) {
900
- const row: any = {};
901
-
902
- // A. Add Group fields to row (if groupBy exists)
903
- if (groupBy && groupBy.length > 0) {
904
- if (groupRecords.length > 0) {
905
- const firstRecord = groupRecords[0];
906
- for (const field of groupBy) {
907
- this.setValueByPath(row, field, getValueByPath(firstRecord, field));
908
- }
909
- }
910
- }
911
-
912
- // B. Compute Aggregations
913
- if (aggregations) {
914
- for (const agg of aggregations) {
915
- const value = this.computeAggregate(groupRecords, agg);
916
- row[agg.alias] = value;
917
- }
918
- }
919
-
920
- resultRows.push(row);
921
- }
922
-
923
- return resultRows;
924
- }
925
-
926
- private computeAggregate(records: any[], agg: any): any {
927
- const { function: func, field } = agg;
928
-
929
- const values = field ? records.map(r => getValueByPath(r, field)) : [];
930
-
931
- switch (func) {
932
- case 'count':
933
- if (!field || field === '*') return records.length;
934
- return values.filter(v => v !== null && v !== undefined).length;
935
-
936
- case 'sum':
937
- case 'avg': {
938
- const nums = values.filter(v => typeof v === 'number');
939
- const sum = nums.reduce((a, b) => a + b, 0);
940
- if (func === 'sum') return sum;
941
- return nums.length > 0 ? sum / nums.length : null;
942
- }
943
-
944
- case 'min': {
945
- // Handle comparable values
946
- const valid = values.filter(v => v !== null && v !== undefined);
947
- if (valid.length === 0) return null;
948
- // Works for numbers and strings
949
- return valid.reduce((min, v) => (v < min ? v : min), valid[0]);
950
- }
951
-
952
- case 'max': {
953
- const valid = values.filter(v => v !== null && v !== undefined);
954
- if (valid.length === 0) return null;
955
- return valid.reduce((max, v) => (v > max ? v : max), valid[0]);
956
- }
957
-
958
- default:
959
- return null;
960
- }
961
- }
962
-
963
- private setValueByPath(obj: any, path: string, value: any) {
964
- const parts = path.split('.');
965
- let current = obj;
966
- for (let i = 0; i < parts.length - 1; i++) {
967
- const part = parts[i];
968
- if (!current[part]) current[part] = {};
969
- current = current[part];
970
- }
971
- current[parts[parts.length - 1]] = value;
972
- }
973
-
974
- // ===================================
975
- // Schema Management
976
- // ===================================
977
-
978
- async syncSchema(object: string, schema: any, options?: DriverOptions) {
979
- if (!this.db[object]) {
980
- this.db[object] = [];
981
- this.logger.info('Created in-memory table', { object });
982
- }
983
- }
984
-
985
- async dropTable(object: string, options?: DriverOptions) {
986
- if (this.db[object]) {
987
- const recordCount = this.db[object].length;
988
- delete this.db[object];
989
- this.logger.info('Dropped in-memory table', { object, recordCount });
990
- }
991
- }
992
-
993
- // ===================================
994
- // Helpers
995
- // ===================================
996
-
997
- /**
998
- * Apply manual sorting (Mingo sort has CJS build issues).
999
- */
1000
- private applySort(records: any[], sortFields: any[]): any[] {
1001
- const sorted = [...records];
1002
- for (let i = sortFields.length - 1; i >= 0; i--) {
1003
- const sortItem = sortFields[i];
1004
- let field: string;
1005
- let direction: string;
1006
- if (typeof sortItem === 'object' && !Array.isArray(sortItem)) {
1007
- field = sortItem.field;
1008
- direction = sortItem.order || sortItem.direction || 'asc';
1009
- } else if (Array.isArray(sortItem)) {
1010
- [field, direction] = sortItem;
1011
- } else {
1012
- continue;
1013
- }
1014
- sorted.sort((a, b) => {
1015
- const aVal = getValueByPath(a, field);
1016
- const bVal = getValueByPath(b, field);
1017
- if (aVal == null && bVal == null) return 0;
1018
- if (aVal == null) return 1;
1019
- if (bVal == null) return -1;
1020
- if (aVal < bVal) return direction === 'desc' ? 1 : -1;
1021
- if (aVal > bVal) return direction === 'desc' ? -1 : 1;
1022
- return 0;
1023
- });
1024
- }
1025
- return sorted;
1026
- }
1027
-
1028
- /**
1029
- * Project specific fields from a record.
1030
- */
1031
- private projectFields(record: any, fields: string[]): any {
1032
- const result: any = {};
1033
- for (const field of fields) {
1034
- const value = getValueByPath(record, field);
1035
- if (value !== undefined) {
1036
- result[field] = value;
1037
- }
1038
- }
1039
- // Always include id if not explicitly listed
1040
- if (!fields.includes('id') && record.id !== undefined) {
1041
- result.id = record.id;
1042
- }
1043
- return result;
1044
- }
1045
-
1046
- private getTable(name: string) {
1047
- if (!this.db[name]) {
1048
- this.db[name] = [];
1049
- }
1050
- return this.db[name];
1051
- }
1052
-
1053
- private generateId(objectName?: string) {
1054
- const key = objectName || '_global';
1055
- const counter = (this.idCounters.get(key) || 0) + 1;
1056
- this.idCounters.set(key, counter);
1057
- const timestamp = Date.now();
1058
- return `${key}-${timestamp}-${counter}`;
1059
- }
1060
-
1061
- // ===================================
1062
- // Persistence
1063
- // ===================================
1064
-
1065
- /**
1066
- * Mark the database as dirty, triggering persistence save.
1067
- */
1068
- private markDirty(): void {
1069
- if (this.persistenceAdapter) {
1070
- this.persistenceAdapter.save(this.db);
1071
- }
1072
- }
1073
-
1074
- /**
1075
- * Flush pending persistence writes to ensure data is safely stored.
1076
- */
1077
- async flush(): Promise<void> {
1078
- if (this.persistenceAdapter) {
1079
- await this.persistenceAdapter.flush();
1080
- }
1081
- }
1082
-
1083
- /**
1084
- * Detect whether the current runtime is a browser environment.
1085
- */
1086
- private isBrowserEnvironment(): boolean {
1087
- return typeof globalThis.localStorage !== 'undefined';
1088
- }
1089
-
1090
- /**
1091
- * Detect whether the current runtime is a serverless/edge environment.
1092
- *
1093
- * Checks well-known environment variables set by serverless platforms:
1094
- * - `VERCEL` / `VERCEL_ENV` — Vercel Functions / Edge
1095
- * - `AWS_LAMBDA_FUNCTION_NAME` — AWS Lambda
1096
- * - `NETLIFY` — Netlify Functions
1097
- * - `FUNCTIONS_WORKER_RUNTIME` — Azure Functions
1098
- * - `K_SERVICE` — Google Cloud Run / Cloud Functions
1099
- * - `FUNCTION_TARGET` — Google Cloud Functions (Node.js)
1100
- * - `DENO_DEPLOYMENT_ID` — Deno Deploy
1101
- *
1102
- * Returns `false` when `process` or `process.env` is unavailable
1103
- * (e.g. browser or edge runtimes without a Node.js process object).
1104
- */
1105
- private isServerlessEnvironment(): boolean {
1106
- if (typeof globalThis.process === 'undefined' || !globalThis.process.env) {
1107
- return false;
1108
- }
1109
- const env = globalThis.process.env;
1110
- return !!(
1111
- env.VERCEL ||
1112
- env.VERCEL_ENV ||
1113
- env.AWS_LAMBDA_FUNCTION_NAME ||
1114
- env.NETLIFY ||
1115
- env.FUNCTIONS_WORKER_RUNTIME ||
1116
- env.K_SERVICE ||
1117
- env.FUNCTION_TARGET ||
1118
- env.DENO_DEPLOYMENT_ID
1119
- );
1120
- }
1121
-
1122
- private static readonly SERVERLESS_PERSISTENCE_WARNING =
1123
- 'Serverless environment detected — file-system persistence is disabled in auto mode. ' +
1124
- 'Data will NOT be persisted across function invocations. ' +
1125
- 'Set persistence: false to silence this warning, or provide a custom adapter ' +
1126
- '(e.g. Upstash Redis, Vercel KV) via persistence: { adapter: yourAdapter }.';
1127
-
1128
- /**
1129
- * Initialize the persistence adapter based on configuration.
1130
- * Defaults to 'auto' when persistence is not specified.
1131
- * Use `persistence: false` to explicitly disable persistence.
1132
- *
1133
- * In serverless environments (Vercel, AWS Lambda, etc.), auto mode disables
1134
- * file-system persistence and emits a warning. Use `persistence: false` or
1135
- * supply a custom adapter for serverless-safe operation.
1136
- */
1137
- private async initPersistence(): Promise<void> {
1138
- const persistence = this.config.persistence === undefined ? 'auto' : this.config.persistence;
1139
- if (persistence === false) return;
1140
-
1141
- if (typeof persistence === 'string') {
1142
- if (persistence === 'auto') {
1143
- if (this.isBrowserEnvironment()) {
1144
- const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
1145
- this.persistenceAdapter = new LocalStoragePersistenceAdapter();
1146
- this.logger.debug('Auto-detected browser environment, using localStorage persistence');
1147
- } else if (this.isServerlessEnvironment()) {
1148
- this.logger.warn(InMemoryDriver.SERVERLESS_PERSISTENCE_WARNING);
1149
- } else {
1150
- const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
1151
- this.persistenceAdapter = new FileSystemPersistenceAdapter();
1152
- this.logger.debug('Auto-detected Node.js environment, using file persistence');
1153
- }
1154
- } else if (persistence === 'file') {
1155
- const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
1156
- this.persistenceAdapter = new FileSystemPersistenceAdapter();
1157
- } else if (persistence === 'local') {
1158
- const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
1159
- this.persistenceAdapter = new LocalStoragePersistenceAdapter();
1160
- } else {
1161
- throw new Error(`Unknown persistence type: "${persistence}". Use 'file', 'local', or 'auto'.`);
1162
- }
1163
- } else if ('adapter' in persistence && persistence.adapter) {
1164
- this.persistenceAdapter = persistence.adapter;
1165
- } else if ('type' in persistence) {
1166
- if (persistence.type === 'auto') {
1167
- if (this.isBrowserEnvironment()) {
1168
- const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
1169
- this.persistenceAdapter = new LocalStoragePersistenceAdapter({
1170
- key: persistence.key,
1171
- });
1172
- this.logger.debug('Auto-detected browser environment, using localStorage persistence');
1173
- } else if (this.isServerlessEnvironment()) {
1174
- this.logger.warn(InMemoryDriver.SERVERLESS_PERSISTENCE_WARNING);
1175
- } else {
1176
- const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
1177
- this.persistenceAdapter = new FileSystemPersistenceAdapter({
1178
- path: persistence.path,
1179
- autoSaveInterval: persistence.autoSaveInterval,
1180
- });
1181
- this.logger.debug('Auto-detected Node.js environment, using file persistence');
1182
- }
1183
- } else if (persistence.type === 'file') {
1184
- const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
1185
- this.persistenceAdapter = new FileSystemPersistenceAdapter({
1186
- path: persistence.path,
1187
- autoSaveInterval: persistence.autoSaveInterval,
1188
- });
1189
- } else if (persistence.type === 'local') {
1190
- const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
1191
- this.persistenceAdapter = new LocalStoragePersistenceAdapter({
1192
- key: persistence.key,
1193
- });
1194
- }
1195
- }
1196
-
1197
- if (this.persistenceAdapter) {
1198
- this.logger.debug('Persistence adapter initialized');
1199
- }
1200
- }
1201
- }