@atscript/db 0.1.151 → 0.1.153

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 (45) hide show
  1. package/dist/agg.cjs +1 -1
  2. package/dist/agg.d.cts +1 -1
  3. package/dist/agg.d.mts +1 -1
  4. package/dist/agg.mjs +1 -1
  5. package/dist/{aggregate-fns-CyaZyb9I.mjs → aggregate-fns-B_sgl4mB.mjs} +1 -1
  6. package/dist/{aggregate-fns-C-UJRobm.cjs → aggregate-fns-DFtKAv7Q.cjs} +1 -1
  7. package/dist/{buckets-Cb0zmhDc.d.mts → buckets-CBKL_ZWE.d.mts} +205 -7
  8. package/dist/{buckets-tzd8iIM2.d.cts → buckets-jy8BfswO.d.cts} +205 -7
  9. package/dist/{column-diff-CU7KHj9-.cjs → column-diff-CbdWi_uZ.cjs} +729 -28
  10. package/dist/{column-diff-CnyLjbQ8.mjs → column-diff-DrE4ho_m.mjs} +730 -29
  11. package/dist/{db-error-DRQH4sLY.cjs → db-error-JcfJqqpk.cjs} +29 -0
  12. package/dist/{db-error-Az85UhTX.mjs → db-error-eSsQX7rt.mjs} +24 -1
  13. package/dist/{fk-diff-Bw26lwSc.d.cts → fk-diff-BIJ8l_hF.d.mts} +1 -1
  14. package/dist/{fk-diff-lcAnwvm9.d.mts → fk-diff-D1mrLMjU.d.cts} +1 -1
  15. package/dist/index.cjs +93 -5
  16. package/dist/index.d.cts +29 -5
  17. package/dist/index.d.mts +29 -5
  18. package/dist/index.mjs +92 -6
  19. package/dist/{nested-writer-cvbVAkNo.mjs → nested-writer-BOj_c_lI.mjs} +1 -1
  20. package/dist/{nested-writer-BzbOPSUp.cjs → nested-writer-Bl1SZS7x.cjs} +1 -1
  21. package/dist/ops.cjs +1 -1
  22. package/dist/ops.mjs +1 -1
  23. package/dist/plugin.cjs +48 -1
  24. package/dist/plugin.mjs +48 -1
  25. package/dist/rel.cjs +2 -2
  26. package/dist/rel.d.cts +2 -2
  27. package/dist/rel.d.mts +2 -2
  28. package/dist/rel.mjs +2 -2
  29. package/dist/{relation-helpers-Bdo4NHPC.d.mts → relation-helpers-BUsxNNGU.d.mts} +1 -1
  30. package/dist/{relation-helpers-DxIZKvnJ.d.cts → relation-helpers-D_8bydc2.d.cts} +1 -1
  31. package/dist/{relation-loader-DYab07lw.mjs → relation-loader-CNBMPfRr.mjs} +2 -1
  32. package/dist/{relation-loader-fNjHgBMs.cjs → relation-loader-DeulLrAw.cjs} +2 -1
  33. package/dist/sync.cjs +2 -2
  34. package/dist/sync.d.cts +2 -2
  35. package/dist/sync.d.mts +2 -2
  36. package/dist/sync.mjs +2 -2
  37. package/dist/{validator-tBNvM1qc.d.cts → validator-B3xlbrWf.d.cts} +36 -2
  38. package/dist/{validator-tBNvM1qc.d.mts → validator-B3xlbrWf.d.mts} +36 -2
  39. package/dist/{validator-BTiIOTKP.mjs → validator-BACewoRj.mjs} +51 -7
  40. package/dist/{validator-UcuJxHNT.cjs → validator-BxWwh3wf.cjs} +50 -6
  41. package/dist/validator.cjs +2 -2
  42. package/dist/validator.d.cts +1 -1
  43. package/dist/validator.d.mts +1 -1
  44. package/dist/validator.mjs +2 -2
  45. package/package.json +8 -8
@@ -1,12 +1,12 @@
1
- const require_db_error = require("./db-error-DRQH4sLY.cjs");
2
- const require_aggregate_fns = require("./aggregate-fns-C-UJRobm.cjs");
3
- const require_nested_writer = require("./nested-writer-BzbOPSUp.cjs");
1
+ const require_db_error = require("./db-error-JcfJqqpk.cjs");
2
+ const require_aggregate_fns = require("./aggregate-fns-DFtKAv7Q.cjs");
3
+ const require_nested_writer = require("./nested-writer-Bl1SZS7x.cjs");
4
4
  const require_search_fields = require("./search-fields-DwrsJUC1.cjs");
5
5
  const require_object = require("./object-Djg28csK.cjs");
6
6
  require("./agg.cjs");
7
7
  const require_aggregate_rules = require("./aggregate-rules-D_bsCpUI.cjs");
8
8
  const require_ops = require("./ops.cjs");
9
- const require_validator = require("./validator-UcuJxHNT.cjs");
9
+ const require_validator = require("./validator-BxWwh3wf.cjs");
10
10
  let _atscript_typescript_utils = require("@atscript/typescript/utils");
11
11
  let _uniqu_core = require("@uniqu/core");
12
12
  let node_async_hooks = require("node:async_hooks");
@@ -252,6 +252,14 @@ function sourceFieldSeals(sourceType, logicalPath) {
252
252
  return seals;
253
253
  }
254
254
  /**
255
+ * The annotations of one LOGICAL path of a source table (`undefined` for a
256
+ * path the type does not declare).
257
+ * @since 0.1.153
258
+ */
259
+ function sourceFieldMetadata(sourceType, logicalPath) {
260
+ return sourceIndex(sourceType).flatMap.get(logicalPath)?.metadata;
261
+ }
262
+ /**
255
263
  * Resolves a LOGICAL path of a source table (a view field's chain ref, an
256
264
  * aggregate's field, a predicate operand) to where it is physically stored.
257
265
  * Internal — `AtscriptDbView.resolveRefSource` is the public entry.
@@ -400,6 +408,26 @@ function rejectVersionExempt(path, why) {
400
408
  throw new Error(`@db.column.version.exempt on "${path}": ${why}`);
401
409
  }
402
410
  /**
411
+ * When `fieldType` is a union / tuple / intersection, marks the entries its
412
+ * items already fired at the same `path` (onField is post-order) as members.
413
+ */
414
+ function markComplexMembers(collected, path, fieldType) {
415
+ const kind = fieldType.type.kind;
416
+ if (kind !== "union" && kind !== "tuple" && kind !== "intersection") return;
417
+ const items = new Set(fieldType.type.items);
418
+ const childPrefix = `${path}.`;
419
+ for (let i = collected.length - 1; i >= 0; i--) {
420
+ const entry = collected[i];
421
+ if (entry.path === path) {
422
+ if (items.has(entry.type)) entry.member = true;
423
+ } else if (!entry.path.startsWith(childPrefix)) break;
424
+ }
425
+ }
426
+ /** A built-in primitive inlined as a member (`number.int | null`), not a named alias. */
427
+ function isInlinePrimitive(type) {
428
+ return type.type.kind === "" && type.ref === void 0;
429
+ }
430
+ /**
403
431
  * Computed metadata for a database table or view.
404
432
  *
405
433
  * Contains all field metadata, physical mapping indexes, relation definitions,
@@ -439,6 +467,8 @@ var TableMetadata = class {
439
467
  navFields = /* @__PURE__ */ new Set();
440
468
  ignoredFields = /* @__PURE__ */ new Set();
441
469
  uniqueProps = /* @__PURE__ */ new Set();
470
+ /** Adapter-contributed multi-field unique keys (logical paths) — see `TMetadataOverrides.addUniqueKeys`. */
471
+ uniqueKeys = [];
442
472
  defaults = /* @__PURE__ */ new Map();
443
473
  /** Logical path → `@db.column` override (top-level keys only on document storage). */
