@atscript/db-memory 0.1.146 → 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
@@ -156,18 +167,156 @@ const memoryVisitor = {
156
167
  message: `Unsupported filter operator: ${op}`
157
168
  }]);
158
169
  }
170
+ },
171
+ relation(field, op, operand) {
172
+ return relationPredicate(void 0, field, op, operand);
159
173
  }
160
174
  };
161
175
  /**
176
+ * Identity of the key tuple `fields` of `row` (dot-paths read nested), or
177
+ * `undefined` when a component is `null` / missing — a NULL key component
178
+ * never correlates (SQL `=` semantics: `$some` false, `$none` true).
179
+ * Every component is type-tagged (see {@link keyPart}), matching the strict
180
+ * equality the memory filter uses everywhere (`valuesEqual`): `1` ≠ `"1"`,
181
+ * `5n` ≠ `"5n"`, a `Date` ≠ its ISO string; `Date`s compare by instant.
182
+ */
183
+ function correlationKey(row, fields) {
184
+ const parts = [];
185
+ for (const field of fields) {
186
+ const part = keyPart((0, _atscript_db.getPath)(row, field));
187
+ if (part === void 0) return;
188
+ parts.push(part);
189
+ }
190
+ return JSON.stringify(parts);
191
+ }
192
+ /**
193
+ * Type-tagged identity of one key component, or `undefined` when it never
194
+ * equals anything (`null` / missing, `NaN`).
195
+ */
196
+ function keyPart(value) {
197
+ switch (typeof value) {
198
+ case "string": return `s:${value}`;
199
+ case "number": return Number.isNaN(value) ? void 0 : `n:${value === 0 ? 0 : value}`;
200
+ case "bigint": return `b:${value}`;
201
+ case "boolean": return `t:${value}`;
202
+ case "undefined": return;
203
+ default:
204
+ if (value === null) return void 0;
205
+ if (value instanceof Date) {
206
+ const time = value.getTime();
207
+ return Number.isNaN(time) ? void 0 : `d:${time}`;
208
+ }
209
+ return `o:${JSON.stringify(value, (_k, v) => typeof v === "bigint" ? `${v}n` : v)}`;
210
+ }
211
+ }
212
+ /** Source-side correlation columns of a resolved predicate (physical names). */
213
+ function sourceColumns(node) {
214
+ return node.junction ? node.junction.toSource.map((p) => p.source) : node.pairs.map((p) => p.source);
215
+ }
216
+ /**
217
+ * The visitor's `relation` callback: a row satisfies `$some` when its source
218
+ * correlation tuple is in the predicate's prepared set, `$none` otherwise.
219
+ */
220
+ function relationPredicate(sets, field, op, operand) {
221
+ const node = operand;
222
+ const set = (0, _atscript_db.isResolvedRelationFilter)(node) ? sets?.get(node) : void 0;
223
+ if (!set) throw new _atscript_db.DbError("REL_FILTER_NOT_SUPPORTED", [{
224
+ path: field,
225
+ message: `Relational predicate "${op}" on "${field}" was not prepared — call prepareRelationSets() before buildMemoryPredicate()`
226
+ }]);
227
+ const columns = sourceColumns(node);
228
+ const some = op === "$some";
229
+ return (row) => {
230
+ const key = correlationKey(row, columns);
231
+ return (key !== void 0 && set.has(key)) === some;
232
+ };
233
+ }
234
+ /**
235
+ * Resolves every relational predicate of a TRANSLATED filter (nested ones
236
+ * included) into a correlation set, loading each related table through
237
+ * `load`:
238
+ *
239
+ * - `to` / `from`: the related rows matching the inner filter contribute their
240
+ * `pairs[].target` tuples — equal to the source tuple (`pairs[].source`) of
241
+ * every row that has such a related row.
242
+ * - `via`: the matching related rows' `junction.toTarget[].target` tuples
243
+ * select junction rows (also filtered by the junction part of
244
+ * `@db.rel.filter`), which contribute their `junction.toSource[].junction`
245
+ * tuples.
246
+ *
247
+ * Nested predicates are prepared against the related table's rows first
248
+ * (their source is this predicate's target). Returns `undefined` when the
249
+ * filter holds no predicate — callers then stay on the plain synchronous path.
250
+ *
251
+ * @since 0.1.147
252
+ */
253
+ async function prepareRelationSets(filter, load) {
254
+ if (!(0, _atscript_db.containsRelationFilter)(filter)) return;
255
+ const sets = /* @__PURE__ */ new Map();
256
+ await prepareLevel(filter, load, sets);
257
+ return sets;
258
+ }
259
+ /** Prepares the predicates at one level of `filter` (and, recursively, their operands). */
260
+ async function prepareLevel(filter, load, sets) {
261
+ const nodes = [];
262
+ (0, _atscript_db.forEachResolvedRelation)(filter, (node) => nodes.push(node), false);
263
+ for (const node of nodes) if (!sets.has(node)) sets.set(node, await correlationSet(node, load, sets));
264
+ }
265
+ /** Matching rows of `adapter`'s table for `filter` (its own predicates prepared first). */
266
+ async function matchingRows(adapter, filter, load, sets) {
267
+ const rows = await load(adapter);
268
+ if (!filter || Object.keys(filter).length === 0) return rows;
269
+ await prepareLevel(filter, load, sets);
270
+ return rows.filter(buildMemoryPredicate(filter, sets));
271
+ }
272
+ /** The source correlation tuples that have a related row matching `node`. */
273
+ async function correlationSet(node, load, sets) {
274
+ const targets = await matchingRows(node.target.adapter, node.filter, load, sets);
275
+ const result = /* @__PURE__ */ new Set();
276
+ if (!node.junction) {
277
+ const columns = node.pairs.map((p) => p.target);
278
+ for (const row of targets) {
279
+ const key = correlationKey(row, columns);
280
+ if (key !== void 0) result.add(key);
281
+ }
282
+ return result;
283
+ }
284
+ const { junction } = node;
285
+ const targetKeys = /* @__PURE__ */ new Set();
286
+ const targetColumns = junction.toTarget.map((p) => p.target);
287
+ for (const row of targets) {
288
+ const key = correlationKey(row, targetColumns);
289
+ if (key !== void 0) targetKeys.add(key);
290
+ }
291
+ if (targetKeys.size === 0) return result;
292
+ const toTarget = junction.toTarget.map((p) => p.junction);
293
+ const toSource = junction.toSource.map((p) => p.junction);
294
+ for (const row of await matchingRows(junction.adapter, junction.filter, load, sets)) {
295
+ const targetKey = correlationKey(row, toTarget);
296
+ if (targetKey === void 0 || !targetKeys.has(targetKey)) continue;
297
+ const sourceKey = correlationKey(row, toSource);
298
+ if (sourceKey !== void 0) result.add(sourceKey);
299
+ }
300
+ return result;
301
+ }
302
+ /**
162
303
  * Compiles a {@link FilterExpr} into an in-memory row predicate
163
304
  * `(row) => boolean`, reusing the shared {@link walkFilter} walker so filter
164
305
  * structure matches the SQL/Mongo adapters by construction.
165
306
  *
166
307
  * An empty/absent filter (for which `walkFilter` returns `undefined`) compiles
167
308
  * to a match-everything predicate.
309
+ *
310
+ * A filter holding relational predicates (`ResolvedRelationFilter` operands,
311
+ * since 0.1.147) needs `relationSets` — prepared internally by
312
+ * `MemoryAdapter` from the related tables; a standalone call on such a
313
+ * filter throws `REL_FILTER_NOT_SUPPORTED`.
168
314
  */
