@atscript/db 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.
Files changed (59) hide show
  1. package/dist/{agg-CV7y8nC6.d.cts → agg-42CSpGDR.d.cts} +2 -2
  2. package/dist/{agg-D5DHsAby.d.mts → agg-Wo-smrYV.d.mts} +3 -3
  3. package/dist/agg.cjs +1 -1
  4. package/dist/agg.d.cts +2 -2
  5. package/dist/agg.d.mts +2 -2
  6. package/dist/agg.mjs +1 -1
  7. package/dist/{aggregate-fns-CGBv3E8S.cjs → aggregate-fns-C-UJRobm.cjs} +52 -5
  8. package/dist/{aggregate-fns-CfsveE1w.mjs → aggregate-fns-CyaZyb9I.mjs} +35 -6
  9. package/dist/aggregate-rules-D_bsCpUI.cjs +26 -0
  10. package/dist/aggregate-rules-jdPrxqWa.mjs +15 -0
  11. package/dist/{buckets-DRycmhOW.d.mts → buckets-CNdTOnei.d.mts} +832 -33
  12. package/dist/{buckets-DYFu0eZ8.d.cts → buckets-DJiYlMXc.d.cts} +832 -33
  13. package/dist/{column-diff-D_Kyuh0S.cjs → column-diff-CUU4GvYg.cjs} +1546 -193
  14. package/dist/{column-diff-e2oHc71_.mjs → column-diff-Cp6ZoyRE.mjs} +1529 -200
  15. package/dist/{db-error-D5uilS_A.mjs → db-error-Az85UhTX.mjs} +32 -1
  16. package/dist/{db-error-DTkkeu5b.cjs → db-error-DRQH4sLY.cjs} +55 -0
  17. package/dist/{column-diff-Q9UmWn5x.d.mts → fk-diff-BQ4krij8.d.cts} +48 -2
  18. package/dist/{column-diff-w-Mym_3w.d.cts → fk-diff-DsaIijVX.d.mts} +48 -2
  19. package/dist/index.cjs +95 -35
  20. package/dist/index.d.cts +92 -22
  21. package/dist/index.d.mts +92 -22
  22. package/dist/index.mjs +65 -35
  23. package/dist/{nested-writer-xfQwxplL.cjs → nested-writer-BUQvk6QT.cjs} +734 -2
  24. package/dist/{nested-writer-CnOOAehr.mjs → nested-writer-CUBoq1ZO.mjs} +598 -4
  25. package/dist/numeric-operand-B1jKH7x5.mjs +21 -0
  26. package/dist/numeric-operand-DKfiRLYp.cjs +26 -0
  27. package/dist/ops.cjs +1 -1
  28. package/dist/ops.mjs +1 -1
  29. package/dist/plugin.cjs +293 -5
  30. package/dist/plugin.mjs +294 -6
  31. package/dist/rel.cjs +2 -2
  32. package/dist/rel.d.cts +2 -2
  33. package/dist/rel.d.mts +2 -2
  34. package/dist/rel.mjs +2 -2
  35. package/dist/{relation-helpers-B-0NRKat.d.mts → relation-helpers-Ba0v49sn.d.mts} +1 -1
  36. package/dist/{relation-helpers-BOMm_HUI.d.cts → relation-helpers-kX7jjgME.d.cts} +1 -1
  37. package/dist/relation-loader-ByY1Byrl.mjs +370 -0
  38. package/dist/relation-loader-D8OrdH-r.cjs +369 -0
  39. package/dist/shared.cjs +4 -1
  40. package/dist/shared.d.cts +16 -4
  41. package/dist/shared.d.mts +16 -4
  42. package/dist/shared.mjs +3 -2
  43. package/dist/sync.cjs +11 -7
  44. package/dist/sync.d.cts +2 -25
  45. package/dist/sync.d.mts +2 -25
  46. package/dist/sync.mjs +11 -7
  47. package/dist/{validation-utils-DOsB4e6G.cjs → validation-utils-Da2GjobR.cjs} +35 -24
  48. package/dist/{validation-utils-CMR4fe2M.mjs → validation-utils-Dq0uZ7ef.mjs} +35 -24
  49. package/dist/{validator-Clu2q_7z.mjs → validator-BTiIOTKP.mjs} +9 -3
  50. package/dist/{validator-CVS-onRg.cjs → validator-UcuJxHNT.cjs} +8 -2
  51. package/dist/{validator-Bw6ks9Hy.d.mts → validator-tBNvM1qc.d.cts} +31 -7
  52. package/dist/{validator-Bw6ks9Hy.d.cts → validator-tBNvM1qc.d.mts} +31 -7
  53. package/dist/validator.cjs +2 -2
  54. package/dist/validator.d.cts +1 -1
  55. package/dist/validator.d.mts +1 -1
  56. package/dist/validator.mjs +2 -2
  57. package/package.json +8 -8
  58. package/dist/relation-loader-CBPY6kM7.cjs +0 -461
  59. package/dist/relation-loader-D9XuXaMv.mjs +0 -462
