@atscript/db-memory 0.1.131 → 0.1.133

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
@@ -96,7 +96,7 @@ Deliberate — matching a real engine here is hard or impossible and unnecessary
96
96
  - **No array-element matching** — the dot-path getter matches scalars and nested-object paths; Mongo-style implicit array-element / `$elemMatch` matching is not provided.
97
97
  - **Non-atomic stored batch writes** — `insertMany`/`updateMany`/`replaceMany`/`deleteMany` apply sequentially with no rollback; a mid-batch conflict leaves earlier items written. Single writes are safe. (Provider tables are read-only, so this never applies to the primary mode.)
98
98
  - **Provider tables are read-only, with no cross-request pagination stability** — page 1 and page 2 are separate requests over separate snapshots.
99
- - **No native aggregation** — `$groupBy` throws a typed `INVALID_QUERY` (clean 4xx, not 500).
99
+ - **Aggregation is an in-process scan** — `$groupBy` / aggregates / calendar buckets / `$having` / `$count` run in JS over one snapshot with SQL semantics (null and missing group together; `sum`/`avg` over no value are `null`). Fine for the small sets this adapter targets; there is no index-backed grouping.
100
100
  - **Relations `$with`** — resolved by core's app-level batch loading, not natively.
101
101
  - **No FTS / vector / geo / `$search`** — unsupported.
102
102
  - **Not a production datastore** — nothing is persisted or shared across processes.
package/dist/index.cjs CHANGED
@@ -1,5 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let _atscript_db = require("@atscript/db");
3
+ let _atscript_db_agg = require("@atscript/db/agg");
4
+ let _uniqu_core = require("@uniqu/core");
3
5
  //#region src/lib/memory-filter.ts
4
6
  /**
5
7
  * Dot-path getter. Splits `path` on `.` and walks plain objects, returning the
@@ -22,22 +24,20 @@ function getPath(row, path) {
22
24
  return current;
23
25
  }
24
26
  /**
25
- * Like {@link getPath}, but reports whether the FINAL key EXISTS rather than
26
- * its value. A key holding `null` counts as present. This is what lets
27
- * `$exists` distinguish a `null`-valued field (present) from an absent one —
28
- * a distinction {@link getPath} alone cannot make (both would read back as a
29
- * nullish value). Same array limitation as {@link getPath}.
27
+ * {@link getPath} compiled for one path: split once, and a single segment is
28
+ * a direct property read — for callers that read the same path off many rows.
30
29
  */