444
474
  columnMap = /* @__PURE__ */ new Map();
@@ -579,7 +609,7 @@ var TableMetadata = class {
579
609
  topLevelArrayTag: adapter.getTopLevelArrayTag?.() ?? "db.__topLevelArray",
580
610
  excludePhantomTypes: true,
581
611
  onField: (path, fieldType, metadata) => {
582
- if (isNavRelation(metadata)) this.navFields.add(path);
612
+ markComplexMembers(collected, path, fieldType);
583
613
  collected.push({
584
614
  path,
585
615
  type: fieldType,
@@ -587,11 +617,14 @@ var TableMetadata = class {
587
617
  });
588
618
  }
589
619
  });
620
+ for (const entry of collected) if (!entry.member && isNavRelation(entry.metadata)) this.navFields.add(entry.path);
621
+ this._dropMemberMetadata(collected);
590
622
  for (const entry of collected) {
591
623
  if (require_object.findAncestorInSet(entry.path, this.navFields) !== void 0) {
592
624
  this.ignoredFields.add(entry.path);
593
625
  continue;
594
626
  }
627
+ if (entry.member) continue;
595
628
  this._scanGenericAnnotations(entry.path, entry.type, entry.metadata, logger);
596
629
  if (entry.metadata.has("db.column.derived")) this.derivedFields.set(entry.path, this._validateDerivedField(entry.path, entry.type, entry.metadata, type));
597
630
  adapter.onFieldScanned?.(entry.path, entry.type, entry.metadata);
@@ -625,6 +658,37 @@ var TableMetadata = class {
625
658
  }
626
659
  }
627
660
  /**
661
+ * `flattenAnnotatedType` merges the metadata of every union / tuple member
662
+ * into the synthetic flat entry of the prop's path, so a member's
663
+ * annotations would reach the column (DDL type, size, default). Since
664
+ * atscript 0.1.103 a built-in primitive member carries its built-in
665
+ * annotations (`number.timestamp.created | null` → `@db.default.now`,
666
+ * `string.char` → `@expect.maxLength 1`). Rebuild such an entry from the
667
+ * prop's own annotations plus the non-`db.*` annotations of named members
668
+ * (aliases, interfaces), so the column is described as before 0.1.103.
669
+ */
670
+ _dropMemberMetadata(collected) {
671
+ const byPath = /* @__PURE__ */ new Map();
672
+ for (const entry of collected) {
673
+ const list = byPath.get(entry.path);
674
+ if (list) list.push(entry);
675
+ else byPath.set(entry.path, [entry]);
676
+ }
677
+ for (const [path, entries] of byPath) {
678
+ if (!entries.some((e) => e.member)) continue;
679
+ const flat = this.flatMap.get(path);
680
+ if (!flat?.__flat_union) continue;
681
+ const metadata = /* @__PURE__ */ new Map();
682
+ for (const entry of entries) {
683
+ if (entry.member && isInlinePrimitive(entry.type)) continue;
684
+ for (const [key, value] of entry.metadata) if (!entry.member || !key.startsWith("db.")) metadata.set(key, value);
685
+ }
686
+ const target = flat.metadata;
687
+ target.clear();
688
+ for (const [key, value] of metadata) target.set(key, value);
689
+ }
690
+ }
691
+ /**
628
692
  * Applies adapter-provided metadata overrides atomically.
629
693
  * Processing order: injectFields → removePrimaryKeys → addPrimaryKeys → addUniqueFields.
630
694
  */
@@ -638,6 +702,7 @@ var TableMetadata = class {
638
702
  for (const field of overrides.addPrimaryKeys) if (!this.primaryKeys.includes(field)) this.primaryKeys.push(field);
639
703
  }
640
704
  if (overrides.addUniqueFields) for (const field of overrides.addUniqueFields) this.uniqueProps.add(field);
705
+ if (overrides.addUniqueKeys) this.uniqueKeys.push(...overrides.addUniqueKeys.map((key) => [...key]));
641
706
  }
642
707
  /**
643
708
  * Scans `@db.*` and `@meta.id` annotations on a field during flattening.
@@ -992,7 +1057,11 @@ var TableMetadata = class {
992
1057
  _buildFieldDescriptors(adapter, rootType) {
993
1058
  const descriptors = [];
994
1059
  const skipFlattening = this.nestedObjects;
995
- const indexedFields = new Set([...this.primaryKeys, ...this.uniqueProps]);
1060
+ const indexedFields = new Set([
1061
+ ...this.primaryKeys,
1062
+ ...this.uniqueProps,
1063
+ ...this.uniqueKeys.flat()
1064
+ ]);
996
1065
  for (const index of this.indexes.values()) for (const f of index.fields) indexedFields.add(f.name);
997
1066
  for (const [path, type] of this.flatMap.entries()) {
998
1067
  if (!path) continue;
@@ -1187,6 +1256,8 @@ var TableMetadata = class {
1187
1256
  * overrides (`addUniqueFields`); these aren't reflected in
1188
1257
  * `this.indexes` but still legitimate addressing identifications.
1189
1258
  * Deduped against any single-field unique index already captured.
1259
+ * - `this.uniqueKeys` — multi-field uniques contributed by adapter
1260
+ * overrides (`addUniqueKeys`).
1190
1261
  */
1191
1262
  _buildIdentifications() {
1192
1263
  const out = [];
@@ -1211,6 +1282,10 @@ var TableMetadata = class {
1211
1282
  source: `unique:${field}`
1212
1283
  });
1213
1284
  }
1285
+ for (const key of this.uniqueKeys) out.push({
1286
+ fields: [...key],
1287
+ source: `unique:${key.join("+")}`
1288
+ });
1214
1289
  this._identifications = out;
1215
1290
  }
1216
1291
  /** Legitimate row-identifier shapes — primary key first, then each unique index. */
@@ -1575,6 +1650,18 @@ function rewriteIntegerRegex(filter, meta) {
1575
1650
  }
1576
1651
  //#endregion
1577
1652
  //#region src/query/aggregate-expr.ts
1653
+ /**
1654
+ * Output aliases of the computed `$select` entries of a grouped query —
1655
+ * aggregates, expressions and calendar buckets (`$as`, else uniqu's
1656
+ * `resolveAlias`).
1657
+ */
1658
+ function computedAliases(select) {
1659
+ const aliases = /* @__PURE__ */ new Set();
1660
+ if (!Array.isArray(select)) return aliases;
1661
+ for (const item of select) if ((0, _uniqu_core.isAggregateExpr)(item) || (0, _uniqu_core.isBucketExpr)(item)) aliases.add((0, _uniqu_core.resolveAlias)(item));
1662
+ else if ((0, _uniqu_core.isAggregateOfExpr)(item) || (0, _uniqu_core.isSelectArithExpr)(item)) aliases.add(item.$as);
1663
+ return aliases;
1664
+ }
1578
1665
  /** Most nodes a group-level expression may have once the aliases it names are inlined (SQL repeats them). */
1579
1666
  const EXPANDED_MAX_NODES = 4 * _uniqu_core.ARITH_MAX_NODES;
1580
1667
  /** Most expanded nodes ALL the expression entries of one query may have together. */