169
- function buildMemoryPredicate(filter) {
170
- return (0, _atscript_db.walkFilter)(filter, memoryVisitor) ?? (() => true);
315
+ function buildMemoryPredicate(filter, relationSets) {
316
+ return (0, _atscript_db.walkFilter)(filter, relationSets ? {
317
+ ...memoryVisitor,
318
+ relation: (field, op, operand) => relationPredicate(relationSets, field, op, operand)
319
+ } : memoryVisitor) ?? (() => true);
171
320
  }
172
321
  //#endregion
173
322
  //#region src/lib/memory-engine.ts
@@ -326,10 +475,28 @@ function projectRow(row, projection, opts) {
326
475
  function aggregateRows(rows, controls) {
327
476
  const $select = controls.$select;
328
477
  const groupBy = controls.$groupBy ?? [];
329
- const aggregates = $select?.aggregates ?? [];
330
- 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
+ }));
331
498
  const keyReaders = groupBy.map((key) => groupKeyReader(key, controls));
332
- const accumulators = aggregates.map(accumulatorFactory);
499
+ const accumulators = entries.map((e) => e.create);
333
500
  const newGroup = (values) => ({
334
501
  values,
335
502
  accs: accumulators.map((create) => create())
@@ -358,6 +525,7 @@ function aggregateRows(rows, controls) {
358
525
  const row = {};
359
526
  for (let i = 0; i < groupBy.length; i++) setPath(row, groupBy[i], values[i]);
360
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));
361
529
  out.push(row);
362
530
  }