@@ -1,6 +1,134 @@
1
- import { i as DepthLimitExceededError, r as DbError } from "./db-error-D5uilS_A.mjs";
2
- import { r as getPath } from "./object-TkiJQ-Dp.mjs";
1
+ import { i as DepthLimitExceededError, r as DbError } from "./db-error-Az85UhTX.mjs";
2
+ import { a as isPlainObject, r as getPath } from "./object-TkiJQ-Dp.mjs";
3
3
  import { ValidatorError } from "@atscript/typescript/utils";
4
+ import { isRelationOp } from "@uniqu/core";
5
+ //#region src/query/query-tree.ts
6
+ /**
7
+ * `true` when a query-tree operand is a field reference (`{ field, type? }`)
8
+ * rather than a literal — e.g. the right side of a field-to-field comparison.
9
+ * @since 0.1.136
10
+ */
11
+ function isFieldRef(value) {
12
+ return value !== null && typeof value === "object" && "field" in value;
13
+ }
14
+ /**
15
+ * Translates a JS-emitted query tree into a FilterExpr.
16
+ * Resolves field references (type + field path) to physical column names
17
+ * via the provided resolver function.
18
+ */
19
+ function translateQueryTree(node, resolveField) {
20
+ if ("$and" in node) return { $and: node.$and.map((n) => translateQueryTree(n, resolveField)) };
21
+ if ("$or" in node) return { $or: node.$or.map((n) => translateQueryTree(n, resolveField)) };
22
+ if ("$not" in node) return { $not: translateQueryTree(node.$not, resolveField) };
23
+ const comp = node;
24
+ const leftField = resolveField(comp.left);
25
+ if (isFieldRef(comp.right)) {
26
+ const rightField = resolveField(comp.right);
27
+ return { [leftField]: { [comp.op]: { $field: rightField } } };
28
+ }
29
+ if (comp.op === "$exists") return { [leftField]: { $exists: comp.right !== false } };
30
+ return { [leftField]: { [comp.op]: comp.right } };
31
+ }
32
+ /**
33
+ * Calls `leaf` with the field path of every field-reference leaf of a
34
+ * computed-column expression (`@db.compute`), in source order — leaves name
35
+ * the view's own fields.
36
+ * @since 0.1.147
37
+ */
38
+ function walkViewExpr(expr, leaf) {
39
+ if (typeof expr === "number") return;
40
+ if ("field" in expr) {
41
+ leaf(expr.field);
42
+ return;
43
+ }
44
+ for (const arg of expr.args) walkViewExpr(arg, leaf);
45
+ }
46
+ /**
47
+ * Evaluates a computed expression in process (the memory adapter, a test, a
48
+ * third-party adapter) with the semantics every adapter shares: IEEE double,
49
+ * a leaf is `Number()`-ed, NULL / undefined propagates as `null`, `/` by zero
50
+ * is `null`, `coalesce` returns its first non-null value.
51
+ * @since 0.1.148
52
+ */
53
+ function evaluateExpr(expr, leaf) {
54
+ if (typeof expr === "number") return expr;
55
+ if ("field" in expr) {
56
+ const v = leaf(expr.field);
57
+ if (v === null || v === void 0) return null;
58
+ const n = Number(v);
59
+ return Number.isNaN(n) ? null : n;
60
+ }
61
+ if (expr.op === "coalesce") {
62
+ for (const arg of expr.args) {
63
+ const v = evaluateExpr(arg, leaf);
64
+ if (v !== null) return v;
65
+ }
66
+ return null;
67
+ }
68
+ const a = evaluateExpr(expr.args[0], leaf);
69
+ if (a === null) return null;
70
+ if (expr.op === "neg") return -a;
71
+ const b = evaluateExpr(expr.args[1], leaf);
72
+ if (b === null) return null;
73
+ switch (expr.op) {
74
+ case "+": return a + b;
75
+ case "-": return a - b;
76
+ case "*": return a * b;
77
+ default: return b === 0 ? null : a / b;
78
+ }
79
+ }
80
+ /**
81
+ * Whether a computed-column expression may yield NULL: a `/` (division by
82
+ * zero is NULL), a leaf for which `nullableLeaf` holds, or an operation over
83
+ * a nullable operand — `coalesce` only when every argument is nullable.
84
+ * @since 0.1.147
85
+ */
86
+ function viewExprNullable(expr, nullableLeaf) {
87
+ if (typeof expr === "number") return false;
88
+ if ("field" in expr) return nullableLeaf(expr.field);
89
+ if (expr.op === "/") return true;
90
+ if (expr.op === "coalesce") return expr.args.every((a) => viewExprNullable(a, nullableLeaf));
91
+ return expr.args.some((a) => viewExprNullable(a, nullableLeaf));
92
+ }
93
+ /**
94
+ * The `@db.compute` expression of a view field, if any.
95
+ * @since 0.1.147
96
+ */
97
+ function computeOf(fieldType) {
98
+ return fieldType?.metadata.get("db.compute");
99
+ }
100
+ /**
101
+ * The transitive non-computed operands of the `@db.compute` field `field` of
102
+ * `viewType`: its leaves, a computed leaf replaced by its own operands, and
103
+ * (`via`) the computed fields passed through. `undefined` when `field` is not
104
+ * computed. Cycles are not followed (they are rejected where the view's
105
+ * columns are built).
106
+ * @since 0.1.147
107
+ */
108
+ function computedOperands(viewType, field) {
109
+ const props = viewType.type.kind === "object" ? viewType.type.props : void 0;
110
+ const exprOf = (name) => computeOf(props?.get(name));
111
+ const root = exprOf(field);
112
+ if (root === void 0) return void 0;
113
+ const out = /* @__PURE__ */ new Set();
114
+ const via = [];
115
+ const seen = new Set([field]);
116
+ const visit = (expr) => walkViewExpr(expr, (path) => {
117
+ const nested = exprOf(path);
118
+ if (nested === void 0) out.add(path);
119
+ else if (!seen.has(path)) {
120
+ seen.add(path);
121
+ via.push(path);
122
+ visit(nested);
123
+ }
124
+ });
125
+ visit(root);
126
+ return {
127
+ operands: [...out],
128
+ via
129
+ };
130
+ }
131
+ //#endregion
4
132
  //#region src/rel/relation-helpers.ts
