@objectstack/driver-memory 2.0.6 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,34 +1,8 @@
1
1
  // src/memory-driver.ts
2
2
  import { createLogger } from "@objectstack/core";
3
+ import { Query, Aggregator } from "mingo";
3
4
 
4
5
  // src/memory-matcher.ts
5
- function match(record, filter) {
6
- if (!filter || Object.keys(filter).length === 0) return true;
7
- if (Array.isArray(filter.$and)) {
8
- if (!filter.$and.every((f) => match(record, f))) {
9
- return false;
10
- }
11
- }
12
- if (Array.isArray(filter.$or)) {
13
- if (!filter.$or.some((f) => match(record, f))) {
14
- return false;
15
- }
16
- }
17
- if (filter.$not) {
18
- if (match(record, filter.$not)) {
19
- return false;
20
- }
21
- }
22
- for (const key of Object.keys(filter)) {
23
- if (key.startsWith("$")) continue;
24
- const condition = filter[key];
25
- const value = getValueByPath(record, key);
26
- if (!checkCondition(value, condition)) {
27
- return false;
28
- }
29
- }
30
- return true;
31
- }
32
6
  function getValueByPath(obj, path) {
33
7
  if (path === "_id" && obj._id === void 0 && obj.id !== void 0) {
34
8
  return obj.id;
@@ -36,89 +10,19 @@ function getValueByPath(obj, path) {
36
10
  if (!path.includes(".")) return obj[path];
37
11
  return path.split(".").reduce((o, i) => o ? o[i] : void 0, obj);
38
12
  }
39
- function checkCondition(value, condition) {
40
- if (typeof condition !== "object" || condition === null || condition instanceof Date || Array.isArray(condition)) {
41
- return value == condition;
42
- }
43
- const keys = Object.keys(condition);
44
- const isOperatorObject = keys.some((k) => k.startsWith("$"));
45
- if (!isOperatorObject) {
46
- return JSON.stringify(value) === JSON.stringify(condition);
47
- }
48
- for (const op of keys) {
49
- const target = condition[op];
50
- if (value === void 0 && op !== "$exists" && op !== "$ne") {
51
- return false;
52
- }
53
- switch (op) {
54
- case "$eq":
55
- if (value != target) return false;
56
- break;
57
- case "$ne":
58
- if (value == target) return false;
59
- break;
60
- // Numeric / Date
61
- case "$gt":
62
- if (!(value > target)) return false;
63
- break;
64
- case "$gte":
65
- if (!(value >= target)) return false;
66
- break;
67
- case "$lt":
68
- if (!(value < target)) return false;
69
- break;
70
- case "$lte":
71
- if (!(value <= target)) return false;
72
- break;
73
- case "$between":
74
- if (Array.isArray(target) && (value < target[0] || value > target[1])) return false;
75
- break;
76
- // Sets
77
- case "$in":
78
- if (!Array.isArray(target) || !target.includes(value)) return false;
79
- break;
80
- case "$nin":
81
- if (Array.isArray(target) && target.includes(value)) return false;
82
- break;
83
- // Existence
84
- case "$exists":
85
- const exists = value !== void 0 && value !== null;
86
- if (exists !== !!target) return false;
87
- break;
88
- // Strings
89
- case "$contains":
90
- if (typeof value !== "string" || !value.includes(target)) return false;
91
- break;
92
- case "$startsWith":
93
- if (typeof value !== "string" || !value.startsWith(target)) return false;
94
- break;
95
- case "$endsWith":
96
- if (typeof value !== "string" || !value.endsWith(target)) return false;
97
- break;
98
- case "$regex":
99
- try {
100
- const re = new RegExp(target, condition.$options || "");
101
- if (!re.test(String(value))) return false;
102
- } catch (e) {
103
- return false;
104
- }
105
- break;
106
- default:
107
- break;
108
- }
109
- }
110
- return true;
111
- }
112
13
 
113
14
  // src/memory-driver.ts
114
15
  var InMemoryDriver = class {
115
16
  constructor(config) {
116
17
  this.name = "com.objectstack.driver.memory";
117
18
  this.type = "driver";
118
- this.version = "0.0.1";
19
+ this.version = "1.0.0";
20
+ this.idCounters = /* @__PURE__ */ new Map();
21
+ this.transactions = /* @__PURE__ */ new Map();
119
22
  this.supports = {
120
23
  // Transaction & Connection Management
121
- transactions: false,
24
+ transactions: true,
25
+ // Snapshot-based transactions
122
26
  // Query Operations
123
27
  queryFilters: true,
124
28
  // Implemented via memory-matcher
@@ -152,7 +56,7 @@ var InMemoryDriver = class {
152
56
  this.db = {};
153
57
  this.config = config || {};
154
58
  this.logger = config?.logger || createLogger({ level: "info", format: "pretty" });
155
- this.logger.debug("InMemory driver instance created", { config: this.config });
59
+ this.logger.debug("InMemory driver instance created");
156
60
  }
157
61
  // Duck-typed RuntimePlugin hook
158
62
  install(ctx) {
@@ -168,7 +72,20 @@ var InMemoryDriver = class {
168
72
  // Lifecycle
169
73
  // ===================================
170
74
  async connect() {
171
- this.logger.info("InMemory Database Connected (Virtual)");
75
+ if (this.config.initialData) {
76
+ for (const [objectName, records] of Object.entries(this.config.initialData)) {
77
+ const table = this.getTable(objectName);
78
+ for (const record of records) {
79
+ const id = record.id || this.generateId(objectName);
80
+ table.push({ ...record, id });
81
+ }
82
+ }
83
+ this.logger.info("InMemory Database Connected with initial data", {
84
+ tables: Object.keys(this.config.initialData).length
85
+ });
86
+ } else {
87
+ this.logger.info("InMemory Database Connected (Virtual)");
88
+ }
172
89
  }
173
90
  async disconnect() {
174
91
  const tableCount = Object.keys(this.db).length;
@@ -199,25 +116,20 @@ var InMemoryDriver = class {
199
116
  async find(object, query, options) {
200
117
  this.logger.debug("Find operation", { object, query });
201
118
  const table = this.getTable(object);
202
- let results = table;
119
+ let results = [...table];
203
120
  if (query.where) {
204
- results = results.filter((record) => match(record, query.where));
121
+ const mongoQuery = this.convertToMongoQuery(query.where);
122
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
123
+ const mingoQuery = new Query(mongoQuery);
124
+ results = mingoQuery.find(results).all();
125
+ }
205
126
  }
206
127
  if (query.groupBy || query.aggregations && query.aggregations.length > 0) {
207
128
  results = this.performAggregation(results, query);
208
129
  }
209
130
  if (query.orderBy) {
210
131
  const sortFields = Array.isArray(query.orderBy) ? query.orderBy : [query.orderBy];
211
- results.sort((a, b) => {
212
- for (const { field, order } of sortFields) {
213
- const valA = getValueByPath(a, field);
214
- const valB = getValueByPath(b, field);
215
- if (valA === valB) continue;
216
- const comparison = valA > valB ? 1 : -1;
217
- return order === "desc" ? -comparison : comparison;
218
- }
219
- return 0;
220
- });
132
+ results = this.applySort(results, sortFields);
221
133
  }
222
134
  if (query.offset) {
223
135
  results = results.slice(query.offset);
@@ -225,6 +137,9 @@ var InMemoryDriver = class {
225
137
  if (query.limit) {
226
138
  results = results.slice(0, query.limit);
227
139
  }
140
+ if (query.fields && Array.isArray(query.fields) && query.fields.length > 0) {
141
+ results = results.map((record) => this.projectFields(record, query.fields));
142
+ }
228
143
  this.logger.debug("Find completed", { object, resultCount: results.length });
229
144
  return results;
230
145
  }
@@ -246,31 +161,38 @@ var InMemoryDriver = class {
246
161
  this.logger.debug("Create operation", { object, hasData: !!data });
247
162
  const table = this.getTable(object);
248
163
  const newRecord = {
249
- id: data.id || this.generateId(),
164
+ id: data.id || this.generateId(object),
250
165
  ...data,
251
- created_at: data.created_at || /* @__PURE__ */ new Date(),
252
- updated_at: data.updated_at || /* @__PURE__ */ new Date()
166
+ created_at: data.created_at || (/* @__PURE__ */ new Date()).toISOString(),
167
+ updated_at: data.updated_at || (/* @__PURE__ */ new Date()).toISOString()
253
168
  };
254
169
  table.push(newRecord);
255
170
  this.logger.debug("Record created", { object, id: newRecord.id, tableSize: table.length });
256
- return newRecord;
171
+ return { ...newRecord };
257
172
  }
258
173
  async update(object, id, data, options) {
259
174
  this.logger.debug("Update operation", { object, id });
260
175
  const table = this.getTable(object);
261
176
  const index = table.findIndex((r) => r.id == id);
262
177
  if (index === -1) {
263
- this.logger.warn("Record not found for update", { object, id });
264
- throw new Error(`Record with ID ${id} not found in ${object}`);
178
+ if (this.config.strictMode) {
179
+ this.logger.warn("Record not found for update", { object, id });
180
+ throw new Error(`Record with ID ${id} not found in ${object}`);
181
+ }
182
+ return null;
265
183
  }
266
184
  const updatedRecord = {
267
185
  ...table[index],
268
186
  ...data,
269
- updated_at: /* @__PURE__ */ new Date()
187
+ id: table[index].id,
188
+ // Preserve original ID
189
+ created_at: table[index].created_at,
190
+ // Preserve created_at
191
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
270
192
  };
271
193
  table[index] = updatedRecord;
272
194
  this.logger.debug("Record updated", { object, id });
273
- return updatedRecord;
195
+ return { ...updatedRecord };
274
196
  }
275
197
  async upsert(object, data, conflictKeys, options) {
276
198
  this.logger.debug("Upsert operation", { object, conflictKeys });
@@ -294,6 +216,9 @@ var InMemoryDriver = class {
294
216
  const table = this.getTable(object);
295
217
  const index = table.findIndex((r) => r.id == id);
296
218
  if (index === -1) {
219
+ if (this.config.strictMode) {
220
+ throw new Error(`Record with ID ${id} not found in ${object}`);
221
+ }
297
222
  this.logger.warn("Record not found for deletion", { object, id });
298
223
  return false;
299
224
  }
@@ -302,11 +227,15 @@ var InMemoryDriver = class {
302
227
  return true;
303
228
  }
304
229
  async count(object, query, options) {
305
- let results = this.getTable(object);
230
+ let records = this.getTable(object);
306
231
  if (query?.where) {
307
- results = results.filter((record) => match(record, query.where));
232
+ const mongoQuery = this.convertToMongoQuery(query.where);
233
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
234
+ const mingoQuery = new Query(mongoQuery);
235
+ records = mingoQuery.find(records).all();
236
+ }
308
237
  }
309
- const count = results.length;
238
+ const count = records.length;
310
239
  this.logger.debug("Count operation", { object, count });
311
240
  return count;
312
241
  }
@@ -324,7 +253,11 @@ var InMemoryDriver = class {
324
253
  const table = this.getTable(object);
325
254
  let targetRecords = table;
326
255
  if (query && query.where) {
327
- targetRecords = targetRecords.filter((r) => match(r, query.where));
256
+ const mongoQuery = this.convertToMongoQuery(query.where);
257
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
258
+ const mingoQuery = new Query(mongoQuery);
259
+ targetRecords = mingoQuery.find(targetRecords).all();
260
+ }
328
261
  }
329
262
  const count = targetRecords.length;
330
263
  for (const record of targetRecords) {
@@ -333,7 +266,7 @@ var InMemoryDriver = class {
333
266
  const updated = {
334
267
  ...table[index],
335
268
  ...data,
336
- updated_at: /* @__PURE__ */ new Date()
269
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
337
270
  };
338
271
  table[index] = updated;
339
272
  }
@@ -345,13 +278,20 @@ var InMemoryDriver = class {
345
278
  this.logger.debug("DeleteMany operation", { object, query });
346
279
  const table = this.getTable(object);
347
280
  const initialLength = table.length;
348
- const remaining = table.filter((r) => {
349
- if (!query || !query.where) return false;
350
- const matches = match(r, query.where);
351
- return !matches;
352
- });
353
- this.db[object] = remaining;
354
- const count = initialLength - remaining.length;
281
+ if (query && query.where) {
282
+ const mongoQuery = this.convertToMongoQuery(query.where);
283
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
284
+ const mingoQuery = new Query(mongoQuery);
285
+ const matched = mingoQuery.find(table).all();
286
+ const matchedIds = new Set(matched.map((r) => r.id));
287
+ this.db[object] = table.filter((r) => !matchedIds.has(r.id));
288
+ } else {
289
+ this.db[object] = [];
290
+ }
291
+ } else {
292
+ this.db[object] = [];
293
+ }
294
+ const count = initialLength - this.db[object].length;
355
295
  this.logger.debug("DeleteMany completed", { object, count });
356
296
  return { count };
357
297
  }
@@ -368,27 +308,213 @@ var InMemoryDriver = class {
368
308
  this.logger.debug("BulkDelete completed", { object, count: ids.length });
369
309
  }
370
310
  // ===================================
371
- // Schema & Transactions
311
+ // Transaction Management
372
312
  // ===================================
373
- async syncSchema(object, schema, options) {
374
- if (!this.db[object]) {
375
- this.db[object] = [];
376
- this.logger.info("Created in-memory table", { object });
313
+ async beginTransaction() {
314
+ const txId = `tx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
315
+ const snapshot = {};
316
+ for (const [table, records] of Object.entries(this.db)) {
317
+ snapshot[table] = records.map((r) => ({ ...r }));
377
318
  }
319
+ const transaction = { id: txId, snapshot };
320
+ this.transactions.set(txId, transaction);
321
+ this.logger.debug("Transaction started", { txId });
322
+ return { id: txId };
378
323
  }
379
- async dropTable(object, options) {
380
- if (this.db[object]) {
381
- const recordCount = this.db[object].length;
382
- delete this.db[object];
383
- this.logger.info("Dropped in-memory table", { object, recordCount });
324
+ async commit(txHandle) {
325
+ const txId = txHandle?.id;
326
+ if (!txId || !this.transactions.has(txId)) {
327
+ this.logger.warn("Commit called with unknown transaction");
328
+ return;
384
329
  }
330
+ this.transactions.delete(txId);
331
+ this.logger.debug("Transaction committed", { txId });
385
332
  }
386
- async beginTransaction() {
387
- throw new Error("Transactions not supported in InMemoryDriver");
333
+ async rollback(txHandle) {
334
+ const txId = txHandle?.id;
335
+ if (!txId || !this.transactions.has(txId)) {
336
+ this.logger.warn("Rollback called with unknown transaction");
337
+ return;
338
+ }
339
+ const tx = this.transactions.get(txId);
340
+ this.db = tx.snapshot;
341
+ this.transactions.delete(txId);
342
+ this.logger.debug("Transaction rolled back", { txId });
343
+ }
344
+ // ===================================
345
+ // Utility Methods
346
+ // ===================================
347
+ /**
348
+ * Remove all data from the store.
349
+ */
350
+ async clear() {
351
+ this.db = {};
352
+ this.idCounters.clear();
353
+ this.logger.debug("All data cleared");
354
+ }
355
+ /**
356
+ * Get total number of records across all tables.
357
+ */
358
+ getSize() {
359
+ return Object.values(this.db).reduce((sum, table) => sum + table.length, 0);
360
+ }
361
+ /**
362
+ * Get distinct values for a field, optionally filtered.
363
+ */
364
+ async distinct(object, field, query) {
365
+ let records = this.getTable(object);
366
+ if (query?.where) {
367
+ const mongoQuery = this.convertToMongoQuery(query.where);
368
+ if (mongoQuery && Object.keys(mongoQuery).length > 0) {
369
+ const mingoQuery = new Query(mongoQuery);
370
+ records = mingoQuery.find(records).all();
371
+ }
372
+ }
373
+ const values = /* @__PURE__ */ new Set();
374
+ for (const record of records) {
375
+ const value = getValueByPath(record, field);
376
+ if (value !== void 0 && value !== null) {
377
+ values.add(value);
378
+ }
379
+ }
380
+ return Array.from(values);
381
+ }
382
+ /**
383
+ * Execute a MongoDB-style aggregation pipeline using Mingo.
384
+ *
385
+ * Supports all standard MongoDB pipeline stages:
386
+ * - $match, $group, $sort, $project, $unwind, $limit, $skip
387
+ * - $addFields, $replaceRoot, $lookup (limited), $count
388
+ * - Accumulator operators: $sum, $avg, $min, $max, $first, $last, $push, $addToSet
389
+ *
390
+ * @example
391
+ * // Group by status and count
392
+ * const results = await driver.aggregate('orders', [
393
+ * { $match: { status: 'completed' } },
394
+ * { $group: { _id: '$customer', totalAmount: { $sum: '$amount' } } }
395
+ * ]);
396
+ *
397
+ * @example
398
+ * // Calculate average with filter
399
+ * const results = await driver.aggregate('products', [
400
+ * { $match: { category: 'electronics' } },
401
+ * { $group: { _id: null, avgPrice: { $avg: '$price' } } }
402
+ * ]);
403
+ */
404
+ async aggregate(object, pipeline, options) {
405
+ this.logger.debug("Aggregate operation", { object, stageCount: pipeline.length });
406
+ const records = this.getTable(object).map((r) => ({ ...r }));
407
+ const aggregator = new Aggregator(pipeline);
408
+ const results = aggregator.run(records);
409
+ this.logger.debug("Aggregate completed", { object, resultCount: results.length });
410
+ return results;
411
+ }
412
+ // ===================================
413
+ // Query Conversion (ObjectQL → MongoDB)
414
+ // ===================================
415
+ /**
416
+ * Convert ObjectQL filter format to MongoDB query format for Mingo.
417
+ *
418
+ * Supports:
419
+ * 1. AST Comparison Node: { type: 'comparison', field, operator, value }
420
+ * 2. AST Logical Node: { type: 'logical', operator: 'and'|'or', conditions: [...] }
421
+ * 3. Legacy Array Format: [['field', 'op', value], 'and', ['field2', 'op', value2]]
422
+ * 4. MongoDB Format: { field: value } or { field: { $eq: value } } (passthrough)
423
+ */
424
+ convertToMongoQuery(filters) {
425
+ if (!filters) return {};
426
+ if (!Array.isArray(filters) && typeof filters === "object") {
427
+ if (filters.type === "comparison") {
428
+ return this.convertConditionToMongo(filters.field, filters.operator, filters.value) || {};
429
+ }
430
+ if (filters.type === "logical") {
431
+ const conditions = filters.conditions?.map((c) => this.convertToMongoQuery(c)) || [];
432
+ if (conditions.length === 0) return {};
433
+ if (conditions.length === 1) return conditions[0];
434
+ const op = filters.operator === "or" ? "$or" : "$and";
435
+ return { [op]: conditions };
436
+ }
437
+ return filters;
438
+ }
439
+ if (!Array.isArray(filters) || filters.length === 0) return {};
440
+ const logicGroups = [
441
+ { logic: "and", conditions: [] }
442
+ ];
443
+ let currentLogic = "and";
444
+ for (const item of filters) {
445
+ if (typeof item === "string") {
446
+ const newLogic = item.toLowerCase();
447
+ if (newLogic !== currentLogic) {
448
+ currentLogic = newLogic;
449
+ logicGroups.push({ logic: currentLogic, conditions: [] });
450
+ }
451
+ } else if (Array.isArray(item)) {
452
+ const [field, operator, value] = item;
453
+ const cond = this.convertConditionToMongo(field, operator, value);
454
+ if (cond) logicGroups[logicGroups.length - 1].conditions.push(cond);
455
+ }
456
+ }
457
+ const allConditions = [];
458
+ for (const group of logicGroups) {
459
+ if (group.conditions.length === 0) continue;
460
+ if (group.conditions.length === 1) {
461
+ allConditions.push(group.conditions[0]);
462
+ } else {
463
+ const op = group.logic === "or" ? "$or" : "$and";
464
+ allConditions.push({ [op]: group.conditions });
465
+ }
466
+ }
467
+ if (allConditions.length === 0) return {};
468
+ if (allConditions.length === 1) return allConditions[0];
469
+ return { $and: allConditions };
388
470
  }
389
- async commit() {
471
+ /**
472
+ * Convert a single ObjectQL condition to MongoDB operator format.
473
+ */
474
+ convertConditionToMongo(field, operator, value) {
475
+ switch (operator) {
476
+ case "=":
477
+ case "==":
478
+ return { [field]: value };
479
+ case "!=":
480
+ case "<>":
481
+ return { [field]: { $ne: value } };
482
+ case ">":
483
+ return { [field]: { $gt: value } };
484
+ case ">=":
485
+ return { [field]: { $gte: value } };
486
+ case "<":
487
+ return { [field]: { $lt: value } };
488
+ case "<=":
489
+ return { [field]: { $lte: value } };
490
+ case "in":
491
+ return { [field]: { $in: value } };
492
+ case "nin":
493
+ case "not in":
494
+ return { [field]: { $nin: value } };
495
+ case "contains":
496
+ case "like":
497
+ return { [field]: { $regex: new RegExp(this.escapeRegex(value), "i") } };
498
+ case "startswith":
499
+ case "starts_with":
500
+ return { [field]: { $regex: new RegExp(`^${this.escapeRegex(value)}`, "i") } };
501
+ case "endswith":
502
+ case "ends_with":
503
+ return { [field]: { $regex: new RegExp(`${this.escapeRegex(value)}$`, "i") } };
504
+ case "between":
505
+ if (Array.isArray(value) && value.length === 2) {
506
+ return { [field]: { $gte: value[0], $lte: value[1] } };
507
+ }
508
+ return null;
509
+ default:
510
+ return null;
511
+ }
390
512
  }
391
- async rollback() {
513
+ /**
514
+ * Escape special regex characters for safe literal matching.
515
+ */
516
+ escapeRegex(str) {
517
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
392
518
  }
393
519
  // ===================================
394
520
  // Aggregation Logic
@@ -409,14 +535,10 @@ var InMemoryDriver = class {
409
535
  groups.get(key).push(record);
410
536
  }
411
537
  } else {
412
- if (aggregations && aggregations.length > 0) {
413
- groups.set("all", records);
414
- } else {
415
- groups.set("all", records);
416
- }
538
+ groups.set("all", records);
417
539
  }
418
540
  const resultRows = [];
419
- for (const [key, groupRecords] of groups.entries()) {
541
+ for (const [_key, groupRecords] of groups.entries()) {
420
542
  const row = {};
421
543
  if (groupBy && groupBy.length > 0) {
422
544
  if (groupRecords.length > 0) {
@@ -475,16 +597,475 @@ var InMemoryDriver = class {
475
597
  current[parts[parts.length - 1]] = value;
476
598
  }
477
599
  // ===================================
600
+ // Schema Management
601
+ // ===================================
602
+ async syncSchema(object, schema, options) {
603
+ if (!this.db[object]) {
604
+ this.db[object] = [];
605
+ this.logger.info("Created in-memory table", { object });
606
+ }
607
+ }
608
+ async dropTable(object, options) {
609
+ if (this.db[object]) {
610
+ const recordCount = this.db[object].length;
611
+ delete this.db[object];
612
+ this.logger.info("Dropped in-memory table", { object, recordCount });
613
+ }
614
+ }
615
+ // ===================================
478
616
  // Helpers
479
617
  // ===================================
618
+ /**
619
+ * Apply manual sorting (Mingo sort has CJS build issues).
620
+ */
621
+ applySort(records, sortFields) {
622
+ const sorted = [...records];
623
+ for (let i = sortFields.length - 1; i >= 0; i--) {
624
+ const sortItem = sortFields[i];
625
+ let field;
626
+ let direction;
627
+ if (typeof sortItem === "object" && !Array.isArray(sortItem)) {
628
+ field = sortItem.field;
629
+ direction = sortItem.order || sortItem.direction || "asc";
630
+ } else if (Array.isArray(sortItem)) {
631
+ [field, direction] = sortItem;
632
+ } else {
633
+ continue;
634
+ }
635
+ sorted.sort((a, b) => {
636
+ const aVal = getValueByPath(a, field);
637
+ const bVal = getValueByPath(b, field);
638
+ if (aVal == null && bVal == null) return 0;
639
+ if (aVal == null) return 1;
640
+ if (bVal == null) return -1;
641
+ if (aVal < bVal) return direction === "desc" ? 1 : -1;
642
+ if (aVal > bVal) return direction === "desc" ? -1 : 1;
643
+ return 0;
644
+ });
645
+ }
646
+ return sorted;
647
+ }
648
+ /**
649
+ * Project specific fields from a record.
650
+ */
651
+ projectFields(record, fields) {
652
+ const result = {};
653
+ for (const field of fields) {
654
+ const value = getValueByPath(record, field);
655
+ if (value !== void 0) {
656
+ result[field] = value;
657
+ }
658
+ }
659
+ if (!fields.includes("id") && record.id !== void 0) {
660
+ result.id = record.id;
661
+ }
662
+ return result;
663
+ }
480
664
  getTable(name) {
481
665
  if (!this.db[name]) {
482
666
  this.db[name] = [];
483
667
  }
484
668
  return this.db[name];
485
669
  }
486
- generateId() {
487
- return Math.random().toString(36).substring(2, 15);
670
+ generateId(objectName) {
671
+ const key = objectName || "_global";
672
+ const counter = (this.idCounters.get(key) || 0) + 1;
673
+ this.idCounters.set(key, counter);
674
+ const timestamp = Date.now();
675
+ return `${key}-${timestamp}-${counter}`;
676
+ }
677
+ };
678
+
679
+ // src/memory-analytics.ts
680
+ import { createLogger as createLogger2 } from "@objectstack/core";
681
+ var MemoryAnalyticsService = class {
682
+ constructor(config) {
683
+ this.driver = config.driver;
684
+ this.cubes = new Map(config.cubes.map((c) => [c.name, c]));
685
+ this.logger = config.logger || createLogger2({ level: "info", format: "pretty" });
686
+ this.logger.debug("MemoryAnalyticsService initialized", { cubeCount: this.cubes.size });
687
+ }
688
+ /**
689
+ * Execute an analytical query using the memory driver's aggregation pipeline
690
+ */
691
+ async query(query) {
692
+ this.logger.debug("Executing analytics query", { cube: query.cube, measures: query.measures });
693
+ if (!query.cube) {
694
+ throw new Error("Cube name is required");
695
+ }
696
+ const cube = this.cubes.get(query.cube);
697
+ if (!cube) {
698
+ throw new Error(`Cube not found: ${query.cube}`);
699
+ }
700
+ const pipeline = [];
701
+ if (query.filters && query.filters.length > 0) {
702
+ const matchStage = {};
703
+ for (const filter of query.filters) {
704
+ const mongoOp = this.convertOperatorToMongo(filter.operator);
705
+ const fieldPath = this.resolveFieldPath(cube, filter.member);
706
+ if (filter.values && filter.values.length > 0) {
707
+ if (mongoOp === "$in") {
708
+ matchStage[fieldPath] = { $in: filter.values };
709
+ } else if (mongoOp === "$nin") {
710
+ matchStage[fieldPath] = { $nin: filter.values };
711
+ } else {
712
+ matchStage[fieldPath] = { [mongoOp]: filter.values[0] };
713
+ }
714
+ } else if (mongoOp === "$exists") {
715
+ matchStage[fieldPath] = { $exists: filter.operator === "set" };
716
+ }
717
+ }
718
+ if (Object.keys(matchStage).length > 0) {
719
+ pipeline.push({ $match: matchStage });
720
+ }
721
+ }
722
+ if (query.timeDimensions && query.timeDimensions.length > 0) {
723
+ for (const timeDim of query.timeDimensions) {
724
+ const fieldPath = this.resolveFieldPath(cube, timeDim.dimension);
725
+ if (timeDim.dateRange) {
726
+ const range = Array.isArray(timeDim.dateRange) ? timeDim.dateRange : this.parseDateRangeString(timeDim.dateRange);
727
+ if (range.length === 2) {
728
+ pipeline.push({
729
+ $match: {
730
+ [fieldPath]: {
731
+ $gte: new Date(range[0]),
732
+ $lte: new Date(range[1])
733
+ }
734
+ }
735
+ });
736
+ }
737
+ }
738
+ }
739
+ }
740
+ const groupStage = { _id: {} };
741
+ if (query.dimensions && query.dimensions.length > 0) {
742
+ for (const dim of query.dimensions) {
743
+ const fieldPath = this.resolveFieldPath(cube, dim);
744
+ const dimName = this.getShortName(dim);
745
+ groupStage._id[dimName] = `$${fieldPath}`;
746
+ }
747
+ } else {
748
+ groupStage._id = null;
749
+ }
750
+ if (query.measures && query.measures.length > 0) {
751
+ for (const measure of query.measures) {
752
+ const measureDef = this.resolveMeasure(cube, measure);
753
+ const measureName = this.getShortName(measure);
754
+ if (measureDef) {
755
+ const aggregator = this.buildAggregator(measureDef);
756
+ groupStage[measureName] = aggregator;
757
+ }
758
+ }
759
+ }
760
+ pipeline.push({ $group: groupStage });
761
+ const projectStage = { _id: 0 };
762
+ if (query.dimensions && query.dimensions.length > 0) {
763
+ for (const dim of query.dimensions) {
764
+ const dimName = this.getShortName(dim);
765
+ projectStage[dimName] = `$_id.${dimName}`;
766
+ }
767
+ }
768
+ if (query.measures && query.measures.length > 0) {
769
+ for (const measure of query.measures) {
770
+ const measureName = this.getShortName(measure);
771
+ projectStage[measureName] = `$${measureName}`;
772
+ }
773
+ }
774
+ pipeline.push({ $project: projectStage });
775
+ if (query.order && Object.keys(query.order).length > 0) {
776
+ const sortStage = {};
777
+ for (const [field, direction] of Object.entries(query.order)) {
778
+ const shortName = this.getShortName(field);
779
+ sortStage[shortName] = direction === "asc" ? 1 : -1;
780
+ }
781
+ pipeline.push({ $sort: sortStage });
782
+ }
783
+ if (query.offset) {
784
+ pipeline.push({ $skip: query.offset });
785
+ }
786
+ if (query.limit) {
787
+ pipeline.push({ $limit: query.limit });
788
+ }
789
+ const tableName = this.extractTableName(cube.sql);
790
+ const rawRows = await this.driver.aggregate(tableName, pipeline);
791
+ const rows = rawRows.map((row) => {
792
+ const renamedRow = {};
793
+ if (query.dimensions) {
794
+ for (const dim of query.dimensions) {
795
+ const shortName = this.getShortName(dim);
796
+ if (shortName in row) {
797
+ renamedRow[dim] = row[shortName];
798
+ }
799
+ }
800
+ }
801
+ if (query.measures) {
802
+ for (const measure of query.measures) {
803
+ const shortName = this.getShortName(measure);
804
+ if (shortName in row) {
805
+ renamedRow[measure] = row[shortName];
806
+ }
807
+ }
808
+ }
809
+ return renamedRow;
810
+ });
811
+ const fields = [];
812
+ if (query.dimensions) {
813
+ for (const dim of query.dimensions) {
814
+ const dimension = this.resolveDimension(cube, dim);
815
+ fields.push({
816
+ name: dim,
817
+ type: dimension?.type || "string"
818
+ });
819
+ }
820
+ }
821
+ if (query.measures) {
822
+ for (const measure of query.measures) {
823
+ const measureDef = this.resolveMeasure(cube, measure);
824
+ fields.push({
825
+ name: measure,
826
+ type: this.measureTypeToFieldType(measureDef?.type || "count")
827
+ });
828
+ }
829
+ }
830
+ this.logger.debug("Analytics query completed", { rowCount: rows.length });
831
+ return {
832
+ rows,
833
+ fields,
834
+ sql: this.generateSqlFromPipeline(tableName, pipeline)
835
+ // For debugging
836
+ };
837
+ }
838
+ /**
839
+ * Get available cube metadata for discovery
840
+ */
841
+ async getMeta(cubeName) {
842
+ const cubes = cubeName ? [this.cubes.get(cubeName)].filter(Boolean) : Array.from(this.cubes.values());
843
+ return cubes.map((cube) => ({
844
+ name: cube.name,
845
+ title: cube.title,
846
+ measures: Object.entries(cube.measures).map(([key, measure]) => ({
847
+ name: `${cube.name}.${key}`,
848
+ type: measure.type,
849
+ title: measure.label
850
+ })),
851
+ dimensions: Object.entries(cube.dimensions).map(([key, dimension]) => ({
852
+ name: `${cube.name}.${key}`,
853
+ type: dimension.type,
854
+ title: dimension.label
855
+ }))
856
+ }));
857
+ }
858
+ /**
859
+ * Generate SQL representation for debugging/transparency
860
+ */
861
+ async generateSql(query) {
862
+ if (!query.cube) {
863
+ throw new Error("Cube name is required");
864
+ }
865
+ const cube = this.cubes.get(query.cube);
866
+ if (!cube) {
867
+ throw new Error(`Cube not found: ${query.cube}`);
868
+ }
869
+ const tableName = this.extractTableName(cube.sql);
870
+ const selectClauses = [];
871
+ const groupByClauses = [];
872
+ if (query.dimensions && query.dimensions.length > 0) {
873
+ for (const dim of query.dimensions) {
874
+ const fieldPath = this.resolveFieldPath(cube, dim);
875
+ selectClauses.push(`${fieldPath} AS "${dim}"`);
876
+ groupByClauses.push(fieldPath);
877
+ }
878
+ }
879
+ if (query.measures && query.measures.length > 0) {
880
+ for (const measure of query.measures) {
881
+ const measureDef = this.resolveMeasure(cube, measure);
882
+ if (measureDef) {
883
+ const aggSql = this.measureToSql(measureDef);
884
+ selectClauses.push(`${aggSql} AS "${measure}"`);
885
+ }
886
+ }
887
+ }
888
+ const whereClauses = [];
889
+ if (query.filters && query.filters.length > 0) {
890
+ for (const filter of query.filters) {
891
+ const fieldPath = this.resolveFieldPath(cube, filter.member);
892
+ const sqlOp = this.operatorToSql(filter.operator);
893
+ if (filter.values && filter.values.length > 0) {
894
+ whereClauses.push(`${fieldPath} ${sqlOp} '${filter.values[0]}'`);
895
+ }
896
+ }
897
+ }
898
+ let sql = `SELECT ${selectClauses.join(", ")} FROM ${tableName}`;
899
+ if (whereClauses.length > 0) {
900
+ sql += ` WHERE ${whereClauses.join(" AND ")}`;
901
+ }
902
+ if (groupByClauses.length > 0) {
903
+ sql += ` GROUP BY ${groupByClauses.join(", ")}`;
904
+ }
905
+ if (query.order) {
906
+ const orderClauses = Object.entries(query.order).map(
907
+ ([field, dir]) => `"${field}" ${dir.toUpperCase()}`
908
+ );
909
+ sql += ` ORDER BY ${orderClauses.join(", ")}`;
910
+ }
911
+ if (query.limit) {
912
+ sql += ` LIMIT ${query.limit}`;
913
+ }
914
+ if (query.offset) {
915
+ sql += ` OFFSET ${query.offset}`;
916
+ }
917
+ return { sql, params: [] };
918
+ }
919
+ // ===================================
920
+ // Helper Methods
921
+ // ===================================
922
+ resolveFieldPath(cube, member) {
923
+ const parts = member.split(".");
924
+ const fieldName = parts.length > 1 ? parts[1] : parts[0];
925
+ const dimension = cube.dimensions[fieldName];
926
+ if (dimension) {
927
+ return dimension.sql.replace(/^\$/, "");
928
+ }
929
+ const measure = cube.measures[fieldName];
930
+ if (measure) {
931
+ return measure.sql.replace(/^\$/, "");
932
+ }
933
+ return fieldName;
934
+ }
935
+ resolveMeasure(cube, measureName) {
936
+ const parts = measureName.split(".");
937
+ const fieldName = parts.length > 1 ? parts[1] : parts[0];
938
+ return cube.measures[fieldName];
939
+ }
940
+ resolveDimension(cube, dimensionName) {
941
+ const parts = dimensionName.split(".");
942
+ const fieldName = parts.length > 1 ? parts[1] : parts[0];
943
+ return cube.dimensions[fieldName];
944
+ }
945
+ getShortName(fullName) {
946
+ const parts = fullName.split(".");
947
+ return parts.length > 1 ? parts[1] : parts[0];
948
+ }
949
+ buildAggregator(measure) {
950
+ const fieldPath = measure.sql.replace(/^\$/, "");
951
+ switch (measure.type) {
952
+ case "count":
953
+ return { $sum: 1 };
954
+ case "sum":
955
+ return { $sum: `$${fieldPath}` };
956
+ case "avg":
957
+ return { $avg: `$${fieldPath}` };
958
+ case "min":
959
+ return { $min: `$${fieldPath}` };
960
+ case "max":
961
+ return { $max: `$${fieldPath}` };
962
+ case "count_distinct":
963
+ return { $addToSet: `$${fieldPath}` };
964
+ // Will need post-processing for count
965
+ default:
966
+ return { $sum: 1 };
967
+ }
968
+ }
969
+ measureTypeToFieldType(measureType) {
970
+ switch (measureType) {
971
+ case "count":
972
+ case "sum":
973
+ case "count_distinct":
974
+ return "number";
975
+ case "avg":
976
+ case "min":
977
+ case "max":
978
+ return "number";
979
+ case "string":
980
+ return "string";
981
+ case "boolean":
982
+ return "boolean";
983
+ default:
984
+ return "number";
985
+ }
986
+ }
987
+ convertOperatorToMongo(operator) {
988
+ const opMap = {
989
+ "equals": "$eq",
990
+ "notEquals": "$ne",
991
+ "contains": "$regex",
992
+ "notContains": "$not",
993
+ "gt": "$gt",
994
+ "gte": "$gte",
995
+ "lt": "$lt",
996
+ "lte": "$lte",
997
+ "set": "$exists",
998
+ "notSet": "$exists",
999
+ "inDateRange": "$gte"
1000
+ // Will need special handling
1001
+ };
1002
+ return opMap[operator] || "$eq";
1003
+ }
1004
+ operatorToSql(operator) {
1005
+ const opMap = {
1006
+ "equals": "=",
1007
+ "notEquals": "!=",
1008
+ "contains": "LIKE",
1009
+ "notContains": "NOT LIKE",
1010
+ "gt": ">",
1011
+ "gte": ">=",
1012
+ "lt": "<",
1013
+ "lte": "<="
1014
+ };
1015
+ return opMap[operator] || "=";
1016
+ }
1017
+ measureToSql(measure) {
1018
+ const fieldPath = measure.sql.replace(/^\$/, "");
1019
+ switch (measure.type) {
1020
+ case "count":
1021
+ return "COUNT(*)";
1022
+ case "sum":
1023
+ return `SUM(${fieldPath})`;
1024
+ case "avg":
1025
+ return `AVG(${fieldPath})`;
1026
+ case "min":
1027
+ return `MIN(${fieldPath})`;
1028
+ case "max":
1029
+ return `MAX(${fieldPath})`;
1030
+ case "count_distinct":
1031
+ return `COUNT(DISTINCT ${fieldPath})`;
1032
+ default:
1033
+ return "COUNT(*)";
1034
+ }
1035
+ }
1036
+ extractTableName(sql) {
1037
+ return sql.trim();
1038
+ }
1039
+ parseDateRangeString(range) {
1040
+ const now = /* @__PURE__ */ new Date();
1041
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1042
+ if (range === "today") {
1043
+ return [today.toISOString(), new Date(today.getTime() + 864e5).toISOString()];
1044
+ } else if (range.startsWith("last ")) {
1045
+ const parts = range.split(" ");
1046
+ const num = parseInt(parts[1]);
1047
+ const unit = parts[2];
1048
+ const start = new Date(today);
1049
+ if (unit.startsWith("day")) {
1050
+ start.setDate(start.getDate() - num);
1051
+ } else if (unit.startsWith("week")) {
1052
+ start.setDate(start.getDate() - num * 7);
1053
+ } else if (unit.startsWith("month")) {
1054
+ start.setMonth(start.getMonth() - num);
1055
+ } else if (unit.startsWith("year")) {
1056
+ start.setFullYear(start.getFullYear() - num);
1057
+ }
1058
+ return [start.toISOString(), now.toISOString()];
1059
+ }
1060
+ return [range, range];
1061
+ }
1062
+ generateSqlFromPipeline(table, pipeline) {
1063
+ const stages = pipeline.map((stage, idx) => {
1064
+ const op = Object.keys(stage)[0];
1065
+ return `/* Stage ${idx + 1}: ${op} */ ${JSON.stringify(stage[op])}`;
1066
+ }).join("\n");
1067
+ return `-- MongoDB Aggregation Pipeline on table: ${table}
1068
+ ${stages}`;
488
1069
  }
489
1070
  };
490
1071
 
@@ -506,6 +1087,7 @@ var index_default = {
506
1087
  };
507
1088
  export {
508
1089
  InMemoryDriver,
1090
+ MemoryAnalyticsService,
509
1091
  index_default as default
510
1092
  };
511
1093
  //# sourceMappingURL=index.mjs.map