31
- function hasPath(row, path) {
30
+ function pathReader(path) {
31
+ if (!path.includes(".")) return (row) => row[path];
32
32
  const segments = path.split(".");
33
- const last = segments.pop();
34
- let current = row;
35
- for (const seg of segments) {
36
- if (current === null || typeof current !== "object" || Array.isArray(current)) return false;
37
- current = current[seg];
38
- }
39
- if (current === null || typeof current !== "object" || Array.isArray(current)) return false;
40
- return Object.prototype.hasOwnProperty.call(current, last);
33
+ return (row) => {
34
+ let current = row;
35
+ for (const seg of segments) {
36
+ if (current === null || typeof current !== "object" || Array.isArray(current)) return;
37
+ current = current[seg];
38
+ }
39
+ return current;
40
+ };
41
41
  }
42
42
  /**
43
43
  * Deep-equality for leaf values, used by `$eq`/`$ne`/`$in`/`$nin`.
@@ -170,7 +170,7 @@ const memoryVisitor = {
170
170
  return fieldValue != null && regex.test(stringifyLeaf(fieldValue));
171
171
  };
172
172
  }
173
- case "$exists": return (row) => value === hasPath(row, field);
173
+ case "$exists": return (row) => value === !evalEq(row, field, null);
174
174
  default: throw new _atscript_db.DbError("INVALID_QUERY", [{
175
175
  path: field,
176
176
  message: `Unsupported filter operator: ${op}`
@@ -290,6 +290,18 @@ function sortRows(rows, $sort, tieBreak) {
290
290
  }).map((decorated) => decorated.row);
291
291
  }
292
292
  /**
293
+ * Applies `$skip` then `$limit` (both optional) via a single slice. No-op
294
+ * pagination (the common unpaginated read) returns the input as-is: callers
295
+ * pass a fresh, non-store-aliased array and copy what they return, so the
296
+ * whole-array `.slice` copy that `slice(0, undefined)` would make is skipped.
297
+ */
298
+ function paginate(rows, skip, limit) {
299
+ const start = skip ?? 0;
300
+ const end = limit === void 0 ? void 0 : start + limit;
301
+ if (start === 0 && end === void 0) return rows;
302
+ return rows.slice(start, end);
303
+ }
304
+ /**
293
305
  * Projects a plain row per a `{ path: 0 | 1 }` projection map. Decoupled from
294
306
  * `UniquSelect`: the caller passes the resolved projection map (e.g. from
295
307
  * `$select.asProjection`) so the engine has no query-layer dependency.
@@ -324,6 +336,195 @@ function projectRow(row, projection, opts) {
324
336
  return out;
325
337
  }
326
338
  //#endregion
339
+ //#region src/lib/memory-aggregate.ts
340
+ /**
341
+ * The in-memory grouping engine behind {@link MemoryAdapter.aggregate}: a pure
342
+ * function over already-filtered rows (nested PHYSICAL documents), so stored
343
+ * and provider (read-through) mode share it unchanged. Pipeline: group →
344
+ * accumulate → `$having` → `$sort` → `$skip`/`$limit`, or → `$count` (the
345
+ * groups that survive `$having`).
346
+ *
347
+ * Decisions (the SQL adapters' semantics):
348
+ * - null and missing form ONE group; a calendar-bucket key is the
349
+ * `@uniqu/core` kernel label (the SQLite UDF runs the same kernel);
350
+ * - `sum` / `avg` over no numeric value are `null`; `min` / `max` order with
351
+ * the `$sort` comparator; any other `$fn` is `INVALID_QUERY`;
352
+ * - group identity is type-tagged: `1` and `"1"` never share a group (a
353
+ * number and a bigint of one value do), `Date`s group by instant, JSON
354
+ * values by a key-order-independent serialization;
355
+ * - the returned rows carry exactly what `$select` asks for (every group key
356
+ * without one), nested like Mongo's `$project` yields them, with
357
+ * object-valued leaves copied so no store-owned value escapes.
358
+ */
359
+ function aggregateRows(rows, controls) {
360
+ const $select = controls.$select;
361
+ const groupBy = controls.$groupBy ?? [];
362
+ const aggregates = $select?.aggregates ?? [];
363
+ const aliases = aggregates.map((expr) => (0, _atscript_db_agg.resolveAlias)(expr));
364
+ const keyReaders = groupBy.map((key) => groupKeyReader(key, controls));
365
+ const accumulators = aggregates.map(accumulatorFactory);
366
+ const newGroup = (values) => ({
367
+ values,
368
+ accs: accumulators.map((create) => create())
369
+ });
370
+ let groups;
371
+ if (keyReaders.length === 0) {
372
+ const group = newGroup([]);
373
+ for (const row of rows) for (const acc of group.accs) acc.add(row);
374
+ groups = [group];
375
+ } else {
376
+ const byIdentity = /* @__PURE__ */ new Map();
377
+ for (const row of rows) {
378
+ const values = keyReaders.map((read) => read(row));
379
+ const identity = groupIdentity(values);
380
+ let group = byIdentity.get(identity);
381
+ if (!group) {
382
+ group = newGroup(values);
383
+ byIdentity.set(identity, group);
384
+ }
385
+ for (const acc of group.accs) acc.add(row);
386
+ }
387
+ groups = byIdentity.values();
388
+ }
389
+ let out = [];
390
+ for (const { values, accs } of groups) {
391
+ const row = {};
392
+ for (let i = 0; i < groupBy.length; i++) setPath(row, groupBy[i], values[i]);
393
+ for (let i = 0; i < aliases.length; i++) row[aliases[i]] = accs[i].result();
394
+ out.push(row);
395
+ }
396
+ const having = controls.$having;
397
+ if (having) out = out.filter(buildMemoryPredicate(having));
398
+ if (controls.$count) return [{ count: out.length }];
399
+ const paged = paginate(sortRows(out, controls.$sort), controls.$skip, controls.$limit);
400
+ const fields = ($select === void 0 ? groupBy : $select.asArray ?? []).map((field) => [field, pathReader(field)]);
401
+ const outputAliases = $select === void 0 ? [] : [...($select.buckets ?? []).map((b) => b.alias), ...aliases];
402
+ return paged.map((row) => {
403
+ const picked = {};
404
+ for (const [field, read] of fields) setPath(picked, field, copyValue(read(row) ?? null));
405
+ for (const alias of outputAliases) picked[alias] = copyValue(row[alias]);
406
+ return picked;
407
+ });
408
+ }
409
+ /** Group values and min / max results may be store-owned objects (JSON values): hand back copies. */
410
+ function copyValue(value) {
411
+ return value !== null && typeof value === "object" ? structuredClone(value) : value;
412
+ }
413
+ /** Reads one `$groupBy` key off a row: a bucket alias → its label, a path → its value. */
414
+ function groupKeyReader(key, controls) {
415
+ const bucket = controls.$select?.bucketByAlias(key);
416
+ if (bucket) {
417
+ const label = (0, _uniqu_core.bucketer)(bucket.unit, bucket.tz, bucket.weekStart);
418
+ const read = pathReader(bucket.field);
419
+ return (row) => label(read(row));
420
+ }
421
+ const read = pathReader(key);
422
+ return (row) => read(row) ?? null;
423
+ }
424
+ /**
425
+ * The identity of a group's key values: one {@link identityToken} per key,
426
+ * length-prefixed when there are several, so no value can forge a separator.
427
+ */
428
+ function groupIdentity(values) {
429
+ if (values.length === 1) return identityToken(values[0]);
430
+ let identity = "";
431
+ for (const value of values) {
432
+ const token = identityToken(value);
433
+ identity += `${token.length}:${token}`;
434
+ }
435
+ return identity;
436
+ }
437
+ /** A type-tagged identity token for one key value (see {@link aggregateRows}). */
438
+ function identityToken(value) {
439
+ if (value === null || value === void 0) return "z";
440
+ if (value instanceof Date) return `D${value.getTime()}`;
441
+ switch (typeof value) {
442
+ case "string": return `s${value}`;
443
+ case "number":
444
+ case "bigint": return `n${String(value)}`;
445
+ case "boolean": return value ? "b1" : "b0";
446
+ default: return `j${stableJson(value)}`;
447
+ }
448
+ }
449
+ /** `JSON.stringify` with object keys sorted at every level. */
450
+ function stableJson(value) {
451
+ return JSON.stringify(value, (_key, v) => {
452
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return v;
453
+ const sorted = {};
454
+ for (const k of Object.keys(v).toSorted()) sorted[k] = v[k];
455
+ return sorted;
456
+ });
457
+ }
458
+ /** Numeric value of a measure, or `undefined` for null / missing / non-numeric. */
459
+ function numericValue(value) {
460
+ if (typeof value === "number") return Number.isNaN(value) ? void 0 : value;
461
+ if (typeof value === "bigint") return Number(value);
462
+ if (typeof value === "string" && value.trim() !== "") {
463
+ const n = Number(value);
464
+ return Number.isNaN(n) ? void 0 : n;
465
+ }
466
+ }
467
+ /**
468
+ * Resolves an aggregate expression to an accumulator factory (one accumulator
469
+ * per group). Validated up front, so an unknown `$fn` is rejected even when
470
+ * there are no rows.
471
+ */
472
+ function accumulatorFactory(expr) {
473
+ const field = expr.$field;
474
+ const read = pathReader(field);
475
+ switch (expr.$fn) {
476
+ case "count": {
477
+ const all = field === "*";
478
+ return () => {
479
+ let n = 0;
480
+ return {
481
+ add: (row) => {
482
+ if (all || read(row) != null) n++;
483
+ },
484
+ result: () => n
485
+ };
486
+ };
487
+ }
488
+ case "sum":
489
+ case "avg": {
490
+ const avg = expr.$fn === "avg";
491
+ return () => {
492
+ let sum = 0;
493
+ let n = 0;
494
+ return {
495
+ add: (row) => {
496
+ const v = numericValue(read(row));
497
+ if (v !== void 0) {
498
+ sum += v;
499
+ n++;
500
+ }
501
+ },
502
+ result: () => n === 0 ? null : avg ? sum / n : sum
503
+ };
504
+ };
505
+ }
506
+ case "min":
507
+ case "max": {
508
+ const sign = expr.$fn === "min" ? -1 : 1;
509
+ return () => {
510
+ let best = null;
511
+ return {
512
+ add: (row) => {
513
+ const v = read(row);
514
+ if (v == null) return;
515
+ if (best === null || compareLeaves(v, best) * sign > 0) best = v;
516
+ },
517
+ result: () => best
518
+ };
519
+ };
520
+ }
521
+ default: throw new _atscript_db.DbError("INVALID_QUERY", [{
522
+ path: "$select",
523
+ message: `Unsupported aggregate function "${String(expr.$fn)}" — use count, sum, avg, min or max`
524
+ }]);
525
+ }
526
+ }
527
+ //#endregion
327
528
  //#region src/lib/memory-adapter.ts
328
529
  /**
329
530
  * In-memory {@link BaseDbAdapter} implementation.
@@ -561,8 +762,7 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
561
762
  * Write guard for provider-backed (read-only) mode. Called first in every one
562
763
  * of the 8 write methods so all mutation entry points reject identically. Uses
563
764
  * `INVALID_QUERY` (moost-db's validation interceptor maps it to HTTP 400, NOT
564
- * 500 — the same choice `aggregate()` makes) since there is no dedicated
565
- * read-only error code.
765
+ * 500) since there is no dedicated read-only error code.
566
766
  */
567
767
  _assertWritable() {
568
768
  if (this._provider) throw new _atscript_db.DbError("INVALID_QUERY", [{
@@ -803,13 +1003,6 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
803
1003
  _sortRows(rows, $sort) {
804
1004
  return sortRows(rows, $sort, (r) => this.pkKey(r));
805
1005
  }
806
- /** Applies `$skip` then `$limit` (both optional) via a single slice. */
807
- _paginate(rows, skip, limit) {
808
- const start = skip ?? 0;
809
- const end = limit === void 0 ? void 0 : start + limit;
810
- if (start === 0 && end === void 0) return rows;
811
- return rows.slice(start, end);
812
- }
813
1006
  /**
814
1007
  * Projects a stored row per `$select` and returns a fresh, deep-cloned object
815
1008
  * so the store can never be mutated through a returned value.
@@ -875,25 +1068,32 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
875
1068
  async findManyWithCount(query) {
876
1069
  const filtered = await this._filteredRows(query);
877
1070
  const { $sort, $skip, $limit, $select } = this._readControls(query.controls ?? {});
878
- const sorted = this._sortRows(filtered, $sort);
879
1071
  return {
880
- data: this._paginate(sorted, $skip, $limit).map((row) => this._projectAndClone(row, $select)),
1072
+ data: paginate(this._sortRows(filtered, $sort), $skip, $limit).map((row) => this._projectAndClone(row, $select)),
881
1073
  count: filtered.length
882
1074
  };
883
1075
  }
884
1076
  /**
885
- * Aggregation (`$groupBy`) is a documented v1 non-goal for the in-memory
886
- * adapter. The inherited base default throws a PLAIN `Error`, which a readable
887
- * REST controller would surface as an unhandled HTTP 500 when a `?$groupBy=`
888
- * query routes here. Throwing a typed {@link DbError} with `INVALID_QUERY`
889
- * instead converts that into a clean client error — moost-db's validation
890
- * interceptor maps `INVALID_QUERY` → HTTP 400.
1077
+ * Grouped query (`$groupBy` + aggregates, calendar buckets, `$having`,
1078
+ * `$sort`, `$skip` / `$limit`, `$count`) over ONE filtered snapshot — the
1079
+ * pure {@link aggregateRows} engine, so provider (read-through) mode works
1080
+ * unchanged. SQL-adapter semantics: null and missing group together,
1081
+ * `sum` / `avg` over no numeric value are `null`, `$count` counts the groups
1082
+ * that survive `$having`.
1083
+ *
1084
+ * `$search` never reaches here: this adapter is not searchable, so the core
1085
+ * rejects a grouped search before dispatch.
891
1086
  */
892
- async aggregate(_query) {
893
- throw new _atscript_db.DbError("INVALID_QUERY", [{
894
- path: "",
895
- message: "Aggregation ($groupBy) is not supported by the in-memory adapter"
896
- }]);
1087
+ async aggregate(query) {
1088
+ return aggregateRows(await this._filteredRows(query), query.controls ?? {});
1089
+ }
1090
+ /**
1091
+ * Every calendar-bucket unit: labels come from the `@uniqu/core` kernel
1092
+ * (Node's ICU zone data — the list the core validates zones against, so
1093
+ * there is no zone this adapter cannot resolve).
1094
+ */
1095
+ calendarBucketUnits() {
1096
+ return _atscript_db.ALL_BUCKET_UNITS;
897
1097
  }
898
1098
  async updateMany(filter, data, ops) {
899
1099
  this._assertWritable();
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbUpdateResult, TFieldOps } from "@atscript/db";
1
+ import { BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbUpdateResult, TFieldOps } from "@atscript/db";
2
2
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
3
3
 
4
4
  //#region src/lib/memory-adapter.d.ts
@@ -192,8 +192,7 @@ declare class MemoryAdapter extends BaseDbAdapter {
192
192
  * Write guard for provider-backed (read-only) mode. Called first in every one
193
193
  * of the 8 write methods so all mutation entry points reject identically. Uses
194
194
  * `INVALID_QUERY` (moost-db's validation interceptor maps it to HTTP 400, NOT
195
- * 500 — the same choice `aggregate()` makes) since there is no dedicated
196
- * read-only error code.
195
+ * 500) since there is no dedicated read-only error code.
197
196
  */
198
197
  private _assertWritable;
199
198
  /**
@@ -308,8 +307,6 @@ declare class MemoryAdapter extends BaseDbAdapter {
308
307
  * {@link sortRows}, injecting {@link pkKey} as the total-order tie-break.
309
308
  */
310
309
  private _sortRows;
311
- /** Applies `$skip` then `$limit` (both optional) via a single slice. */
312
- private _paginate;
313
310
  /**
314
311
  * Projects a stored row per `$select` and returns a fresh, deep-cloned object
315
312
  * so the store can never be mutated through a returned value.
@@ -353,14 +350,23 @@ declare class MemoryAdapter extends BaseDbAdapter {
353
350
  count: number;
354
351
  }>;
355
352
  /**
356
- * Aggregation (`$groupBy`) is a documented v1 non-goal for the in-memory
357
- * adapter. The inherited base default throws a PLAIN `Error`, which a readable
358
- * REST controller would surface as an unhandled HTTP 500 when a `?$groupBy=`
359
- * query routes here. Throwing a typed {@link DbError} with `INVALID_QUERY`
360
- * instead converts that into a clean client error — moost-db's validation
361
- * interceptor maps `INVALID_QUERY` → HTTP 400.
353
+ * Grouped query (`$groupBy` + aggregates, calendar buckets, `$having`,
354
+ * `$sort`, `$skip` / `$limit`, `$count`) over ONE filtered snapshot — the
355
+ * pure {@link aggregateRows} engine, so provider (read-through) mode works
356
+ * unchanged. SQL-adapter semantics: null and missing group together,
357
+ * `sum` / `avg` over no numeric value are `null`, `$count` counts the groups
358
+ * that survive `$having`.
359
+ *
360
+ * `$search` never reaches here: this adapter is not searchable, so the core
361
+ * rejects a grouped search before dispatch.
362
+ */
363
+ aggregate(query: DbQuery): Promise<Array<Record<string, unknown>>>;
364
+ /**
365
+ * Every calendar-bucket unit: labels come from the `@uniqu/core` kernel
366
+ * (Node's ICU zone data — the list the core validates zones against, so
367
+ * there is no zone this adapter cannot resolve).
362
368
  */
363
- aggregate(_query: DbQuery): Promise<Array<Record<string, unknown>>>;
369
+ calendarBucketUnits(): ReadonlySet<BucketUnit>;
364
370
  updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps): Promise<TDbUpdateResult>;
365
371
  replaceMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult>;
366
372
  deleteMany(filter: FilterExpr): Promise<TDbDeleteResult>;
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbUpdateResult, TFieldOps } from "@atscript/db";
1
+ import { BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbUpdateResult, TFieldOps } from "@atscript/db";
2
2
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
3
3
 
4
4
  //#region src/lib/memory-adapter.d.ts
@@ -192,8 +192,7 @@ declare class MemoryAdapter extends BaseDbAdapter {
192
192
  * Write guard for provider-backed (read-only) mode. Called first in every one
193
193
  * of the 8 write methods so all mutation entry points reject identically. Uses
194
194
  * `INVALID_QUERY` (moost-db's validation interceptor maps it to HTTP 400, NOT
195
- * 500 — the same choice `aggregate()` makes) since there is no dedicated
196
- * read-only error code.
195
+ * 500) since there is no dedicated read-only error code.
197
196
  */
198
197
  private _assertWritable;
199
198
  /**
@@ -308,8 +307,6 @@ declare class MemoryAdapter extends BaseDbAdapter {
308
307
  * {@link sortRows}, injecting {@link pkKey} as the total-order tie-break.
309
308
  */
310
309
  private _sortRows;
311
- /** Applies `$skip` then `$limit` (both optional) via a single slice. */
312
- private _paginate;
313
310
  /**
314
311
  * Projects a stored row per `$select` and returns a fresh, deep-cloned object
315
312
  * so the store can never be mutated through a returned value.
@@ -353,14 +350,23 @@ declare class MemoryAdapter extends BaseDbAdapter {
353
350
  count: number;
354
351
  }>;
355
352
  /**
356
- * Aggregation (`$groupBy`) is a documented v1 non-goal for the in-memory
357
- * adapter. The inherited base default throws a PLAIN `Error`, which a readable
358
- * REST controller would surface as an unhandled HTTP 500 when a `?$groupBy=`
359
- * query routes here. Throwing a typed {@link DbError} with `INVALID_QUERY`
360
- * instead converts that into a clean client error — moost-db's validation
361
- * interceptor maps `INVALID_QUERY` → HTTP 400.
353
+ * Grouped query (`$groupBy` + aggregates, calendar buckets, `$having`,
354
+ * `$sort`, `$skip` / `$limit`, `$count`) over ONE filtered snapshot — the
355
+ * pure {@link aggregateRows} engine, so provider (read-through) mode works
356
+ * unchanged. SQL-adapter semantics: null and missing group together,
357
+ * `sum` / `avg` over no numeric value are `null`, `$count` counts the groups
358
+ * that survive `$having`.
359
+ *
360
+ * `$search` never reaches here: this adapter is not searchable, so the core
361
+ * rejects a grouped search before dispatch.
362
+ */
363
+ aggregate(query: DbQuery): Promise<Array<Record<string, unknown>>>;
364
+ /**
365
+ * Every calendar-bucket unit: labels come from the `@uniqu/core` kernel
366
+ * (Node's ICU zone data — the list the core validates zones against, so
367
+ * there is no zone this adapter cannot resolve).
362
368
  */
363
- aggregate(_query: DbQuery): Promise<Array<Record<string, unknown>>>;
369
+ calendarBucketUnits(): ReadonlySet<BucketUnit>;
364
370
  updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps): Promise<TDbUpdateResult>;
365
371
  replaceMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult>;
366
372
  deleteMany(filter: FilterExpr): Promise<TDbDeleteResult>;
package/dist/index.mjs CHANGED
@@ -1,4 +1,6 @@
1
- import { BaseDbAdapter, DbError, DbSpace, walkFilter } from "@atscript/db";
1
+ import { ALL_BUCKET_UNITS, BaseDbAdapter, DbError, DbSpace, walkFilter } from "@atscript/db";
2
+ import { resolveAlias } from "@atscript/db/agg";
3
+ import { bucketer } from "@uniqu/core";
2
4
  //#region src/lib/memory-filter.ts
3
5
  /**
4
6
  * Dot-path getter. Splits `path` on `.` and walks plain objects, returning the
@@ -21,22 +23,20 @@ function getPath(row, path) {
21
23
  return current;
22
24
  }
23
25
  /**
24
- * Like {@link getPath}, but reports whether the FINAL key EXISTS rather than
25
- * its value. A key holding `null` counts as present. This is what lets
26
- * `$exists` distinguish a `null`-valued field (present) from an absent one —
27
- * a distinction {@link getPath} alone cannot make (both would read back as a
28
- * nullish value). Same array limitation as {@link getPath}.
26
+ * {@link getPath} compiled for one path: split once, and a single segment is
27
+ * a direct property read — for callers that read the same path off many rows.
29
28
  */
30
- function hasPath(row, path) {
29
+ function pathReader(path) {
30
+ if (!path.includes(".")) return (row) => row[path];
31
31
  const segments = path.split(".");
32
- const last = segments.pop();
33
- let current = row;
34
- for (const seg of segments) {
35
- if (current === null || typeof current !== "object" || Array.isArray(current)) return false;
36
- current = current[seg];
37
- }
38
- if (current === null || typeof current !== "object" || Array.isArray(current)) return false;
39
- return Object.prototype.hasOwnProperty.call(current, last);
32
+ return (row) => {
33
+ let current = row;
34
+ for (const seg of segments) {
35
+ if (current === null || typeof current !== "object" || Array.isArray(current)) return;
36
+ current = current[seg];
37
+ }
38
+ return current;
39
+ };
40
40
  }
41
41
  /**
42
42
  * Deep-equality for leaf values, used by `$eq`/`$ne`/`$in`/`$nin`.
@@ -169,7 +169,7 @@ const memoryVisitor = {
169
169
  return fieldValue != null && regex.test(stringifyLeaf(fieldValue));
170
170
  };
171
171
  }
172
- case "$exists": return (row) => value === hasPath(row, field);
172
+ case "$exists": return (row) => value === !evalEq(row, field, null);
173
173
  default: throw new DbError("INVALID_QUERY", [{
174
174
  path: field,
175
175
  message: `Unsupported filter operator: ${op}`
@@ -289,6 +289,18 @@ function sortRows(rows, $sort, tieBreak) {
289
289
  }).map((decorated) => decorated.row);
290
290
  }
291
291
  /**
292
+ * Applies `$skip` then `$limit` (both optional) via a single slice. No-op
293
+ * pagination (the common unpaginated read) returns the input as-is: callers
294
+ * pass a fresh, non-store-aliased array and copy what they return, so the
295
+ * whole-array `.slice` copy that `slice(0, undefined)` would make is skipped.
296
+ */
297
+ function paginate(rows, skip, limit) {
298
+ const start = skip ?? 0;
299
+ const end = limit === void 0 ? void 0 : start + limit;
300
+ if (start === 0 && end === void 0) return rows;
301
+ return rows.slice(start, end);
302
+ }
303
+ /**
292
304
  * Projects a plain row per a `{ path: 0 | 1 }` projection map. Decoupled from
293
305
  * `UniquSelect`: the caller passes the resolved projection map (e.g. from
294
306
  * `$select.asProjection`) so the engine has no query-layer dependency.
@@ -323,6 +335,195 @@ function projectRow(row, projection, opts) {
323
335
  return out;
324
336
  }
325
337
  //#endregion
338
+ //#region src/lib/memory-aggregate.ts
339
+ /**
340
+ * The in-memory grouping engine behind {@link MemoryAdapter.aggregate}: a pure
341
+ * function over already-filtered rows (nested PHYSICAL documents), so stored
342
+ * and provider (read-through) mode share it unchanged. Pipeline: group →
343
+ * accumulate → `$having` → `$sort` → `$skip`/`$limit`, or → `$count` (the
344
+ * groups that survive `$having`).
345
+ *
346
+ * Decisions (the SQL adapters' semantics):
347
+ * - null and missing form ONE group; a calendar-bucket key is the
348
+ * `@uniqu/core` kernel label (the SQLite UDF runs the same kernel);
349
+ * - `sum` / `avg` over no numeric value are `null`; `min` / `max` order with
350
+ * the `$sort` comparator; any other `$fn` is `INVALID_QUERY`;
351
+ * - group identity is type-tagged: `1` and `"1"` never share a group (a
352
+ * number and a bigint of one value do), `Date`s group by instant, JSON
353
+ * values by a key-order-independent serialization;
354
+ * - the returned rows carry exactly what `$select` asks for (every group key
355
+ * without one), nested like Mongo's `$project` yields them, with
356
+ * object-valued leaves copied so no store-owned value escapes.
357
+ */
358
+ function aggregateRows(rows, controls) {
359
+ const $select = controls.$select;
360
+ const groupBy = controls.$groupBy ?? [];
361
+ const aggregates = $select?.aggregates ?? [];
362
+ const aliases = aggregates.map((expr) => resolveAlias(expr));
363
+ const keyReaders = groupBy.map((key) => groupKeyReader(key, controls));
364
+ const accumulators = aggregates.map(accumulatorFactory);
365
+ const newGroup = (values) => ({
366
+ values,
367
+ accs: accumulators.map((create) => create())
368
+ });
369
+ let groups;
370
+ if (keyReaders.length === 0) {
371
+ const group = newGroup([]);
372
+ for (const row of rows) for (const acc of group.accs) acc.add(row);
373
+ groups = [group];
374
+ } else {
375
+ const byIdentity = /* @__PURE__ */ new Map();
376
+ for (const row of rows) {
377
+ const values = keyReaders.map((read) => read(row));
378
+ const identity = groupIdentity(values);
379
+ let group = byIdentity.get(identity);
380
+ if (!group) {
381
+ group = newGroup(values);
382
+ byIdentity.set(identity, group);
383
+ }
384
+ for (const acc of group.accs) acc.add(row);
385
+ }
386
+ groups = byIdentity.values();
387
+ }
388
+ let out = [];
389
+ for (const { values, accs } of groups) {
390
+ const row = {};
391
+ for (let i = 0; i < groupBy.length; i++) setPath(row, groupBy[i], values[i]);
392
+ for (let i = 0; i < aliases.length; i++) row[aliases[i]] = accs[i].result();
393
+ out.push(row);
394
+ }
395
+ const having = controls.$having;
396
+ if (having) out = out.filter(buildMemoryPredicate(having));
397
+ if (controls.$count) return [{ count: out.length }];
398
+ const paged = paginate(sortRows(out, controls.$sort), controls.$skip, controls.$limit);
399
+ const fields = ($select === void 0 ? groupBy : $select.asArray ?? []).map((field) => [field, pathReader(field)]);
400
+ const outputAliases = $select === void 0 ? [] : [...($select.buckets ?? []).map((b) => b.alias), ...aliases];
401
+ return paged.map((row) => {
402
+ const picked = {};
403
+ for (const [field, read] of fields) setPath(picked, field, copyValue(read(row) ?? null));
404
+ for (const alias of outputAliases) picked[alias] = copyValue(row[alias]);
405
+ return picked;
406
+ });
407
+ }
408
+ /** Group values and min / max results may be store-owned objects (JSON values): hand back copies. */
409
+ function copyValue(value) {
410
+ return value !== null && typeof value === "object" ? structuredClone(value) : value;
411
+ }
412
+ /** Reads one `$groupBy` key off a row: a bucket alias → its label, a path → its value. */
413
+ function groupKeyReader(key, controls) {
414
+ const bucket = controls.$select?.bucketByAlias(key);
415
+ if (bucket) {
416
+ const label = bucketer(bucket.unit, bucket.tz, bucket.weekStart);
417
+ const read = pathReader(bucket.field);
418
+ return (row) => label(read(row));
419
+ }
420
+ const read = pathReader(key);
421
+ return (row) => read(row) ?? null;
422
+ }
423
+ /**
424
+ * The identity of a group's key values: one {@link identityToken} per key,
425
+ * length-prefixed when there are several, so no value can forge a separator.
426
+ */
427
+ function groupIdentity(values) {
428
+ if (values.length === 1) return identityToken(values[0]);
429
+ let identity = "";
430
+ for (const value of values) {
431
+ const token = identityToken(value);
432
+ identity += `${token.length}:${token}`;
433
+ }
434
+ return identity;
435
+ }
436
+ /** A type-tagged identity token for one key value (see {@link aggregateRows}). */
437
+ function identityToken(value) {
438
+ if (value === null || value === void 0) return "z";
439
+ if (value instanceof Date) return `D${value.getTime()}`;
440
+ switch (typeof value) {
441
+ case "string": return `s${value}`;
442
+ case "number":
443
+ case "bigint": return `n${String(value)}`;
444
+ case "boolean": return value ? "b1" : "b0";
445
+ default: return `j${stableJson(value)}`;
446
+ }
447
+ }
448
+ /** `JSON.stringify` with object keys sorted at every level. */
449
+ function stableJson(value) {
450
+ return JSON.stringify(value, (_key, v) => {
451
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return v;
452
+ const sorted = {};
453
+ for (const k of Object.keys(v).toSorted()) sorted[k] = v[k];
454
+ return sorted;
455
+ });
456
+ }
457
+ /** Numeric value of a measure, or `undefined` for null / missing / non-numeric. */
458
+ function numericValue(value) {
459
+ if (typeof value === "number") return Number.isNaN(value) ? void 0 : value;
460
+ if (typeof value === "bigint") return Number(value);
461
+ if (typeof value === "string" && value.trim() !== "") {
462
+ const n = Number(value);
463
+ return Number.isNaN(n) ? void 0 : n;
464
+ }
465
+ }
466
+ /**
467
+ * Resolves an aggregate expression to an accumulator factory (one accumulator
468
+ * per group). Validated up front, so an unknown `$fn` is rejected even when
469
+ * there are no rows.
470
+ */
471
+ function accumulatorFactory(expr) {
472
+ const field = expr.$field;
473
+ const read = pathReader(field);
474
+ switch (expr.$fn) {
475
+ case "count": {
476
+ const all = field === "*";
477
+ return () => {
478
+ let n = 0;
479
+ return {
480
+ add: (row) => {
481
+ if (all || read(row) != null) n++;
482
+ },
483
+ result: () => n
484
+ };
485
+ };
486
+ }
487
+ case "sum":
488
+ case "avg": {
489
+ const avg = expr.$fn === "avg";
490
+ return () => {
491
+ let sum = 0;
492
+ let n = 0;
493
+ return {
494
+ add: (row) => {
495
+ const v = numericValue(read(row));
496
+ if (v !== void 0) {
497
+ sum += v;
498
+ n++;
499
+ }
500
+ },
501
+ result: () => n === 0 ? null : avg ? sum / n : sum
502
+ };
503
+ };
504
+ }
505
+ case "min":
506
+ case "max": {
507
+ const sign = expr.$fn === "min" ? -1 : 1;
508
+ return () => {
509
+ let best = null;
510
+ return {
511
+ add: (row) => {
512
+ const v = read(row);
513
+ if (v == null) return;
514
+ if (best === null || compareLeaves(v, best) * sign > 0) best = v;
515
+ },
516
+ result: () => best
517
+ };
518
+ };
519
+ }
520
+ default: throw new DbError("INVALID_QUERY", [{
521
+ path: "$select",
522
+ message: `Unsupported aggregate function "${String(expr.$fn)}" — use count, sum, avg, min or max`
523
+ }]);
524
+ }
525
+ }
526
+ //#endregion
326
527
  //#region src/lib/memory-adapter.ts
327
528
  /**
328
529
  * In-memory {@link BaseDbAdapter} implementation.
@@ -560,8 +761,7 @@ var MemoryAdapter = class extends BaseDbAdapter {
560
761
  * Write guard for provider-backed (read-only) mode. Called first in every one
561
762
  * of the 8 write methods so all mutation entry points reject identically. Uses
562
763
  * `INVALID_QUERY` (moost-db's validation interceptor maps it to HTTP 400, NOT
563
- * 500 — the same choice `aggregate()` makes) since there is no dedicated
564
- * read-only error code.
764
+ * 500) since there is no dedicated read-only error code.
565
765
  */
566
766
  _assertWritable() {
567
767
  if (this._provider) throw new DbError("INVALID_QUERY", [{
@@ -802,13 +1002,6 @@ var MemoryAdapter = class extends BaseDbAdapter {
802
1002
  _sortRows(rows, $sort) {
803
1003
  return sortRows(rows, $sort, (r) => this.pkKey(r));
804
1004
  }
805
- /** Applies `$skip` then `$limit` (both optional) via a single slice. */
806
- _paginate(rows, skip, limit) {
807
- const start = skip ?? 0;
808
- const end = limit === void 0 ? void 0 : start + limit;
809
- if (start === 0 && end === void 0) return rows;
810
- return rows.slice(start, end);
811
- }
812
1005
  /**
813
1006
  * Projects a stored row per `$select` and returns a fresh, deep-cloned object
814
1007
  * so the store can never be mutated through a returned value.
@@ -874,25 +1067,32 @@ var MemoryAdapter = class extends BaseDbAdapter {
874
1067
  async findManyWithCount(query) {
875
1068
  const filtered = await this._filteredRows(query);
876
1069
  const { $sort, $skip, $limit, $select } = this._readControls(query.controls ?? {});
877
- const sorted = this._sortRows(filtered, $sort);
878
1070
  return {
879
- data: this._paginate(sorted, $skip, $limit).map((row) => this._projectAndClone(row, $select)),
1071
+ data: paginate(this._sortRows(filtered, $sort), $skip, $limit).map((row) => this._projectAndClone(row, $select)),
880
1072
  count: filtered.length
881
1073
  };
882
1074
  }
883
1075
  /**
884
- * Aggregation (`$groupBy`) is a documented v1 non-goal for the in-memory
885
- * adapter. The inherited base default throws a PLAIN `Error`, which a readable
886
- * REST controller would surface as an unhandled HTTP 500 when a `?$groupBy=`
887
- * query routes here. Throwing a typed {@link DbError} with `INVALID_QUERY`
888
- * instead converts that into a clean client error — moost-db's validation
889
- * interceptor maps `INVALID_QUERY` → HTTP 400.
1076
+ * Grouped query (`$groupBy` + aggregates, calendar buckets, `$having`,
1077
+ * `$sort`, `$skip` / `$limit`, `$count`) over ONE filtered snapshot — the
1078
+ * pure {@link aggregateRows} engine, so provider (read-through) mode works
1079
+ * unchanged. SQL-adapter semantics: null and missing group together,
1080
+ * `sum` / `avg` over no numeric value are `null`, `$count` counts the groups
1081
+ * that survive `$having`.
1082
+ *
1083
+ * `$search` never reaches here: this adapter is not searchable, so the core
1084
+ * rejects a grouped search before dispatch.
890
1085
  */
891
- async aggregate(_query) {
892
- throw new DbError("INVALID_QUERY", [{
893
- path: "",
894
- message: "Aggregation ($groupBy) is not supported by the in-memory adapter"
895
- }]);
1086
+ async aggregate(query) {
1087
+ return aggregateRows(await this._filteredRows(query), query.controls ?? {});
1088
+ }
1089
+ /**
1090
+ * Every calendar-bucket unit: labels come from the `@uniqu/core` kernel
1091
+ * (Node's ICU zone data — the list the core validates zones against, so
1092
+ * there is no zone this adapter cannot resolve).
1093
+ */
1094
+ calendarBucketUnits() {
1095
+ return ALL_BUCKET_UNITS;
896
1096
  }
897
1097
  async updateMany(filter, data, ops) {
898
1098
  this._assertWritable();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-memory",
3
- "version": "0.1.131",
3
+ "version": "0.1.133",
4
4
  "description": "In-memory adapter for atscript-db.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -38,12 +38,14 @@
38
38
  "devDependencies": {
39
39
  "@atscript/core": "^0.1.92",
40
40
  "@atscript/typescript": "^0.1.92",
41
+ "@uniqu/core": "^0.1.10",
41
42
  "unplugin-atscript": "^0.1.92"
42
43
  },
43
44
  "peerDependencies": {
44
45
  "@atscript/core": "^0.1.92",
45
46
  "@atscript/typescript": "^0.1.92",
46
- "@atscript/db": "^0.1.131"
47
+ "@uniqu/core": "^0.1.10",
48
+ "@atscript/db": "^0.1.133"
47
49
  },
48
50
  "scripts": {
49
51
  "postinstall": "asc -f dts",