@atscript/db 0.1.146 → 0.1.147

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-CfsveE1w.mjs → aggregate-fns-C0BymEqa.mjs} +3 -1
  6. package/dist/{aggregate-fns-CGBv3E8S.cjs → aggregate-fns-CtsRbZn9.cjs} +8 -0
  7. package/dist/{buckets-DYFu0eZ8.d.cts → buckets-CjdPipCC.d.cts} +522 -17
  8. package/dist/{buckets-DRycmhOW.d.mts → buckets-DYopfd2Y.d.mts} +522 -17
  9. package/dist/{column-diff-e2oHc71_.mjs → column-diff-CO06iQvI.mjs} +862 -115
  10. package/dist/{column-diff-D_Kyuh0S.cjs → column-diff-Dt7EDJsA.cjs} +872 -113
  11. package/dist/{column-diff-Q9UmWn5x.d.mts → fk-diff-BtOmL4OW.d.mts} +48 -2
  12. package/dist/{column-diff-w-Mym_3w.d.cts → fk-diff-DLqwVkKG.d.cts} +48 -2
  13. package/dist/index.cjs +28 -33
  14. package/dist/index.d.cts +40 -15
  15. package/dist/index.d.mts +40 -15
  16. package/dist/index.mjs +5 -33
  17. package/dist/{nested-writer-CnOOAehr.mjs → nested-writer-D35UsDBT.mjs} +542 -2
  18. package/dist/{nested-writer-xfQwxplL.cjs → nested-writer-SqtYJHF0.cjs} +666 -0
  19. package/dist/plugin.cjs +266 -5
  20. package/dist/plugin.mjs +267 -6
  21. package/dist/rel.cjs +2 -2
  22. package/dist/rel.d.cts +2 -2
  23. package/dist/rel.d.mts +2 -2
  24. package/dist/rel.mjs +2 -2
  25. package/dist/{relation-helpers-B-0NRKat.d.mts → relation-helpers-C2bLtE-t.d.mts} +1 -1
  26. package/dist/{relation-helpers-BOMm_HUI.d.cts → relation-helpers-DH3LvwLV.d.cts} +1 -1
  27. package/dist/relation-loader-BFhVuJ-B.cjs +369 -0
  28. package/dist/relation-loader-C0qE8xgR.mjs +370 -0
  29. package/dist/shared.cjs +1 -1
  30. package/dist/shared.d.cts +4 -3
  31. package/dist/shared.d.mts +4 -3
  32. package/dist/shared.mjs +1 -1
  33. package/dist/sync.cjs +10 -6
  34. package/dist/sync.d.cts +2 -25
  35. package/dist/sync.d.mts +2 -25
  36. package/dist/sync.mjs +10 -6
  37. package/dist/{validation-utils-DOsB4e6G.cjs → validation-utils-Da2GjobR.cjs} +35 -24
  38. package/dist/{validation-utils-CMR4fe2M.mjs → validation-utils-Dq0uZ7ef.mjs} +35 -24
  39. package/dist/{validator-Bw6ks9Hy.d.mts → validator-CMnTI6r7.d.cts} +9 -6
  40. package/dist/{validator-Bw6ks9Hy.d.cts → validator-CMnTI6r7.d.mts} +9 -6
  41. package/dist/validator.d.cts +1 -1
  42. package/dist/validator.d.mts +1 -1
  43. package/package.json +8 -8
  44. package/dist/relation-loader-CBPY6kM7.cjs +0 -461
  45. package/dist/relation-loader-D9XuXaMv.mjs +0 -462
@@ -1,6 +1,6 @@
1
1
  const require_db_error = require("./db-error-DTkkeu5b.cjs");
2
- const require_aggregate_fns = require("./aggregate-fns-CGBv3E8S.cjs");
3
- const require_nested_writer = require("./nested-writer-xfQwxplL.cjs");
2
+ const require_aggregate_fns = require("./aggregate-fns-CtsRbZn9.cjs");
3
+ const require_nested_writer = require("./nested-writer-SqtYJHF0.cjs");
4
4
  const require_derived_rules = require("./derived-rules-YstgIxG-.cjs");
5
5
  const require_object = require("./object-Djg28csK.cjs");
6
6
  require("./agg.cjs");
