@objectstack/driver-memory 4.0.4 → 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.
- package/README.md +89 -8
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +14 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +31 -6
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -617
- package/objectstack.config.ts +0 -260
- package/src/in-memory-strategy.ts +0 -47
- package/src/index.ts +0 -32
- package/src/memory-analytics.test.ts +0 -346
- package/src/memory-analytics.ts +0 -518
- package/src/memory-driver.test.ts +0 -722
- package/src/memory-driver.ts +0 -1206
- package/src/memory-matcher.ts +0 -177
- package/src/persistence/file-adapter.ts +0 -103
- package/src/persistence/index.ts +0 -4
- package/src/persistence/local-storage-adapter.ts +0 -60
- package/src/persistence/persistence.test.ts +0 -298
- package/tsconfig.json +0 -27
- package/vitest.config.ts +0 -22
package/src/memory-driver.ts
DELETED
|
@@ -1,1206 +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
|
-
* Checks for window, document AND a functional localStorage to avoid
|
|
1086
|
-
* false positives in Node.js runtimes that partially polyfill globals.
|
|
1087
|
-
*/
|
|
1088
|
-
private isBrowserEnvironment(): boolean {
|
|
1089
|
-
const g = globalThis as any;
|
|
1090
|
-
return typeof g.window !== 'undefined'
|
|
1091
|
-
&& typeof g.document !== 'undefined'
|
|
1092
|
-
&& typeof g.localStorage?.setItem === 'function';
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
/**
|
|
1096
|
-
* Detect whether the current runtime is a serverless/edge environment.
|
|
1097
|
-
*
|
|
1098
|
-
* Checks well-known environment variables set by serverless platforms:
|
|
1099
|
-
* - `VERCEL` / `VERCEL_ENV` — Vercel Functions / Edge
|
|
1100
|
-
* - `AWS_LAMBDA_FUNCTION_NAME` — AWS Lambda
|
|
1101
|
-
* - `NETLIFY` — Netlify Functions
|
|
1102
|
-
* - `FUNCTIONS_WORKER_RUNTIME` — Azure Functions
|
|
1103
|
-
* - `K_SERVICE` — Google Cloud Run / Cloud Functions
|
|
1104
|
-
* - `FUNCTION_TARGET` — Google Cloud Functions (Node.js)
|
|
1105
|
-
* - `DENO_DEPLOYMENT_ID` — Deno Deploy
|
|
1106
|
-
*
|
|
1107
|
-
* Returns `false` when `process` or `process.env` is unavailable
|
|
1108
|
-
* (e.g. browser or edge runtimes without a Node.js process object).
|
|
1109
|
-
*/
|
|
1110
|
-
private isServerlessEnvironment(): boolean {
|
|
1111
|
-
if (typeof globalThis.process === 'undefined' || !globalThis.process.env) {
|
|
1112
|
-
return false;
|
|
1113
|
-
}
|
|
1114
|
-
const env = globalThis.process.env;
|
|
1115
|
-
return !!(
|
|
1116
|
-
env.VERCEL ||
|
|
1117
|
-
env.VERCEL_ENV ||
|
|
1118
|
-
env.AWS_LAMBDA_FUNCTION_NAME ||
|
|
1119
|
-
env.NETLIFY ||
|
|
1120
|
-
env.FUNCTIONS_WORKER_RUNTIME ||
|
|
1121
|
-
env.K_SERVICE ||
|
|
1122
|
-
env.FUNCTION_TARGET ||
|
|
1123
|
-
env.DENO_DEPLOYMENT_ID
|
|
1124
|
-
);
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
private static readonly SERVERLESS_PERSISTENCE_WARNING =
|
|
1128
|
-
'Serverless environment detected — file-system persistence is disabled in auto mode. ' +
|
|
1129
|
-
'Data will NOT be persisted across function invocations. ' +
|
|
1130
|
-
'Set persistence: false to silence this warning, or provide a custom adapter ' +
|
|
1131
|
-
'(e.g. Upstash Redis, Vercel KV) via persistence: { adapter: yourAdapter }.';
|
|
1132
|
-
|
|
1133
|
-
/**
|
|
1134
|
-
* Initialize the persistence adapter based on configuration.
|
|
1135
|
-
* Defaults to 'auto' when persistence is not specified.
|
|
1136
|
-
* Use `persistence: false` to explicitly disable persistence.
|
|
1137
|
-
*
|
|
1138
|
-
* In serverless environments (Vercel, AWS Lambda, etc.), auto mode disables
|
|
1139
|
-
* file-system persistence and emits a warning. Use `persistence: false` or
|
|
1140
|
-
* supply a custom adapter for serverless-safe operation.
|
|
1141
|
-
*/
|
|
1142
|
-
private async initPersistence(): Promise<void> {
|
|
1143
|
-
const persistence = this.config.persistence === undefined ? 'auto' : this.config.persistence;
|
|
1144
|
-
if (persistence === false) return;
|
|
1145
|
-
|
|
1146
|
-
if (typeof persistence === 'string') {
|
|
1147
|
-
if (persistence === 'auto') {
|
|
1148
|
-
if (this.isBrowserEnvironment()) {
|
|
1149
|
-
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
|
|
1150
|
-
this.persistenceAdapter = new LocalStoragePersistenceAdapter();
|
|
1151
|
-
this.logger.debug('Auto-detected browser environment, using localStorage persistence');
|
|
1152
|
-
} else if (this.isServerlessEnvironment()) {
|
|
1153
|
-
this.logger.warn(InMemoryDriver.SERVERLESS_PERSISTENCE_WARNING);
|
|
1154
|
-
} else {
|
|
1155
|
-
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
|
|
1156
|
-
this.persistenceAdapter = new FileSystemPersistenceAdapter();
|
|
1157
|
-
this.logger.debug('Auto-detected Node.js environment, using file persistence');
|
|
1158
|
-
}
|
|
1159
|
-
} else if (persistence === 'file') {
|
|
1160
|
-
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
|
|
1161
|
-
this.persistenceAdapter = new FileSystemPersistenceAdapter();
|
|
1162
|
-
} else if (persistence === 'local') {
|
|
1163
|
-
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
|
|
1164
|
-
this.persistenceAdapter = new LocalStoragePersistenceAdapter();
|
|
1165
|
-
} else {
|
|
1166
|
-
throw new Error(`Unknown persistence type: "${persistence}". Use 'file', 'local', or 'auto'.`);
|
|
1167
|
-
}
|
|
1168
|
-
} else if ('adapter' in persistence && persistence.adapter) {
|
|
1169
|
-
this.persistenceAdapter = persistence.adapter;
|
|
1170
|
-
} else if ('type' in persistence) {
|
|
1171
|
-
if (persistence.type === 'auto') {
|
|
1172
|
-
if (this.isBrowserEnvironment()) {
|
|
1173
|
-
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
|
|
1174
|
-
this.persistenceAdapter = new LocalStoragePersistenceAdapter({
|
|
1175
|
-
key: persistence.key,
|
|
1176
|
-
});
|
|
1177
|
-
this.logger.debug('Auto-detected browser environment, using localStorage persistence');
|
|
1178
|
-
} else if (this.isServerlessEnvironment()) {
|
|
1179
|
-
this.logger.warn(InMemoryDriver.SERVERLESS_PERSISTENCE_WARNING);
|
|
1180
|
-
} else {
|
|
1181
|
-
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
|
|
1182
|
-
this.persistenceAdapter = new FileSystemPersistenceAdapter({
|
|
1183
|
-
path: persistence.path,
|
|
1184
|
-
autoSaveInterval: persistence.autoSaveInterval,
|
|
1185
|
-
});
|
|
1186
|
-
this.logger.debug('Auto-detected Node.js environment, using file persistence');
|
|
1187
|
-
}
|
|
1188
|
-
} else if (persistence.type === 'file') {
|
|
1189
|
-
const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js');
|
|
1190
|
-
this.persistenceAdapter = new FileSystemPersistenceAdapter({
|
|
1191
|
-
path: persistence.path,
|
|
1192
|
-
autoSaveInterval: persistence.autoSaveInterval,
|
|
1193
|
-
});
|
|
1194
|
-
} else if (persistence.type === 'local') {
|
|
1195
|
-
const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js');
|
|
1196
|
-
this.persistenceAdapter = new LocalStoragePersistenceAdapter({
|
|
1197
|
-
key: persistence.key,
|
|
1198
|
-
});
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
if (this.persistenceAdapter) {
|
|
1203
|
-
this.logger.debug('Persistence adapter initialized');
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
}
|