363
531
  const having = controls.$having;
@@ -365,7 +533,7 @@ function aggregateRows(rows, controls) {
365
533
  if (controls.$count) return [{ count: out.length }];
366
534
  const paged = paginate(sortRows(out, controls.$sort), controls.$skip, controls.$limit);
367
535
  const fields = ($select === void 0 ? groupBy : $select.asArray ?? []).map((field) => [field, pathReader(field)]);
368
- 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];
369
537
  return paged.map((row) => {
370
538
  const picked = {};
371
539
  for (const [field, read] of fields) setPath(picked, field, copyValue(read(row) ?? null));
@@ -431,76 +599,109 @@ function numericValue(value) {
431
599
  return Number.isNaN(n) ? void 0 : n;
432
600
  }
433
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
+ }
434
607
  /**
435
- * Resolves an aggregate expression to an accumulator factory (one accumulator
436
- * per group). Validated up front, so an unknown `$fn` is rejected even when
437
- * there are no rows.
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
+ }
670
+ /**
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.
438
674
  */
439
675
  function accumulatorFactory(expr) {
440
676
  (0, _atscript_db_agg.assertAggregateFn)(expr.$fn);
441
677
  const field = expr.$field;
442
678
  const read = pathReader(field);
443
- switch (expr.$fn) {
444
- case "count": {
445
- const all = field === "*";
446
- return () => {
447
- let n = 0;
448
- return {
449
- add: (row) => {
450
- if (all || read(row) != null) n++;
451
- },
452
- result: () => n
453
- };
454
- };
455
- }
456
- case "sum":
457
- case "avg": {
458
- const avg = expr.$fn === "avg";
459
- return () => {
460
- let sum = 0;
461
- let n = 0;
462
- return {
463
- add: (row) => {
464
- const v = numericValue(read(row));
465
- if (v !== void 0) {
466
- sum += v;
467
- n++;
468
- }
469
- },
470
- result: () => n === 0 ? null : avg ? sum / n : sum
471
- };
472
- };
473
- }
474
- case "countDistinct": return () => {
475
- const plain = /* @__PURE__ */ new Set();
476
- const tokens = /* @__PURE__ */ new Set();
679
+ if (expr.$fn === "count") {
680
+ const all = field === "*";
681
+ return () => {
682
+ let n = 0;
477
683
  return {
478
684
  add: (row) => {
479
- const v = read(row);
480
- if (v == null) return;
481
- if (typeof v === "string" || typeof v === "boolean") plain.add(v);
482
- else tokens.add(identityToken(v));
685
+ if (all || read(row) != null) n++;
483
686
  },
484
- result: () => plain.size + tokens.size
687
+ result: () => n
485
688
  };
486
689
  };
487
- case "min":
488
- case "max": {
489
- const sign = expr.$fn === "min" ? -1 : 1;
490
- return () => {
491
- let best = null;
492
- return {
493
- add: (row) => {
494
- const v = read(row);
495
- if (v == null) return;
496
- if (best === null || compareLeaves(v, best) * sign > 0) best = v;
497
- },
498
- result: () => best
499
- };
500
- };
501
- }
502
- default: return expr.$fn;
503
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);
504
705
  }
505
706
  //#endregion
506
707
  //#region src/lib/memory-adapter.ts
@@ -527,7 +728,7 @@ const databases = /* @__PURE__ */ new WeakMap();
527
728
  * runtime-owned entity with NO database can be observed as a READ-ONLY
528
729
  * atscript table; all writes are rejected (see {@link _assertWritable}).
529
730
  */
530
- var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
731
+ var MemoryAdapter = class MemoryAdapter extends _atscript_db.BaseDbAdapter {
531
732
  /**
532
733
  * The in-memory database: the space's (see {@link databases}), resolved once
533
734
  * in {@link registerSpace}; an adapter used without a space keeps this own one.
@@ -583,6 +784,19 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
583
784
  return !fd.encrypted;
584
785
  }
585
786
  /**
787
+ * Relational predicates (`{ nav: { $some | $none: … } }`) are evaluated in
788
+ * reads AND mutation filters. Before a filter holding them is compiled, every
789
+ * related table (and junction) is loaded ONCE for that operation — through
790
+ * its own adapter's snapshot seam, so a provider-backed related table works
791
+ * too — and each predicate becomes a set of matching correlation keys
792
+ * (see {@link _relationSets}). Predicate-free filters take the plain path.
793
+ *
794
+ * @since 0.1.147
795
+ */
796
+ supportsRelationFilters(_mode) {
797
+ return true;
798
+ }
799
+ /**
586
800
  * Coerces a by-id value to the id field's declared leaf type. The framework
587
801
  * calls `adapter.prepareId(value, fieldType)` when building by-id filters, so a
588
802
  * URL id like `"21"` reaches this adapter as the STRING `"21"`. Memory does
@@ -643,14 +857,14 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
643
857
  /**
644
858
  * Physical names of the primary-key field(s). Single `@meta.id` resolves via
645
859
  * {@link AtscriptDbReadable.metaIdPhysical}; a composite key maps each logical
646
- * PK path through `pathToPhysical` (falling back to the name itself). Memoized
860
+ * PK path through `physicalPath` (`@db.column` renames included). Memoized
647
861
  * because it is stable per adapter (1:1 with a fixed table) yet read on every
648
862
  * `pkKey`/projection — recomputing would re-read `this._table.*` and re-allocate.
649
863
  */
650
864
  _physicalPkFields() {
651
865
  if (this._pkFieldsCache) return this._pkFieldsCache;
652
866
  const metaIdPhysical = this._table.metaIdPhysical;
653
- const fields = metaIdPhysical ? [metaIdPhysical] : this._table.primaryKeys.map((pk) => this._table.pathToPhysical.get(pk) ?? pk);
867
+ const fields = metaIdPhysical ? [metaIdPhysical] : this._table.primaryKeys.map((pk) => this._table.physicalPath(pk));
654
868
  this._pkFieldsCache = fields;
655
869
  return fields;
656
870
  }
@@ -808,6 +1022,39 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
808
1022
  return [...this._peekState()?.rows.values() ?? []];
809
1023
  }
810
1024
  /**
1025
+ * A per-operation row loader for relational predicates: each related table
1026
+ * is snapshotted at most once per operation (the first predicate that needs
1027
+ * it loads it; later ones — and nested ones — reuse that snapshot), so a
1028
+ * provider-backed related table is invoked once per read. `own` seeds this
1029
+ * table's snapshot so a self relation reads the same rows as the outer scan.
1030
+ */
1031
+ _snapshotLoader(own) {
1032
+ const snapshots = /* @__PURE__ */ new Map();
1033
+ if (own) snapshots.set(this, own);
1034
+ return (adapter) => {
1035
+ let rows = snapshots.get(adapter);
1036
+ if (!rows) {
1037
+ if (!(adapter instanceof MemoryAdapter)) throw new _atscript_db.DbError("REL_FILTER_NOT_SUPPORTED", [{
1038
+ path: "",
1039
+ message: "Relational predicate: the related table is not served by the memory adapter"
1040
+ }]);
1041
+ rows = Promise.resolve(adapter._loadRows());
1042
+ snapshots.set(adapter, rows);
1043
+ }
1044
+ return rows;
1045
+ };
1046
+ }
1047
+ /**
1048
+ * The prepared correlation sets for `filter`'s relational predicates.
1049
+ * Callers only await it when `containsRelationFilter(filter)` — a
1050
+ * predicate-free filter keeps its fully synchronous path. Write methods call
1051
+ * it BEFORE reading the table state, so the related tables (a self relation
1052
+ * included) are snapshotted before any row changes, like SQL.
1053
+ */
1054
+ async _relationSets(filter, own) {
1055
+ return prepareRelationSets(filter, this._snapshotLoader(own));
1056
+ }
1057
+ /**
811
1058
  * Clones the payload in, applies the version default, generates any
812
1059
  * `@db.default.increment` values, enforces PK + unique constraints, stores
813
1060
  * the row, and returns the resolved inserted id. Clone-in (`structuredClone`)
@@ -876,6 +1123,22 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
876
1123
  insertedIds
877
1124
  };
878
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
+ }
879
1142
  /**
880
1143
  * Selects the stored rows a write should touch, as `{ key, row }` pairs so
881
1144
  * callers can mutate in place and re-key. Stored mode scans the table's Map
@@ -891,11 +1154,14 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
891
1154
  *
892
1155
  * When `many` is `false` at most the first match is returned. A table that
893
1156
  * does not exist (`state` undefined) matches nothing and is not created.
1157
+ *
1158
+ * A filter with relational predicates needs `relationSets`, prepared by the
1159
+ * caller BEFORE it reads `state` (see {@link _relationSets}).
894
1160
  */
895
- _selectForWrite(state, filter, expectedVersion, many) {
1161
+ _selectForWrite(state, filter, expectedVersion, many, relationSets) {
896
1162
  const versionColumn = this._table.versionColumnPhysical;
897
1163
  if (expectedVersion !== void 0 && versionColumn === void 0) throw new Error("expectedVersion requires a versioned table");
898
- const match = buildMemoryPredicate(filter);
1164
+ const match = buildMemoryPredicate(filter, relationSets);
899
1165
  const matched = [];
900
1166
  for (const [key, row] of state?.rows ?? []) {
901
1167
  if (!match(row)) continue;
@@ -975,8 +1241,9 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
975
1241
  }
976
1242
  async replaceOne(filter, data, expectedVersion) {
977
1243
  this._assertWritable();
1244
+ const sets = (0, _atscript_db.containsRelationFilter)(filter) ? await this._relationSets(filter) : void 0;
978
1245
  const state = this._peekState();
979
- const matched = this._selectForWrite(state, filter, expectedVersion, false);
1246
+ const matched = this._selectForWrite(state, filter, expectedVersion, false, sets);
980
1247
  if (!state || matched.length === 0) return {
981
1248
  matchedCount: 0,
982
1249
  modifiedCount: 0
@@ -993,8 +1260,9 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
993
1260
  }
994
1261
  async updateOne(filter, data, ops, expectedVersion) {
995
1262
  this._assertWritable();
1263
+ const sets = (0, _atscript_db.containsRelationFilter)(filter) ? await this._relationSets(filter) : void 0;
996
1264
  const state = this._peekState();
997
- const matched = this._selectForWrite(state, filter, expectedVersion, false);
1265
+ const matched = this._selectForWrite(state, filter, expectedVersion, false, sets);
998
1266
  if (!state || matched.length === 0) return {
999
1267
  matchedCount: 0,
1000
1268
  modifiedCount: 0
@@ -1008,8 +1276,9 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
1008
1276
  }
1009
1277
  async deleteOne(filter) {
1010
1278
  this._assertWritable();
1279
+ const sets = (0, _atscript_db.containsRelationFilter)(filter) ? await this._relationSets(filter) : void 0;
1011
1280
  const state = this._peekState();
1012
- const matched = this._selectForWrite(state, filter, void 0, false);
1281
+ const matched = this._selectForWrite(state, filter, void 0, false, sets);
1013
1282
  if (!state || matched.length === 0) return { deletedCount: 0 };
1014
1283
  state.rows.delete(matched[0].key);
1015
1284
  return { deletedCount: 1 };
@@ -1061,8 +1330,14 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
1061
1330
  * working set — one provider invocation per logical read.
1062
1331
  */
1063
1332
  async _filteredRows(query) {
1064
- const match = buildMemoryPredicate(query.filter);
1065
- return (await this._loadRows()).filter(match);
1333
+ if (!(0, _atscript_db.containsRelationFilter)(query.filter)) {
1334
+ const match = buildMemoryPredicate(query.filter);
1335
+ return (await this._loadRows()).filter(match);
1336
+ }
1337
+ const own = Promise.resolve(this._loadRows());
1338
+ own.catch(() => {});
1339
+ const sets = await this._relationSets(query.filter, own);
1340
+ return (await own).filter(buildMemoryPredicate(query.filter, sets));
1066
1341
  }
1067
1342
  async findOne(query) {
1068
1343
  const filtered = await this._filteredRows(query);
@@ -1113,18 +1388,27 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
1113
1388
  calendarBucketUnits() {
1114
1389
  return _atscript_db.ALL_BUCKET_UNITS;
1115
1390
  }
1116
- /** Every aggregate function, `countDistinct` included. */
1391
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
1117
1392
  aggregateFns() {
1118
1393
  return _atscript_db.ALL_AGGREGATE_FNS;
1119
1394
  }
1395
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
1396
+ supportsAggregateExpressions() {
1397
+ return true;
1398
+ }
1399
+ /** Managed views render nothing (non-goal) — both are accepted like `aggregateFns()`. */
1400
+ viewCapabilities() {
1401
+ return _atscript_db.ALL_VIEW_CAPABILITIES;
1402
+ }
1120
1403
  async updateMany(filter, data, ops) {
1121
1404
  this._assertWritable();
1405
+ const sets = (0, _atscript_db.containsRelationFilter)(filter) ? await this._relationSets(filter) : void 0;
1122
1406
  const state = this._peekState();
1123
1407
  if (!state) return {
1124
1408
  matchedCount: 0,
1125
1409
  modifiedCount: 0
1126
1410
  };
1127
- const matched = this._selectForWrite(state, filter, void 0, true);
1411
+ const matched = this._selectForWrite(state, filter, void 0, true, sets);
1128
1412
  for (const { key, row } of matched) this._commitUpdate(state, key, row, data, ops);
1129
1413
  return {
1130
1414
  matchedCount: matched.length,
@@ -1133,12 +1417,13 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
1133
1417
  }
1134
1418
  async replaceMany(filter, data) {
1135
1419
  this._assertWritable();
1420
+ const sets = (0, _atscript_db.containsRelationFilter)(filter) ? await this._relationSets(filter) : void 0;
1136
1421
  const state = this._peekState();
1137
1422
  if (!state) return {
1138
1423
  matchedCount: 0,
1139
1424
  modifiedCount: 0
1140
1425
  };
1141
- const matched = this._selectForWrite(state, filter, void 0, true);
1426
+ const matched = this._selectForWrite(state, filter, void 0, true, sets);
1142
1427
  for (const { key, row } of matched) this._commitUpdate(state, key, row, data);
1143
1428
  return {
1144
1429
  matchedCount: matched.length,
@@ -1147,9 +1432,10 @@ var MemoryAdapter = class extends _atscript_db.BaseDbAdapter {
1147
1432
  }
1148
1433
  async deleteMany(filter) {
1149
1434
  this._assertWritable();
1435
+ const sets = (0, _atscript_db.containsRelationFilter)(filter) ? await this._relationSets(filter) : void 0;
1150
1436
  const state = this._peekState();
1151
1437
  if (!state) return { deletedCount: 0 };
1152
- const matched = this._selectForWrite(state, filter, void 0, true);
1438
+ const matched = this._selectForWrite(state, filter, void 0, true, sets);
1153
1439
  for (const { key } of matched) state.rows.delete(key);
1154
1440
  return { deletedCount: matched.length };
1155
1441
  }
@@ -1232,6 +1518,10 @@ function setMemoryProvider(space, type, fn) {
1232
1518
  if (!(adapter instanceof MemoryAdapter)) throw new Error("setMemoryProvider: table is not backed by MemoryAdapter");
1233
1519
  adapter.setProvider(fn);
1234
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
+ }
1235
1525
  //#endregion
1236
1526
  //#region src/lib/index.ts
1237
1527
  /**
@@ -1244,7 +1534,8 @@ function setMemoryProvider(space, type, fn) {
1244
1534
  * adapter has been built.
1245
1535
  */
1246
1536
  function createAdapter() {
1247
- return new _atscript_db.DbSpace(() => new MemoryAdapter());
1537
+ const space = new _atscript_db.DbSpace(() => new MemoryAdapter(), { onClose: () => clearMemoryDatabase(space) });
1538
+ return space;
1248
1539
  }
1249
1540
  //#endregion
1250
1541
  exports.MemoryAdapter = MemoryAdapter;