@@ -471,6 +471,14 @@ var TableMetadata = class {
471
471
  jsonValueParents = /* @__PURE__ */ new Set();
472
472
  /** Every field descriptor's `physicalName` — reserved names a bucket alias may not take. */
473
473
  physicalNames = /* @__PURE__ */ new Set();
474
+ /**
475
+ * Resolves / guards relational filter predicates (`{ nav: { $some: … } }`)
476
+ * against the related tables — installed by the owning readable when the
477
+ * table has navigation fields and a table resolver (a `DbSpace`). The field
478
+ * mappers and the path guard reach the related tables through it.
479
+ * @since 0.1.147
480
+ */
481
+ relationFilters;
474
482
  _built = false;
475
483
  _identifications;
476
484
  _alwaysAddressable;
@@ -552,7 +560,7 @@ var TableMetadata = class {
552
560
  if (!this.nestedObjects) this._classifyFields();
553
561
  const overrides = adapter.getMetadataOverrides?.(this);
554
562
  if (overrides) this._applyOverrides(overrides);
555
- this._buildFieldDescriptors(adapter);
563
+ this._buildFieldDescriptors(adapter, type);
556
564
  this._buildGuardIndexes();
557
565
  if (!this.nestedObjects) this._buildLeafIndexes();
558
566
  this._buildIdentifications();
@@ -615,12 +623,15 @@ var TableMetadata = class {
615
623
  const isArr = fieldType.type.kind === "array";
616
624
  const elementType = isArr ? fieldType.type.of : fieldType;
617
625
  const resolveTarget = () => elementType?.ref?.type() ?? elementType;
626
+ const relFilter = metadata.get("db.rel.filter");
618
627
  this.relations.set(fieldName, {
619
628
  direction,
620
629
  alias,
621
630
  targetType: resolveTarget,
622
631
  isArray: isArr,
623
- ...direction === "via" ? { viaType: raw } : {}
632
+ ...direction === "via" ? { viaType: raw } : {},
633
+ ...metadata.has("db.rel.filterable") ? { filterable: true } : {},
634
+ ...relFilter ? { filter: relFilter } : {}
624
635
  });
625
636
  }
626
637
  if (metadata.has("db.rel.FK")) {
@@ -878,7 +889,7 @@ var TableMetadata = class {
878
889
  * Called once during build() — everything it needs
879
890
  * (flatMap, indexes, columnMap, etc.) is already populated.
880
891
  */
881
- _buildFieldDescriptors(adapter) {
892
+ _buildFieldDescriptors(adapter, rootType) {
882
893
  const descriptors = [];
883
894
  const skipFlattening = this.nestedObjects;
884
895
  const indexedFields = new Set([...this.primaryKeys, ...this.uniqueProps]);
@@ -929,10 +940,21 @@ var TableMetadata = class {
929
940
  unitRefField,
930
941
  encrypted: isEncrypted || underEncrypted || void 0,
931
942
  isGeoPoint: isGeoPointType(type) || void 0,
932
- derived: this.derivedFields.get(path)
943
+ derived: this.derivedFields.get(path),
944
+ computed: computedMeta(rootType, path)
933
945
  });
934
946
  }
935
- this._resolveFkTargetFields(descriptors);
947
+ const flatCache = /* @__PURE__ */ new Map();
948
+ const flatOf = (type) => {
949
+ let flat = flatCache.get(type);
950
+ if (!flat) {
951
+ flat = (0, _atscript_typescript_utils.flattenAnnotatedType)(type);
952
+ flatCache.set(type, flat);
953
+ }
954
+ return flat;
955
+ };
956
+ this._resolveFkTargetFields(descriptors, flatOf);
957
+ this._resolveFkPhysicalFields(flatOf);
936
958
  Object.freeze(descriptors);
937
959
  this.fieldDescriptors = descriptors;
938
960
  this.columnDescriptors = Object.freeze(descriptors.filter((fd) => !fd.ignored && !(skipFlattening && fd.derived)));
@@ -956,9 +978,37 @@ var TableMetadata = class {
956
978
  }
957
979
  }
958
980
  /**
981
+ * Fills `physicalFields` / `physicalTargetFields` on every FK: the local
982
+ * side from this table's path maps, the target side from the referenced
983
+ * type's own `@db.column` renames (same storage rules as this table — a
984
+ * dotted target path is a flattened column on relational storage, a
985
+ * renamed top-level key on document storage).
986
+ */
987
+ _resolveFkPhysicalFields(flatOf) {
988
+ const targetPhysical = (fk, field) => {
989
+ const targetType = fk.targetTypeRef?.();
990
+ if (!targetType) return field;
991
+ const flat = flatOf(targetType);
992
+ const columnOf = (path) => flat.get(path)?.metadata?.get("db.column");
993
+ if (this.nestedObjects) {
994
+ const dot = field.indexOf(".");
995
+ const top = dot === -1 ? field : field.slice(0, dot);
996
+ const renamed = columnOf(top);
997
+ return renamed === void 0 ? field : renamed + field.slice(top.length);
998
+ }
999
+ return relationalColumnName(field, columnOf(field), field.includes("."));
1000
+ };
1001
+ for (const fk of this.foreignKeys.values()) {
1002
+ fk.physicalFields = fk.fields.map((f) => this.physicalPath(f));
1003
+ fk.physicalTargetFields = fk.targetFields.map((f) => targetPhysical(fk, f));
1004
+ const targetSchema = fk.targetTypeRef?.()?.metadata?.get("db.schema");
1005
+ if (targetSchema) fk.targetSchema = targetSchema;
1006
+ }
1007
+ }
1008
+ /**
959
1009
  * Resolves `fkTargetField` for FK fields in field descriptors.
960
1010
  */
961
- _resolveFkTargetFields(descriptors) {
1011
+ _resolveFkTargetFields(descriptors, flatOf) {
962
1012
  if (this.foreignKeys.size === 0) return;
963
1013
  const fkFieldToTarget = /* @__PURE__ */ new Map();
964
1014
  for (const fk of this.foreignKeys.values()) {
@@ -969,18 +1019,12 @@ var TableMetadata = class {
969
1019
  });
970
1020
  }
971
1021
  if (fkFieldToTarget.size === 0) return;
972
- const flatCache = /* @__PURE__ */ new Map();
973
1022
  for (const descriptor of descriptors) {
974
1023
  const target = fkFieldToTarget.get(descriptor.path);
975
1024
  if (!target) continue;
976
1025
  const targetType = target.targetTypeRef();
977
1026
  if (!targetType) continue;
978
- let targetFlatMap = flatCache.get(targetType);
979
- if (!targetFlatMap) {
980
- targetFlatMap = (0, _atscript_typescript_utils.flattenAnnotatedType)(targetType);
981
- flatCache.set(targetType, targetFlatMap);
982
- }
983
- const targetFieldType = targetFlatMap.get(target.targetField);
1027
+ const targetFieldType = flatOf(targetType).get(target.targetField);
984
1028
  if (!targetFieldType) continue;
985
1029
  const targetMetadata = targetFieldType.metadata;
986
1030
  if (targetMetadata?.has("db.encrypted")) throw new Error(`FK field "${descriptor.path}" references encrypted field "${target.targetField}" — joins are impossible over ciphertext`);
@@ -1072,6 +1116,15 @@ var TableMetadata = class {
1072
1116
  this.preferredId = selected ? [...selected.fields] : [...this.primaryKeys];
1073
1117
  }
1074
1118
  };
1119
+ /** `TDbFieldMeta.computed` of a top-level `@db.compute` view field (since 0.1.147). */
1120
+ function computedMeta(rootType, path) {
1121
+ if (path.includes(".")) return void 0;
1122
+ const computed = require_nested_writer.computedOperands(rootType, path);
1123
+ return computed ? {
1124
+ operands: Object.freeze(computed.operands),
1125
+ via: Object.freeze(computed.via)
1126
+ } : void 0;
1127
+ }
1075
1128
  //#endregion
1076
1129
  //#region src/query/uniqu-select.ts
1077
1130
  /**
@@ -1372,19 +1425,43 @@ var FieldMappingStrategy = class {
1372
1425
  return translated;
1373
1426
  }
1374
1427
  /**
1375
- * Recursively walks a filter expression, applying `@db.column` key renames
1376
- * (document paths — {@link TableMetadata.documentPath}) and adapter-specific
1377
- * value formatting via `formatFilterValue`.
1428
+ * Translates a logical filter for the adapter: relational predicates are
1429
+ * resolved first (`resolveRelationFilterTree`), then every key and value
1430
+ * goes through {@link translateResolvedFilter}. `depth` is the predicate
1431
+ * level of `filter` itself (0 for a query's own filter; the related tables
1432
+ * translate predicate operands at deeper levels).
1433
+ */
1434
+ translateFilter(filter, meta, depth = 0) {
1435
+ const has = require_nested_writer.containsRelationFilter(filter);
1436
+ const resolved = has ? require_nested_writer.resolveRelationFilterTree(filter, meta, depth) : filter;
1437
+ return this.noteTranslated(filter, this.translateResolvedFilter(resolved, meta), has);
1438
+ }
1439
+ /**
1440
+ * `out` — the translation of the caller's `filter` — with its pre-scan
1441
+ * result (`has`) cached for the adapter's repeated `containsRelationFilter`
1442
+ * checks; only when the core built it (never the caller's own object).
1443
+ */
1444
+ noteTranslated(filter, out, has) {
1445
+ if (out !== filter) require_nested_writer.noteRelationFilter(out, has);
1446
+ return out;
1447
+ }
1448
+ /**
1449
+ * Recursively walks a filter expression (predicates already resolved),
1450
+ * applying `@db.column` key renames (document paths —
1451
+ * {@link TableMetadata.documentPath}) and adapter-specific value formatting
1452
+ * via `formatFilterValue`. A resolved predicate passes through under its
1453
+ * navigation-field key.
1378
1454
  *
1379
1455
  * The relational mapper overrides this to use `leafByLogical` for deeper
1380
1456
  * key resolution (flattened nested paths).
1381
1457
  */
1382
- translateFilter(filter, meta) {
1458
+ translateResolvedFilter(filter, meta) {
1383
1459
  if (!filter || typeof filter !== "object") return filter;
1384
1460
  if (!meta.toStorageFormatters && meta.columnMap.size === 0 && meta.derivedFields.size === 0) return filter;
1385
1461
  const result = {};
1386
- for (const [key, value] of Object.entries(filter)) if (key === "$and" || key === "$or") result[key] = value.map((f) => this.translateFilter(f, meta));
1387
- else if (key === "$not") result[key] = this.translateFilter(value, meta);
1462
+ for (const [key, value] of Object.entries(filter)) if (key === "$and" || key === "$or") result[key] = value.map((f) => this.translateResolvedFilter(f, meta));
1463
+ else if (key === "$not") result[key] = this.translateResolvedFilter(value, meta);
1464
+ else if (meta.navFields.has(key)) result[key] = value;
1388
1465
  else if (key.startsWith("$")) result[key] = value;
1389
1466
  else {
1390
1467
  const physical = this.physicalPath(key, meta);
@@ -1686,10 +1763,13 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
1686
1763
  return result;
1687
1764
  }
1688
1765
  translateQuery(query, meta) {
1766
+ const logical = query.filter;
1767
+ const has = require_nested_writer.containsRelationFilter(logical);
1768
+ const filter = has ? require_nested_writer.resolveRelationFilterTree(logical, meta, 0) : logical;
1689
1769
  if (!meta.requiresMappings) {
1690
1770
  const controls = query.controls;
1691
1771
  return {
1692
- filter: meta.toStorageFormatters ? this.translateFilter(query.filter, meta) : query.filter,
1772
+ filter: meta.toStorageFormatters ? this.noteTranslated(logical, this.translateResolvedFilter(filter, meta), has) : filter,
1693
1773
  controls: {
1694
1774
  ...controls,
1695
1775
  $with: void 0,
@@ -1699,7 +1779,7 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
1699
1779
  };
1700
1780
  }
1701
1781
  return {
1702
- filter: this.translateFilterWithRename(query.filter, meta),
1782
+ filter: this.noteTranslated(logical, this.translateFilterWithRename(filter, meta), has),
1703
1783
  controls: query.controls ? this.translateControls(query.controls, meta) : {},
1704
1784
  insights: query.insights
1705
1785
  };
@@ -1709,10 +1789,10 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
1709
1789
  return meta.leafByLogical.get(logical)?.physicalName ?? logical;
1710
1790
  }
1711
1791
  /**
1712
- * Overrides the base `translateFilter` to use `leafByLogical` for key resolution
1792
+ * Overrides the base `translateResolvedFilter` to use `leafByLogical` for key resolution
1713
1793
  * (handles flattened nested paths like `contact.email` → `contact__email`).
1714
1794
  */
1715
- translateFilter(filter, meta) {
1795
+ translateResolvedFilter(filter, meta) {
1716
1796
  if (!filter || typeof filter !== "object") return filter;
1717
1797
  if (!meta.requiresMappings && !meta.toStorageFormatters) return filter;
1718
1798
  return this.translateFilterWithRename(filter, meta);
@@ -1720,13 +1800,16 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
1720
1800
  /**
1721
1801
  * Translates filter with key renaming from logical to physical names.
1722
1802
  * Used by the relational query path where field paths must be mapped
1723
- * to `__`-separated column names.
1803
+ * to `__`-separated column names. Relational predicates must already be
1804
+ * resolved (`translateFilter` / `translateQuery` do it); a resolved
1805
+ * predicate passes through under its navigation-field key.
1724
1806
  */
1725
1807
  translateFilterWithRename(filter, meta) {
1726
1808
  if (!filter || typeof filter !== "object") return filter;
1727
1809
  const result = {};
1728
1810
  for (const [key, value] of Object.entries(filter)) if (key === "$and" || key === "$or") result[key] = value.map((f) => this.translateFilterWithRename(f, meta));
1729
1811
  else if (key === "$not") result[key] = this.translateFilterWithRename(value, meta);
1812
+ else if (meta.navFields.has(key)) result[key] = value;
1730
1813
  else if (key.startsWith("$")) result[key] = value;
1731
1814
  else {
1732
1815
  const physical = meta.leafByLogical.get(key)?.physicalName ?? key;
@@ -1856,6 +1939,234 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
1856
1939
  }
1857
1940
  };
1858
1941
  //#endregion
1942
+ //#region src/query/filter-values.ts
1943
+ const OPAQUE = {
1944
+ kinds: new Set(["any"]),
1945
+ timestamp: false
1946
+ };
1947
+ const NUMBER = {
1948
+ kinds: new Set(["number"]),
1949
+ timestamp: false
1950
+ };
1951
+ const INTEGER = {
1952
+ kinds: new Set(["integer"]),
1953
+ timestamp: false
1954
+ };
1955
+ const STRING = {
1956
+ kinds: new Set(["string"]),
1957
+ timestamp: false
1958
+ };
1959
+ function collectKinds(type, kinds, out, depth = 0) {
1960
+ const def = type?.type;
1961
+ const metadata = type?.metadata;
1962
+ if (!def || depth > 8) {
1963
+ kinds.add("any");
1964
+ return;
1965
+ }
1966
+ switch (def.kind) {
1967
+ case "": switch (def.designType) {
1968
+ case "string":
1969
+ kinds.add("string");
1970
+ return;
1971
+ case "number": {
1972
+ const timestamp = def.tags?.has("timestamp") === true;
1973
+ if (timestamp) out.timestamp = true;
1974
+ const integer = timestamp || def.tags?.has("int") === true || metadata?.has?.("expect.int") === true;
1975
+ kinds.add(integer ? "integer" : "number");
1976
+ return;
1977
+ }
1978
+ case "decimal":
1979
+ kinds.add("decimal");
1980
+ return;
1981
+ case "boolean":
1982
+ kinds.add("boolean");
1983
+ return;
1984
+ case "null":
1985
+ case "undefined":
1986
+ case "never": return;
1987
+ default:
1988
+ kinds.add("any");
1989
+ return;
1990
+ }
1991
+ case "union":
1992
+ for (const item of def.items ?? []) collectKinds(item, kinds, out, depth + 1);
1993
+ return;
1994
+ case "array":
1995
+ collectKinds(def.of, kinds, out, depth + 1);
1996
+ return;
1997
+ default: kinds.add("any");
1998
+ }
1999
+ }
2000
+ const typeCache = /* @__PURE__ */ new WeakMap();
2001
+ /**
2002
+ * The value kinds a filter on `fd` accepts (cached per descriptor). A leaf
2003
+ * inside a JSON value (a `@db.json` object or an array, addressable on
2004
+ * nested-object adapters) is opaque: its contents are not schema-enforced.
2005
+ */
2006
+ function valueTypeOf(meta, fd) {
2007
+ let vt = typeCache.get(fd);
2008
+ if (vt) return vt;
2009
+ if (jsonValueAncestor(fd.path, meta.jsonValueParents) !== void 0 || fd.encrypted || fd.isGeoPoint || fd.designType === "json" || fd.designType === "object" || !fd.type) vt = OPAQUE;
2010
+ else {
2011
+ const kinds = /* @__PURE__ */ new Set();
2012
+ const out = { timestamp: false };
2013
+ collectKinds(fd.type, kinds, out);
2014
+ const metadata = fd.type.metadata;
2015
+ if ((fd.defaultValue?.kind === "fn" && fd.defaultValue.fn !== "uuid" || metadata?.has?.("db.agg.count") === true || metadata?.has?.("db.agg.countDistinct") === true) && kinds.delete("number")) kinds.add("integer");
2016
+ vt = kinds.size === 0 ? OPAQUE : {
2017
+ kinds,
2018
+ timestamp: out.timestamp
2019
+ };
2020
+ }
2021
+ typeCache.set(fd, vt);
2022
+ return vt;
2023
+ }
2024
+ /** A decimal literal (`5`, `-1.5`, `.5`, `1e3`), surrounding blanks allowed — no hex, no `Infinity`. */
2025
+ const NUMERIC_RE = /^\s*[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?\s*$/i;
2026
+ /** An integer literal (`5`, `-12`), surrounding blanks allowed. */
2027
+ const INTEGER_RE = /^\s*[+-]?\d+\s*$/;
2028
+ function acceptsScalar(kind, value) {
2029
+ switch (kind) {
2030
+ case "any": return true;
2031
+ case "string": return typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint";
2032
+ case "number":
2033
+ case "decimal": return typeof value === "number" || typeof value === "bigint" || typeof value === "string" && NUMERIC_RE.test(value);
2034
+ case "integer": return typeof value === "number" && Number.isInteger(value) || typeof value === "bigint" || typeof value === "string" && INTEGER_RE.test(value);
2035
+ default: return typeof value === "boolean" || value === 0 || value === 1;
2036
+ }
2037
+ }
2038
+ /** The first element of `value` (itself when not an array) `vt` rejects, or `undefined`. */
2039
+ function rejectedValue(vt, value) {
2040
+ if (vt.kinds.has("any") || value === null || value === void 0) return void 0;
2041
+ if (Array.isArray(value)) {
2042
+ for (const item of value) {
2043
+ const bad = rejectedValue(vt, item);
2044
+ if (bad) return bad;
2045
+ }
2046
+ return;
2047
+ }
2048
+ if (typeof value === "object" && !require_object.isPlainObject(value)) return void 0;
2049
+ for (const kind of vt.kinds) if (acceptsScalar(kind, value)) return void 0;
2050
+ return { value };
2051
+ }
2052
+ const KIND_LABEL = {
2053
+ string: "a string",
2054
+ number: "a number",
2055
+ integer: "an integer",
2056
+ decimal: "a decimal (number or numeric string)",
2057
+ boolean: "a boolean"
2058
+ };
2059
+ function expectedOf(vt) {
2060
+ const labels = [];
2061
+ for (const kind of vt.kinds) {
2062
+ if (kind === "any") continue;
2063
+ labels.push(kind === "integer" && vt.timestamp ? "an integer (epoch milliseconds)" : KIND_LABEL[kind]);
2064
+ }
2065
+ return labels.join(" or ");
2066
+ }
2067
+ function describeValue(value) {
2068
+ if (typeof value === "string") return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value);
2069
+ if (require_object.isPlainObject(value)) return "an object";
2070
+ return String(value);
2071
+ }
2072
+ function valueError(path, op, message) {
2073
+ return new require_db_error.DbError("INVALID_QUERY", [{
2074
+ path,
2075
+ message: `Invalid filter value for "${path}"${op ? ` (${op})` : ""}: ${message}`
2076
+ }]);
2077
+ }
2078
+ function holdsStrings(vt) {
2079
+ return vt.kinds.has("string") || vt.kinds.has("any");
2080
+ }
2081
+ function checkRegex(path, vt, op, pattern) {
2082
+ if (!holdsStrings(vt)) throw valueError(path, op, `a pattern match needs a string field, "${path}" holds ${expectedOf(vt)}`);
2083
+ if (typeof pattern !== "string" && !(pattern instanceof RegExp)) throw valueError(path, op, `expected a regular expression, got ${describeValue(pattern)}`);
2084
+ }
2085
+ function checkValue(path, vt, op, value) {
2086
+ if (value instanceof RegExp) {
2087
+ checkRegex(path, vt, op ?? "RegExp", value);
2088
+ return;
2089
+ }
2090
+ const bad = rejectedValue(vt, value);
2091
+ if (bad) throw valueError(path, op, `expected ${expectedOf(vt)}, got ${describeValue(bad.value)}`);
2092
+ }
2093
+ /** Operators whose operand is compared with the field's values. */
2094
+ const COMPARE_OPS = new Set([
2095
+ "$eq",
2096
+ "$ne",
2097
+ "$gt",
2098
+ "$gte",
2099
+ "$lt",
2100
+ "$lte",
2101
+ "$in",
2102
+ "$nin"
2103
+ ]);
2104
+ /** Checks one filter entry's value (a bare value or an operator map) against `vt`. */
2105
+ function checkEntry(path, vt, value) {
2106
+ if (vt.kinds.has("any")) return;
2107
+ if (!require_object.isPlainObject(value)) {
2108
+ checkValue(path, vt, void 0, value);
2109
+ return;
2110
+ }
2111
+ for (const [op, operand] of Object.entries(value)) if (op === "$regex") checkRegex(path, vt, op, operand);
2112
+ else if (COMPARE_OPS.has(op)) checkValue(path, vt, op, operand);
2113
+ }
2114
+ /**
2115
+ * Walks `filter` (through `$and` / `$or` / `$not`; relational predicates are
2116
+ * the related table's) and checks every entry whose key `typeOf` knows.
2117
+ */
2118
+ function walkFilterValues(filter, typeOf) {
2119
+ if (!require_object.isPlainObject(filter)) return;
2120
+ for (const [key, value] of Object.entries(filter)) {
2121
+ if (key === "$and" || key === "$or") {
2122
+ if (Array.isArray(value)) for (const child of value) walkFilterValues(child, typeOf);
2123
+ continue;
2124
+ }
2125
+ if (key === "$not") {
2126
+ walkFilterValues(value, typeOf);
2127
+ continue;
2128
+ }
2129
+ if (key.startsWith("$") || require_nested_writer.hasRelationOp(value)) continue;
2130
+ const vt = typeOf(key);
2131
+ if (vt) checkEntry(key, vt, value);
2132
+ }
2133
+ }
2134
+ /**
2135
+ * Rejects (`INVALID_QUERY`, `path` = the field) a filter value that cannot
2136
+ * denote its field's declared type — see the module notes for the accepted
2137
+ * forms. Unknown paths are skipped (the path guard owns them).
2138
+ */
2139
+ function guardFilterValues(meta, filter) {
2140
+ if (!filter) return;
2141
+ walkFilterValues(filter, (key) => {
2142
+ const fd = meta.descriptorByPath.get(key);
2143
+ return fd ? valueTypeOf(meta, fd) : void 0;
2144
+ });
2145
+ }
2146
+ /**
2147
+ * `$having` values: an aggregate alias is a number (`count`,
2148
+ * `countDistinct`, `sum`, `avg`) or its source field's type (`min` / `max`),
2149
+ * a calendar-bucket alias a string label, any other key a `$groupBy`
2150
+ * field's own type.
2151
+ */
2152
+ function guardHavingValues(meta, controls) {
2153
+ if (!controls?.$having) return;
2154
+ const aliases = /* @__PURE__ */ new Map();
2155
+ if (Array.isArray(controls.$select)) {
2156
+ for (const item of controls.$select) if ((0, _uniqu_core.isAggregateExpr)(item)) {
2157
+ const fd = item.$fn === "min" || item.$fn === "max" ? meta.descriptorByPath.get(item.$field) : void 0;
2158
+ const counts = item.$fn === "count" || item.$fn === "countDistinct";
2159
+ aliases.set((0, _uniqu_core.resolveAlias)(item), fd ? valueTypeOf(meta, fd) : counts ? INTEGER : NUMBER);
2160
+ } else if ((0, _uniqu_core.isBucketExpr)(item)) aliases.set((0, _uniqu_core.resolveAlias)(item), STRING);
2161
+ }
2162
+ walkFilterValues(controls.$having, (key) => {
2163
+ const alias = aliases.get(key);
2164
+ if (alias) return alias;
2165
+ const fd = meta.descriptorByPath.get(key);
2166
+ return fd ? valueTypeOf(meta, fd) : void 0;
2167
+ });
2168
+ }
2169
+ //#endregion
1859
2170
  //#region src/query/query-guards.ts
1860
2171
  /**
1861
2172
  * Engine-agnostic query-time guards, applied in the core layer BEFORE filter
@@ -1882,6 +2193,9 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
1882
2193
  * {@link canFilterLeaf}) needs → `INVALID_QUERY` (see {@link guardPaths}).
1883
2194
  * Runs after the checks above so `ENC_*` codes keep firing first for
1884
2195
  * encrypted subtrees.
2196
+ * - every filter / `$having` comparison value must be able to denote its
2197
+ * field's declared type (`?n='x'` on a number) → `INVALID_QUERY`, after
2198
+ * the path guard (see `guardFilterValues`, since 0.1.147).
1885
2199
  */
1886
2200
  /** Validates a `[lng, lat]` tuple (GeoJSON coordinate order). */
1887
2201
  function assertGeoPoint(point, path) {
@@ -1938,6 +2252,10 @@ function guardFilter(meta, adapter, filter, encCode = "ENC_FIELD_FILTER") {
1938
2252
  continue;
1939
2253
  }
1940
2254
  if (key.startsWith("$")) continue;
2255
+ if (require_nested_writer.hasRelationOp(value)) {
2256
+ guardRelationOperands(key, value);
2257
+ continue;
2258
+ }
1941
2259
  if (hasEncrypted && isEncryptedRef(meta, key)) throw encryptedRefError(encCode, key, "filter on");
1942
2260
  if (!(0, _uniqu_core.isPrimitive)(value)) {
1943
2261
  for (const [op, opValue] of Object.entries(value)) if (op === "$geoWithin") guardGeoWithin(meta, adapter, key, opValue);
@@ -1948,6 +2266,23 @@ function guardFilter(meta, adapter, filter, encCode = "ENC_FIELD_FILTER") {
1948
2266
  }
1949
2267
  }
1950
2268
  }
2269
+ /**
2270
+ * Shape rules of a relational predicate's operator map: only `$some` /
2271
+ * `$none` (never mixed with comparison operators), each operand a filter
2272
+ * object.
2273
+ */
2274
+ function guardRelationOperands(key, ops) {
2275
+ for (const [op, operand] of Object.entries(ops)) {
2276
+ if (!(0, _uniqu_core.isRelationOp)(op)) throw new require_db_error.DbError("INVALID_QUERY", [{
2277
+ path: key,
2278
+ message: `Cannot mix "$some" / "$none" with "${op}" on "${key}"`
2279
+ }]);
2280
+ if (!require_object.isPlainObject(operand)) throw new require_db_error.DbError("INVALID_QUERY", [{
2281
+ path: key,
2282
+ message: `"${op}" on "${key}" expects a filter object`
2283
+ }]);
2284
+ }
2285
+ }
1951
2286
  /** Rejects `$sort` keys referencing encrypted fields. */
1952
2287
  function guardSort(meta, sort) {
1953
2288
  if (!sort || typeof sort !== "object" || meta.encryptedFields.size === 0) return;
@@ -2021,8 +2356,18 @@ function filterPredicateOf(value) {
2021
2356
  const ops = value;
2022
2357
  if ("$geoWithin" in ops) return "geo";
2023
2358
  const keys = Object.keys(ops);
2359
+ if (keys.length > 0 && keys.every(_uniqu_core.isRelationOp)) return "relation";
2024
2360
  return keys.length === 1 && keys[0] === "$exists" ? "exists" : "compare";
2025
2361
  }
2362
+ /** The `{ op, filter }` list of a `relation` entry's operator map. */
2363
+ function relationOpsOf(value) {
2364
+ const out = [];
2365
+ for (const [op, filter] of Object.entries(value)) if ((0, _uniqu_core.isRelationOp)(op)) out.push({
2366
+ op,
2367
+ filter
2368
+ });
2369
+ return out;
2370
+ }
2026
2371
  /**
2027
2372
  * Whether a stored leaf physically supports a filter predicate of this class
2028
2373
  * — the one rule the core path guard and moost-db's HTTP capability index
@@ -2039,6 +2384,7 @@ function filterPredicateOf(value) {
2039
2384
  function canFilterLeaf(fd, predicate, adapter) {
2040
2385
  if (fd.encrypted) return false;
2041
2386
  switch (predicate) {
2387
+ case "relation": return false;
2042
2388
  case "exists": return true;
2043
2389
  case "geo": return fd.isGeoPoint === true && adapter.isGeoSearchable();
2044
2390
  default: return adapter.canFilterField(fd);
@@ -2150,10 +2496,17 @@ function collectQueryPaths(query, aggregate) {
2150
2496
  bucket: [],
2151
2497
  aggregateMode: false
2152
2498
  };
2153
- collectFilterKeys(query.filter, (path, value) => refs.filter.push({
2154
- path,
2155
- predicate: filterPredicateOf(value)
2156
- }), void 0, refs);
2499
+ collectFilterKeys(query.filter, (path, value) => {
2500
+ const predicate = filterPredicateOf(value);
2501
+ refs.filter.push(predicate === "relation" ? {
2502
+ path,
2503
+ predicate,
2504
+ relation: relationOpsOf(value)
2505
+ } : {
2506
+ path,
2507
+ predicate
2508
+ });
2509
+ }, void 0, refs);
2157
2510
  const controls = query.controls ?? {};
2158
2511
  const rawGroupBy = controls.$groupBy;
2159
2512
  const groupBy = Array.isArray(rawGroupBy) ? rawGroupBy.filter((f) => typeof f === "string") : typeof rawGroupBy === "string" ? [rawGroupBy] : [];
@@ -2245,8 +2598,11 @@ function pathSourceOf(meta) {
2245
2598
  function guardPath(meta, adapter, path, op, predicate = "compare") {
2246
2599
  const verb = OP_VERB[op];
2247
2600
  const { kind, parent } = classifyQueryPath(pathSourceOf(meta), path);
2601
+ if (predicate === "relation" && kind !== "nav" && kind !== "unknown") throw pathError(path, `"$some" / "$none" are only valid on a navigation relation — "${path}" is not one`);
2248
2602
  switch (kind) {
2249
- case "nav": throw pathError(path, `Cannot ${verb} "${path}" — navigation path`);
2603
+ case "nav":
2604
+ if (op === "filter" && predicate === "relation" && parent === void 0) return;
2605
+ throw pathError(path, navPathMessage(path, verb, op, parent));
2250
2606
  case "leaf": {
2251
2607
  if (op === "select") return;
2252
2608
  const fd = meta.descriptorByPath.get(path);
@@ -2271,6 +2627,35 @@ function guardPath(meta, adapter, path, op, predicate = "compare") {
2271
2627
  }
2272
2628
  }
2273
2629
  /**
2630
+ * The rejection of a navigation path in a non-predicate position. A filter
2631
+ * names the predicate that expresses it: `ticket.status` →
2632
+ * `{ ticket: { $some: { status: … } } }`.
2633
+ */
2634
+ function navPathMessage(path, verb, op, parent) {
2635
+ const base = `Cannot ${verb} "${path}" — navigation path`;
2636
+ if (op !== "filter") return base;
2637
+ return `${base}; use { ${parent ?? path}: { $some: ${parent === void 0 ? "…" : `{ ${path.slice(parent.length + 1)}: … }`} } }`;
2638
+ }
2639
+ /**
2640
+ * A `relation` filter entry: the adapter must render predicates in this
2641
+ * mode, the table must be wired to its related tables (a `DbSpace`), and
2642
+ * each operand is guarded by the related table (depth / count caps, the
2643
+ * related table's own path rules) — see `TRelationFilterHost.guard`.
2644
+ */
2645
+ function guardRelationRef(meta, adapter, ref, state) {
2646
+ const path = state.path ? `${state.path}.${ref.path}` : ref.path;
2647
+ const mode = state.write ? "write" : "read";
2648
+ if (!adapter.supportsRelationFilters(mode)) throw new require_db_error.DbError("REL_FILTER_NOT_SUPPORTED", [{
2649
+ path,
2650
+ message: `Relational predicates ($some / $none) are not supported by this adapter${state.write ? " in mutation filters" : ""}`
2651
+ }]);
2652
+ if (!meta.relationFilters) throw new require_db_error.DbError("REL_FILTER_NOT_SUPPORTED", [{
2653
+ path,
2654
+ message: `Relational predicate on "${path}" needs the table to come from a DbSpace (no table resolver)`
2655
+ }]);
2656
+ for (const { op, filter } of ref.relation ?? []) meta.relationFilters.guard(ref.path, op, filter, state);
2657
+ }
2658
+ /**
2274
2659
  * Core backstop for every read / aggregate / mutation-filter entry point:
2275
2660
  * each referenced path (see {@link collectQueryPaths}) must exist on THIS
2276
2661
  * adapter with the physical capability the position needs (see
@@ -2285,20 +2670,29 @@ function guardPath(meta, adapter, path, op, predicate = "compare") {
2285
2670
  * `bucket`), `$groupBy` fields are checked, and computed aliases (`$as` or
2286
2671
  * the default) are exempt in `$sort` / `$having`.
2287
2672
  *
2673
+ * Then every filter comparison value must be able to denote its field's
2674
+ * declared type (`guardFilterValues`, since 0.1.147) — after the paths, so
2675
+ * an unknown or unfilterable field keeps its own rejection.
2676
+ *
2288
2677
  * Returns the collected refs so callers can run further structural rules
2289
2678
  * (see {@link checkHavingKeys}) without walking the query again.
2290
2679
  */
2291
- function guardPaths(meta, adapter, query, aggregate = false) {
2680
+ function guardPaths(meta, adapter, query, aggregate = false, state) {
2292
2681
  if (!query) return;
2293
2682
  const refs = collectQueryPaths(query, aggregate);
2294
2683
  if (refs.unsupportedOperator !== void 0) throw pathError(refs.unsupportedOperator, unsupportedOperatorMessage(refs.unsupportedOperator));
2295
- for (const ref of refs.filter) guardPath(meta, adapter, ref.path, "filter", ref.predicate);
2684
+ let relState = state;
2685
+ for (const ref of refs.filter) {
2686
+ guardPath(meta, adapter, ref.path, "filter", ref.predicate);
2687
+ if (ref.predicate === "relation") guardRelationRef(meta, adapter, ref, relState ??= require_nested_writer.relGuardState());
2688
+ }
2296
2689
  for (const path of refs.sort) guardPath(meta, adapter, path, "sort");
2297
2690
  for (const path of refs.select) guardPath(meta, adapter, path, "select");
2298
2691
  for (const path of refs.aggregate) guardPath(meta, adapter, path, "aggregate");
2299
2692
  for (const path of refs.bucket) guardPath(meta, adapter, path, "bucket");
2300
2693
  for (const path of refs.groupBy) guardPath(meta, adapter, path, "groupBy");
2301
2694
  for (const path of refs.having) guardPath(meta, adapter, path, "having");
2695
+ guardFilterValues(meta, query.filter);
2302
2696
  return refs;
2303
2697
  }
2304
2698
  /**
@@ -2306,12 +2700,12 @@ function guardPaths(meta, adapter, query, aggregate = false) {
2306
2700
  * normalizer (a calendar bucket is invalid outside a grouped query), then
2307
2701
  * the path guard.
2308
2702
  */
2309
- function guardQuery(meta, adapter, query) {
2703
+ function guardQuery(meta, adapter, query, state) {
2310
2704
  if (!query) return;
2311
2705
  guardFilter(meta, adapter, query.filter);
2312
2706
  guardSort(meta, query.controls?.$sort);
2313
2707
  normalizeComputedSelect(query.controls, meta, false);
2314
- guardPaths(meta, adapter, query);
2708
+ guardPaths(meta, adapter, query, false, state);
2315
2709
  }
2316
2710
  /**
2317
2711
  * `$having` is a post-aggregation filter, so a key is either a computed
@@ -2337,7 +2731,7 @@ function checkHavingKeys(refs) {
2337
2731
  * (`AGG_FN_NOT_SUPPORTED`) and calendar-bucket units
2338
2732
  * (`BUCKET_NOT_SUPPORTED`), then the `$having` key rule
2339
2733
  * ({@link checkHavingKeys} — after the path guard so an unknown key still
2340
- * reads `Unknown field`).
2734
+ * reads `Unknown field`), then the `$having` values (`guardHavingValues`).
2341
2735
  *
2342
2736
  * `buckets` are the query's resolved calendar buckets when the caller already
2343
2737
  * ran `normalizeComputedSelect` (resolved here otherwise).
@@ -2360,6 +2754,7 @@ function guardAggregate(meta, adapter, query, resolved) {
2360
2754
  guardBucketUnits(adapter, buckets);
2361
2755
  const having = refs ? checkHavingKeys(refs) : void 0;
2362
2756
  if (having) throw new require_db_error.DbError("INVALID_QUERY", [having]);
2757
+ guardHavingValues(meta, controls);
2363
2758
  }
2364
2759
  /**
2365
2760
  * Rejects an aggregate whose (known — the normalizer checked the name)
@@ -2544,7 +2939,13 @@ var AtscriptDbReadable = class {
2544
2939
  }
2545
2940
  /** Ensures metadata is built. Called before any metadata access. */
2546
2941
  _ensureBuilt() {
2547
- if (!this._meta.isBuilt) this._meta.build(this.type, this.adapter, this.logger);
2942
+ if (!this._meta.isBuilt) {
2943
+ this._meta.build(this.type, this.adapter, this.logger);
2944
+ if (this._meta.navFields.size > 0 && this._tableResolver) {
2945
+ const resolver = this._tableResolver;
2946
+ this._meta.relationFilters = require_nested_writer.createRelationFilterHost(this, (type) => resolver(type));
2947
+ }
2948
+ }
2548
2949
  if (this._meta.encryptedFields.size > 0 && !this._encryption) throw new require_db_error.DbError("ENC_CONFIG_MISSING", [{
2549
2950
  path: "",
2550
2951
  message: `Table "${this.tableName}" declares @db.encrypted fields but the DbSpace has no encryption configuration — pass { encryption: { defaultKeyId, keys } } to the DbSpace options`
@@ -2571,6 +2972,56 @@ var AtscriptDbReadable = class {
2571
2972
  _guardQuery(query) {
2572
2973
  guardQuery(this._meta, this.adapter, query);
2573
2974
  }
2975
+ /**
2976
+ * Guards a relational predicate operand against THIS table — the filter
2977
+ * guard and the path guard with the predicate's shared `state` (depth,
2978
+ * count, read/write mode). Called by the source table's relation host.
2979
+ *
2980
+ * @internal Core wiring for relational predicates; not consumer API.
2981
+ */
2982
+ _guardRelationOperand(filter, state) {
2983
+ this._ensureBuilt();
2984
+ guardFilter(this._meta, this.adapter, filter);
2985
+ guardPaths(this._meta, this.adapter, { filter }, false, state);
2986
+ }
2987
+ /**
2988
+ * Translates a relational predicate operand for THIS table's adapter (its
2989
+ * own field mapper; nested predicates resolved at `depth + 1`).
2990
+ *
2991
+ * @internal Core wiring for relational predicates; not consumer API.
2992
+ */
2993
+ _resolveRelationOperand(filter, depth) {
2994
+ this._ensureBuilt();
2995
+ return this._fieldMapper.translateFilter(filter, this._meta, depth);
2996
+ }
2997
+ /**
2998
+ * Translates a logical query (filter + controls) for THIS table's adapter
2999
+ * after the read guards — exactly what `findMany` hands the adapter.
3000
+ * For adapters that load `$with` relations natively and must address the
3001
+ * related table's physical names.
3002
+ *
3003
+ * @internal Adapter-facing surface; not part of the consumer API.
3004
+ * @since 0.1.147
3005
+ */
3006
+ _translateForAdapter(query) {
3007
+ this._ensureBuilt();
3008
+ this._guardQuery(query);
3009
+ return this._fieldMapper.translateQuery(query, this._meta);
3010
+ }
3011
+ /**
3012
+ * Physical rows of THIS table → logical rows (field mapping, value
3013
+ * formatters, decryption) — what every read does before `$with` loading.
3014
+ * `controls` are the logical read controls the rows were read with.
3015
+ *
3016
+ * @internal Adapter-facing surface; not part of the consumer API.
3017
+ * @since 0.1.147
3018
+ */
3019
+ async _rowsFromAdapter(rows, controls) {
3020
+ this._ensureBuilt();
3021
+ const out = this._fromRead(rows, controls);
3022
+ await this._decryptRows(out);
3023
+ return out;
3024
+ }
2574
3025
  _encryptedPathsCache;
2575
3026
  /** Pre-split `encryptedFields` paths — computed once, reused on every read/write. */
2576
3027
  get _encryptedPaths() {
@@ -2825,6 +3276,15 @@ var AtscriptDbReadable = class {
2825
3276
  this._ensureBuilt();
2826
3277
  return this._meta.pathToPhysical;
2827
3278
  }
3279
+ /**
3280
+ * Physical column (or document path) of a logical field path —
3281
+ * `@db.column` renames and flattening applied.
3282
+ * @since 0.1.147
3283
+ */
3284
+ physicalPath(logical) {
3285
+ this._ensureBuilt();
3286
+ return this._meta.physicalPath(logical);
3287
+ }
2828
3288
  /** Precomputed physical column name → logical dot-path map (inverse). */
2829
3289
  get physicalToPath() {
2830
3290
  this._ensureBuilt();
@@ -2879,10 +3339,14 @@ var AtscriptDbReadable = class {
2879
3339
  widened
2880
3340
  };
2881
3341
  }
2882
- /** Reconstructs + decrypts a read's rows, loads its `$with` relations and strips widened keys. */
2883
- async _finishRead(results, read) {
2884
- const rows = this._fromRead(results, read.controls);
2885
- await this._decryptRows(rows);
3342
+ /**
3343
+ * Reconstructs + decrypts a read's rows, keeps the ones `pick` selects (all
3344
+ * by default), loads their `$with` relations and strips widened keys.
3345
+ */
3346
+ async _finishRead(results, read, pick) {
3347
+ const all = this._fromRead(results, read.controls);
3348
+ await this._decryptRows(all);
3349
+ const rows = pick ? pick(all) : all;
2886
3350
  if (read.withRelations?.length) {
2887
3351
  await this.loadRelations(rows, read.withRelations);
2888
3352
  for (const key of read.widened) for (const row of rows) require_object.deletePath(row, key);
@@ -2991,11 +3455,12 @@ var AtscriptDbReadable = class {
2991
3455
  * table. Defense-in-depth for query-path validation: `flattenAnnotatedType`
2992
3456
  * still truncates real self-referential cycles, so paths like
2993
3457
  * `parent.parent.name` on a self-ref schema would miss `flatMap.has` but
2994
- * remain valid field references on the target.
3458
+ * remain valid field references on the target — a path may cross the same
3459
+ * relation any number of times (callers cap the depth).
2995
3460
  *
2996
- * Cycle-safe via a visited set keyed on `<tableName>:<navField>`.
3461
+ * Terminates on cyclic schemas: every hop consumes one path segment.
2997
3462
  */
2998
- isValidFieldPath(path, _visited) {
3463
+ isValidFieldPath(path) {
2999
3464
  if (this.flatMap.has(path)) return true;
3000
3465
  const dotIdx = path.indexOf(".");
3001
3466
  if (dotIdx === -1) return false;
@@ -3003,11 +3468,7 @@ var AtscriptDbReadable = class {
3003
3468
  const tail = path.slice(dotIdx + 1);
3004
3469
  const targetTable = this.relatedTable(head);
3005
3470
  if (!targetTable || typeof targetTable.isValidFieldPath !== "function") return false;
3006
- const visited = _visited ?? /* @__PURE__ */ new Set();
3007
- const cycleKey = `${this.tableName}:${head}`;
3008
- if (visited.has(cycleKey)) return false;
3009
- visited.add(cycleKey);
3010
- return targetTable.isValidFieldPath(tail, visited);
3471
+ return targetTable.isValidFieldPath(tail);
3011
3472
  }
3012
3473
  /**
3013
3474
  * Creates a new validator with custom options.
@@ -3041,6 +3502,22 @@ var AtscriptDbReadable = class {
3041
3502
  return await this._finishRead(await this.adapter.findMany(read.translated), read);
3042
3503
  }
3043
3504
  /**
3505
+ * `findMany` for the generic `$with` loader. With `partitionBy` (logical
3506
+ * fields), `$skip` / `$limit` apply per group of rows sharing those fields'
3507
+ * values (`BaseDbAdapter.findManyPerPartition`); `pick` chooses which of the
3508
+ * read rows to keep before their own `$with` relations load.
3509
+ *
3510
+ * @internal Relation-loader surface; not part of the consumer API.
3511
+ * @since 0.1.147
3512
+ */
3513
+ async _findManyForRelation(query, opts) {
3514
+ this._ensureBuilt();
3515
+ this._guardQuery(query);
3516
+ const read = this._translateRead(query);
3517
+ const results = opts.partitionBy ? await this.adapter.findManyPerPartition(read.translated, opts.partitionBy.map((field) => this._meta.physicalPath(field))) : await this.adapter.findMany(read.translated);
3518
+ return this._finishRead(results, read, opts.pick);
3519
+ }
3520
+ /**
3044
3521
  * Counts records matching the query.
3045
3522
  */
3046
3523
  async count(query) {
@@ -3561,7 +4038,7 @@ var AtscriptDbReadable = class {
3561
4038
  * Public entry point for relation loading. Used by adapters for nested $with delegation.
3562
4039
  */
3563
4040
  async loadRelations(rows, withRelations) {
3564
- const { loadRelationsImpl } = await Promise.resolve().then(() => require("./relation-loader-CBPY6kM7.cjs")).then((n) => n.relation_loader_exports);
4041
+ const { loadRelationsImpl } = await Promise.resolve().then(() => require("./relation-loader-BFhVuJ-B.cjs")).then((n) => n.relation_loader_exports);
3565
4042
  return loadRelationsImpl(rows, withRelations, this);
3566
4043
  }
3567
4044
  /**
@@ -3627,8 +4104,14 @@ function createFailureCollector(what) {
3627
4104
  //#region src/base-adapter.ts
3628
4105
  const EMPTY_DEFAULT_FNS = /* @__PURE__ */ new Set();
3629
4106
  const EMPTY_BUCKET_UNITS = /* @__PURE__ */ new Set();
3630
- /** Every calendar-bucket unit — what an adapter that renders them all returns from `calendarBucketUnits()`. */
4107
+ /**
4108
+ * Every calendar-bucket unit — what an adapter that renders them all returns
4109
+ * from `calendarBucketUnits()`. Includes `'hour'` since 0.1.147.
4110
+ */
3631
4111
  const ALL_BUCKET_UNITS = new Set(_uniqu_core.BUCKET_UNITS);
4112
+ const NO_VIEW_CAPABILITIES = /* @__PURE__ */ new Set();
4113
+ /** Every view capability — what the bundled adapters return from `viewCapabilities()`. @since 0.1.147 */
4114
+ const ALL_VIEW_CAPABILITIES = new Set(["compute", "firstJoin"]);
3632
4115
  const txStorage = new node_async_hooks.AsyncLocalStorage();
3633
4116
  /** The innermost open transaction of `owner` in the current async chain. */
3634
4117
  function findTxContext(owner) {
@@ -3688,6 +4171,26 @@ var BaseDbAdapter = class {
3688
4171
  registerReadable(readable, logger) {
3689
4172
  this._table = readable;
3690
4173
  if (logger) this.logger = logger;
4174
+ if (readable.isView) this._guardViewCapabilities(readable);
4175
+ }
4176
+ /**
4177
+ * Makes `ensureTable()` of a managed view fail closed (since 0.1.147): it
4178
+ * throws before the adapter renders a computed column / first-row join its
4179
+ * {@link viewCapabilities} does not list — schema sync refuses such a view
4180
+ * up front, a direct `ensureTable()` call must not render it as a plain
4181
+ * (row-multiplying) join either. Wraps the subclass's own implementation, so
4182
+ * third-party adapters get the guard without code changes.
4183
+ */
4184
+ _guardViewCapabilities(readable) {
4185
+ if (Object.prototype.hasOwnProperty.call(this, "ensureTable")) return;
4186
+ const view = readable;
4187
+ if (typeof view.viewCapabilityProblems !== "function") return;
4188
+ const ensureTable = this.ensureTable.bind(this);
4189
+ this.ensureTable = (opts) => {
4190
+ const problems = view.viewCapabilityProblems();
4191
+ if (problems.length > 0) return Promise.reject(new Error(problems.join("; ")));
4192
+ return ensureTable(opts);
4193
+ };
3691
4194
  }
3692
4195
  /**
3693
4196
  * Called by {@link DbSpace} right after its factory builds this adapter —
@@ -3872,8 +4375,10 @@ var BaseDbAdapter = class {
3872
4375
  * be adopted adapter by adapter. An adapter that returns a unit must group
3873
4376
  * by the bucket alias in `$groupBy` — see `controls.$select.buckets`
3874
4377
  * (`TResolvedBucket`: physical `field`, source `fd`) — and return the
3875
- * `YYYY-MM-DD` label of the bucket's first local day (null for a null or
3876
- * out-of-range source, uniqu's `bucketLabel` semantics). Since 0.1.132.
4378
+ * `YYYY-MM-DD` label of the bucket's first local day, or for `'hour'` the
4379
+ * local wall-clock hour `YYYY-MM-DDTHH:00` (null for a null or out-of-range
4380
+ * source, uniqu's `bucketLabel` semantics). Since 0.1.132; `'hour'` since
4381
+ * 0.1.147 — an adapter returning {@link ALL_BUCKET_UNITS} must render it.
3877
4382
  */
3878
4383
  calendarBucketUnits() {
3879
4384
  return EMPTY_BUCKET_UNITS;
@@ -3904,6 +4409,18 @@ var BaseDbAdapter = class {
3904
4409
  */
3905
4410
  viewRenderRevision() {}
3906
4411
  /**
4412
+ * The managed-view features this adapter renders: `compute` — computed
4413
+ * columns (`@db.compute`); `firstJoin` — first-row joins (the ordered 4th
4414
+ * argument of `@db.view.joins`). Schema sync refuses a view using a feature
4415
+ * not listed. The default is EMPTY (fail-closed): a third-party adapter
4416
+ * opts in once it renders them.
4417
+ *
4418
+ * @since 0.1.147
4419
+ */
4420
+ viewCapabilities() {
4421
+ return NO_VIEW_CAPABILITIES;
4422
+ }
4423
+ /**
3907
4424
  * Whether this adapter enforces foreign key constraints natively.
3908
4425
  * When `true`, the generic layer skips application-level cascade/setNull
3909
4426
  * on delete — the DB engine handles it (e.g. SQLite `ON DELETE CASCADE`).
@@ -3961,6 +4478,39 @@ var BaseDbAdapter = class {
3961
4478
  return false;
3962
4479
  }
3963
4480
  /**
4481
+ * Whether this adapter renders relational filter predicates
4482
+ * (`{ nav: { $some | $none: … } }`) in `mode` — `read` for find / count /
4483
+ * search / aggregate filters, `write` for mutation filters
4484
+ * (`updateMany`, `deleteMany`, …). Default `false`: the core rejects such
4485
+ * filters with `REL_FILTER_NOT_SUPPORTED` before they reach the adapter.
4486
+ *
4487
+ * An adapter returning `true` receives each predicate already resolved by
4488
+ * the core: the filter visitor's `relation(field, op, operand)` callback
4489
+ * gets a `ResolvedRelationFilter` operand (`kind`, physical
4490
+ * correlation `pairs`, `target` / `junction` tables with their adapters,
4491
+ * and the inner `filter` already translated to the target's physical
4492
+ * names, nested predicates resolved too). Keep `relation` on every
4493
+ * `walkFilter` visitor that may meet such a filter.
4494
+ *
4495
+ * @since 0.1.147
4496
+ */
4497
+ supportsRelationFilters(_mode) {
4498
+ return false;
4499
+ }
4500
+ /**
4501
+ * Whether `other` serves a table of the SAME store as this adapter, so one
4502
+ * statement / pipeline can correlate both (a relational predicate renders
4503
+ * the related table inside this table's query). Default: same adapter class
4504
+ * and same {@link _transactionOwner} (the driver / pool / client the
4505
+ * adapter was built with). Override when the owner is shared across
4506
+ * separate databases (e.g. one Mongo client over several databases).
4507
+ *
4508
+ * @since 0.1.147
4509
+ */
4510
+ sharesStoreWith(other) {
4511
+ return other.constructor === this.constructor && other._transactionOwner() === this._transactionOwner();
4512
+ }
4513
+ /**
3964
4514
  * Loads relations onto result rows using adapter-native operations.
3965
4515
  * Only called when {@link supportsNativeRelations} returns `true`.
3966
4516
  *
@@ -4197,6 +4747,31 @@ var BaseDbAdapter = class {
4197
4747
  };
4198
4748
  }
4199
4749
  /**
4750
+ * Reads like {@link findMany}, except that `$skip` / `$limit` apply to each
4751
+ * partition — the rows sharing the values of the `partitionBy` columns
4752
+ * (physical names) — instead of to the whole result. The generic `$with`
4753
+ * loader reads the related rows of many parent rows at once this way, so a
4754
+ * relation's `$skip` / `$limit` page each parent row's related rows.
4755
+ * `$sort` orders the rows within a partition; how partitions interleave is
4756
+ * unspecified.
4757
+ *
4758
+ * Default: one {@link findMany} without `$skip` / `$limit`, paged per
4759
+ * partition in memory. The SQL adapters override it with a `ROW_NUMBER()`
4760
+ * window, so only the kept rows are read.
4761
+ *
4762
+ * @since 0.1.147
4763
+ */
4764
+ async findManyPerPartition(query, partitionBy) {
4765
+ const { $skip, $limit, ...controls } = query.controls;
4766
+ return require_nested_writer.slicePerGroup(await this.findMany({
4767
+ ...query,
4768
+ controls
4769
+ }), (row) => require_nested_writer.compositeKey(partitionBy, row, require_object.getPath), {
4770
+ skip: $skip ?? void 0,
4771
+ limit: $limit ?? void 0
4772
+ });
4773
+ }
4774
+ /**
4200
4775
  * Executes an aggregate query (GROUP BY + aggregate functions).
4201
4776
  * Default throws — override in adapters that support aggregation.
4202
4777
  */
@@ -4267,6 +4842,8 @@ var BaseDbAdapter = class {
4267
4842
  //#endregion
4268
4843
  //#region src/strategies/application-integrity.ts
4269
4844
  const MAX_CASCADE_DEPTH = 100;
4845
+ /** Rows per pinned-key delete batch (keeps `IN (…)` / `$or` lists bounded). */
4846
+ const PIN_BATCH = 1e3;
4270
4847
  const cascadeStorage = new node_async_hooks.AsyncLocalStorage();
4271
4848
  /**
4272
4849
  * Integrity strategy for adapters without native FK support (e.g. MongoDB).
@@ -4335,6 +4912,12 @@ var ApplicationIntegrity = class extends IntegrityStrategy {
4335
4912
  * - `restrict`: throws if any children exist
4336
4913
  * - `cascade`: recursively deletes child records
4337
4914
  * - `setNull`: sets FK fields to null
4915
+ *
4916
+ * When `filter` holds a relational predicate, returns the matched rows
4917
+ * PINNED by primary key (see {@link TCascadePin}): the caller deletes those
4918
+ * rows, never re-evaluating `filter` on data the cascade just changed (a
4919
+ * `$some` over a cascaded child relation would otherwise stop matching and
4920
+ * leave the parent behind). Any other filter → `undefined`.
4338
4921
  */
4339
4922
  async cascadeBeforeDelete(filter, tableName, meta, cascadeResolver, translateFilter, adapter) {
4340
4923
  const parentCtx = cascadeStorage.getStore();
@@ -4346,6 +4929,11 @@ var ApplicationIntegrity = class extends IntegrityStrategy {
4346
4929
  }]);
4347
4930
  const targets = cascadeResolver(tableName);
4348
4931
  if (targets.length === 0) return;
4932
+ const pkPhysical = require_nested_writer.containsRelationFilter(filter) ? meta.primaryKeys.map((pk) => meta.physicalPath(pk)) : void 0;
4933
+ if (pkPhysical?.length === 0) throw new require_db_error.DbError("REL_FILTER_NOT_SUPPORTED", [{
4934
+ path: "",
4935
+ message: "Cannot delete by a relational predicate with application-level cascades: the table has no primary key"
4936
+ }]);
4349
4937
  const neededLogical = /* @__PURE__ */ new Set();
4350
4938
  for (const t of targets) for (const tf of t.fk.targetFields) neededLogical.add(tf);
4351
4939
  for (const pk of meta.primaryKeys) neededLogical.add(pk);
@@ -4360,7 +4948,8 @@ var ApplicationIntegrity = class extends IntegrityStrategy {
4360
4948
  filter: translateFilter(filter),
4361
4949
  controls: { $select: new UniquSelect(physicalFields) }
4362
4950
  });
4363
- if (rawRecords.length === 0) return;
4951
+ const pin = pkPhysical ? pinByPrimaryKey(rawRecords, pkPhysical) : void 0;
4952
+ if (rawRecords.length === 0) return pin;
4364
4953
  const allRecords = rawRecords.map((r) => {
4365
4954
  const mapped = {};
4366
4955
  for (const [key, val] of Object.entries(r)) mapped[physicalToLogical.get(key) ?? key] = val;
@@ -4376,7 +4965,7 @@ var ApplicationIntegrity = class extends IntegrityStrategy {
4376
4965
  addedKeys.push(key);
4377
4966
  records.push(record);
4378
4967
  }
4379
- if (records.length === 0) return;
4968
+ if (records.length === 0) return pin;
4380
4969
  try {
4381
4970
  await cascadeStorage.run({
4382
4971
  visited,
@@ -4416,6 +5005,7 @@ var ApplicationIntegrity = class extends IntegrityStrategy {
4416
5005
  } finally {
4417
5006
  for (const key of addedKeys) visited.delete(key);
4418
5007
  }
5008
+ return pin;
4419
5009
  }
4420
5010
  needsCascade(cascadeResolver) {
4421
5011
  return !!cascadeResolver;
@@ -4456,6 +5046,23 @@ var ApplicationIntegrity = class extends IntegrityStrategy {
4456
5046
  return orFilters.length === 1 ? orFilters[0] : { $or: orFilters };
4457
5047
  }
4458
5048
  };
5049
+ /**
5050
+ * Adapter-ready filters addressing exactly `rows` by their (physical) primary
5051
+ * key, in batches of {@link PIN_BATCH}: `{ pk: { $in } }` for a single key,
5052
+ * `{ $or: [{ a, b }, …] }` for a composite one. Values are the raw stored
5053
+ * values the adapter returned, so no value formatting is re-applied.
5054
+ */
5055
+ function pinByPrimaryKey(rows, pk) {
5056
+ const out = [];
5057
+ for (let i = 0; i < rows.length; i += PIN_BATCH) {
5058
+ const batch = rows.slice(i, i + PIN_BATCH);
5059
+ if (pk.length === 1) {
5060
+ const field = pk[0];
5061
+ out.push({ [field]: { $in: batch.map((r) => r[field]) } });
5062
+ } else out.push({ $or: batch.map((r) => Object.fromEntries(pk.map((f) => [f, r[f]]))) });
5063
+ }
5064
+ return out;
5065
+ }
4459
5066
  //#endregion
4460
5067
  //#region src/patch/array-ops-resolver.ts
4461
5068
  /**
@@ -5331,7 +5938,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
5331
5938
  const filter = this._andScope(pinned, opts?.scope);
5332
5939
  const translated = this._fieldMapper.translateFilter(filter, this._meta);
5333
5940
  if (guard) await guard(new RemoveGuardContext(id, filter, this));
5334
- if (needsCascade) await this._integrity.cascadeBeforeDelete(filter, this.tableName, this._meta, this._cascadeResolver, (f) => this._fieldMapper.translateFilter(f, this._meta), this.adapter);
5941
+ if (needsCascade) {
5942
+ const pin = await this._integrity.cascadeBeforeDelete(filter, this.tableName, this._meta, this._cascadeResolver, (f) => this._fieldMapper.translateFilter(f, this._meta), this.adapter);
5943
+ if (pin) return pin.length > 0 ? this.adapter.deleteOne(pin[0]) : { deletedCount: 0 };
5944
+ }
5335
5945
  return this.adapter.deleteOne(translated);
5336
5946
  };
5337
5947
  return require_nested_writer.remapDeleteFkViolation(this.tableName, () => guard || needsCascade || candidates.length > 1 ? this.adapter.withTransaction(run) : run());
@@ -5375,7 +5985,12 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
5375
5985
  this._ensureBuilt();
5376
5986
  this._guardMutationFilter(filter);
5377
5987
  if (this._integrity.needsCascade(this._cascadeResolver)) return require_nested_writer.remapDeleteFkViolation(this.tableName, () => this.adapter.withTransaction(async () => {
5378
- await this._integrity.cascadeBeforeDelete(filter, this.tableName, this._meta, this._cascadeResolver, (f) => this._fieldMapper.translateFilter(f, this._meta), this.adapter);
5988
+ const pin = await this._integrity.cascadeBeforeDelete(filter, this.tableName, this._meta, this._cascadeResolver, (f) => this._fieldMapper.translateFilter(f, this._meta), this.adapter);
5989
+ if (pin) {
5990
+ let deletedCount = 0;
5991
+ for (const batch of pin) deletedCount += (await this.adapter.deleteMany(batch)).deletedCount;
5992
+ return { deletedCount };
5993
+ }
5379
5994
  return this.adapter.deleteMany(this._fieldMapper.translateFilter(filter, this._meta));
5380
5995
  }));
5381
5996
  return require_nested_writer.remapDeleteFkViolation(this.tableName, () => this.adapter.deleteMany(this._fieldMapper.translateFilter(filter, this._meta)));
@@ -5397,7 +6012,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
5397
6012
  /** Engine-agnostic guard for user-supplied mutation filters (updateMany/deleteMany/…). */
5398
6013
  _guardMutationFilter(filter) {
5399
6014
  guardFilter(this._meta, this.adapter, filter);
5400
- guardPaths(this._meta, this.adapter, { filter });
6015
+ guardPaths(this._meta, this.adapter, { filter }, false, require_nested_writer.relGuardState(true));
5401
6016
  }
5402
6017
  /**
5403
6018
  * Encrypts `@db.encrypted` field values in place on (already validated)
@@ -5794,8 +6409,16 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
5794
6409
  };
5795
6410
  //#endregion
5796
6411
  //#region src/table/db-view.ts
5797
- /** The `@db.agg.*` annotations, one per supported aggregate function. */
5798
- const AGG_KEYS = require_aggregate_fns.SUPPORTED_AGGREGATE_FNS.map((fn) => `db.agg.${fn}`);
6412
+ /**
6413
+ * The single `@meta.id` field of a first-row join target (its source type,
6414
+ * past `@db.alias`) — the anchor of the join's correlated subquery.
6415
+ * @throws when the target declares no or several primary-key fields.
6416
+ */
6417
+ function firstJoinKey(view, type, scope) {
6418
+ const ids = type.type.kind === "object" ? [...type.type.props.entries()].filter(([, p]) => p.metadata.has("meta.id")) : [];
6419
+ if (ids.length !== 1) throw new Error(`View "${view}": the first-row join on "${scope}" needs a target with exactly one @meta.id field (found ${ids.length})`);
6420
+ return ids[0][0];
6421
+ }
5799
6422
  /**
5800
6423
  * Reads a view field's `@db.agg.*` annotation. The compiled value is
5801
6424
  * `{ field?, condition? }`; models compiled by older versions carry `true`
@@ -5803,7 +6426,7 @@ const AGG_KEYS = require_aggregate_fns.SUPPORTED_AGGREGATE_FNS.map((fn) => `db.a
5803
6426
  * A missing field or `true` is `'*'` (COUNT(*)).
5804
6427
  */
5805
6428
  function readViewAgg(metadata) {
5806
- for (const key of AGG_KEYS) {
6429
+ for (const key of require_aggregate_fns.AGG_ANNOTATIONS) {
5807
6430
  const val = metadata?.get(key);
5808
6431
  if (val === void 0) continue;
5809
6432
  const aggFn = key.slice(7);
@@ -5881,7 +6504,9 @@ function inheritViewFieldSeals(viewType) {
5881
6504
  try {
5882
6505
  const forRef = viewType.metadata.get("db.view.for");
5883
6506
  const entryType = forRef && refType(forRef);
5884
- for (const [fieldName, fieldType] of viewType.type.props.entries()) {
6507
+ const props = viewType.type.props;
6508
+ for (const [fieldName, fieldType] of props.entries()) {
6509
+ if (require_nested_writer.computeOf(fieldType) !== void 0) continue;
5885
6510
  const { agg, sourceType, sourcePath } = viewFieldSource(fieldName, fieldType, entryType);
5886
6511
  if (agg?.aggField === "*" || !sourceType) continue;
5887
6512
  const source = viewSourceOf(sourceType).type;
@@ -5892,6 +6517,14 @@ function inheritViewFieldSeals(viewType) {
5892
6517
  if (agg) throw new Error(`View "${require_nested_writer.tableNameOf(viewType)}" field "${fieldName}": @db.agg.${agg.aggFn} over the @db.encrypted field "${sourcePath}" — ciphertext cannot be aggregated`);
5893
6518
  fieldType.metadata.set("db.encrypted", true);
5894
6519
  }
6520
+ for (const [fieldName, fieldType] of props.entries()) {
6521
+ const computed = require_nested_writer.computedOperands(viewType, fieldName);
6522
+ if (!computed) continue;
6523
+ const { operands, via } = computed;
6524
+ const encrypted = operands.find((p) => props.get(p)?.metadata.has("db.encrypted"));
6525
+ if (encrypted) throw new Error(`View "${require_nested_writer.tableNameOf(viewType)}": @db.compute over the @db.encrypted field "${encrypted}" — ciphertext cannot be computed`);
6526
+ if ([...operands, ...via].some((p) => props.get(p)?.metadata.has("db.writeOnly"))) fieldType.metadata.set("db.writeOnly", true);
6527
+ }
5895
6528
  } catch (error) {
5896
6529
  sealedViews.delete(viewType);
5897
6530
  throw error;
@@ -5954,13 +6587,39 @@ var AtscriptDbView = class extends AtscriptDbReadable {
5954
6587
  if (rawJoins) for (const join of rawJoins) {
5955
6588
  const targetType = refType(join.target);
5956
6589
  const target = viewSourceOf(targetType());
5957
- joins.push({
6590
+ const viewJoin = {
5958
6591
  targetType,
5959
6592
  targetTable: target.table,
5960
6593
  scope: target.name,
5961
6594
  condition: join.condition,
5962
6595
  kind: join.kind === "left" ? "left" : "inner"
5963
- });
6596
+ };
6597
+ if (join.order?.length) {
6598
+ const key = firstJoinKey(this.tableName, target.type, target.name);
6599
+ const order = join.order.map((item) => {
6600
+ const seals = sourceFieldSeals(target.type, item.ref.field);
6601
+ if (seals.encrypted || seals.writeOnly) throw new Error(`View "${this.tableName}": the first-row join on "${target.name}" cannot order by the ${seals.encrypted ? "@db.encrypted" : "@db.writeOnly"} field "${item.ref.field}"`);
6602
+ return {
6603
+ ref: {
6604
+ type: targetType,
6605
+ field: item.ref.field
6606
+ },
6607
+ desc: item.desc === true
6608
+ };
6609
+ });
6610
+ if (!order.some((item) => item.ref.field === key)) order.push({
6611
+ ref: {
6612
+ type: targetType,
6613
+ field: key
6614
+ },
6615
+ desc: false
6616
+ });
6617
+ viewJoin.first = {
6618
+ order,
6619
+ key
6620
+ };
6621
+ }
6622
+ joins.push(viewJoin);
5964
6623
  }
5965
6624
  const filter = metadata.get("db.view.filter");
5966
6625
  const having = metadata.get("db.view.having");
@@ -6037,6 +6696,26 @@ var AtscriptDbView = class extends AtscriptDbReadable {
6037
6696
  this._columnMappings ??= this._buildColumnMappings();
6038
6697
  return this._columnMappings;
6039
6698
  }
6699
+ /**
6700
+ * Why the bound adapter cannot render this managed view: one message per
6701
+ * computed column / first-row join whose feature its `viewCapabilities()`
6702
+ * does not list. Empty for an external view or when every feature is
6703
+ * rendered. Schema sync refuses such a view; the adapter's `ensureTable()`
6704
+ * throws for it (fail-closed for adapters that predate the features).
6705
+ * @since 0.1.147
6706
+ */
6707
+ viewCapabilityProblems() {
6708
+ if (this.isExternal) return [];
6709
+ const caps = this.dbAdapter.viewCapabilities();
6710
+ const problems = [];
6711
+ if (!caps.has("compute")) {
6712
+ for (const col of this.getViewColumnMappings()) if (col.expr !== void 0) problems.push(`View "${this.tableName}" field "${col.viewPath}": computed columns (@db.compute) are not supported by this adapter (viewCapabilities())`);
6713
+ }
6714
+ if (!caps.has("firstJoin")) {
6715
+ for (const join of this.viewPlan.joins) if (join.first) problems.push(`View "${this.tableName}" join "${join.scope}": first-row joins are not supported by this adapter (viewCapabilities())`);
6716
+ }
6717
+ return problems;
6718
+ }
6040
6719
  _buildColumnMappings() {
6041
6720
  const plan = this.viewPlan;
6042
6721
  const mappings = [];
@@ -6051,6 +6730,17 @@ var AtscriptDbView = class extends AtscriptDbReadable {
6051
6730
  const leftJoined = new Set(plan.joins.filter((j) => j.kind === "left").map((j) => j.scope));
6052
6731
  for (const [fieldName, fieldType] of this._type.type.props.entries()) {
6053
6732
  if (ignored.has(fieldName)) continue;
6733
+ const expr = require_nested_writer.computeOf(fieldType);
6734
+ if (expr !== void 0) {
6735
+ mappings.push({
6736
+ viewColumn: viewName(fieldName) ?? fieldName,
6737
+ viewPath: fieldName,
6738
+ sourceTable: plan.entryTable,
6739
+ sourceColumn: "",
6740
+ expr
6741
+ });
6742
+ continue;
6743
+ }
6054
6744
  const { agg, chained, sourceType, sourcePath } = viewFieldSource(fieldName, fieldType, plan.entryType);
6055
6745
  const aggField = agg?.aggField;
6056
6746
  if (aggField === "*" && agg?.aggFn !== "count") fail(fieldName, `aggregate "${agg?.aggFn}" needs a field — only count accepts *`);
@@ -6087,8 +6777,37 @@ var AtscriptDbView = class extends AtscriptDbReadable {
6087
6777
  ...agg
6088
6778
  } : mapping);
6089
6779
  }
6780
+ this._checkComputed(mappings, fail);
6090
6781
  return mappings;
6091
6782
  }
6783
+ /**
6784
+ * The runtime twin of the compile-time `@db.compute` rules the renderers
6785
+ * rely on: every leaf names a (non-ignored) view column, computed columns
6786
+ * form no cycle; sets each computed mapping's `nullable`.
6787
+ */
6788
+ _checkComputed(mappings, fail) {
6789
+ const computed = mappings.filter((m) => m.expr !== void 0);
6790
+ if (computed.length === 0) return;
6791
+ const byPath = new Map(mappings.map((m) => [m.viewPath, m]));
6792
+ const props = this._type.type.kind === "object" ? this._type.type.props : void 0;
6793
+ const state = /* @__PURE__ */ new Map();
6794
+ const visit = (m, chain) => {
6795
+ if (state.get(m.viewPath) === "done") return;
6796
+ if (state.get(m.viewPath) === "visiting") fail(m.viewPath, `@db.compute depends on itself: ${[...chain, m.viewPath].join(" → ")}`);
6797
+ state.set(m.viewPath, "visiting");
6798
+ require_nested_writer.walkViewExpr(m.expr, (path) => {
6799
+ const leaf = byPath.get(path);
6800
+ if (!leaf) fail(m.viewPath, `@db.compute references "${path}", which is not a column of the view`);
6801
+ if (leaf.expr !== void 0) visit(leaf, [...chain, m.viewPath]);
6802
+ });
6803
+ state.set(m.viewPath, "done");
6804
+ if (require_nested_writer.viewExprNullable(m.expr, (path) => {
6805
+ const leaf = byPath.get(path);
6806
+ return leaf.expr === void 0 ? props?.get(path)?.optional === true : leaf.nullable === true;
6807
+ })) m.nullable = true;
6808
+ };
6809
+ for (const m of computed) visit(m, []);
6810
+ }
6092
6811
  /** One view column over one physical source (a column or a JSON leaf). */
6093
6812
  _leafMapping(viewPath, viewColumn, sourceTable, source, joinNullable) {
6094
6813
  const mapping = {
@@ -6121,6 +6840,62 @@ function isAtscriptDbView(readable) {
6121
6840
  return readable.isView;
6122
6841
  }
6123
6842
  //#endregion
6843
+ //#region src/schema/fk-diff.ts
6844
+ /** Canonical key for an FK: sorted local field names, comma-joined. */
6845
+ function fkKey(fields) {
6846
+ return [...fields].toSorted().join(",");
6847
+ }
6848
+ /**
6849
+ * Physical local / target column names of a desired FK (`@db.column` renames
6850
+ * applied) — what DDL, constraint sync, the FK diff and the snapshot compare.
6851
+ */
6852
+ function fkColumns(fk) {
6853
+ return {
6854
+ fields: fk.physicalFields ?? fk.fields,
6855
+ targetFields: fk.physicalTargetFields ?? fk.targetFields
6856
+ };
6857
+ }
6858
+ /**
6859
+ * Compares desired FK constraints against stored snapshot to detect
6860
+ * additions, removals, and property changes (target table, target fields,
6861
+ * onDelete, onUpdate).
6862
+ */
6863
+ function computeForeignKeyDiff(desired, existingSnapshot) {
6864
+ const added = [];
6865
+ const removed = [];
6866
+ const changed = [];
6867
+ const existingByKey = /* @__PURE__ */ new Map();
6868
+ for (const fk of existingSnapshot) existingByKey.set(fkKey(fk.fields), fk);
6869
+ const desiredKeys = /* @__PURE__ */ new Set();
6870
+ for (const fk of desired.values()) {
6871
+ const key = fkKey(fkColumns(fk).fields);
6872
+ desiredKeys.add(key);
6873
+ const existing = existingByKey.get(key);
6874
+ if (!existing) added.push(fk);
6875
+ else if (fkPropertiesDiffer(fk, existing)) changed.push({
6876
+ desired: fk,
6877
+ existing
6878
+ });
6879
+ }
6880
+ for (const [key, fk] of existingByKey) if (!desiredKeys.has(key)) removed.push(fk);
6881
+ return {
6882
+ added,
6883
+ removed,
6884
+ changed
6885
+ };
6886
+ }
6887
+ /** Whether the FK diff contains any changes. */
6888
+ function hasForeignKeyChanges(diff) {
6889
+ return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
6890
+ }
6891
+ function fkPropertiesDiffer(desired, existing) {
6892
+ if (desired.targetTable !== existing.targetTable) return true;
6893
+ if (fkKey(fkColumns(desired).targetFields) !== fkKey(existing.targetFields)) return true;
6894
+ if ((desired.onDelete ?? void 0) !== (existing.onDelete ?? void 0)) return true;
6895
+ if ((desired.onUpdate ?? void 0) !== (existing.onUpdate ?? void 0)) return true;
6896
+ return false;
6897
+ }
6898
+ //#endregion
6124
6899
  //#region src/schema/schema-hash.ts
6125
6900
  /**
6126
6901
  * The physical sources a stored view snapshot reads: its entry table and the
@@ -6187,9 +6962,9 @@ function computeTableSnapshot(readable, typeMapper, tableOptions) {
6187
6962
  }))
6188
6963
  })).toSorted((a, b) => a.key.localeCompare(b.key));
6189
6964
  const foreignKeys = [...readable.foreignKeys.values()].map((fk) => ({
6190
- fields: [...fk.fields].toSorted(),
6965
+ fields: [...fkColumns(fk).fields].toSorted(),
6191
6966
  targetTable: fk.targetTable,
6192
- targetFields: [...fk.targetFields].toSorted(),
6967
+ targetFields: [...fkColumns(fk).targetFields].toSorted(),
6193
6968
  onDelete: fk.onDelete,
6194
6969
  onUpdate: fk.onUpdate
6195
6970
  })).toSorted((a, b) => a.fields.join(",").localeCompare(b.fields.join(",")));
@@ -6217,7 +6992,9 @@ function computeViewSnapshot(view) {
6217
6992
  const plan = view.viewPlan;
6218
6993
  const qualify = (ref) => view.resolveFieldRef(ref, (n) => n);
6219
6994
  const canonical = (node) => JSON.stringify(canonicalizeQueryNode(node, qualify));
6220
- const columns = view.getViewColumnMappings().map((m) => {
6995
+ const mappings = view.getViewColumnMappings();
6996
+ const columnOf = new Map(mappings.map((m) => [m.viewPath, m.viewColumn]));
6997
+ const columns = mappings.map((m) => {
6221
6998
  const col = {
6222
6999
  column: m.viewColumn,
6223
7000
  sourceTable: m.sourceTable,
@@ -6230,6 +7007,7 @@ function computeViewSnapshot(view) {
6230
7007
  if (m.aggFn) col.aggFn = m.aggFn;
6231
7008
  if (m.aggField) col.aggField = m.aggField;
6232
7009
  if (m.aggFilter) col.aggFilter = canonical(m.aggFilter);
7010
+ if (m.expr !== void 0) col.expr = JSON.stringify(canonicalizeViewExpr(m.expr, (path) => columnOf.get(path) ?? path));
6233
7011
  return col;
6234
7012
  }).toSorted((a, b) => a.column < b.column ? -1 : a.column > b.column ? 1 : 0);
6235
7013
  const result = {
@@ -6243,6 +7021,7 @@ function computeViewSnapshot(view) {
6243
7021
  condition: canonical(j.condition)
6244
7022
  };
6245
7023
  if (j.kind === "left") join.kind = "left";
7024
+ if (j.first) join.order = JSON.stringify(j.first.order.map((item) => [qualify(item.ref), item.desc ? -1 : 1]));
6246
7025
  return join;
6247
7026
  }),
6248
7027
  columns
@@ -6281,6 +7060,20 @@ function canonicalizeQueryNode(node, qualify) {
6281
7060
  return out;
6282
7061
  }
6283
7062
  /**
7063
+ * Converts a `@db.compute` expression into a serializable structure whose
7064
+ * JSON is a stable function of its meaning: leaves become the view column
7065
+ * they read (`column(path)`), literals `{ n }`, operations `{ op, a }`.
7066
+ * @since 0.1.147
7067
+ */
7068
+ function canonicalizeViewExpr(expr, column) {
7069
+ if (typeof expr === "number") return { n: expr };
7070
+ if ("field" in expr) return { c: column(expr.field) };
7071
+ return {
7072
+ op: expr.op,
7073
+ a: expr.args.map((arg) => canonicalizeViewExpr(arg, column))
7074
+ };
7075
+ }
7076
+ /**
6284
7077
  * Computes a deterministic hash string from multiple table snapshots.
6285
7078
  * Uses FNV-1a for speed — not cryptographic, just needs stability + collision resistance.
6286
7079
  */
@@ -6344,52 +7137,6 @@ function fnv1a(str) {
6344
7137
  return Math.trunc(hash).toString(16).padStart(8, "0");
6345
7138
  }
6346
7139
  //#endregion
6347
- //#region src/schema/fk-diff.ts
6348
- /** Canonical key for an FK: sorted local field names, comma-joined. */
6349
- function fkKey(fields) {
6350
- return [...fields].toSorted().join(",");
6351
- }
6352
- /**
6353
- * Compares desired FK constraints against stored snapshot to detect
6354
- * additions, removals, and property changes (target table, target fields,
6355
- * onDelete, onUpdate).
6356
- */
6357
- function computeForeignKeyDiff(desired, existingSnapshot) {
6358
- const added = [];
6359
- const removed = [];
6360
- const changed = [];
6361
- const existingByKey = /* @__PURE__ */ new Map();
6362
- for (const fk of existingSnapshot) existingByKey.set(fkKey(fk.fields), fk);
6363
- const desiredKeys = /* @__PURE__ */ new Set();
6364
- for (const fk of desired.values()) {
6365
- const key = fkKey(fk.fields);
6366
- desiredKeys.add(key);
6367
- const existing = existingByKey.get(key);
6368
- if (!existing) added.push(fk);
6369
- else if (fkPropertiesDiffer(fk, existing)) changed.push({
6370
- desired: fk,
6371
- existing
6372
- });
6373
- }
6374
- for (const [key, fk] of existingByKey) if (!desiredKeys.has(key)) removed.push(fk);
6375
- return {
6376
- added,
6377
- removed,
6378
- changed
6379
- };
6380
- }
6381
- /** Whether the FK diff contains any changes. */
6382
- function hasForeignKeyChanges(diff) {
6383
- return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
6384
- }
6385
- function fkPropertiesDiffer(desired, existing) {
6386
- if (desired.targetTable !== existing.targetTable) return true;
6387
- if (fkKey(desired.targetFields) !== fkKey(existing.targetFields)) return true;
6388
- if ((desired.onDelete ?? void 0) !== (existing.onDelete ?? void 0)) return true;
6389
- if ((desired.onUpdate ?? void 0) !== (existing.onUpdate ?? void 0)) return true;
6390
- return false;
6391
- }
6392
- //#endregion
6393
7140
  //#region src/schema/column-diff.ts
6394
7141
  /**
6395
7142
  * Why a derived column (desired, live, or both) must be dropped and re-added,
@@ -6530,6 +7277,12 @@ Object.defineProperty(exports, "ALL_BUCKET_UNITS", {
6530
7277
  return ALL_BUCKET_UNITS;
6531
7278
  }
6532
7279
  });
7280
+ Object.defineProperty(exports, "ALL_VIEW_CAPABILITIES", {
7281
+ enumerable: true,
7282
+ get: function() {
7283
+ return ALL_VIEW_CAPABILITIES;
7284
+ }
7285
+ });
6533
7286
  Object.defineProperty(exports, "ApplicationIntegrity", {
6534
7287
  enumerable: true,
6535
7288
  get: function() {
@@ -6722,6 +7475,12 @@ Object.defineProperty(exports, "decomposePatch", {
6722
7475
  return decomposePatch;
6723
7476
  }
6724
7477
  });
7478
+ Object.defineProperty(exports, "fkColumns", {
7479
+ enumerable: true,
7480
+ get: function() {
7481
+ return fkColumns;
7482
+ }
7483
+ });
6725
7484
  Object.defineProperty(exports, "fkKey", {
6726
7485
  enumerable: true,
6727
7486
  get: function() {