5
133
  /**
6
134
  * Finds the FK entry that connects a `@db.rel.to` relation to its target.
@@ -52,10 +180,462 @@ function tableNameOf(type) {
52
180
  function resolveRelationTargetTable(relation) {
53
181
  return tableNameOf(relation.targetType());
54
182
  }
183
+ /**
184
+ * A string key of `fields`' values on `obj` — equal for rows that agree on
185
+ * every field (`null` / `undefined` key distinctly from any string). `read`
186
+ * reads one field (default: a top-level property).
187
+ */
188
+ function compositeKey(fields, obj, read = (o, f) => o[f]) {
189
+ let key = "";
190
+ for (let i = 0; i < fields.length; i++) {
191
+ if (i > 0) key += "\0\0";
192
+ const v = read(obj, fields[i]);
193
+ key += v === null || v === void 0 ? "\0" : String(v);
194
+ }
195
+ return key;
196
+ }
197
+ /**
198
+ * Keeps, of each group of `rows` sharing `keyOf(row)`, the rows `page`
199
+ * selects — `$skip` / `$limit` applied per group, in the order of `rows`.
200
+ * The order of the kept rows is preserved.
201
+ */
202
+ function slicePerGroup(rows, keyOf, page) {
203
+ const skip = page.skip ?? 0;
204
+ const end = page.limit === void 0 ? Infinity : skip + page.limit;
205
+ const seen = /* @__PURE__ */ new Map();
206
+ const kept = [];
207
+ for (const row of rows) {
208
+ const key = keyOf(row);
209
+ const index = seen.get(key) ?? 0;
210
+ seen.set(key, index + 1);
211
+ if (index >= skip && index < end) kept.push(row);
212
+ }
213
+ return kept;
214
+ }
215
+ //#endregion
216
+ //#region src/query/relation-filter.ts
217
+ /**
218
+ * Relational filter predicates (`{ nav: { $some | $none: <filter on the
219
+ * related table> } }`) — resolution against the related tables.
220
+ *
221
+ * Semantics: `rel(r, nav)` is exactly the set of related rows `$with=nav`
222
+ * loads for row `r` (same foreign-key pairing, the relation's
223
+ * `@db.rel.filter` included). `$some: F` holds when one of them matches `F`,
224
+ * `$none: F` when none does; a NULL foreign-key component means "no related
225
+ * row". The core resolves each predicate into a {@link ResolvedRelationFilter}
226
+ * (physical names on every side, the inner filter translated by the related
227
+ * table's own field mapper) before the adapter sees the filter — adapters
228
+ * only render it.
229
+ *
230
+ * @since 0.1.147
231
+ */
232
+ /**
233
+ * Maximum nesting of relational predicates in one filter, server-added ones
234
+ * included (a predicate inside a predicate's operand counts one level). The
235
+ * core backstop for every caller; higher than moost-db's per-client limit
236
+ * (`REL_FILTER_CLIENT_MAX_DEPTH`) so server overlays (row scopes,
237
+ * `transformRelationFilter`) have headroom above what a client may send.
238
+ */
239
+ const REL_FILTER_MAX_DEPTH = 4;
240
+ /** Maximum number of relational predicates in one filter (nested and server-added ones included). See {@link REL_FILTER_MAX_DEPTH}. */
241
+ const REL_FILTER_MAX_NODES = 16;
242
+ /**
243
+ * Cross-realm brand of {@link ResolvedRelationFilter}: `instanceof` fails when
244
+ * two copies of `@atscript/db` are loaded (ESM + CJS, or nested installs) and
245
+ * an adapter from one sees nodes built by the other.
246
+ */
247
+ const RESOLVED_BRAND = Symbol.for("@atscript/db:ResolvedRelationFilter");
248
+ /**
249
+ * A relational predicate as adapters receive it — the operand of
250
+ * `FilterVisitor.relation(field, op, operand)` once the core translated the
251
+ * filter. Every name is physical:
252
+ *
253
+ * - `to` / `from`: `pairs` correlate a SOURCE column with a TARGET column
254
+ * (`target.<pair.target> = source.<pair.source>`, one pair per composite
255
+ * key part);
256
+ * - `via`: `pairs` is empty — `junction.toSource` correlates the junction
257
+ * with the source row, `junction.toTarget` with the target row.
258
+ *
259
+ * `filter` is the inner filter on the TARGET (already translated by the
260
+ * target's field mapper: renames, flattening, value formatters; nested
261
+ * predicates resolved the same way), conjoined with the target part of the
262
+ * relation's `@db.rel.filter`. `{}` matches every related row.
263
+ *
264
+ * @since 0.1.147
265
+ */
266
+ var ResolvedRelationFilter = class {
267
+ /** @internal cross-realm brand (see {@link isResolvedRelationFilter}). */
268
+ [RESOLVED_BRAND] = true;
269
+ kind;
270
+ /** Logical navigation field name on the source table. */
271
+ nav;
272
+ source;
273
+ target;
274
+ pairs;
275
+ junction;
276
+ filter;
277
+ constructor(init) {
278
+ this.kind = init.kind;
279
+ this.nav = init.nav;
280
+ this.source = init.source;
281
+ this.target = init.target;
282
+ this.pairs = init.pairs;
283
+ this.junction = init.junction;
284
+ this.filter = init.filter;
285
+ }
286
+ };
287
+ /** `true` for a {@link ResolvedRelationFilter} (the resolved operand of a predicate). */
288
+ function isResolvedRelationFilter(value) {
289
+ return typeof value === "object" && value !== null && value[RESOLVED_BRAND] === true;
290
+ }
291
+ /** `true` when `value` (a filter entry's value) is an operator map with a `$some` / `$none` key. */
292
+ function hasRelationOp(value) {
293
+ if (!isPlainObject(value)) return false;
294
+ for (const key in value) if (isRelationOp(key)) return true;
295
+ return false;
296
+ }
297
+ /**
298
+ * Pre-scan results of filters the core BUILT (translated / resolved
299
+ * filters, never mutated after) — adapters scan the same translated filter
300
+ * several times per operation. Caller-owned filters are never cached: they
301
+ * may be mutated between two queries.
302
+ */
303
+ const relationFilterCache = /* @__PURE__ */ new WeakMap();
304
+ /** @internal Records the pre-scan result of a filter the core built. */
305
+ function noteRelationFilter(filter, has) {
306
+ if (filter && typeof filter === "object") relationFilterCache.set(filter, has);
307
+ }
308
+ /**
309
+ * `true` when `filter` holds a relational predicate anywhere outside
310
+ * predicate operands (through `$and` / `$or` / `$not`) — the cheap pre-scan
311
+ * the field mappers and renderers use to keep predicate-free filters on
312
+ * their fast paths. Results for filters the core translated are cached.
313
+ */
314
+ function containsRelationFilter(filter) {
315
+ if (!filter || typeof filter !== "object") return false;
316
+ const cached = relationFilterCache.get(filter);
317
+ if (cached !== void 0) return cached;
318
+ for (const [key, value] of Object.entries(filter)) if (key === "$and" || key === "$or") {
319
+ if (Array.isArray(value) && value.some((child) => containsRelationFilter(child))) return true;
320
+ } else if (key === "$not") {
321
+ if (containsRelationFilter(value)) return true;
322
+ } else if (!key.startsWith("$") && hasRelationOp(value)) return true;
323
+ return false;
324
+ }
325
+ /**
326
+ * Calls `visit` for every resolved predicate of a TRANSLATED filter,
327
+ * depth-first: the top-level ones, then (with `nested`) those inside each
328
+ * operand — target filters and junction filters alike. Adapters use it to
329
+ * prepare per-predicate data (memory snapshots, self-referencing checks).
330
+ */
331
+ function forEachResolvedRelation(filter, visit, nested = true) {
332
+ if (!filter || typeof filter !== "object") return;
333
+ for (const [key, value] of Object.entries(filter)) if (key === "$and" || key === "$or") {
334
+ if (Array.isArray(value)) for (const child of value) forEachResolvedRelation(child, visit, nested);
335
+ } else if (key === "$not") forEachResolvedRelation(value, visit, nested);
336
+ else if (!key.startsWith("$") && isPlainObject(value)) for (const [op, operand] of Object.entries(value)) {
337
+ if (!isRelationOp(op) || !isResolvedRelationFilter(operand)) continue;
338
+ visit(operand, op);
339
+ if (nested) {
340
+ forEachResolvedRelation(operand.filter, visit, nested);
341
+ forEachResolvedRelation(operand.junction?.filter, visit, nested);
342
+ }
343
+ }
344
+ }
345
+ /** A fresh {@link TRelGuardState} for one query. */
346
+ function relGuardState(write = false) {
347
+ return {
348
+ depth: 0,
349
+ counter: { nodes: 0 },
350
+ write,
351
+ path: ""
352
+ };
353
+ }
354
+ function invalid(path, message) {
355
+ return new DbError("INVALID_QUERY", [{
356
+ path,
357
+ message
358
+ }]);
359
+ }
360
+ function notSupported(path, message) {
361
+ return new DbError("REL_FILTER_NOT_SUPPORTED", [{
362
+ path,
363
+ message
364
+ }]);
365
+ }
366
+ /** The present, non-empty `parts` ANDed (`{}` when none, a single one as is). */
367
+ function andFilters(...parts) {
368
+ const present = parts.filter((p) => p != null && Object.keys(p).length > 0);
369
+ if (present.length === 0) return {};
370
+ if (present.length === 1) return present[0];
371
+ return { $and: present };
372
+ }
373
+ const NO_STATIC_FILTER = {};
374
+ const staticFilterCache = /* @__PURE__ */ new WeakMap();
375
+ /**
376
+ * A relation's `@db.rel.filter` as logical filters per side — what `$with`
377
+ * loading and relational predicates both AND into the related rows (the
378
+ * filter is part of the relation's meaning). Top-level `and` conditions are
379
+ * split by side: an unqualified field and the related type's fields go to
380
+ * `target`, the `@db.rel.via` junction's to `junction`. A single condition
381
+ * that reads both sides (e.g. an `or` across them) or compares two fields is
382
+ * rejected with `INVALID_QUERY` — `name` is the navigation field (error path).
383
+ *
384
+ * @since 0.1.147
385
+ */
386
+ function relationStaticFilter(relation, name = "") {
387
+ if (!relation.filter) return NO_STATIC_FILTER;
388
+ const cached = staticFilterCache.get(relation);
389
+ if (cached) return cached;
390
+ const targetType = relation.targetType();
391
+ const junctionType = relation.viaType?.();
392
+ const targetName = tableNameOf(targetType);
393
+ const junctionName = junctionType ? tableNameOf(junctionType) : void 0;
394
+ const sideOf = (ref) => {
395
+ const type = ref.type?.();
396
+ if (!type || type === targetType || tableNameOf(type) === targetName) return "target";
397
+ if (junctionType && (type === junctionType || tableNameOf(type) === junctionName)) return "junction";
398
+ throw invalid(name, `@db.rel.filter on "${name}" references a type other than the related type` + (junctionType ? " and the junction" : "") + " — only those can be referenced");
399
+ };
400
+ const target = [];
401
+ const junction = [];
402
+ const conjuncts = "$and" in relation.filter ? relation.filter.$and : [relation.filter];
403
+ for (const conjunct of conjuncts) {
404
+ const sides = /* @__PURE__ */ new Set();
405
+ collectSides(conjunct, (ref) => sides.add(sideOf(ref)), name);
406
+ if (sides.size > 1) throw invalid(name, `@db.rel.filter on "${name}" has a condition reading both the junction and the related type — split it into separate "and" conditions`);
407
+ const filter = translateQueryTree(conjunct, (ref) => ref.field);
408
+ (sides.has("junction") ? junction : target).push(filter);
409
+ }
410
+ const result = {
411
+ target: target.length > 0 ? andFilters(...target) : void 0,
412
+ junction: junction.length > 0 ? andFilters(...junction) : void 0
413
+ };
414
+ staticFilterCache.set(relation, result);
415
+ return result;
416
+ }
417
+ function collectSides(node, visit, name) {
418
+ if ("$and" in node) {
419
+ for (const child of node.$and) collectSides(child, visit, name);
420
+ return;
421
+ }
422
+ if ("$or" in node) {
423
+ for (const child of node.$or) collectSides(child, visit, name);
424
+ return;
425
+ }
426
+ if ("$not" in node) {
427
+ collectSides(node.$not, visit, name);
428
+ return;
429
+ }
430
+ visit(node.left);
431
+ if (isFieldRef(node.right)) throw invalid(name, `@db.rel.filter on "${name}" compares two fields — only field-to-value conditions are supported`);
432
+ }
433
+ function zip(left, right, a, b) {
434
+ return left.map((value, i) => ({
435
+ [a]: value,
436
+ [b]: right[i]
437
+ }));
438
+ }
439
+ /**
440
+ * The {@link TRelationFilterHost} of a readable: resolves a navigation
441
+ * field's related table (and junction) through `resolve` (the readable's
442
+ * table resolver), pairing foreign keys exactly like `$with` loading does
443
+ * (`findFKEntryForRelation` / `findRemoteFK`).
444
+ */
445
+ function createRelationFilterHost(owner, resolve) {
446
+ const plans = /* @__PURE__ */ new Map();
447
+ const sameAdapter = (other, path, what) => {
448
+ if (!owner.getAdapter().sharesStoreWith(other.getAdapter())) throw notSupported(path, `Relational predicate on "${path}": the ${what} table lives in a different database or adapter`);
449
+ };
450
+ const planOf = (nav, path) => {
451
+ const cached = plans.get(nav);
452
+ if (cached) return cached;
453
+ const meta = owner.getMetadata();
454
+ const relation = meta.relations.get(nav);
455
+ if (!relation) throw invalid(path, `"$some" / "$none" are only valid on a navigation relation — "${nav}" is not one`);
456
+ const target = resolve(relation.targetType());
457
+ if (!target) throw notSupported(path, `Relational predicate on "${path}": the related table is not available`);
458
+ sameAdapter(target, path, "related");
459
+ let plan;
460
+ if (relation.direction === "to") {
461
+ const fk = findFKEntryForRelation(relation, meta.foreignKeys);
462
+ if (!fk) throw invalid(path, `Relation "${nav}" has no foreign key to filter by`);
463
+ plan = {
464
+ kind: "to",
465
+ relation,
466
+ target,
467
+ pairs: zip(fk.fields, fk.targetFields, "source", "target")
468
+ };
469
+ } else if (relation.direction === "from") {
470
+ const fk = findRemoteFK(target.getMetadata(), owner.tableName, relation.alias);
471
+ if (!fk) throw invalid(path, `Relation "${nav}" has no foreign key to filter by`);
472
+ plan = {
473
+ kind: "from",
474
+ relation,
475
+ target,
476
+ pairs: zip(fk.targetFields, fk.fields, "source", "target")
477
+ };
478
+ } else {
479
+ const junctionType = relation.viaType?.();
480
+ const junction = junctionType ? resolve(junctionType) : void 0;
481
+ if (!junction) throw notSupported(path, `Relational predicate on "${path}": the junction table is not available`);
482
+ sameAdapter(junction, path, "junction");
483
+ const junctionMeta = junction.getMetadata();
484
+ const fkToThis = findRemoteFK(junctionMeta, owner.tableName);
485
+ const fkToTarget = findRemoteFK(junctionMeta, tableNameOf(relation.targetType()));
486
+ if (!fkToThis || !fkToTarget) throw invalid(path, `Relation "${nav}" has no junction foreign keys to filter by`);
487
+ if (fkToThis === fkToTarget) throw invalid(path, `Relation "${nav}" is a self-referencing many-to-many — relational predicates cannot tell its two junction keys apart`);
488
+ plan = {
489
+ kind: "via",
490
+ relation,
491
+ target,
492
+ pairs: [],
493
+ junction: {
494
+ owner: junction,
495
+ toSource: zip(fkToThis.fields, fkToThis.targetFields, "junction", "source"),
496
+ toTarget: zip(fkToTarget.fields, fkToTarget.targetFields, "junction", "target")
497
+ }
498
+ };
499
+ }
500
+ plans.set(nav, plan);
501
+ return plan;
502
+ };
503
+ const tableOf = (o) => {
504
+ const adapter = o.getAdapter();
505
+ return {
506
+ table: adapter.resolveTableName(),
507
+ name: adapter.resolveTableName(false),
508
+ adapter
509
+ };
510
+ };
511
+ return {
512
+ guard(nav, op, inner, state) {
513
+ const path = state.path ? `${state.path}.${nav}` : nav;
514
+ const depth = state.depth + 1;
515
+ if (depth > 4) throw invalid("", `Relational predicates nest at most 4 levels deep`);
516
+ if (++state.counter.nodes > 16) throw invalid("", `At most 16 relational predicates per query`);
517
+ if (!isPlainObject(inner)) throw invalid(path, `"${op}" on "${path}" expects a filter object`);
518
+ const plan = planOf(nav, path);
519
+ relationStaticFilter(plan.relation, path);
520
+ try {
521
+ plan.target._guardRelationOperand(inner, {
522
+ ...state,
523
+ depth,
524
+ path
525
+ });
526
+ } catch (error) {
527
+ throw prefixError(error, path);
528
+ }
529
+ },
530
+ resolve(nav, _op, inner, depth) {
531
+ if (depth > 4) throw invalid("", `Relational predicates nest at most 4 levels deep`);
532
+ const plan = planOf(nav, nav);
533
+ const ownerMeta = owner.getMetadata();
534
+ const targetMeta = plan.target.getMetadata();
535
+ const statics = relationStaticFilter(plan.relation, nav);
536
+ const filter = plan.target._resolveRelationOperand(andFilters(statics.target, inner), depth);
537
+ let junction;
538
+ if (plan.junction) {
539
+ const junctionMeta = plan.junction.owner.getMetadata();
540
+ junction = {
541
+ ...tableOf(plan.junction.owner),
542
+ toSource: plan.junction.toSource.map((p) => ({
543
+ junction: junctionMeta.physicalPath(p.junction),
544
+ source: ownerMeta.physicalPath(p.source)
545
+ })),
546
+ toTarget: plan.junction.toTarget.map((p) => ({
547
+ junction: junctionMeta.physicalPath(p.junction),
548
+ target: targetMeta.physicalPath(p.target)
549
+ })),
550
+ ...statics.junction ? { filter: plan.junction.owner._resolveRelationOperand(statics.junction, depth) } : {}
551
+ };
552
+ }
553
+ return new ResolvedRelationFilter({
554
+ kind: plan.kind,
555
+ nav,
556
+ source: tableOf(owner),
557
+ target: tableOf(plan.target),
558
+ pairs: plan.pairs.map((p) => ({
559
+ source: ownerMeta.physicalPath(p.source),
560
+ target: targetMeta.physicalPath(p.target)
561
+ })),
562
+ junction,
563
+ filter
564
+ });
565
+ }
566
+ };
567
+ }
568
+ /** Re-throws a related table's guard error with its paths under the navigation chain. */
569
+ function prefixError(error, path) {
570
+ if (!(error instanceof DbError)) return error;
571
+ if (error.errors.every((e) => !e.path)) return error;
572
+ if (error.errors.every((e) => e.path === path || e.path.startsWith(`${path}.`))) return error;
573
+ const errors = error.errors.map((e) => ({
574
+ path: e.path ? `${path}.${e.path}` : path,
575
+ message: e.message
576
+ }));
577
+ return new DbError(error.code, errors, `${errors[0]?.message ?? error.message} (in "${path}")`);
578
+ }
579
+ /**
580
+ * Resolves every relational predicate of a (logical) filter through
581
+ * `meta.relationFilters` — the step every field-mapper entry point runs
582
+ * first. Predicates at the top of `filter` are at level `depth + 1`.
583
+ * Already-resolved operands pass through, so translating twice is safe.
584
+ * One pass: unchanged subtrees (and `filter` itself, when nothing needed
585
+ * resolving) are returned as they are.
586
+ */
587
+ function resolveRelationFilterTree(filter, meta, depth) {
588
+ let out;
589
+ for (const [key, value] of Object.entries(filter)) {
590
+ let next = value;
591
+ if (key === "$and" || key === "$or") {
592
+ if (Array.isArray(value)) {
593
+ let children;
594
+ for (let i = 0; i < value.length; i++) {
595
+ const child = value[i];
596
+ if (!child || typeof child !== "object") continue;
597
+ const resolved = resolveRelationFilterTree(child, meta, depth);
598
+ if (resolved === child) continue;
599
+ children ??= [...value];
600
+ children[i] = resolved;
601
+ }
602
+ if (children) next = children;
603
+ }
604
+ } else if (key === "$not") {
605
+ if (value && typeof value === "object") next = resolveRelationFilterTree(value, meta, depth);
606
+ } else if (!key.startsWith("$") && hasRelationOp(value)) next = resolvePredicateMap(key, value, meta, depth);
607
+ if (next === value) continue;
608
+ out ??= { ...filter };
609
+ out[key] = next;
610
+ }
611
+ if (!out) return filter;
612
+ noteRelationFilter(out, true);
613
+ return out;
614
+ }
615
+ /** One `{ $some | $none: … }` map of `key` resolved (operands already resolved pass through). */
616
+ function resolvePredicateMap(key, value, meta, depth) {
617
+ const host = meta.relationFilters;
618
+ if (!host) {
619
+ if (!meta.navFields.has(key)) throw invalid(key, `"$some" / "$none" are only valid on a navigation relation — "${key}" is not one`);
620
+ throw notSupported(key, `Relational predicate on "${key}" needs the table to come from a DbSpace (no table resolver)`);
621
+ }
622
+ const ops = {};
623
+ for (const [op, operand] of Object.entries(value)) {
624
+ if (!isRelationOp(op)) throw invalid(key, `Cannot mix "$some" / "$none" with "${op}" on "${key}"`);
625
+ ops[op] = isResolvedRelationFilter(operand) ? operand : host.resolve(key, op, operand, depth + 1);
626
+ }
627
+ return ops;
628
+ }
55
629
  //#endregion
