@objectstack/driver-memory 4.0.4 → 4.1.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/README.md CHANGED
@@ -1,21 +1,102 @@
1
1
  # @objectstack/driver-memory
2
2
 
3
- In-Memory Database access layer for ObjectStack. Supports rich querying capabilities (MongoDB-style operators) on standard JavaScript arrays.
3
+ > In-memory ObjectQL driver for ObjectStack zero-config storage for development, unit tests, Storybook, and browser MSW mocks.
4
4
 
5
- ## Features
5
+ [![npm](https://img.shields.io/npm/v/@objectstack/driver-memory.svg)](https://www.npmjs.com/package/@objectstack/driver-memory)
6
+ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
6
7
 
7
- - **NoSQL Syntax**: Supports `$eq`, `$gt`, `$lt`, `$in`, `$and`, `$or` operators.
8
- - **Sorting & Pagination**: Full support for `sort`, `skip`, `limit`.
9
- - **Zero Config**: Perfect for prototyping, testing, and the **MSW Browser Mock**.
10
- - **Stateful**: Preserves data in memory during the session.
8
+ ## Overview
11
9
 
12
- ## Usage
10
+ Implements the `IDataEngine` contract against in-memory `Map`-backed tables. Supports the full ObjectQL surface: MongoDB-style operators (`$eq`, `$ne`, `$gt`, `$lt`, `$gte`, `$lte`, `$in`, `$nin`, `$and`, `$or`, `$not`), sorting, pagination, aggregations, and joins. Optional persistence adapters serialize state to disk (Node) or `localStorage` (browser).
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pnpm add @objectstack/driver-memory
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ ```typescript
21
+ import { ObjectKernel } from '@objectstack/core';
22
+ import memoryPlugin from '@objectstack/driver-memory';
23
+
24
+ const kernel = new ObjectKernel();
25
+ kernel.use(memoryPlugin); // default plugin
26
+ await kernel.bootstrap();
27
+ ```
28
+
29
+ ### Direct instantiation
13
30
 
14
31
  ```typescript
15
32
  import { InMemoryDriver } from '@objectstack/driver-memory';
16
33
 
17
34
  const driver = new InMemoryDriver();
18
35
  await driver.connect();
36
+ ```
37
+
38
+ ### With filesystem persistence (Node)
39
+
40
+ ```typescript
41
+ import { InMemoryDriver, FileSystemPersistenceAdapter } from '@objectstack/driver-memory';
19
42
 
20
- // Used internally by ObjectQL
43
+ const driver = new InMemoryDriver({
44
+ persistence: new FileSystemPersistenceAdapter('./data/snapshot.json'),
45
+ });
46
+ await driver.connect();
21
47
  ```
48
+
49
+ ### With `localStorage` persistence (browser)
50
+
51
+ ```typescript
52
+ import { InMemoryDriver, LocalStoragePersistenceAdapter } from '@objectstack/driver-memory';
53
+
54
+ const driver = new InMemoryDriver({
55
+ persistence: new LocalStoragePersistenceAdapter('objectstack:dev'),
56
+ });
57
+ ```
58
+
59
+ ## Key Exports
60
+
61
+ | Export | Kind | Description |
62
+ |:---|:---|:---|
63
+ | `default` | kernel plugin | Drop-in plugin. |
64
+ | `InMemoryDriver` | class | Driver instance for direct use. |
65
+ | `InMemoryStrategy` | class | Query execution strategy used by ObjectQL. |
66
+ | `FileSystemPersistenceAdapter` | class | Node-only persistence. |
67
+ | `LocalStoragePersistenceAdapter` | class | Browser-only persistence. |
68
+ | `MemoryAnalyticsService` | class | Adds analytics aggregations backed by memory store. |
69
+ | `InMemoryDriverConfig`, `PersistenceAdapterInterface`, `MemoryAnalyticsConfig` | types | Configuration shapes. |
70
+
71
+ ## Configuration
72
+
73
+ | Option | Type | Default | Notes |
74
+ |:---|:---|:---|:---|
75
+ | `persistence` | `PersistenceAdapterInterface?` | `undefined` | Optional snapshot store. |
76
+ | `seed` | `Record<string, any[]>?` | `{}` | Initial rows keyed by object name. |
77
+ | `idStrategy` | `'uuid' \| 'auto'` | `'uuid'` | ID generation strategy. |
78
+
79
+ ## When to use
80
+
81
+ - ✅ Development, unit tests, CI, Storybook.
82
+ - ✅ Browser-only demos pairing with [`@objectstack/plugin-msw`](../plugin-msw).
83
+
84
+ ## When not to use
85
+
86
+ - ❌ Production — data is lost on restart without a persistence adapter; durability/concurrency guarantees are minimal.
87
+ - ❌ Multi-process deployments.
88
+
89
+ ## Related Packages
90
+
91
+ - [`@objectstack/objectql`](../../objectql) — query engine.
92
+ - [`@objectstack/driver-sql`](../driver-sql), [`@objectstack/driver-turso`](../driver-turso) — production drivers.
93
+ - [`@objectstack/plugin-msw`](../plugin-msw) — browser mock API.
94
+
95
+ ## Links
96
+
97
+ - 📖 Docs: <https://objectstack.ai/docs>
98
+ - 📚 API Reference: <https://objectstack.ai/docs/references>
99
+
100
+ ## License
101
+
102
+ Apache-2.0 © ObjectStack
package/dist/index.d.mts CHANGED
@@ -395,6 +395,50 @@ declare class MemoryAnalyticsService implements IAnalyticsService {
395
395
  sql: string;
396
396
  params: unknown[];
397
397
  }>;
398
+ /**
399
+ * Normalize filters into a cube-style array regardless of input shape.
400
+ *
401
+ * Accepts:
402
+ * - undefined / null → []
403
+ * - cube-style array `[{member, operator, values}]` → returned as-is
404
+ * - MongoDB FilterCondition object (per spec/data/filter.zod.ts):
405
+ * * implicit equality: `{is_active: true}`
406
+ * * operator wrapper: `{stage: {$nin: [...]}}`
407
+ * * mixed: `{stage: 'won', amount: {$gte: 100}}`
408
+ * → flattened into one cube-style entry per (field, operator) pair
409
+ *
410
+ * Logical combinators (`$and`, `$or`, `$not`) are not yet expanded into
411
+ * the cube pipeline; for current dashboard widget metadata the implicit
412
+ * top-level AND of fields is sufficient. `$and` clauses are flattened
413
+ * into the same AND list.
414
+ */
415
+ private normalizeFilters;
416
+ private flattenFilterCondition;
417
+ /**
418
+ * Map MongoDB-style `$op` keys (from FilterCondition) to the cube-style
419
+ * operator names accepted by `convertOperatorToMongo` / `operatorToSql`.
420
+ */
421
+ private mongoOperatorToCubeOperator;
422
+ /**
423
+ * Stringify a filter value for cube-style storage. Booleans become
424
+ * `'1'/'0'` so that downstream consumers expecting SQLite-style
425
+ * numeric booleans match correctly. The in-memory pipeline uses
426
+ * {@link coerceFilterValue} to recover real JS types from these
427
+ * strings.
428
+ */
429
+ private stringifyForCube;
430
+ /**
431
+ * Recover a runtime value from its cube-stringified form for in-memory
432
+ * comparison. Booleans, integers, floats and ISO-date-like strings are
433
+ * coerced; everything else stays as a string.
434
+ */
435
+ private coerceFilterValue;
436
+ /**
437
+ * Type-aware SQL literal formatter. Booleans and numbers are emitted
438
+ * unquoted; everything else is single-quoted with embedded quotes
439
+ * escaped.
440
+ */
441
+ private toSqlLiteral;
398
442
  private resolveFieldPath;
399
443
  private resolveMeasure;
400
444
  private resolveDimension;
package/dist/index.d.ts CHANGED
@@ -395,6 +395,50 @@ declare class MemoryAnalyticsService implements IAnalyticsService {
395
395
  sql: string;
396
396
  params: unknown[];
397
397
  }>;
398
+ /**
399
+ * Normalize filters into a cube-style array regardless of input shape.
400
+ *
401
+ * Accepts:
402
+ * - undefined / null → []
403
+ * - cube-style array `[{member, operator, values}]` → returned as-is
404
+ * - MongoDB FilterCondition object (per spec/data/filter.zod.ts):
405
+ * * implicit equality: `{is_active: true}`
406
+ * * operator wrapper: `{stage: {$nin: [...]}}`
407
+ * * mixed: `{stage: 'won', amount: {$gte: 100}}`
408
+ * → flattened into one cube-style entry per (field, operator) pair
409
+ *
410
+ * Logical combinators (`$and`, `$or`, `$not`) are not yet expanded into
411
+ * the cube pipeline; for current dashboard widget metadata the implicit
412
+ * top-level AND of fields is sufficient. `$and` clauses are flattened
413
+ * into the same AND list.
414
+ */
415
+ private normalizeFilters;
416
+ private flattenFilterCondition;
417
+ /**
418
+ * Map MongoDB-style `$op` keys (from FilterCondition) to the cube-style
419
+ * operator names accepted by `convertOperatorToMongo` / `operatorToSql`.
420
+ */
421
+ private mongoOperatorToCubeOperator;
422
+ /**
423
+ * Stringify a filter value for cube-style storage. Booleans become
424
+ * `'1'/'0'` so that downstream consumers expecting SQLite-style
425
+ * numeric booleans match correctly. The in-memory pipeline uses
426
+ * {@link coerceFilterValue} to recover real JS types from these
427
+ * strings.
428
+ */
429
+ private stringifyForCube;
430
+ /**
431
+ * Recover a runtime value from its cube-stringified form for in-memory
432
+ * comparison. Booleans, integers, floats and ISO-date-like strings are
433
+ * coerced; everything else stays as a string.
434
+ */
435
+ private coerceFilterValue;
436
+ /**
437
+ * Type-aware SQL literal formatter. Booleans and numbers are emitted
438
+ * unquoted; everything else is single-quoted with embedded quotes
439
+ * escaped.
440
+ */
441
+ private toSqlLiteral;
398
442
  private resolveFieldPath;
399
443
  private resolveMeasure;
400
444
  private resolveDimension;
package/dist/index.js CHANGED
@@ -876,9 +876,14 @@ var _InMemoryDriver = class _InMemoryDriver {
876
876
  performAggregation(records, query) {
877
877
  const { groupBy, aggregations } = query;
878
878
  const groups = /* @__PURE__ */ new Map();
879
+ const normalizeGroupBy = (node) => {
880
+ if (typeof node === "string") return { field: node, alias: node };
881
+ return { field: node.field, alias: node.alias ?? node.field };
882
+ };
879
883
  if (groupBy && groupBy.length > 0) {
880
884
  for (const record of records) {
881
- const keyParts = groupBy.map((field) => {
885
+ const keyParts = groupBy.map((node) => {
886
+ const { field } = normalizeGroupBy(node);
882
887
  const val = getValueByPath(record, field);
883
888
  return val === void 0 || val === null ? "null" : String(val);
884
889
  });
@@ -897,8 +902,9 @@ var _InMemoryDriver = class _InMemoryDriver {
897
902
  if (groupBy && groupBy.length > 0) {
898
903
  if (groupRecords.length > 0) {
899
904
  const firstRecord = groupRecords[0];
900
- for (const field of groupBy) {
901
- this.setValueByPath(row, field, getValueByPath(firstRecord, field));
905
+ for (const node of groupBy) {
906
+ const { field, alias } = normalizeGroupBy(node);
907
+ this.setValueByPath(row, alias, getValueByPath(firstRecord, field));
902
908
  }
903
909
  }
904
910
  }
@@ -1179,18 +1185,20 @@ var MemoryAnalyticsService = class {
1179
1185
  throw new Error(`Cube not found: ${query.cube}`);
1180
1186
  }
1181
1187
  const pipeline = [];
1182
- if (query.filters && query.filters.length > 0) {
1188
+ const normalizedFilters = this.normalizeFilters(query);
1189
+ if (normalizedFilters.length > 0) {
1183
1190
  const matchStage = {};
1184
- for (const filter of query.filters) {
1191
+ for (const filter of normalizedFilters) {
1185
1192
  const mongoOp = this.convertOperatorToMongo(filter.operator);
1186
1193
  const fieldPath = this.resolveFieldPath(cube, filter.member);
1187
1194
  if (filter.values && filter.values.length > 0) {
1195
+ const coerced = filter.values.map((v) => this.coerceFilterValue(v));
1188
1196
  if (mongoOp === "$in") {
1189
- matchStage[fieldPath] = { $in: filter.values };
1197
+ matchStage[fieldPath] = { $in: coerced };
1190
1198
  } else if (mongoOp === "$nin") {
1191
- matchStage[fieldPath] = { $nin: filter.values };
1199
+ matchStage[fieldPath] = { $nin: coerced };
1192
1200
  } else {
1193
- matchStage[fieldPath] = { [mongoOp]: filter.values[0] };
1201
+ matchStage[fieldPath] = { [mongoOp]: coerced[0] };
1194
1202
  }
1195
1203
  } else if (mongoOp === "$exists") {
1196
1204
  matchStage[fieldPath] = { $exists: filter.operator === "set" };
@@ -1367,12 +1375,14 @@ var MemoryAnalyticsService = class {
1367
1375
  }
1368
1376
  }
1369
1377
  const whereClauses = [];
1370
- if (query.filters && query.filters.length > 0) {
1371
- for (const filter of query.filters) {
1378
+ const normalizedFilters = this.normalizeFilters(query);
1379
+ if (normalizedFilters.length > 0) {
1380
+ for (const filter of normalizedFilters) {
1372
1381
  const fieldPath = this.resolveFieldPath(cube, filter.member);
1373
1382
  const sqlOp = this.operatorToSql(filter.operator);
1374
1383
  if (filter.values && filter.values.length > 0) {
1375
- whereClauses.push(`${fieldPath} ${sqlOp} '${filter.values[0]}'`);
1384
+ const literal = this.toSqlLiteral(filter.values[0]);
1385
+ whereClauses.push(`${fieldPath} ${sqlOp} ${literal}`);
1376
1386
  }
1377
1387
  }
1378
1388
  }
@@ -1400,6 +1410,147 @@ var MemoryAnalyticsService = class {
1400
1410
  // ===================================
1401
1411
  // Helper Methods
1402
1412
  // ===================================
1413
+ /**
1414
+ * Normalize filters into a cube-style array regardless of input shape.
1415
+ *
1416
+ * Accepts:
1417
+ * - undefined / null → []
1418
+ * - cube-style array `[{member, operator, values}]` → returned as-is
1419
+ * - MongoDB FilterCondition object (per spec/data/filter.zod.ts):
1420
+ * * implicit equality: `{is_active: true}`
1421
+ * * operator wrapper: `{stage: {$nin: [...]}}`
1422
+ * * mixed: `{stage: 'won', amount: {$gte: 100}}`
1423
+ * → flattened into one cube-style entry per (field, operator) pair
1424
+ *
1425
+ * Logical combinators (`$and`, `$or`, `$not`) are not yet expanded into
1426
+ * the cube pipeline; for current dashboard widget metadata the implicit
1427
+ * top-level AND of fields is sufficient. `$and` clauses are flattened
1428
+ * into the same AND list.
1429
+ */
1430
+ normalizeFilters(query) {
1431
+ if (!query || typeof query !== "object") return [];
1432
+ const out = [];
1433
+ const where = query.where;
1434
+ if (where && typeof where === "object" && !Array.isArray(where)) {
1435
+ this.flattenFilterCondition(where, out);
1436
+ }
1437
+ return out;
1438
+ }
1439
+ flattenFilterCondition(cond, out) {
1440
+ for (const [key, raw] of Object.entries(cond)) {
1441
+ if (raw == null) continue;
1442
+ if (key === "$and" && Array.isArray(raw)) {
1443
+ for (const sub of raw) {
1444
+ if (sub && typeof sub === "object") {
1445
+ this.flattenFilterCondition(sub, out);
1446
+ }
1447
+ }
1448
+ continue;
1449
+ }
1450
+ if (key === "$or" || key === "$not") continue;
1451
+ if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
1452
+ const wrapper = raw;
1453
+ const opEntries = Object.keys(wrapper).filter((k) => k.startsWith("$"));
1454
+ if (opEntries.length > 0) {
1455
+ for (const opKey of opEntries) {
1456
+ const cubeOp = this.mongoOperatorToCubeOperator(opKey);
1457
+ if (!cubeOp) continue;
1458
+ const v = wrapper[opKey];
1459
+ const values2 = Array.isArray(v) ? v.map((x) => this.stringifyForCube(x)) : [this.stringifyForCube(v)];
1460
+ out.push({ member: key, operator: cubeOp, values: values2 });
1461
+ }
1462
+ continue;
1463
+ }
1464
+ for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
1465
+ this.flattenFilterCondition({ [`${key}.${nestedKey}`]: nestedVal }, out);
1466
+ }
1467
+ continue;
1468
+ }
1469
+ const values = Array.isArray(raw) ? raw.map((x) => this.stringifyForCube(x)) : [this.stringifyForCube(raw)];
1470
+ out.push({
1471
+ member: key,
1472
+ operator: Array.isArray(raw) ? "in" : "equals",
1473
+ values
1474
+ });
1475
+ }
1476
+ }
1477
+ /**
1478
+ * Map MongoDB-style `$op` keys (from FilterCondition) to the cube-style
1479
+ * operator names accepted by `convertOperatorToMongo` / `operatorToSql`.
1480
+ */
1481
+ mongoOperatorToCubeOperator(op) {
1482
+ switch (op) {
1483
+ case "$eq":
1484
+ return "equals";
1485
+ case "$ne":
1486
+ return "notEquals";
1487
+ case "$gt":
1488
+ return "gt";
1489
+ case "$gte":
1490
+ return "gte";
1491
+ case "$lt":
1492
+ return "lt";
1493
+ case "$lte":
1494
+ return "lte";
1495
+ case "$in":
1496
+ return "in";
1497
+ case "$nin":
1498
+ return "notIn";
1499
+ case "$contains":
1500
+ return "contains";
1501
+ case "$notContains":
1502
+ return "notContains";
1503
+ case "$exists":
1504
+ return "set";
1505
+ default:
1506
+ return null;
1507
+ }
1508
+ }
1509
+ /**
1510
+ * Stringify a filter value for cube-style storage. Booleans become
1511
+ * `'1'/'0'` so that downstream consumers expecting SQLite-style
1512
+ * numeric booleans match correctly. The in-memory pipeline uses
1513
+ * {@link coerceFilterValue} to recover real JS types from these
1514
+ * strings.
1515
+ */
1516
+ stringifyForCube(v) {
1517
+ if (v == null) return "";
1518
+ if (typeof v === "boolean") return v ? "1" : "0";
1519
+ if (v instanceof Date) return v.toISOString();
1520
+ if (typeof v === "object") return JSON.stringify(v);
1521
+ return String(v);
1522
+ }
1523
+ /**
1524
+ * Recover a runtime value from its cube-stringified form for in-memory
1525
+ * comparison. Booleans, integers, floats and ISO-date-like strings are
1526
+ * coerced; everything else stays as a string.
1527
+ */
1528
+ coerceFilterValue(s) {
1529
+ if (s === "true") return true;
1530
+ if (s === "false") return false;
1531
+ if (s === "null") return null;
1532
+ if (/^-?\d+$/.test(s)) {
1533
+ const n = Number(s);
1534
+ if (Number.isFinite(n)) return n;
1535
+ }
1536
+ if (/^-?\d+\.\d+$/.test(s)) {
1537
+ const n = Number(s);
1538
+ if (Number.isFinite(n)) return n;
1539
+ }
1540
+ return s;
1541
+ }
1542
+ /**
1543
+ * Type-aware SQL literal formatter. Booleans and numbers are emitted
1544
+ * unquoted; everything else is single-quoted with embedded quotes
1545
+ * escaped.
1546
+ */
1547
+ toSqlLiteral(s) {
1548
+ if (s === "true") return "1";
1549
+ if (s === "false") return "0";
1550
+ if (s === "null") return "NULL";
1551
+ if (/^-?\d+(\.\d+)?$/.test(s)) return s;
1552
+ return `'${s.replace(/'/g, "''")}'`;
1553
+ }
1403
1554
  resolveFieldPath(cube, member) {
1404
1555
  const parts = member.split(".");
1405
1556
  const fieldName = parts.length > 1 ? parts[1] : parts[0];
@@ -1416,7 +1567,20 @@ var MemoryAnalyticsService = class {
1416
1567
  resolveMeasure(cube, measureName) {
1417
1568
  const parts = measureName.split(".");
1418
1569
  const fieldName = parts.length > 1 ? parts[1] : parts[0];
1419
- return cube.measures[fieldName];
1570
+ const direct = cube.measures[fieldName];
1571
+ if (direct) return direct;
1572
+ const aggTypes = ["count", "sum", "avg", "min", "max", "count_distinct"];
1573
+ for (const type of aggTypes) {
1574
+ const suffix = `_${type}`;
1575
+ if (fieldName.endsWith(suffix)) {
1576
+ const baseField = fieldName.slice(0, -suffix.length);
1577
+ const candidate = cube.measures[baseField];
1578
+ if (candidate && candidate.type === type) {
1579
+ return candidate;
1580
+ }
1581
+ }
1582
+ }
1583
+ return void 0;
1420
1584
  }
1421
1585
  resolveDimension(cube, dimensionName) {
1422
1586
  const parts = dimensionName.split(".");
@@ -1475,6 +1639,8 @@ var MemoryAnalyticsService = class {
1475
1639
  "gte": "$gte",
1476
1640
  "lt": "$lt",
1477
1641
  "lte": "$lte",
1642
+ "in": "$in",
1643
+ "notIn": "$nin",
1478
1644
  "set": "$exists",
1479
1645
  "notSet": "$exists",
1480
1646
  "inDateRange": "$gte"