@atscript/db-memory 0.1.147 → 0.1.148

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.cjs CHANGED
@@ -74,15 +74,26 @@ function stringifyLeaf(v) {
74
74
  /** `$eq` semantics, factored out so `$ne` can be its exact negation. */
75
75
  function evalEq(row, field, value) {
76
76
  const fieldValue = (0, _atscript_db.getPath)(row, field);
77
- if (value === null) return fieldValue == null;
77
+ if (value === null) return matchesScalar(fieldValue, null);
78
78
  if (value instanceof RegExp) return fieldValue != null && value.test(stringifyLeaf(fieldValue));
79
+ return matchesScalar(fieldValue, value);
80
+ }
81
+ /**
82
+ * Mongo containment: a scalar compared with an array field matches when ANY element
83
+ * equals it (`{ labels: "bug" }` matches `["bug", "ui"]`). Non-array fields compare as before.
84
+ * `null` also matches a missing field (`undefined`) and an array holding a `null` element,
85
+ * as `{ labels: null }` / `$in: [null]` do in Mongo.
86
+ */
87
+ function matchesScalar(fieldValue, value) {
88
+ if (value === null) return fieldValue == null || Array.isArray(fieldValue) && fieldValue.includes(null);
89
+ if (Array.isArray(fieldValue) && !Array.isArray(value)) return fieldValue.some((el) => valuesEqual(el, value));
79
90
  return valuesEqual(fieldValue, value);
80
91
  }
81
92
  /** `$in` membership, factored out so `$nin` can be its exact negation. */
82
93
  function evalIn(row, field, value) {
83
94
  if (!Array.isArray(value)) return false;
84
95
  const fieldValue = (0, _atscript_db.getPath)(row, field);
85
- return value.some((element) => valuesEqual(fieldValue, element));
96
+ return value.some((element) => matchesScalar(fieldValue, element));
86
97
  }
87
98
  /**
88
99
  * Coerce a leaf to something the JS relational operators can order. `Date`s
@@ -464,10 +475,28 @@ function projectRow(row, projection, opts) {
464
475
  function aggregateRows(rows, controls) {
465
476
  const $select = controls.$select;
466
477
  const groupBy = controls.$groupBy ?? [];
467
- const aggregates = $select?.aggregates ?? [];
468
- const aliases = aggregates.map((expr) => (0, _atscript_db_agg.resolveAlias)(expr));
478
+ const entries = [
479
+ ...($select?.aggregates ?? []).map((expr) => ({
480
+ alias: (0, _atscript_db_agg.resolveAlias)(expr),
481
+ create: accumulatorFactory(expr)
482
+ })),
483
+ ...($select?.exprAggregates ?? []).map((e) => ({
484
+ alias: e.alias,
485
+ create: exprAccumulatorFactory(e)
486
+ })),
487
+ ...($select?.firstLast ?? []).map((fl) => ({
488
+ alias: fl.alias,
489
+ create: firstLastFactory(fl, $select?.rowOrder ?? [])
490
+ }))
491
+ ];
492
+ const aliases = entries.map((e) => e.alias);
493
+ const groupExprs = ($select?.exprs ?? []).map((e) => ({
494
+ alias: e.alias,
495
+ expr: e.expr,
496
+ readers: new Map(e.names.map((name) => [name, pathReader(name)]))
497
+ }));
469
498
  const keyReaders = groupBy.map((key) => groupKeyReader(key, controls));
470
- const accumulators = aggregates.map(accumulatorFactory);
499
+ const accumulators = entries.map((e) => e.create);
471
500
  const newGroup = (values) => ({
472
501
  values,
473
502
  accs: accumulators.map((create) => create())
@@ -496,6 +525,7 @@ function aggregateRows(rows, controls) {
496
525
  const row = {};
497
526
  for (let i = 0; i < groupBy.length; i++) setPath(row, groupBy[i], values[i]);
498
527
  for (let i = 0; i < aliases.length; i++) row[aliases[i]] = accs[i].result();
528
+ for (const e of groupExprs) row[e.alias] = (0, _atscript_db.evaluateExpr)(e.expr, (name) => Object.hasOwn(row, name) ? row[name] : e.readers.get(name)(row));
499
529
  out.push(row);
500
530
  }
501
531
  const having = controls.$having;
@@ -503,7 +533,7 @@ function aggregateRows(rows, controls) {
503
533
  if (controls.$count) return [{ count: out.length }];
504
534
  const paged = paginate(sortRows(out, controls.$sort), controls.$skip, controls.$limit);
505
535
  const fields = ($select === void 0 ? groupBy : $select.asArray ?? []).map((field) => [field, pathReader(field)]);
506
- const outputAliases = $select === void 0 ? [] : [...($select.buckets ?? []).map((b) => b.alias), ...aliases];
536
+ const outputAliases = $select === void 0 ? [] : [...($select.buckets ?? []).map((b) => b.alias), ...$select.computedAliases];
507
537
  return paged.map((row) => {
508
538
  const picked = {};
509
539
  for (const [field, read] of fields) setPath(picked, field, copyValue(read(row) ?? null));
@@ -569,76 +599,109 @@ function numericValue(value) {
569
599
  return Number.isNaN(n) ? void 0 : n;
570
600
  }
571
601
  }
602
+ /** `sum` / `avg` / `min` / `max` over a per-row arithmetic expression; NULL results are skipped like NULL fields. */
603
+ function exprAccumulatorFactory(e) {
604
+ const readers = new Map(e.names.map((name) => [name, pathReader(name)]));
605
+ return fnAccumulator(e.fn, (row) => (0, _atscript_db.evaluateExpr)(e.expr, (field) => readers.get(field)(row)));
606
+ }
607
+ /**
608
+ * `first` / `last`: the value of `fl.column` on the group's representative
609
+ * row — the least (`first`) or greatest (`last`) row by `rowOrder` (NULL
610
+ * smallest; the primary key is the final key, so the pick is deterministic).
611
+ */
612
+ function firstLastFactory(fl, rowOrder) {
613
+ const keys = rowOrder.map((k) => ({
614
+ read: pathReader(k.column),
615
+ sign: k.desc ? -1 : 1
616
+ }));
617
+ const read = pathReader(fl.column);
618
+ const sign = fl.fn === "first" ? -1 : 1;
619
+ const compare = (a, b) => {
620
+ for (const k of keys) {
621
+ const c = compareLeaves(k.read(a), k.read(b)) * k.sign;
622
+ if (c !== 0) return c;
623
+ }
624
+ return 0;
625
+ };
626
+ return () => {
627
+ let best;
628
+ return {
629
+ add: (row) => {
630
+ if (best === void 0 || compare(row, best) * sign > 0) best = row;
631
+ },
632
+ result: () => best === void 0 ? null : copyValue(read(best) ?? null)
633
+ };
634
+ };
635
+ }
636
+ /**
637
+ * `sum` / `avg` / `min` / `max` over the values `read` yields per row — a
638
+ * field's, or a per-row expression's. `sum` / `avg` skip non-numeric values
639
+ * and are `null` over none; `min` / `max` skip null / missing and order with
640
+ * the `$sort` comparator.
641
+ */
642
+ function fnAccumulator(fn, read) {
643
+ if (fn === "sum" || fn === "avg") return () => {
644
+ let sum = 0;
645
+ let n = 0;
646
+ return {
647
+ add: (row) => {
648
+ const v = numericValue(read(row));
649
+ if (v !== void 0) {
650
+ sum += v;
651
+ n++;
652
+ }
653
+ },
654
+ result: () => n === 0 ? null : fn === "avg" ? sum / n : sum
655
+ };
656
+ };
657
+ const sign = fn === "min" ? -1 : 1;
658
+ return () => {
659
+ let best = null;
660
+ return {
661
+ add: (row) => {
662
+ const v = read(row);
663
+ if (v == null) return;
664
+ if (best === null || compareLeaves(v, best) * sign > 0) best = v;
665
+ },
666
+ result: () => best
667
+ };
668
+ };
669
+ }
572
670
  /**
573
- * Resolves an aggregate expression to an accumulator factory (one accumulator
574
- * per group). Validated up front, so an unknown `$fn` is rejected even when
575
- * there are no rows.
671
+ * Resolves a plain aggregate (`first` / `last` are {@link firstLastFactory}'s)
672
+ * to an accumulator factory (one accumulator per group). Validated up front,
673
+ * so an unknown `$fn` is rejected even when there are no rows.
576
674
  */
577
675
  function accumulatorFactory(expr) {
578
676
  (0, _atscript_db_agg.assertAggregateFn)(expr.$fn);
579
677
  const field = expr.$field;
580
678
  const read = pathReader(field);
581
- switch (expr.$fn) {
582
- case "count": {
583
- const all = field === "*";
584
- return () => {
585
- let n = 0;
586
- return {
587
- add: (row) => {
588
- if (all || read(row) != null) n++;
589
- },
590
- result: () => n
591
- };
592
- };
593
- }
594
- case "sum":
595
- case "avg": {
596
- const avg = expr.$fn === "avg";
597
- return () => {
598
- let sum = 0;
599
- let n = 0;
600
- return {
601
- add: (row) => {
602
- const v = numericValue(read(row));
603
- if (v !== void 0) {
604
- sum += v;
605
- n++;
606
- }
607
- },
608
- result: () => n === 0 ? null : avg ? sum / n : sum
609
- };
610
- };
611
- }
612
- case "countDistinct": return () => {
613
- const plain = /* @__PURE__ */ new Set();
614
- const tokens = /* @__PURE__ */ new Set();
679
+ if (expr.$fn === "count") {
680
+ const all = field === "*";
681
+ return () => {
682
+ let n = 0;
615
683
  return {
616
684
  add: (row) => {
617
- const v = read(row);
618
- if (v == null) return;
619
- if (typeof v === "string" || typeof v === "boolean") plain.add(v);
620
- else tokens.add(identityToken(v));
685
+ if (all || read(row) != null) n++;
621
686
  },
622
- result: () => plain.size + tokens.size
687
+ result: () => n
623
688
  };
624
689
  };
625
- case "min":
626
- case "max": {
627
- const sign = expr.$fn === "min" ? -1 : 1;
628
- return () => {
629
- let best = null;
630
- return {
631
- add: (row) => {
632
- const v = read(row);
633
- if (v == null) return;
634
- if (best === null || compareLeaves(v, best) * sign > 0) best = v;
635
- },
636
- result: () => best
637
- };
638
- };
639
- }
640
- default: return expr.$fn;
641
690
  }
691
+ if (expr.$fn === "countDistinct") return () => {
692
+ const plain = /* @__PURE__ */ new Set();
693
+ const tokens = /* @__PURE__ */ new Set();
694
+ return {
695
+ add: (row) => {
696
+ const v = read(row);
697
+ if (v == null) return;
698
+ if (typeof v === "string" || typeof v === "boolean") plain.add(v);
699
+ else tokens.add(identityToken(v));
700
+ },
701
+ result: () => plain.size + tokens.size
702
+ };
703
+ };
704
+ return fnAccumulator(expr.$fn, read);
642
705
  }
643
706
  //#endregion
644
707
  //#region src/lib/memory-adapter.ts
@@ -1060,6 +1123,22 @@ var MemoryAdapter = class MemoryAdapter extends _atscript_db.BaseDbAdapter {
1060
1123
  insertedIds
1061
1124
  };
1062
1125
  }
1126
+ supportsInsertIgnore() {
1127
+ return true;
1128
+ }
1129
+ /** A row colliding on the primary key or a unique index (`CONFLICT`) becomes a skipped slot. */
1130
+ async insertManyIgnore(data) {
1131
+ this._assertWritable();
1132
+ const state = this._state();
1133
+ return data.map((item) => {
1134
+ try {
1135
+ return { insertedId: this._insertRow(state, item) };
1136
+ } catch (error) {
1137
+ if ((0, _atscript_db.isConflict)(error)) return null;
1138
+ throw error;
1139
+ }
1140
+ });
1141
+ }
1063
1142
  /**
1064
1143
  * Selects the stored rows a write should touch, as `{ key, row }` pairs so
1065
1144
  * callers can mutate in place and re-key. Stored mode scans the table's Map
@@ -1309,10 +1388,14 @@ var MemoryAdapter = class MemoryAdapter extends _atscript_db.BaseDbAdapter {
1309
1388
  calendarBucketUnits() {
1310
1389
  return _atscript_db.ALL_BUCKET_UNITS;
1311
1390
  }
1312
- /** Every aggregate function, `countDistinct` included. */
1391
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
1313
1392
  aggregateFns() {
1314
1393
  return _atscript_db.ALL_AGGREGATE_FNS;
1315
1394
  }
1395
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
1396
+ supportsAggregateExpressions() {
1397
+ return true;
1398
+ }
1316
1399
  /** Managed views render nothing (non-goal) — both are accepted like `aggregateFns()`. */
1317
1400
  viewCapabilities() {
1318
1401
  return _atscript_db.ALL_VIEW_CAPABILITIES;
@@ -1435,6 +1518,10 @@ function setMemoryProvider(space, type, fn) {
1435
1518
  if (!(adapter instanceof MemoryAdapter)) throw new Error("setMemoryProvider: table is not backed by MemoryAdapter");
1436
1519
  adapter.setProvider(fn);
1437
1520
  }
1521
+ /** @internal Drops the in-memory database of a closed space (called from `createAdapter`'s `onClose`). */
1522
+ function clearMemoryDatabase(space) {
1523
+ databases.get(space)?.clear();
1524
+ }
1438
1525
  //#endregion
1439
1526
  //#region src/lib/index.ts
1440
1527
  /**
@@ -1447,7 +1534,8 @@ function setMemoryProvider(space, type, fn) {
1447
1534
  * adapter has been built.
1448
1535
  */
1449
1536
  function createAdapter() {
1450
- return new _atscript_db.DbSpace(() => new MemoryAdapter());
1537
+ const space = new _atscript_db.DbSpace(() => new MemoryAdapter(), { onClose: () => clearMemoryDatabase(space) });
1538
+ return space;
1451
1539
  }
1452
1540
  //#endregion
1453
1541
  exports.MemoryAdapter = MemoryAdapter;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, ResolvedRelationFilter, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TFieldOps, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, ResolvedRelationFilter, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TFieldOps, TViewCapability } from "@atscript/db";
2
2
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
3
3
 
4
4
  //#region src/lib/memory-adapter.d.ts
@@ -269,6 +269,9 @@ declare class MemoryAdapter extends BaseDbAdapter {
269
269
  private _enforceUniqueIndexes;
270
270
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
271
271
  insertMany(data: Array<Record<string, unknown>>): Promise<TDbInsertManyResult>;
272
+ supportsInsertIgnore(): boolean;
273
+ /** A row colliding on the primary key or a unique index (`CONFLICT`) becomes a skipped slot. */
274
+ insertManyIgnore(data: Array<Record<string, unknown>>): Promise<TDbInsertIgnoreSlot[]>;
272
275
  /**
273
276
  * Selects the stored rows a write should touch, as `{ key, row }` pairs so
274
277
  * callers can mutate in place and re-key. Stored mode scans the table's Map
@@ -406,8 +409,10 @@ declare class MemoryAdapter extends BaseDbAdapter {
406
409
  * there is no zone this adapter cannot resolve).
407
410
  */
408
411
  calendarBucketUnits(): ReadonlySet<BucketUnit>;
409
- /** Every aggregate function, `countDistinct` included. */
412
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
410
413
  aggregateFns(): ReadonlySet<AggregateFn>;
414
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
415
+ supportsAggregateExpressions(): boolean;
411
416
  /** Managed views render nothing (non-goal) — both are accepted like `aggregateFns()`. */
412
417
  viewCapabilities(): ReadonlySet<TViewCapability>;
413
418
  updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps): Promise<TDbUpdateResult>;
@@ -570,4 +575,4 @@ declare function projectRow(row: Record<string, unknown>, projection?: Record<st
570
575
  */
571
576
  declare function createAdapter(): DbSpace;
572
577
  //#endregion
573
- export { MemoryAdapter, MemoryProviderFn, buildMemoryPredicate, createAdapter, projectRow, setMemoryProvider, sortRows };
578
+ export { MemoryAdapter, type MemoryProviderFn, buildMemoryPredicate, createAdapter, projectRow, setMemoryProvider, sortRows };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, ResolvedRelationFilter, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TFieldOps, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, ResolvedRelationFilter, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TFieldOps, TViewCapability } from "@atscript/db";
2
2
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
3
3
 
4
4
  //#region src/lib/memory-adapter.d.ts
@@ -269,6 +269,9 @@ declare class MemoryAdapter extends BaseDbAdapter {
269
269
  private _enforceUniqueIndexes;
270
270
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
271
271
  insertMany(data: Array<Record<string, unknown>>): Promise<TDbInsertManyResult>;
272
+ supportsInsertIgnore(): boolean;
273
+ /** A row colliding on the primary key or a unique index (`CONFLICT`) becomes a skipped slot. */
274
+ insertManyIgnore(data: Array<Record<string, unknown>>): Promise<TDbInsertIgnoreSlot[]>;
272
275
  /**
273
276
  * Selects the stored rows a write should touch, as `{ key, row }` pairs so
274
277
  * callers can mutate in place and re-key. Stored mode scans the table's Map
@@ -406,8 +409,10 @@ declare class MemoryAdapter extends BaseDbAdapter {
406
409
  * there is no zone this adapter cannot resolve).
407
410
  */
408
411
  calendarBucketUnits(): ReadonlySet<BucketUnit>;
409
- /** Every aggregate function, `countDistinct` included. */
412
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
410
413
  aggregateFns(): ReadonlySet<AggregateFn>;
414
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
415
+ supportsAggregateExpressions(): boolean;
411
416
  /** Managed views render nothing (non-goal) — both are accepted like `aggregateFns()`. */
412
417
  viewCapabilities(): ReadonlySet<TViewCapability>;
413
418
  updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps): Promise<TDbUpdateResult>;
@@ -570,4 +575,4 @@ declare function projectRow(row: Record<string, unknown>, projection?: Record<st
570
575
  */
571
576
  declare function createAdapter(): DbSpace;
572
577
  //#endregion
573
- export { MemoryAdapter, MemoryProviderFn, buildMemoryPredicate, createAdapter, projectRow, setMemoryProvider, sortRows };
578
+ export { MemoryAdapter, type MemoryProviderFn, buildMemoryPredicate, createAdapter, projectRow, setMemoryProvider, sortRows };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, containsRelationFilter, deletePath, forEachResolvedRelation, getPath, getPath as getPath$1, isAtscriptDbView, isResolvedRelationFilter, walkFilter } from "@atscript/db";
1
+ import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, containsRelationFilter, deletePath, evaluateExpr, forEachResolvedRelation, getPath, getPath as getPath$1, isAtscriptDbView, isConflict, isResolvedRelationFilter, walkFilter } from "@atscript/db";
2
2
  import { assertAggregateFn, resolveAlias } from "@atscript/db/agg";
3
3
  import { bucketer } from "@uniqu/core";
4
4
  //#region src/lib/memory-filter.ts
@@ -73,15 +73,26 @@ function stringifyLeaf(v) {
73
73
  /** `$eq` semantics, factored out so `$ne` can be its exact negation. */
74
74
  function evalEq(row, field, value) {
75
75
  const fieldValue = getPath$1(row, field);
76
- if (value === null) return fieldValue == null;
76
+ if (value === null) return matchesScalar(fieldValue, null);
77
77
  if (value instanceof RegExp) return fieldValue != null && value.test(stringifyLeaf(fieldValue));
78
+ return matchesScalar(fieldValue, value);
79
+ }
80
+ /**
81
+ * Mongo containment: a scalar compared with an array field matches when ANY element
82
+ * equals it (`{ labels: "bug" }` matches `["bug", "ui"]`). Non-array fields compare as before.
83
+ * `null` also matches a missing field (`undefined`) and an array holding a `null` element,
84
+ * as `{ labels: null }` / `$in: [null]` do in Mongo.
85
+ */
86
+ function matchesScalar(fieldValue, value) {
87
+ if (value === null) return fieldValue == null || Array.isArray(fieldValue) && fieldValue.includes(null);
88
+ if (Array.isArray(fieldValue) && !Array.isArray(value)) return fieldValue.some((el) => valuesEqual(el, value));
78
89
  return valuesEqual(fieldValue, value);
79
90
  }
80
91
  /** `$in` membership, factored out so `$nin` can be its exact negation. */
81
92
  function evalIn(row, field, value) {
82
93
  if (!Array.isArray(value)) return false;
83
94
  const fieldValue = getPath$1(row, field);
84
- return value.some((element) => valuesEqual(fieldValue, element));
95
+ return value.some((element) => matchesScalar(fieldValue, element));
85
96
  }
86
97
  /**
87
98
  * Coerce a leaf to something the JS relational operators can order. `Date`s
@@ -463,10 +474,28 @@ function projectRow(row, projection, opts) {
463
474
  function aggregateRows(rows, controls) {
464
475
  const $select = controls.$select;
465
476
  const groupBy = controls.$groupBy ?? [];
466
- const aggregates = $select?.aggregates ?? [];
467
- const aliases = aggregates.map((expr) => resolveAlias(expr));
477
+ const entries = [
478
+ ...($select?.aggregates ?? []).map((expr) => ({
479
+ alias: resolveAlias(expr),
480
+ create: accumulatorFactory(expr)
481
+ })),
482
+ ...($select?.exprAggregates ?? []).map((e) => ({
483
+ alias: e.alias,
484
+ create: exprAccumulatorFactory(e)
485
+ })),
486
+ ...($select?.firstLast ?? []).map((fl) => ({
487
+ alias: fl.alias,
488
+ create: firstLastFactory(fl, $select?.rowOrder ?? [])
489
+ }))
490
+ ];
491
+ const aliases = entries.map((e) => e.alias);
492
+ const groupExprs = ($select?.exprs ?? []).map((e) => ({
493
+ alias: e.alias,
494
+ expr: e.expr,
495
+ readers: new Map(e.names.map((name) => [name, pathReader(name)]))
496
+ }));
468
497
  const keyReaders = groupBy.map((key) => groupKeyReader(key, controls));
469
- const accumulators = aggregates.map(accumulatorFactory);
498
+ const accumulators = entries.map((e) => e.create);
470
499
  const newGroup = (values) => ({
471
500
  values,
472
501
  accs: accumulators.map((create) => create())
@@ -495,6 +524,7 @@ function aggregateRows(rows, controls) {
495
524
  const row = {};
496
525
  for (let i = 0; i < groupBy.length; i++) setPath(row, groupBy[i], values[i]);
497
526
  for (let i = 0; i < aliases.length; i++) row[aliases[i]] = accs[i].result();
527
+ for (const e of groupExprs) row[e.alias] = evaluateExpr(e.expr, (name) => Object.hasOwn(row, name) ? row[name] : e.readers.get(name)(row));
498
528
  out.push(row);
499
529
  }
500
530
  const having = controls.$having;
@@ -502,7 +532,7 @@ function aggregateRows(rows, controls) {
502
532
  if (controls.$count) return [{ count: out.length }];
503
533
  const paged = paginate(sortRows(out, controls.$sort), controls.$skip, controls.$limit);
504
534
  const fields = ($select === void 0 ? groupBy : $select.asArray ?? []).map((field) => [field, pathReader(field)]);
505
- const outputAliases = $select === void 0 ? [] : [...($select.buckets ?? []).map((b) => b.alias), ...aliases];
535
+ const outputAliases = $select === void 0 ? [] : [...($select.buckets ?? []).map((b) => b.alias), ...$select.computedAliases];
506
536
  return paged.map((row) => {
507
537
  const picked = {};
508
538
  for (const [field, read] of fields) setPath(picked, field, copyValue(read(row) ?? null));
@@ -568,76 +598,109 @@ function numericValue(value) {
568
598
  return Number.isNaN(n) ? void 0 : n;
569
599
  }
570
600
  }
601
+ /** `sum` / `avg` / `min` / `max` over a per-row arithmetic expression; NULL results are skipped like NULL fields. */
602
+ function exprAccumulatorFactory(e) {
603
+ const readers = new Map(e.names.map((name) => [name, pathReader(name)]));
604
+ return fnAccumulator(e.fn, (row) => evaluateExpr(e.expr, (field) => readers.get(field)(row)));
605
+ }
606
+ /**
607
+ * `first` / `last`: the value of `fl.column` on the group's representative
608
+ * row — the least (`first`) or greatest (`last`) row by `rowOrder` (NULL
609
+ * smallest; the primary key is the final key, so the pick is deterministic).
610
+ */
611
+ function firstLastFactory(fl, rowOrder) {
612
+ const keys = rowOrder.map((k) => ({
613
+ read: pathReader(k.column),
614
+ sign: k.desc ? -1 : 1
615
+ }));
616
+ const read = pathReader(fl.column);
617
+ const sign = fl.fn === "first" ? -1 : 1;
618
+ const compare = (a, b) => {
619
+ for (const k of keys) {
620
+ const c = compareLeaves(k.read(a), k.read(b)) * k.sign;
621
+ if (c !== 0) return c;
622
+ }
623
+ return 0;
624
+ };
625
+ return () => {
626
+ let best;
627
+ return {
628
+ add: (row) => {
629
+ if (best === void 0 || compare(row, best) * sign > 0) best = row;
630
+ },
631
+ result: () => best === void 0 ? null : copyValue(read(best) ?? null)
632
+ };
633
+ };
634
+ }
635
+ /**
636
+ * `sum` / `avg` / `min` / `max` over the values `read` yields per row — a
637
+ * field's, or a per-row expression's. `sum` / `avg` skip non-numeric values
638
+ * and are `null` over none; `min` / `max` skip null / missing and order with
639
+ * the `$sort` comparator.
640
+ */
641
+ function fnAccumulator(fn, read) {
642
+ if (fn === "sum" || fn === "avg") return () => {
643
+ let sum = 0;
644
+ let n = 0;
645
+ return {
646
+ add: (row) => {
647
+ const v = numericValue(read(row));
648
+ if (v !== void 0) {
649
+ sum += v;
650
+ n++;
651
+ }
652
+ },
653
+ result: () => n === 0 ? null : fn === "avg" ? sum / n : sum
654
+ };
655
+ };
656
+ const sign = fn === "min" ? -1 : 1;
657
+ return () => {
658
+ let best = null;
659
+ return {
660
+ add: (row) => {
661
+ const v = read(row);
662
+ if (v == null) return;
663
+ if (best === null || compareLeaves(v, best) * sign > 0) best = v;
664
+ },
665
+ result: () => best
666
+ };
667
+ };
668
+ }
571
669
  /**
572
- * Resolves an aggregate expression to an accumulator factory (one accumulator
573
- * per group). Validated up front, so an unknown `$fn` is rejected even when
574
- * there are no rows.
670
+ * Resolves a plain aggregate (`first` / `last` are {@link firstLastFactory}'s)
671
+ * to an accumulator factory (one accumulator per group). Validated up front,
672
+ * so an unknown `$fn` is rejected even when there are no rows.
575
673
  */
576
674
  function accumulatorFactory(expr) {
577
675
  assertAggregateFn(expr.$fn);
578
676
  const field = expr.$field;
579
677
  const read = pathReader(field);
580
- switch (expr.$fn) {
581
- case "count": {
582
- const all = field === "*";
583
- return () => {
584
- let n = 0;
585
- return {
586
- add: (row) => {
587
- if (all || read(row) != null) n++;
588
- },
589
- result: () => n
590
- };
591
- };
592
- }
593
- case "sum":
594
- case "avg": {
595
- const avg = expr.$fn === "avg";
596
- return () => {
597
- let sum = 0;
598
- let n = 0;
599
- return {
600
- add: (row) => {
601
- const v = numericValue(read(row));
602
- if (v !== void 0) {
603
- sum += v;
604
- n++;
605
- }
606
- },
607
- result: () => n === 0 ? null : avg ? sum / n : sum
608
- };
609
- };
610
- }
611
- case "countDistinct": return () => {
612
- const plain = /* @__PURE__ */ new Set();
613
- const tokens = /* @__PURE__ */ new Set();
678
+ if (expr.$fn === "count") {
679
+ const all = field === "*";
680
+ return () => {
681
+ let n = 0;
614
682
  return {
615
683
  add: (row) => {
616
- const v = read(row);
617
- if (v == null) return;
618
- if (typeof v === "string" || typeof v === "boolean") plain.add(v);
619
- else tokens.add(identityToken(v));
684
+ if (all || read(row) != null) n++;
620
685
  },
621
- result: () => plain.size + tokens.size
686
+ result: () => n
622
687
  };
623
688
  };
624
- case "min":
625
- case "max": {
626
- const sign = expr.$fn === "min" ? -1 : 1;
627
- return () => {
628
- let best = null;
629
- return {
630
- add: (row) => {
631
- const v = read(row);
632
- if (v == null) return;
633
- if (best === null || compareLeaves(v, best) * sign > 0) best = v;
634
- },
635
- result: () => best
636
- };
637
- };
638
- }
639
- default: return expr.$fn;
640
689
  }
690
+ if (expr.$fn === "countDistinct") return () => {
691
+ const plain = /* @__PURE__ */ new Set();
692
+ const tokens = /* @__PURE__ */ new Set();
693
+ return {
694
+ add: (row) => {
695
+ const v = read(row);
696
+ if (v == null) return;
697
+ if (typeof v === "string" || typeof v === "boolean") plain.add(v);
698
+ else tokens.add(identityToken(v));
699
+ },
700
+ result: () => plain.size + tokens.size
701
+ };
702
+ };
703
+ return fnAccumulator(expr.$fn, read);
641
704
  }
642
705
  //#endregion
643
706
  //#region src/lib/memory-adapter.ts
@@ -1059,6 +1122,22 @@ var MemoryAdapter = class MemoryAdapter extends BaseDbAdapter {
1059
1122
  insertedIds
1060
1123
  };
1061
1124
  }
1125
+ supportsInsertIgnore() {
1126
+ return true;
1127
+ }
1128
+ /** A row colliding on the primary key or a unique index (`CONFLICT`) becomes a skipped slot. */
1129
+ async insertManyIgnore(data) {
1130
+ this._assertWritable();
1131
+ const state = this._state();
1132
+ return data.map((item) => {
1133
+ try {
1134
+ return { insertedId: this._insertRow(state, item) };
1135
+ } catch (error) {
1136
+ if (isConflict(error)) return null;
1137
+ throw error;
1138
+ }
1139
+ });
1140
+ }
1062
1141
  /**
1063
1142
  * Selects the stored rows a write should touch, as `{ key, row }` pairs so
1064
1143
  * callers can mutate in place and re-key. Stored mode scans the table's Map
@@ -1308,10 +1387,14 @@ var MemoryAdapter = class MemoryAdapter extends BaseDbAdapter {
1308
1387
  calendarBucketUnits() {
1309
1388
  return ALL_BUCKET_UNITS;
1310
1389
  }
1311
- /** Every aggregate function, `countDistinct` included. */
1390
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
1312
1391
  aggregateFns() {
1313
1392
  return ALL_AGGREGATE_FNS;
1314
1393
  }
1394
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
1395
+ supportsAggregateExpressions() {
1396
+ return true;
1397
+ }
1315
1398
  /** Managed views render nothing (non-goal) — both are accepted like `aggregateFns()`. */
1316
1399
  viewCapabilities() {
1317
1400
  return ALL_VIEW_CAPABILITIES;
@@ -1434,6 +1517,10 @@ function setMemoryProvider(space, type, fn) {
1434
1517
  if (!(adapter instanceof MemoryAdapter)) throw new Error("setMemoryProvider: table is not backed by MemoryAdapter");
1435
1518
  adapter.setProvider(fn);
1436
1519
  }
1520
+ /** @internal Drops the in-memory database of a closed space (called from `createAdapter`'s `onClose`). */
1521
+ function clearMemoryDatabase(space) {
1522
+ databases.get(space)?.clear();
1523
+ }
1437
1524
  //#endregion
1438
1525
  //#region src/lib/index.ts
1439
1526
  /**
@@ -1446,7 +1533,8 @@ function setMemoryProvider(space, type, fn) {
1446
1533
  * adapter has been built.
1447
1534
  */
1448
1535
  function createAdapter() {
1449
- return new DbSpace(() => new MemoryAdapter());
1536
+ const space = new DbSpace(() => new MemoryAdapter(), { onClose: () => clearMemoryDatabase(space) });
1537
+ return space;
1450
1538
  }
1451
1539
  //#endregion
1452
1540
  export { MemoryAdapter, buildMemoryPredicate, createAdapter, projectRow, setMemoryProvider, sortRows };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-memory",
3
- "version": "0.1.147",
3
+ "version": "0.1.148",
4
4
  "description": "In-memory adapter for atscript-db.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -36,16 +36,16 @@
36
36
  "access": "public"
37
37
  },
38
38
  "devDependencies": {
39
- "@atscript/core": "^0.1.99",
40
- "@atscript/typescript": "^0.1.99",
41
- "@uniqu/core": "^0.1.12",
42
- "unplugin-atscript": "^0.1.99"
39
+ "@atscript/core": "^0.1.100",
40
+ "@atscript/typescript": "^0.1.100",
41
+ "@uniqu/core": "^0.1.13",
42
+ "unplugin-atscript": "^0.1.100"
43
43
  },
44
44
  "peerDependencies": {
45
- "@atscript/core": "^0.1.99",
46
- "@atscript/typescript": "^0.1.99",
47
- "@uniqu/core": "^0.1.12",
48
- "@atscript/db": "^0.1.147"
45
+ "@atscript/core": "^0.1.100",
46
+ "@atscript/typescript": "^0.1.100",
47
+ "@uniqu/core": "^0.1.13",
48
+ "@atscript/db": "^0.1.148"
49
49
  },
50
50
  "scripts": {
51
51
  "postinstall": "asc -f dts",