@@ -2059,7 +2146,7 @@ var FieldMappingStrategy = class {
2059
2146
  */
2060
2147
  translateAggregateQuery(query, meta, buckets, exprs = [], rowOrder) {
2061
2148
  const controls = query.controls;
2062
- const aliases = this.computedAliasSet(controls.$select);
2149
+ const aliases = computedAliases(controls.$select);
2063
2150
  const physicalBuckets = buckets.map((b) => ({
2064
2151
  ...b,
2065
2152
  field: this.physicalPath(b.field, meta),
@@ -2076,6 +2163,7 @@ var FieldMappingStrategy = class {
2076
2163
  $groupBy: this.renamesPaths(meta) ? controls.$groupBy.map((key) => aliases.has(key) ? key : this.physicalPath(key, meta)) : controls.$groupBy,
2077
2164
  $select: select ? new UniquSelect(select, meta.allPhysicalFields, physicalBuckets, computed) : void 0,
2078
2165
  $sort: controls.$sort && this.physicalSort(controls.$sort, meta, aliases),
2166
+ $nulls: controls.$nulls && this.physicalNulls(controls.$nulls, meta, aliases),
2079
2167
  $having: controls.$having ? this.translateHaving(controls.$having, meta, aliases) : void 0
2080
2168
  },
2081
2169
  insights: query.insights
@@ -2099,13 +2187,6 @@ var FieldMappingStrategy = class {
2099
2187
  ...this.translateFilter(fields, meta)
2100
2188
  } : out;
2101
2189
  }
2102
- /** Output aliases of the computed `$select` entries (aggregates, expressions and calendar buckets). */
2103
- computedAliasSet(select) {
2104
- const aliases = /* @__PURE__ */ new Set();
2105
- for (const item of select ?? []) if ((0, _uniqu_core.isAggregateExpr)(item) || (0, _uniqu_core.isBucketExpr)(item)) aliases.add((0, _uniqu_core.resolveAlias)(item));
2106
- else if ((0, _uniqu_core.isAggregateOfExpr)(item) || (0, _uniqu_core.isSelectArithExpr)(item)) aliases.add(item.$as);
2107
- return aliases;
2108
- }
2109
2190
  /**
2110
2191
  * The arithmetic and `$rowOrder` parts of a grouped query with PHYSICAL
2111
2192
  * names: a row-level operand is a column; a group-level operand stays an
@@ -2144,7 +2225,8 @@ var FieldMappingStrategy = class {
2144
2225
  if (rowOrder?.length) {
2145
2226
  order = rowOrder.map((k) => ({
2146
2227
  column: this.physicalPath(k.field, meta),
2147
- desc: k.desc
2228
+ desc: k.desc,
2229
+ ...k.nulls && { nulls: k.nulls }
2148
2230
  }));
2149
2231
  for (const pk of meta.primaryKeys) {
2150
2232
  const column = this.physicalPath(pk, meta);
@@ -2194,9 +2276,20 @@ var FieldMappingStrategy = class {
2194
2276
  }
2195
2277
  /** `$sort` with physical keys; computed `aliases` (grouped queries) pass through. */
2196
2278
  physicalSort(sort, meta, aliases) {
2197
- if (!this.renamesPaths(meta)) return sort;
2279
+ return this.physicalKeys(sort, meta, aliases);
2280
+ }
2281
+ /**
2282
+ * `$nulls` with physical keys, like {@link physicalSort}. The core hands
2283
+ * over only the resolved entries (since 0.1.153).
2284
+ */
2285
+ physicalNulls(nulls, meta, aliases) {
2286
+ return this.physicalKeys(nulls, meta, aliases);
2287
+ }
2288
+ /** `map` with its field-path keys made physical; computed `aliases` pass through. */
2289
+ physicalKeys(map, meta, aliases) {
2290
+ if (!this.renamesPaths(meta)) return map;
2198
2291
  const translated = {};
2199
- for (const [key, dir] of Object.entries(sort)) translated[aliases?.has(key) ? key : this.physicalPath(key, meta)] = dir;
2292
+ for (const [key, value] of Object.entries(map)) translated[aliases?.has(key) ? key : this.physicalPath(key, meta)] = value;
2200
2293
  return translated;
2201
2294
  }
2202
2295
  /**
@@ -2472,7 +2565,8 @@ var DocumentFieldMapper = class extends FieldMappingStrategy {
2472
2565
  ...controls,
2473
2566
  $with: void 0,
2474
2567
  $select: select ? new UniquSelect(select, meta.allPhysicalFields) : void 0,
2475
- $sort: controls?.$sort && this.physicalSort(controls.$sort, meta)
2568
+ $sort: controls?.$sort && this.physicalSort(controls.$sort, meta),
2569
+ $nulls: controls?.$nulls && this.physicalNulls(controls.$nulls, meta)
2476
2570
  },
2477
2571
  insights: query.insights
2478
2572
  };
@@ -2663,6 +2757,7 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
2663
2757
  }
2664
2758
  result.$sort = translated;
2665
2759
  }
2760
+ if (controls.$nulls) result.$nulls = this.physicalNulls(controls.$nulls, meta);
2666
2761
  if (controls.$select) {
2667
2762
  let translatedRaw;
2668
2763
  if (Array.isArray(controls.$select)) {
@@ -3244,6 +3339,10 @@ function collectQueryPaths(query, aggregate) {
3244
3339
  refs.groupBy = bucketAliases.size > 0 ? groupBy.filter((name) => !bucketAliases.has(name)) : groupBy;
3245
3340
  for (const name of sortFieldNames(controls.$sort)) if (!aliases.has(name)) refs.sort.push(name);
3246
3341
  if (refs.aggregateMode) refs.sort.push(...rowOrderKeys(controls.$rowOrder));
3342
+ const nulls = controls.$nulls;
3343
+ if (require_object.isPlainObject(nulls)) {
3344
+ for (const name of Object.keys(nulls)) if (!aliases.has(name) && !refs.sort.includes(name)) refs.sort.push(name);
3345
+ }
3247
3346
  if (refs.aggregateMode) collectFilterKeys(controls.$having, (path) => refs.having.push(path), aliases, refs);
3248
3347
  return refs;
3249
3348
  }
@@ -3515,6 +3614,130 @@ function guardBucketUnits(adapter, buckets) {
3515
3614
  }]);
3516
3615
  }
3517
3616
  //#endregion
3617
+ //#region src/query/nulls.ts
3618
+ /** Annotation key of a field's default NULL placement (`@db.sort.nulls`). */
3619
+ const SORT_NULLS_ANNOTATION = "db.sort.nulls";
3620
+ /**
3621
+ * Per-table NULL-placement resolution (since 0.1.153): built once per
3622
+ * readable, memoizing the per-path answers every sorted read asks for.
3623
+ */
3624
+ var NullsSource = class {
3625
+ _init;
3626
+ _nullable = /* @__PURE__ */ new Map();
3627
+ _hasDefaults;
3628
+ constructor(_init) {
3629
+ this._init = _init;
3630
+ }
3631
+ /** Whether any field carries a `@db.sort.nulls` default. */
3632
+ hasDefaults() {
3633
+ return this._hasDefaults ??= [...this._init.meta.flatMap.keys()].some((path) => this.defaultOf(path) !== void 0);
3634
+ }
3635
+ /** The `@db.sort.nulls` default of the LOGICAL field `path`, if any. */
3636
+ defaultOf(path) {
3637
+ const value = this._init.meta.flatMap.get(path)?.metadata.get(SORT_NULLS_ANNOTATION);
3638
+ return (0, _uniqu_core.isNullsPlacement)(value) ? value : void 0;
3639
+ }
3640
+ /**
3641
+ * Whether ordering by the LOGICAL field `path` can meet NULL (or a missing
3642
+ * value): the field or one of its parent objects is optional, the field is
3643
+ * a derived column, it is not a known field, or the source is a view. A
3644
+ * `$nulls` entry on any other field changes nothing and is dropped, so the
3645
+ * plain `ORDER BY` keeps using the index.
3646
+ */
3647
+ nullable(path) {
3648
+ let answer = this._nullable.get(path);
3649
+ if (answer === void 0) {
3650
+ answer = this._init.isView || this._nullableField(path);
3651
+ this._nullable.set(path, answer);
3652
+ }
3653
+ return answer;
3654
+ }
3655
+ _nullableField(path) {
3656
+ const { meta } = this._init;
3657
+ const fd = meta.descriptorByPath.get(path);
3658
+ if (!fd || fd.derived) return true;
3659
+ for (let p = path;;) {
3660
+ if (meta.flatMap.get(p)?.optional === true) return true;
3661
+ const dot = p.lastIndexOf(".");
3662
+ if (dot === -1) return false;
3663
+ p = p.slice(0, dot);
3664
+ }
3665
+ }
3666
+ /**
3667
+ * The effective `$nulls` of the `$sort` keys of one read: the request's
3668
+ * entry, else the field's `@db.sort.nulls` default; entries on fields that
3669
+ * cannot be NULL ({@link nullable}) are dropped. `aliases` are the computed
3670
+ * output names of a grouped read — they take a request entry only and are
3671
+ * never dropped. Entries for keys that are not ordered by are ignored. On
3672
+ * an adapter without support a request entry is refused and defaults are
3673
+ * dropped.
3674
+ *
3675
+ * @returns LOGICAL keys (or aliases) → placement; `undefined` when none.
3676
+ * @throws DbError `INVALID_QUERY` on a malformed `$nulls`, or a request
3677
+ * entry the adapter cannot honour.
3678
+ */
3679
+ resolveSort(sort, rawNulls, aliases) {
3680
+ const requested = checkNullsControl(rawNulls);
3681
+ if (!require_object.isPlainObject(sort)) return void 0;
3682
+ let nulls;
3683
+ for (const key of Object.keys(sort)) {
3684
+ const asked = requested && Object.hasOwn(requested, key) ? requested[key] : void 0;
3685
+ const placement = aliases?.has(key) ? asked : this._placement(key, asked);
3686
+ if (placement && this._allowed(asked)) (nulls ??= {})[key] = placement;
3687
+ }
3688
+ return nulls;
3689
+ }
3690
+ /**
3691
+ * `$rowOrder` keys (as uniqu resolved them, `nulls` from the request) under
3692
+ * the rules of {@link resolveSort}: defaults filled in, placements on
3693
+ * fields that cannot be NULL (or on an unsupporting adapter) dropped.
3694
+ */
3695
+ resolveRowOrder(rowOrder) {
3696
+ return rowOrder?.map((key) => {
3697
+ const placement = this._placement(key.field, key.nulls);
3698
+ const nulls = placement && this._allowed(key.nulls) ? placement : void 0;
3699
+ if (nulls === key.nulls) return key;
3700
+ return nulls ? {
3701
+ ...key,
3702
+ nulls
3703
+ } : {
3704
+ field: key.field,
3705
+ desc: key.desc
3706
+ };
3707
+ });
3708
+ }
3709
+ /** The placement of field `path` — the request's `asked`, else its default — or `undefined` when it cannot be NULL. */
3710
+ _placement(path, asked) {
3711
+ return this.nullable(path) ? asked ?? this.defaultOf(path) : void 0;
3712
+ }
3713
+ /** Whether a placement may be forwarded; a requested one on an unsupporting adapter is refused. */
3714
+ _allowed(asked) {
3715
+ if (this._init.supported) return true;
3716
+ if (asked) throw new require_db_error.DbError("INVALID_QUERY", [{
3717
+ path: "$nulls",
3718
+ message: `NULL placement ($nulls / :first / :last) is not supported by the adapter of "${this._init.tableName}"`
3719
+ }]);
3720
+ return false;
3721
+ }
3722
+ };
3723
+ /**
3724
+ * The raw `$nulls` control as a checked map, or `undefined` when absent.
3725
+ * @throws DbError `INVALID_QUERY` (`path: "$nulls"`) on a non-object or a
3726
+ * value other than `'first'` / `'last'`.
3727
+ */
3728
+ function checkNullsControl(raw) {
3729
+ if (raw === void 0 || raw === null) return void 0;
3730
+ if (!require_object.isPlainObject(raw)) throw new require_db_error.DbError("INVALID_QUERY", [{
3731
+ path: "$nulls",
3732
+ message: "$nulls must be an object of field → 'first' | 'last'"
3733
+ }]);
3734
+ for (const [field, value] of Object.entries(raw)) if (!(0, _uniqu_core.isNullsPlacement)(value)) throw new require_db_error.DbError("INVALID_QUERY", [{
3735
+ path: "$nulls",
3736
+ message: `$nulls "${field}" must be 'first' or 'last'`
3737
+ }]);
3738
+ return raw;
3739
+ }
3740
+ //#endregion
3518
3741
  //#region src/shared/index-messages.ts
3519
3742
  /**
3520
3743
  * Wording of the "index not found" errors — one source for the core, every
@@ -3598,6 +3821,26 @@ function resolveDefaultFromMetadata(metadata) {
3598
3821
  };
3599
3822
  }
3600
3823
  /**
3824
+ * `query` with `$groupBy` defaulted to `[]` — an aggregate without one is the
3825
+ * ungrouped query (one group), as `$groupBy: []` documents it.
3826
+ * @throws DbError `INVALID_QUERY` when `$groupBy` is present but not an array.
3827
+ */
3828
+ function withGroupBy(query) {
3829
+ const controls = query.controls ?? {};
3830
+ if (Array.isArray(controls.$groupBy)) return query;
3831
+ if (controls.$groupBy !== void 0 && controls.$groupBy !== null) throw new require_db_error.DbError("INVALID_QUERY", [{
3832
+ path: "$groupBy",
3833
+ message: "$groupBy must be an array of field names"
3834
+ }]);
3835
+ return {
3836
+ ...query,
3837
+ controls: {
3838
+ ...controls,
3839
+ $groupBy: []
3840
+ }
3841
+ };
3842
+ }
3843
+ /**
3601
3844
  * Checks whether an id value is type-compatible with a field's design type.
3602
3845
  * Used by `findById` to skip primary-key lookup when the id clearly can't match,
3603
3846
  * falling through to unique-property search instead.
@@ -3739,7 +3982,7 @@ var AtscriptDbReadable = class {
3739
3982
  _translateForAdapter(query) {
3740
3983
  this._ensureBuilt();
3741
3984
  this._guardQuery(query);
3742
- return this._fieldMapper.translateQuery(query, this._meta);
3985
+ return this._fieldMapper.translateQuery(this._withStableSort(query), this._meta);
3743
3986
  }
3744
3987
  /**
3745
3988
  * Physical rows of THIS table → logical rows (field mapping, value
@@ -3839,7 +4082,8 @@ var AtscriptDbReadable = class {
3839
4082
  }
3840
4083
  /**
3841
4084
  * Physical column lists that must be unique: the primary key (when declared)
3842
- * followed by every unique index and every adapter-contributed unique field. Used by conflict-ignoring inserts.
4085
+ * followed by every unique index and every adapter-contributed unique field / key. Used by
4086
+ * conflict-ignoring inserts and view read pruning.
3843
4087
  * @since 0.1.148
3844
4088
  */
3845
4089
  get uniqueKeySets() {
@@ -3856,6 +4100,7 @@ var AtscriptDbReadable = class {
3856
4100
  const column = this._meta.physicalPath(prop);
3857
4101
  if (!sets.some((s) => s.length === 1 && s[0] === column)) sets.push([column]);
3858
4102
  }
4103
+ for (const key of this._meta.uniqueKeys) sets.push(key.map((f) => this._meta.physicalPath(f)));
3859
4104
  for (const set of sets) Object.freeze(set);
3860
4105
  return Object.freeze(sets);
3861
4106
  }
@@ -4100,6 +4345,7 @@ var AtscriptDbReadable = class {
4100
4345
  widened = widen.added;
4101
4346
  }
4102
4347
  }
4348
+ readQuery = this._withStableSort(readQuery);
4103
4349
  return {
4104
4350
  translated: this._fieldMapper.translateQuery(readQuery, this._meta),
4105
4351
  controls: readQuery.controls,
@@ -4121,6 +4367,99 @@ var AtscriptDbReadable = class {
4121
4367
  }
4122
4368
  return rows;
4123
4369
  }
4370
+ /**
4371
+ * `query` with {@link _withPkTieBreak} applied and its `$nulls` resolved
4372
+ * ({@link NullsSource.resolveSort}) — a copy when either changes.
4373
+ */
4374
+ _withStableSort(query) {
4375
+ const controls = query.controls;
4376
+ const raw = controls?.$nulls;
4377
+ const source = this._nulls;
4378
+ const nulls = raw !== void 0 || source.hasDefaults() ? source.resolveSort(controls?.$sort, raw) : void 0;
4379
+ const sort = this._withPkTieBreak(controls);
4380
+ if (!sort && nulls === raw) return query;
4381
+ const next = { ...controls };
4382
+ if (sort) next.$sort = sort;
4383
+ if (nulls) next.$nulls = nulls;
4384
+ else delete next.$nulls;
4385
+ return {
4386
+ ...query,
4387
+ controls: next
4388
+ };
4389
+ }
4390
+ _nullsCache;
4391
+ /** NULL-placement resolution for this table (`$nulls`, `@db.sort.nulls`; since 0.1.153). */
4392
+ get _nulls() {
4393
+ return this._nullsCache ??= new NullsSource({
4394
+ meta: this._meta,
4395
+ tableName: this.tableName,
4396
+ isView: this.isView,
4397
+ supported: this.adapter.supportsNullsPlacement()
4398
+ });
4399
+ }
4400
+ /**
4401
+ * `$sort` with the primary-key tie-breaker appended (since 0.1.153), or
4402
+ * `undefined` when the read keeps its `$sort` as written. Rows that tie on
4403
+ * every `$sort` key otherwise come back in an engine-chosen order that can
4404
+ * differ between two reads, so offset pages overlap or skip rows. The
4405
+ * primary-key fields the `$sort` leaves out are appended in declaration
4406
+ * order, in the direction of the LAST `$sort` key (a backward index scan
4407
+ * serves `amount DESC, id DESC`).
4408
+ *
4409
+ * Unchanged when there is no `$sort` (no order is imposed), when the
4410
+ * `$sort` already names every field of the primary key or of a unique key
4411
+ * over non-nullable fields ({@link _totalOrderKeys}), when the table has no
4412
+ * primary key (a view without `@meta.id`), and on grouped / aggregate /
4413
+ * `$count` reads. Search, vector and geo relevance is never touched: the
4414
+ * key is appended only to an explicit `$sort`. Every read path applies it
4415
+ * ({@link _translateRead}, and {@link _translateForAdapter} for a native
4416
+ * `$with` loader), so nested relation rows get it too.
4417
+ */
4418
+ _withPkTieBreak(controls) {
4419
+ const sort = controls?.$sort;
4420
+ if (!sort || typeof sort !== "object") return void 0;
4421
+ const keys = Object.keys(sort);
4422
+ if (keys.length === 0 || controls.$count) return void 0;
4423
+ if (Array.isArray(controls.$groupBy) && controls.$groupBy.length > 0) return void 0;
4424
+ const select = controls.$select;
4425
+ if (Array.isArray(select) && select.some((item) => (0, _uniqu_core.isAggregateExpr)(item) || (0, _uniqu_core.isAggregateOfExpr)(item))) return;
4426
+ const pk = this._meta.primaryKeys;
4427
+ if (pk.length === 0) return void 0;
4428
+ const sorted = new Set(keys.map((key) => this._meta.physicalPath(key)));
4429
+ if (this._totalOrderKeys().some((set) => set.every((column) => sorted.has(column)))) return;
4430
+ const dir = sort[keys[keys.length - 1]];
4431
+ const next = { ...sort };
4432
+ for (const field of pk) if (!sorted.has(this._meta.physicalPath(field))) next[field] = dir;
4433
+ return next;
4434
+ }
4435
+ _totalOrderKeysCache;
4436
+ /**
4437
+ * Physical column sets that order rows totally: the primary key, then
4438
+ * every unique index (and adapter-contributed unique field) whose fields
4439
+ * are all non-nullable — a unique key over a nullable column admits
4440
+ * several NULLs, so it does not.
4441
+ */
4442
+ _totalOrderKeys() {
4443
+ if (this._totalOrderKeysCache) return this._totalOrderKeysCache;
4444
+ const meta = this._meta;
4445
+ const nullable = (logical) => {
4446
+ for (let path = logical;;) {
4447
+ if (meta.flatMap.get(path)?.optional === true) return true;
4448
+ const dot = path.lastIndexOf(".");
4449
+ if (dot === -1) return false;
4450
+ path = path.slice(0, dot);
4451
+ }
4452
+ };
4453
+ const sets = [];
4454
+ if (meta.primaryKeys.length > 0) sets.push(meta.primaryKeys.map((f) => meta.physicalPath(f)));
4455
+ for (const index of meta.indexes.values()) {
4456
+ if (index.type !== "unique") continue;
4457
+ if (index.fields.every((f) => f.optional !== true && !nullable(meta.physicalToPath.get(f.name) ?? f.name))) sets.push(index.fields.map((f) => f.name));
4458
+ }
4459
+ for (const prop of meta.uniqueProps) if (!nullable(prop)) sets.push([meta.physicalPath(prop)]);
4460
+ this._totalOrderKeysCache = sets;
4461
+ return sets;
4462
+ }
4124
4463
  /** `$select` plus the join keys of `withRelations` it leaves out — `undefined` when none is missing. */
4125
4464
  _widenSelectForWith(select, withRelations) {
4126
4465
  const keys = /* @__PURE__ */ new Set();
@@ -4331,6 +4670,7 @@ var AtscriptDbReadable = class {
4331
4670
  */
4332
4671
  async aggregate(query) {
4333
4672
  this._ensureBuilt();
4673
+ query = withGroupBy(query);
4334
4674
  const { $groupBy, $select } = query.controls;
4335
4675
  const computed = resolveComputedSelect(query.controls, this._meta, true);
4336
4676
  const { buckets } = computed;
@@ -4389,11 +4729,36 @@ var AtscriptDbReadable = class {
4389
4729
  if (!searchTerm.trim()) return query.controls.$count ? [{ count: 0 }] : [];
4390
4730
  }
4391
4731
  guardAggregate(this._meta, this.adapter, query, computed);
4392
- const dbQuery = this._fieldMapper.translateAggregateQuery(query, this._meta, buckets, computed.exprs, computed.rowOrder);
4732
+ const ordered = this._aggregateNulls(query, computed.rowOrder);
4733
+ const dbQuery = this._fieldMapper.translateAggregateQuery(ordered.query, this._meta, buckets, computed.exprs, ordered.rowOrder);
4393
4734
  const results = await this.adapter.aggregate(dbQuery);
4394
4735
  return this._fromRead(results, query.controls, this._aliasFields($select));
4395
4736
  }
4396
4737
  /**
4738
+ * A grouped read's `$nulls` resolved for its `$sort` keys (computed aliases
4739
+ * take a request entry only) and its `$rowOrder` keys — see
4740
+ * {@link NullsSource}.
4741
+ */
4742
+ _aggregateNulls(query, rowOrder) {
4743
+ const controls = query.controls;
4744
+ const source = this._nulls;
4745
+ if (controls.$nulls === void 0 && !source.hasDefaults()) return {
4746
+ query,
4747
+ rowOrder
4748
+ };
4749
+ const nulls = source.resolveSort(controls.$sort, controls.$nulls, computedAliases(controls.$select));
4750
+ const next = { ...controls };
4751
+ if (nulls) next.$nulls = nulls;
4752
+ else delete next.$nulls;
4753
+ return {
4754
+ query: {
4755
+ ...query,
4756
+ controls: next
4757
+ },
4758
+ rowOrder: source.resolveRowOrder(rowOrder)
4759
+ };
4760
+ }
4761
+ /**
4397
4762
  * The computed aliases of one source field that is a boolean or a decimal —
4398
4763
  * `min` / `max` / `first` / `last` — with that field's descriptor: the
4399
4764
  * aggregate row's value is coerced like the column's own on read.
@@ -4427,6 +4792,14 @@ var AtscriptDbReadable = class {
4427
4792
  aggregateFns() {
4428
4793
  return this.adapter.aggregateFns();
4429
4794
  }
4795
+ /**
4796
+ * Whether the adapter honours a requested NULL placement (`$nulls`,
4797
+ * `@db.sort.nulls`) — proxies adapter capability.
4798
+ * @since 0.1.153
4799
+ */
4800
+ supportsNullsPlacement() {
4801
+ return this.adapter.supportsNullsPlacement();
4802
+ }
4430
4803
  /** Whether the adapter can sort by a given field (proxies adapter capability). */
4431
4804
  canSortField(fd) {
4432
4805
  return this.adapter.canSortField(fd);
@@ -4831,7 +5204,7 @@ var AtscriptDbReadable = class {
4831
5204
  * Public entry point for relation loading. Used by adapters for nested $with delegation.
4832
5205
  */
4833
5206
  async loadRelations(rows, withRelations) {
4834
- const { loadRelationsImpl } = await Promise.resolve().then(() => require("./relation-loader-fNjHgBMs.cjs")).then((n) => n.relation_loader_exports);
5207
+ const { loadRelationsImpl } = await Promise.resolve().then(() => require("./relation-loader-DeulLrAw.cjs")).then((n) => n.relation_loader_exports);
4835
5208
  return loadRelationsImpl(rows, withRelations, this);
4836
5209
  }
4837
5210
  /**
@@ -5217,6 +5590,24 @@ var BaseDbAdapter = class {
5217
5590
  return false;
5218
5591
  }
5219
5592
  /**
5593
+ * Whether this adapter honours a requested NULL placement: `controls.$nulls`
5594
+ * (PHYSICAL `$sort` keys → `'first'` / `'last'`, or computed aliases on a
5595
+ * grouped read) and `TRowOrderKey.nulls` on the `first` / `last` row
5596
+ * order. NULL (and, on document stores, a missing value) goes before or
5597
+ * after every non-null value whatever the sort direction. The core only
5598
+ * forwards entries for keys that can be NULL; a key without an entry keeps
5599
+ * the adapter's native order (`$sort`) or "NULL is the smallest value"
5600
+ * (grouped / row order). Default `false` (fail-closed): the core refuses a
5601
+ * request naming a placement with `INVALID_QUERY` and drops
5602
+ * `@db.sort.nulls` defaults. moost-db advertises it as
5603
+ * `/meta.nullsPlacement`.
5604
+ *
5605
+ * @since 0.1.153
5606
+ */
5607
+ supportsNullsPlacement() {
5608
+ return false;
5609
+ }
5610
+ /**
5220
5611
  * Revision of how this adapter renders a managed view (its SQL / pipeline)
5221
5612
  * from an unchanged view definition. Stored in each managed view's sync
5222
5613
  * snapshot when defined, so bumping it recreates every managed view of the
@@ -5240,6 +5631,18 @@ var BaseDbAdapter = class {
5240
5631
  return NO_VIEW_CAPABILITIES;
5241
5632
  }
5242
5633
  /**
5634
+ * Whether reads of a managed view drop the LEFT joins the query does not
5635
+ * need (`AtscriptDbView.readPlan`) — reading through an inline, pruned
5636
+ * definition instead of the stored view. Only adapters that implement it
5637
+ * honour it; each sets its own default from its constructor options (the
5638
+ * bundled MySQL, SQLite and MongoDB adapters: on; PostgreSQL removes such
5639
+ * joins itself). Set it to `false` on one view's adapter
5640
+ * (`space.getAdapter(View)`) to read that view by name again.
5641
+ *
5642
+ * @since 0.1.153
5643
+ */
5644
+ viewJoinPruning = false;
5645
+ /**
5243
5646
  * Whether this adapter enforces foreign key constraints natively.
5244
5647
  * When `true`, the generic layer skips application-level cascade/setNull
5245
5648
  * on delete — the DB engine handles it (e.g. SQLite `ON DELETE CASCADE`).
@@ -5617,10 +6020,13 @@ var BaseDbAdapter = class {
5617
6020
  * and every other error must throw — and a skipped row must never abort the
5618
6021
  * surrounding transaction. Fail-closed by default: throws
5619
6022
  * `DbError("ON_CONFLICT_NOT_SUPPORTED")`; also override
5620
- * {@link supportsInsertIgnore}.
6023
+ * {@link supportsInsertIgnore}. `opts` (since 0.1.153) tells whether the
6024
+ * call joins a caller's transaction and whether to lock the stored rows the
6025
+ * batch collides with — see {@link TDbInsertIgnoreOptions}; an adapter
6026
+ * without row locks ignores it.
5621
6027
  * @since 0.1.148
5622
6028
  */
5623
- insertManyIgnore(_data) {
6029
+ insertManyIgnore(_data, _opts) {
5624
6030
  return Promise.reject(new require_db_error.DbError("ON_CONFLICT_NOT_SUPPORTED", [{
5625
6031
  path: "",
5626
6032
  message: `The ${this.constructor.name} adapter does not support onConflict "ignore"`
@@ -6481,12 +6887,18 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
6481
6887
  }
6482
6888
  async _insertMany(payloads, opts) {
6483
6889
  this._ensureBuilt();
6484
- const { _depth, _action, maxDepth: userMax, guard, check, onConflict } = opts ?? {};
6890
+ const { _depth, _action, maxDepth: userMax, guard, check, onConflict, lockConflicts } = opts ?? {};
6485
6891
  if (onConflict !== void 0 && onConflict !== "error" && onConflict !== "ignore") throw new require_db_error.DbError("INVALID_QUERY", [{
6486
6892
  path: "onConflict",
6487
6893
  message: `Unknown onConflict mode "${String(onConflict)}"`
6488
6894
  }]);
6489
6895
  const ignore = onConflict === "ignore";
6896
+ if (lockConflicts && !ignore) throw new require_db_error.DbError("INVALID_QUERY", [{
6897
+ path: "lockConflicts",
6898
+ message: "lockConflicts requires onConflict \"ignore\""
6899
+ }]);
6900
+ const ignoreOpts = ignore ? { inCallerTransaction: this.adapter.isInTransaction() } : void 0;
6901
+ if (ignoreOpts?.inCallerTransaction && lockConflicts) ignoreOpts.lockConflicts = true;
6490
6902
  const maxDepth = userMax ?? 3;
6491
6903
  const depth = _depth ?? 0;
6492
6904
  const canNest = depth < maxDepth && this._writeTableResolver && this._meta.navFields.size > 0;
@@ -6521,7 +6933,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
6521
6933
  if (canNest) await require_nested_writer.preValidateNestedFrom(host, originals);
6522
6934
  let ignored;
6523
6935
  if (ignore) {
6524
- ignored = await this._insertIgnoring(prepared);
6936
+ ignored = await this._insertIgnoring(prepared, ignoreOpts);
6525
6937
  const keep = ignored.inserted;
6526
6938
  items = keep.map((i) => items[i]);
6527
6939
  originals = canNest ? keep.map((i) => originals[i]) : originals;
@@ -7170,7 +7582,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
7170
7582
  * primary / unique key tuple (NULL components never collide), sends the rest
7171
7583
  * to the adapter and assembles one slot per input row.
7172
7584
  */
7173
- async _insertIgnoring(prepared) {
7585
+ async _insertIgnoring(prepared, opts) {
7174
7586
  const keySets = this.uniqueKeySets;
7175
7587
  const seen = keySets.map(() => /* @__PURE__ */ new Set());
7176
7588
  const conflictAt = /* @__PURE__ */ new Set();
@@ -7187,7 +7599,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
7187
7599
  });
7188
7600
  send.push(i);
7189
7601
  }
7190
- const slots = send.length ? await this.adapter.insertManyIgnore(send.map((i) => prepared[i])) : [];
7602
+ const slots = send.length ? await this.adapter.insertManyIgnore(send.map((i) => prepared[i]), opts) : [];
7191
7603
  if (slots.length !== send.length) throw new require_db_error.DbError("INVALID_QUERY", [{
7192
7604
  path: "",
7193
7605
  message: "Adapter insertManyIgnore must return one slot per input row"
@@ -7448,6 +7860,231 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
7448
7860
  }
7449
7861
  };
7450
7862
  //#endregion
7863
+ //#region src/table/view-read-plan.ts
7864
+ /** Design types whose `=` compares one scalar to one scalar on every engine. */
7865
+ const SCALAR_TYPES = new Set([
7866
+ "string",
7867
+ "number",
7868
+ "boolean",
7869
+ "decimal",
7870
+ "bigint"
7871
+ ]);
7872
+ /** The comparisons of a pure conjunction (nested `$and`s flattened), or `undefined` for any `$or` / `$not`. */
7873
+ function conjuncts(node, out = []) {
7874
+ if ("$and" in node) {
7875
+ for (const child of node.$and) if (!conjuncts(child, out)) return void 0;
7876
+ return out;
7877
+ }
7878
+ if ("$or" in node || "$not" in node) return void 0;
7879
+ out.push(node);
7880
+ return out;
7881
+ }
7882
+ /** Every scope a predicate references (its field refs' tables). */
7883
+ function predicateScopes(host, node, out) {
7884
+ if ("$and" in node || "$or" in node) {
7885
+ const children = node;
7886
+ for (const child of children.$and ?? children.$or ?? []) predicateScopes(host, child, out);
7887
+ return out;
7888
+ }
7889
+ if ("$not" in node) return predicateScopes(host, node.$not, out);
7890
+ const comp = node;
7891
+ out.add(host.resolveRefSource(comp.left).table);
7892
+ if (require_nested_writer.isFieldRef(comp.right)) out.add(host.resolveRefSource(comp.right).table);
7893
+ return out;
7894
+ }
7895
+ /** Whether a literal `value` compares as the scalar design type `designType` (no implicit casts). */
7896
+ function literalMatches(value, designType) {
7897
+ switch (typeof value) {
7898
+ case "string": return designType === "string";
7899
+ case "number": return designType === "number" || designType === "decimal";
7900
+ case "boolean": return designType === "boolean";
7901
+ default: return false;
7902
+ }
7903
+ }
7904
+ /**
7905
+ * Whether the left join `join` matches AT MOST ONE target row per input row,
7906
+ * so dropping it (when nothing reads it) leaves every result row in place:
7907
+ *
7908
+ * - a first-row join (its ON pins the target's primary key to one value);
7909
+ * - otherwise its condition is a conjunction whose `=` comparisons pin EVERY
7910
+ * column of one of the target's unique keys (primary key included) to a
7911
+ * value that does not depend on the target — another scope's field of the
7912
+ * same scalar design type that compares alike (collation, type overrides,
7913
+ * table charset), or a literal of that type.
7914
+ * Other conjuncts only narrow the match.
7915
+ *
7916
+ * Never for an inner join (it filters), a target that is a view (its
7917
+ * uniqueness is unknown), or a predicate with `$or` / `$not`. On a document
7918
+ * store every pinned key field must also be required: a unique index over an
7919
+ * optional field is partial there, and a missing value would match many.
7920
+ */
7921
+ function atMostOneMatch(host, join) {
7922
+ if (join.kind !== "left") return false;
7923
+ const keySets = host.uniqueKeySets(join.targetType());
7924
+ if (!keySets) return false;
7925
+ if (join.first) return true;
7926
+ const comparisons = conjuncts(join.condition);
7927
+ if (!comparisons) return false;
7928
+ const pinned = /* @__PURE__ */ new Set();
7929
+ for (const node of comparisons) {
7930
+ const comp = node;
7931
+ if (comp.op !== "$eq" || comp.right === null || comp.right === void 0) continue;
7932
+ const left = host.resolveRefSource(comp.left);
7933
+ const rightRef = require_nested_writer.isFieldRef(comp.right) ? comp.right : void 0;
7934
+ const right = rightRef ? host.resolveRefSource(rightRef) : void 0;
7935
+ let target;
7936
+ let other;
7937
+ if (left.table === join.scope && right?.table !== join.scope) {
7938
+ target = {
7939
+ ref: comp.left,
7940
+ source: left.source
7941
+ };
7942
+ other = right && rightRef ? {
7943
+ ref: rightRef,
7944
+ source: right.source
7945
+ } : void 0;
7946
+ } else if (right && rightRef && right.table === join.scope && left.table !== join.scope) {
7947
+ target = {
7948
+ ref: rightRef,
7949
+ source: right.source
7950
+ };
7951
+ other = {
7952
+ ref: comp.left,
7953
+ source: left.source
7954
+ };
7955
+ } else continue;
7956
+ const { source } = target;
7957
+ if (source.jsonPath || source.flattened || !SCALAR_TYPES.has(source.designType)) continue;
7958
+ if (host.nested && source.optional) continue;
7959
+ const traits = host.fieldTraits(target.ref);
7960
+ if (traits.encrypted) continue;
7961
+ if (other) {
7962
+ const o = other.source;
7963
+ if (o.jsonPath || o.flattened || o.designType !== source.designType) continue;
7964
+ const otherTraits = host.fieldTraits(other.ref);
7965
+ if (otherTraits.encrypted || otherTraits.compare !== traits.compare) continue;
7966
+ } else if (!literalMatches(comp.right, source.designType)) continue;
7967
+ pinned.add(source.column);
7968
+ }
7969
+ return keySets.some((set) => set.length > 0 && set.every((column) => pinned.has(column)));
7970
+ }
7971
+ function prunerState(host) {
7972
+ const plan = host.viewPlan;
7973
+ if (plan.materialized || !plan.joins.some((j) => j.kind === "left")) return void 0;
7974
+ const columns = host.getViewColumnMappings();
7975
+ if (columns.some((c) => c.aggFn)) return void 0;
7976
+ const joins = plan.joins.map((join) => {
7977
+ const refs = predicateScopes(host, join.condition, /* @__PURE__ */ new Set());
7978
+ for (const { ref } of join.first?.order ?? []) refs.add(host.resolveRefSource(ref).table);
7979
+ refs.delete(join.scope);
7980
+ return {
7981
+ join,
7982
+ droppable: atMostOneMatch(host, join),
7983
+ refs
7984
+ };
7985
+ });
7986
+ if (!joins.some((j) => j.droppable)) return void 0;
7987
+ const base = new Set([plan.entryTable]);
7988
+ if (plan.filter) predicateScopes(host, plan.filter, base);
7989
+ for (const j of joins) if (!j.droppable) base.add(j.join.scope);
7990
+ const byPath = new Map(columns.map((c) => [c.viewPath, c]));
7991
+ const columnScopes = /* @__PURE__ */ new Map();
7992
+ const scopesOf = (c) => {
7993
+ let scopes = columnScopes.get(c);
7994
+ if (scopes) return scopes;
7995
+ scopes = /* @__PURE__ */ new Set();
7996
+ columnScopes.set(c, scopes);
7997
+ if (c.expr === void 0) scopes.add(c.sourceTable);
7998
+ else {
7999
+ const out = scopes;
8000
+ require_nested_writer.walkViewExpr(c.expr, (path) => {
8001
+ const leaf = byPath.get(path);
8002
+ if (!leaf) {
8003
+ for (const j of joins) out.add(j.join.scope);
8004
+ return;
8005
+ }
8006
+ for (const scope of scopesOf(leaf)) out.add(scope);
8007
+ });
8008
+ }
8009
+ return scopes;
8010
+ };
8011
+ for (const c of columns) scopesOf(c);
8012
+ return {
8013
+ base,
8014
+ joins,
8015
+ columnScopes,
8016
+ byColumn: new Map(columns.map((c) => [c.viewColumn, c])),
8017
+ columns
8018
+ };
8019
+ }
8020
+ /**
8021
+ * The read planner of one managed view: memoised per dropped-join set.
8022
+ * @since 0.1.153
8023
+ */
8024
+ var ViewReadPlanner = class {
8025
+ _host;
8026
+ _state;
8027
+ _variants = /* @__PURE__ */ new Map();
8028
+ constructor(_host) {
8029
+ this._host = _host;
8030
+ }
8031
+ /** Whether any join of the view can ever be dropped. */
8032
+ get prunable() {
8033
+ return this._getState() !== void 0;
8034
+ }
8035
+ _getState() {
8036
+ if (this._state === void 0) try {
8037
+ this._state = prunerState(this._host) ?? null;
8038
+ } catch {
8039
+ this._state = null;
8040
+ }
8041
+ return this._state ?? void 0;
8042
+ }
8043
+ /** The mappings a needed (physical) name reads: exact, or a document path above / below it. */
8044
+ _matching(state, name) {
8045
+ const exact = state.byColumn.get(name);
8046
+ if (exact) return [exact];
8047
+ return state.columns.filter((c) => name.startsWith(`${c.viewColumn}.`) || c.viewColumn.startsWith(`${name}.`));
8048
+ }
8049
+ plan(needed) {
8050
+ const state = this._getState();
8051
+ if (!state) return void 0;
8052
+ const keep = new Set(state.base);
8053
+ const read = needed === void 0 ? state.columns : [...needed].flatMap((n) => this._matching(state, n));
8054
+ for (const c of read) for (const scope of state.columnScopes.get(c)) keep.add(scope);
8055
+ const dropped = [];
8056
+ for (let i = state.joins.length - 1; i >= 0; i--) {
8057
+ const { join, droppable, refs } = state.joins[i];
8058
+ if (droppable && !keep.has(join.scope)) dropped.push(join.scope);
8059
+ else for (const scope of refs) keep.add(scope);
8060
+ }
8061
+ if (dropped.length === 0) return void 0;
8062
+ dropped.reverse();
8063
+ const key = dropped.join("\0");
8064
+ let variant = this._variants.get(key);
8065
+ if (!variant) {
8066
+ const gone = new Set(dropped);
8067
+ const plan = this._host.viewPlan;
8068
+ const columns = [];
8069
+ const droppedColumns = [];
8070
+ for (const c of state.columns) if ([...state.columnScopes.get(c)].some((scope) => gone.has(scope))) droppedColumns.push(c.viewColumn);
8071
+ else columns.push(c);
8072
+ variant = {
8073
+ plan: {
8074
+ ...plan,
8075
+ joins: plan.joins.filter((j) => !gone.has(j.scope))
8076
+ },
8077
+ columns,
8078
+ dropped,
8079
+ droppedColumns,
8080
+ key
8081
+ };
8082
+ this._variants.set(key, variant);
8083
+ }
8084
+ return variant;
8085
+ }
8086
+ };
8087
+ //#endregion
7451
8088
  //#region src/table/db-view.ts
7452
8089
  /**
7453
8090
  * The single `@meta.id` field of a first-row join target (its source type,
@@ -7511,6 +8148,22 @@ function viewFieldSource(fieldName, fieldType, entryType) {
7511
8148
  sourcePath: aggField && aggField !== "*" ? aggField : fieldName
7512
8149
  };
7513
8150
  }
8151
+ /**
8152
+ * Field annotations that change how a column compares (`=`): its collation
8153
+ * and engine-type overrides. Two columns of a join condition must agree on
8154
+ * them (and on {@link COMPARE_TABLE_KEYS}) for the condition to prove a
8155
+ * unique-key match (`AtscriptDbView.readPlan`).
8156
+ */
8157
+ const COMPARE_FIELD_KEYS = [
8158
+ "db.column.collate",
8159
+ "db.mysql.type",
8160
+ "db.mysql.collate",
8161
+ "db.mysql.charset",
8162
+ "db.pg.type",
8163
+ "db.pg.collate"
8164
+ ];
8165
+ /** Table annotations that set its columns' default charset / collation. */
8166
+ const COMPARE_TABLE_KEYS = ["db.mysql.charset", "db.mysql.collate"];
7514
8167
  /** View types whose fields already carry their inherited seals. */
7515
8168
  const sealedViews = /* @__PURE__ */ new WeakSet();
7516
8169
  /**
@@ -7586,6 +8239,7 @@ function inheritViewFieldSeals(viewType) {
7586
8239
  var AtscriptDbView = class extends AtscriptDbReadable {
7587
8240
  _viewPlan;
7588
8241
  _columnMappings;
8242
+ _readPlanner;
7589
8243
  get isView() {
7590
8244
  return true;
7591
8245
  }
@@ -7737,6 +8391,53 @@ var AtscriptDbView = class extends AtscriptDbReadable {
7737
8391
  return this._columnMappings;
7738
8392
  }
7739
8393
  /**
8394
+ * The read variant of this managed view for a query that reads only the
8395
+ * `needed` PHYSICAL view columns (`undefined`: every column) — the view
8396
+ * plan with every LEFT join removed that (a) matches at most one row per
8397
+ * input row and (b) feeds no needed column, no other kept join's ON clause
8398
+ * and no `@db.view.filter`. Dropping such a join leaves every row of the
8399
+ * view in place, so a query over the variant returns exactly what it
8400
+ * returns over the view. `undefined` when nothing can be dropped.
8401
+ *
8402
+ * At most one match is proven from the model: a first-row join, or an ON
8403
+ * clause whose `=` conjuncts pin every column of one of the target's unique
8404
+ * keys (primary key included) — see the views guide. Inner joins, joins to
8405
+ * a view, grouped (`@db.agg.*`), materialized and external views are never
8406
+ * pruned. A variant's {@link TViewReadPlan.columns} omit the columns of the
8407
+ * dropped joins, so a column reference the caller failed to declare fails
8408
+ * loudly instead of reading wrong data.
8409
+ *
8410
+ * Memoised per dropped-join set. Adapters use it to read through an
8411
+ * inline, pruned definition instead of the stored view.
8412
+ * @since 0.1.153
8413
+ */
8414
+ readPlan(needed) {
8415
+ if (this.isExternal) return void 0;
8416
+ this._readPlanner ??= new ViewReadPlanner({
8417
+ viewPlan: this.viewPlan,
8418
+ nested: this._nested,
8419
+ getViewColumnMappings: () => this.getViewColumnMappings(),
8420
+ resolveRefSource: (ref) => this.resolveRefSource(ref),
8421
+ uniqueKeySets: (targetType) => {
8422
+ const source = viewSourceOf(targetType).type;
8423
+ if (isViewType(source) || !this._tableResolver) return void 0;
8424
+ const target = this._tableResolver(source);
8425
+ return target && !target.isView ? target.uniqueKeySets : void 0;
8426
+ },
8427
+ fieldTraits: (ref) => {
8428
+ const source = viewSourceOf(ref.type ? ref.type() : this.viewPlan.entryType()).type;
8429
+ const field = sourceFieldMetadata(source, ref.field);
8430
+ const table = source.metadata;
8431
+ const column = field;
8432
+ return {
8433
+ compare: JSON.stringify([...COMPARE_FIELD_KEYS.map((key) => column?.get(key) ?? null), ...COMPARE_TABLE_KEYS.map((key) => table.get(key) ?? null)]),
8434
+ encrypted: field?.has("db.encrypted") === true
8435
+ };
8436
+ }
8437
+ });
8438
+ return this._readPlanner.plan(needed);
8439
+ }
8440
+ /**
7740
8441
  * Why the bound adapter cannot render this managed view: one message per
7741
8442
  * computed column / first-row join whose feature its `viewCapabilities()`
7742
8443
  * does not list. Empty for an external view or when every feature is