@atscript/db 0.1.152 → 0.1.154

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