56
630
  //#region src/shared/keys.ts
57
- /** String form of a key value (an ObjectId stringifies to its hex, a bigint to its digits). */
631
+ /**
632
+ * String form of a key value, equal across driver representations: an
633
+ * ObjectId stringifies to its hex, a bigint to its digits, a Date to its ISO
634
+ * instant, a Buffer / Uint8Array to hex.
635
+ */
58
636
  function keyString(value) {
637
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
638
+ if (value instanceof Uint8Array) return Array.from(value, (b) => b.toString(16).padStart(2, "0")).join("");
59
639
  return String(value);
60
640
  }
61
641
  /** Key equality across driver representations (number vs numeric string, ObjectId vs hex). */
@@ -82,6 +662,20 @@ function rowMatchesKey(row, key) {
82
662
  for (const field in key) if (!sameKey(getPath(row, field), key[field])) return false;
83
663
  return true;
84
664
  }
665
+ /**
666
+ * Identity of the key tuple `fields` of `row` for uniqueness checks, or
667
+ * `undefined` when any component is null / missing (a NULL never collides).
668
+ * Equal across driver representations (see {@link keyString}).
669
+ */
670
+ function uniqueKeyTuple(row, fields) {
671
+ const parts = [];
672
+ for (const field of fields) {
673
+ const value = getPath(row, field);
674
+ if (value === void 0 || value === null) return void 0;
675
+ parts.push(keyString(value));
676
+ }
677
+ return JSON.stringify(parts);
678
+ }
85
679
  //#endregion
86
680
  //#region src/table/error-utils.ts
87
681
  /**
@@ -877,4 +1471,4 @@ async function viaReplace(via, targets, parentPK, maxDepth, depth) {
877
1471
  await viaLinkTargets(via, targets, parentPK, maxDepth, depth);
878
1472
  }
879
1473
  //#endregion
880
- export { findRemoteFK as C, findFKForRelation as S, tableNameOf as T, remapDeleteFkViolation as _, batchPatchNestedFrom as a, sameKey as b, batchReplaceNestedFrom as c, checkDepthOverflow as d, planNestedFromVia as f, enrichFkViolation as g, validateBatch as h, batchInsertNestedVia as i, batchReplaceNestedTo as l, preValidateNestedFrom as m, batchInsertNestedFrom as n, batchPatchNestedTo as o, planPatchNestedTo as p, batchInsertNestedTo as r, batchPatchNestedVia as s, applyPatchNestedTo as t, batchReplaceNestedVia as u, pkTupleKey as v, resolveRelationTargetTable as w, findFKEntryForRelation as x, rowMatchesKey as y };
1474
+ export { isResolvedRelationFilter as A, slicePerGroup as B, REL_FILTER_MAX_NODES as C, createRelationFilterHost as D, containsRelationFilter as E, compositeKey as F, isFieldRef as G, computeOf as H, findFKEntryForRelation as I, walkViewExpr as J, translateQueryTree as K, findFKForRelation as L, relGuardState as M, relationStaticFilter as N, forEachResolvedRelation as O, resolveRelationFilterTree as P, findRemoteFK as R, REL_FILTER_MAX_DEPTH as S, andFilters as T, computedOperands as U, tableNameOf as V, evaluateExpr as W, remapDeleteFkViolation as _, batchPatchNestedFrom as a, sameKey as b, batchReplaceNestedFrom as c, checkDepthOverflow as d, planNestedFromVia as f, enrichFkViolation as g, validateBatch as h, batchInsertNestedVia as i, noteRelationFilter as j, hasRelationOp as k, batchReplaceNestedTo as l, preValidateNestedFrom as m, batchInsertNestedFrom as n, batchPatchNestedTo as o, planPatchNestedTo as p, viewExprNullable as q, batchInsertNestedTo as r, batchPatchNestedVia as s, applyPatchNestedTo as t, batchReplaceNestedVia as u, pkTupleKey as v, ResolvedRelationFilter as w, uniqueKeyTuple as x, rowMatchesKey as y, resolveRelationTargetTable as z };
@@ -0,0 +1,21 @@
1
+ //#region src/shared/numeric-operand.ts
2
+ /** Primitive tags of timestamps (MySQL may store them as native `TIMESTAMP`). */
3
+ const TIMESTAMP_TAGS = [
4
+ "timestamp",
5
+ "created",
6
+ "updated"
7
+ ];
8
+ /**
9
+ * Why a field cannot be an arithmetic operand, or `undefined` when it can: it
10
+ * must be a `number` — not a decimal (exact; an expression is IEEE double) and
11
+ * not a timestamp-tagged number. The one rule behind `@db.compute` (declared,
12
+ * from the `.as` source) and query-time arithmetic (from the runtime
13
+ * descriptor), so the two cannot diverge. Storage rules (`@db.ignore`,
14
+ * encrypted, JSON) stay with the callers.
15
+ */
16
+ function numericTypeProblem({ base, tags }) {
17
+ if (base !== "number" && base !== "integer") return base === "decimal" ? "is a decimal" : `is a ${base}`;
18
+ if (tags && TIMESTAMP_TAGS.some((t) => tags.has(t))) return "is a timestamp";
19
+ }
20
+ //#endregion
21
+ export { numericTypeProblem as t };
@@ -0,0 +1,26 @@
1
+ //#region src/shared/numeric-operand.ts
2
+ /** Primitive tags of timestamps (MySQL may store them as native `TIMESTAMP`). */
3
+ const TIMESTAMP_TAGS = [
4
+ "timestamp",
5
+ "created",
6
+ "updated"
7
+ ];
8
+ /**
9
+ * Why a field cannot be an arithmetic operand, or `undefined` when it can: it
10
+ * must be a `number` — not a decimal (exact; an expression is IEEE double) and
11
+ * not a timestamp-tagged number. The one rule behind `@db.compute` (declared,
12
+ * from the `.as` source) and query-time arithmetic (from the runtime
13
+ * descriptor), so the two cannot diverge. Storage rules (`@db.ignore`,
14
+ * encrypted, JSON) stay with the callers.
15
+ */
16
+ function numericTypeProblem({ base, tags }) {
17
+ if (base !== "number" && base !== "integer") return base === "decimal" ? "is a decimal" : `is a ${base}`;
18
+ if (tags && TIMESTAMP_TAGS.some((t) => tags.has(t))) return "is a timestamp";
19
+ }
20
+ //#endregion
21
+ Object.defineProperty(exports, "numericTypeProblem", {
22
+ enumerable: true,
23
+ get: function() {
24
+ return numericTypeProblem;
25
+ }
26
+ });
package/dist/ops.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_db_error = require("./db-error-DTkkeu5b.cjs");
2
+ const require_db_error = require("./db-error-DRQH4sLY.cjs");
3
3
  //#region src/ops.ts
4
4
  /** Increment a numeric field by `value` (default 1). */
5
5
  function $inc(value = 1) {
package/dist/ops.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { r as DbError } from "./db-error-D5uilS_A.mjs";
1
+ import { r as DbError } from "./db-error-Az85UhTX.mjs";
2
2
  //#region src/ops.ts
3
3
  /** Increment a numeric field by `value` (default 1). */
4
4
  function $inc(value = 1) {