@atscript/db 0.1.134 → 0.1.136

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-CV7y8nC6.d.cts +53 -0
  2. package/dist/agg-D5DHsAby.d.mts +53 -0
  3. package/dist/agg.cjs +2 -0
  4. package/dist/agg.d.cts +3 -35
  5. package/dist/agg.d.mts +3 -35
  6. package/dist/agg.mjs +2 -1
  7. package/dist/aggregate-fns-CGBv3E8S.cjs +87 -0
  8. package/dist/aggregate-fns-CfsveE1w.mjs +58 -0
  9. package/dist/{buckets-Bo2IcPZ_.d.cts → buckets-kKY0RKYN.d.cts} +61 -17
  10. package/dist/{buckets-Bys2QXyk.d.mts → buckets-y4EzoX7D.d.mts} +61 -17
  11. package/dist/{db-space-DtGx5HIC.d.cts → db-space-BX4K3vb6.d.mts} +127 -8
  12. package/dist/{db-space-grfuZcPR.d.mts → db-space-ClG41vJh.d.cts} +127 -8
  13. package/dist/{db-view-pgm2dHIo.cjs → db-view-BAzhEUxB.cjs} +391 -90
  14. package/dist/{db-view-B64IpZ2j.mjs → db-view-BrAtbaOx.mjs} +386 -91
  15. package/dist/index.cjs +17 -3
  16. package/dist/index.d.cts +12 -10
  17. package/dist/index.d.mts +12 -10
  18. package/dist/index.mjs +14 -4
  19. package/dist/{nested-writer-wk1EFUNY.cjs → nested-writer-BZNCuqI6.cjs} +16 -2
  20. package/dist/{nested-writer-CqL24ojl.mjs → nested-writer-FWD5oOYh.mjs} +11 -3
  21. package/dist/plugin.cjs +215 -75
  22. package/dist/plugin.mjs +215 -76
  23. package/dist/rel.cjs +2 -2
  24. package/dist/rel.d.cts +2 -20
  25. package/dist/rel.d.mts +2 -20
  26. package/dist/rel.mjs +2 -2
  27. package/dist/relation-helpers-BVjQflL_.d.cts +30 -0
  28. package/dist/relation-helpers-D4haLo1n.d.mts +30 -0
  29. package/dist/{relation-loader-BD4xANQJ.cjs → relation-loader-6ZB_5KFq.cjs} +1 -1
  30. package/dist/{relation-loader-B68R1LET.mjs → relation-loader-CTFaZpVa.mjs} +1 -1
  31. package/dist/shared.cjs +5 -1
  32. package/dist/shared.d.cts +16 -3
  33. package/dist/shared.d.mts +16 -3
  34. package/dist/shared.mjs +2 -2
  35. package/dist/sync.cjs +37 -10
  36. package/dist/sync.d.cts +24 -4
  37. package/dist/sync.d.mts +24 -4
  38. package/dist/sync.mjs +37 -10
  39. package/dist/{validation-utils-MWOP1Ts4.mjs → validation-utils-B4h-GW4d.mjs} +29 -7
  40. package/dist/{validation-utils-B7SXPkm7.cjs → validation-utils-Dg0hW6dn.cjs} +52 -6
  41. package/dist/{validator-CewfnGZj.d.mts → validator-Drb2N-YL.d.cts} +1 -1
  42. package/dist/{validator-CewfnGZj.d.cts → validator-Drb2N-YL.d.mts} +1 -1
  43. package/dist/validator.d.cts +1 -1
  44. package/dist/validator.d.mts +1 -1
  45. package/package.json +3 -3
@@ -1,7 +1,8 @@
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-BZNCuqI6.cjs");
2
4
  require("./agg.cjs");
3
5
  const require_validator = require("./validator-BtZbcLN2.cjs");
4
- const require_nested_writer = require("./nested-writer-wk1EFUNY.cjs");
5
6
  const require_ops = require("./ops.cjs");
6
7
  let _atscript_typescript_utils = require("@atscript/typescript/utils");
7
8
  let _uniqu_core = require("@uniqu/core");
@@ -17,13 +18,15 @@ const NoopLogger = {
17
18
  //#endregion
18
19
  //#region src/query/buckets.ts
19
20
  /**
20
- * The one normalizer of `$select` computed entries (since 0.1.132) — uniqu's
21
+ * The one normalizer of `$select` computed entries — uniqu's
21
22
  * `resolveBuckets` (entry shapes, unit, time zone canonicalization, week
22
23
  * start, alias syntax and uniqueness, "grouped queries only", "must also
23
24
  * appear in $groupBy", string `$groupBy` entries) with the table's names as
24
25
  * the collision set: a bucket alias may not equal a logical path, a physical
25
26
  * column or a navigation field, so a label is never reverse-mapped as a
26
- * column.
27
+ * column. Aggregate entries are checked against `SUPPORTED_AGGREGATE_FNS`
28
+ * (and `'*'` is `count`'s only); whether the adapter renders a function is
29
+ * `guardAggregate`'s (`aggregateFns()` → `AGG_FN_NOT_SUPPORTED`).
27
30
  *
28
31
  * Which layer validates what:
29
32
  * - **Shapes** (this normalizer) run FIRST at every entry point — the core's
@@ -45,15 +48,21 @@ const NoopLogger = {
45
48
  *
46
49
  * @throws DbError `INVALID_QUERY` carrying every issue (`path` `$select` / `$groupBy`).
47
50
  */
48
- function resolveCalendarBuckets(controls, fields, aggregate) {
51
+ function normalizeComputedSelect(controls, fields, aggregate) {
49
52
  const res = (0, _uniqu_core.resolveBuckets)(controls, {
50
53
  aggregate,
54
+ fns: require_aggregate_fns.SUPPORTED_AGGREGATE_FNS,
51
55
  isField: (name) => fields.flatMap.has(name) || fields.navFields.has(name) || fields.physicalNames.has(name)
52
56
  });
53
57
  if (!res.ok) throw new require_db_error.DbError("INVALID_QUERY", res.issues);
54
58
  return res.buckets;
55
59
  }
56
60
  /**
61
+ * @deprecated since 0.1.136 — renamed {@link normalizeComputedSelect} (it
62
+ * normalizes every computed `$select` entry, aggregates included).
63
+ */
64
+ const resolveCalendarBuckets = normalizeComputedSelect;
65
+ /**
57
66
  * Whether a field's TYPE allows it to be the source of a calendar bucket: a
58
67
  * `number` / `integer` leaf carrying the `timestamp` tag
59
68
  * (`number.timestamp`, `.created`, `.updated`) that is not
@@ -109,6 +118,37 @@ function findAncestorInSet(path, set) {
109
118
  if (set.has(ancestor)) return ancestor;
110
119
  }
111
120
  }
121
+ /**
122
+ * Logical field path → its physical path in document storage (nested
123
+ * objects kept inline). `@db.column` renames (`columnMap`) apply to the
124
+ * annotated key, and a document renames the TOP-LEVEL key only — nested keys
125
+ * are stored as-is — so a dotted path under a renamed top-level object
126
+ * renames its first segment: `profile.bio` under `@db.column 'prof'` →
127
+ * `prof.bio`.
128
+ */
129
+ function documentPath(columnMap, path) {
130
+ const direct = columnMap.get(path);
131
+ if (direct !== void 0) return direct;
132
+ const dot = path.indexOf(".");
133
+ if (dot === -1) return path;
134
+ const top = columnMap.get(path.slice(0, dot));
135
+ return top === void 0 ? path : top + path.slice(dot);
136
+ }
137
+ /** The `__`-separated parent prefix of a dotted path (`a.b.c` → `a__b__`), `""` for a top-level path. */
138
+ function flattenedPrefix(path) {
139
+ const lastDot = path.lastIndexOf(".");
140
+ return lastDot >= 0 ? `${path.slice(0, lastDot).replace(/\./g, "__")}__` : "";
141
+ }
142
+ /**
143
+ * Relational column of a column-stored path: a top-level field is its
144
+ * `@db.column` (`override`) or its name; a leaf of a `flattened` object is
145
+ * its parent segments joined with `__` plus its `@db.column` or own segment
146
+ * (`address.zip` with `@db.column 'zip_code'` → `address__zip_code`).
147
+ */
148
+ function relationalColumnName(path, override, flattened) {
149
+ if (override) return flattened ? flattenedPrefix(path) + override : override;
150
+ return flattened ? path.replace(/\./g, "__") : path;
151
+ }
112
152
  /** Returns true if `metadata` indicates a navigation relation field. */
113
153
  function isNavRelation(metadata) {
114
154
  return metadata.has("db.rel.to") || metadata.has("db.rel.from") || metadata.has("db.rel.via");
@@ -229,12 +269,7 @@ var TableMetadata = class {
229
269
  * first segment: `profile.bio` under `@db.column 'prof'` → `prof.bio`.
230
270
  */
231
271
  documentPath(path) {
232
- const direct = this.columnMap.get(path);
233
- if (direct !== void 0) return direct;
234
- const dot = path.indexOf(".");
235
- if (dot === -1) return path;
236
- const top = this.columnMap.get(path.slice(0, dot));
237
- return top === void 0 ? path : top + path.slice(dot);
272
+ return documentPath(this.columnMap, path);
238
273
  }
239
274
  /**
240
275
  * Runs the full metadata compilation pipeline. Called once by
@@ -352,8 +387,7 @@ var TableMetadata = class {
352
387
  const raw = metadata.get("db.rel.FK");
353
388
  const alias = raw === true ? void 0 : raw;
354
389
  if (fieldType.ref) {
355
- const refTarget = fieldType.ref.type();
356
- const targetTable = refTarget?.metadata?.get("db.table") || refTarget?.id || "";
390
+ const targetTable = require_nested_writer.tableNameOf(fieldType.ref.type());
357
391
  const targetField = fieldType.ref.field;
358
392
  const key = alias || `__auto_${fieldName}`;
359
393
  const existing = this.foreignKeys.get(key);
@@ -496,10 +530,7 @@ var TableMetadata = class {
496
530
  if (findAncestorInSet(path, this.jsonFields) !== void 0) continue;
497
531
  if (findAncestorInSet(path, this.encryptedFields) !== void 0) continue;
498
532
  const isFlattened = findAncestorInSet(path, this.flattenedParents) !== void 0;
499
- const columnOverride = this.columnMap.get(path);
500
- let physicalName;
501
- if (columnOverride) physicalName = isFlattened ? this._flattenedPrefix(path) + columnOverride : columnOverride;
502
- else physicalName = isFlattened ? path.replace(/\./g, "__") : path;
533
+ const physicalName = relationalColumnName(path, this.columnMap.get(path), isFlattened);
503
534
  this.pathToPhysical.set(path, physicalName);
504
535
  this.physicalToPath.set(physicalName, path);
505
536
  const fieldType = this.flatMap.get(path);
@@ -518,11 +549,6 @@ var TableMetadata = class {
518
549
  this.onlyColumnRenames = this.columnMap.size > 0 && this.flattenedParents.size === 0 && this.jsonFields.size === 0;
519
550
  this.requiresMappings = this.flattenedParents.size > 0 || this.jsonFields.size > 0 || this.onlyColumnRenames;
520
551
  }
521
- /** Returns the `__`-separated parent prefix for a dot-separated path, or empty string for top-level paths. */
522
- _flattenedPrefix(path) {
523
- const lastDot = path.lastIndexOf(".");
524
- return lastDot >= 0 ? `${path.slice(0, lastDot).replace(/\./g, "__")}__` : "";
525
- }
526
552
  /** Nearest `@db.encrypted` ancestor of `path` (exclusive), or `undefined`. */
527
553
  /**
528
554
  * Indexes non-ignored descriptors by logical path and retains the JSON-parent
@@ -597,7 +623,7 @@ var TableMetadata = class {
597
623
  const physicalName = skipFlattening ? this.columnMap.get(path) ?? path : this.pathToPhysical.get(path) ?? this.columnMap.get(path) ?? path;
598
624
  const fromLocal = this._columnFromMap.get(path);
599
625
  let renamedFrom;
600
- if (fromLocal) renamedFrom = isFlattened ? this._flattenedPrefix(path) + fromLocal : fromLocal;
626
+ if (fromLocal) renamedFrom = isFlattened ? flattenedPrefix(path) + fromLocal : fromLocal;
601
627
  const currencyCode = type.metadata.get("db.amount.currency");
602
628
  const currencyRefField = type.metadata.get("db.amount.currency.ref");
603
629
  const unitCode = type.metadata.get("db.unit");
@@ -778,7 +804,7 @@ var TableMetadata = class {
778
804
  * An array `$select` holds plain field names and computed entries —
779
805
  * aggregates (`{ $fn, $field }`, {@link aggregates}) and calendar buckets
780
806
  * (`{ $bucket, $field }`, {@link buckets}). Entries arrive normalized
781
- * (`resolveCalendarBuckets` rejects any other shape before translation).
807
+ * (`normalizeComputedSelect` rejects any other shape before translation).
782
808
  */
783
809
  var UniquSelect = class UniquSelect {
784
810
  static UNRESOLVED = Symbol("unresolved");
@@ -911,7 +937,7 @@ var FieldMappingStrategy = class {
911
937
  * equals a field name).
912
938
  *
913
939
  * `buckets` are the query's calendar buckets as the core's normalizer
914
- * resolved them (`resolveCalendarBuckets` — `AtscriptDbReadable.aggregate`
940
+ * resolved them (`normalizeComputedSelect` — `AtscriptDbReadable.aggregate`
915
941
  * runs it before the guards); they reach adapters with `field` made
916
942
  * physical and the source descriptor as `fd`.
917
943
  */
@@ -946,16 +972,28 @@ var FieldMappingStrategy = class {
946
972
  /**
947
973
  * `$select` with its field paths made physical: array-form names and
948
974
  * computed `$field`s (`'*'` kept), or the keys of the object
949
- * (inclusion / exclusion) form.
975
+ * (inclusion / exclusion) form. An aggregate's output alias is fixed
976
+ * (`$as`) from its LOGICAL field first, so a default alias never leaks a
977
+ * physical name (`sum(amount)` over `@db.column 'amount_cents'` stays
978
+ * `sum_amount`); a bucket's alias is already resolved.
950
979
  */
951
980
  physicalSelect(select, meta) {
952
981
  if (!this.renamesPaths(meta)) return select;
953
982
  if (Array.isArray(select)) return select.map((item) => {
954
983
  if (typeof item === "string") return this.physicalPath(item, meta);
955
- if ((0, _uniqu_core.isAggregateExpr)(item) || (0, _uniqu_core.isBucketExpr)(item)) return item.$field === "*" ? item : {
956
- ...item,
957
- $field: this.physicalPath(item.$field, meta)
958
- };
984
+ if ((0, _uniqu_core.isAggregateExpr)(item) || (0, _uniqu_core.isBucketExpr)(item)) {
985
+ if (item.$field === "*") return item;
986
+ const physical = this.physicalPath(item.$field, meta);
987
+ if (physical === item.$field) return item;
988
+ return (0, _uniqu_core.isAggregateExpr)(item) ? {
989
+ ...item,
990
+ $as: (0, _uniqu_core.resolveAlias)(item),
991
+ $field: physical
992
+ } : {
993
+ ...item,
994
+ $field: physical
995
+ };
996
+ }
959
997
  return item;
960
998
  });
961
999
  const translated = {};
@@ -1380,7 +1418,7 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
1380
1418
  * - `$exists` with a non-boolean operand → `INVALID_QUERY`
1381
1419
  * - malformed `$select` computed entries / `$groupBy` entries, calendar
1382
1420
  * buckets outside grouped queries or with a bad unit / zone / alias →
1383
- * `INVALID_QUERY` (the shared normalizer, `resolveCalendarBuckets`)
1421
+ * `INVALID_QUERY` (the shared normalizer, `normalizeComputedSelect`)
1384
1422
  * - a calendar bucket over a field that is not a bucket source
1385
1423
  * (`bucketSourceVerdict`) → `INVALID_QUERY`, or `BUCKET_NOT_SUPPORTED`
1386
1424
  * when the adapter has no calendar buckets at all; a unit the adapter's
@@ -1646,7 +1684,7 @@ function collectFilterKeys(filter, push, skip, refs) {
1646
1684
  * `$sort` / `$having`; a bucket alias is also dropped from `groupBy`, which
1647
1685
  * lists grouped fields only.
1648
1686
  *
1649
- * Entry shapes are not checked here — see `resolveCalendarBuckets`.
1687
+ * Entry shapes are not checked here — see `normalizeComputedSelect`.
1650
1688
  */
1651
1689
  function collectQueryPaths(query, aggregate) {
1652
1690
  const refs = {
@@ -1786,7 +1824,7 @@ function guardPath(meta, adapter, path, op, predicate = "compare") {
1786
1824
  * {@link guardPath}). Adapters may therefore assume every path they receive
1787
1825
  * is physical.
1788
1826
  *
1789
- * Entry shapes are the caller's to normalize first (`resolveCalendarBuckets`
1827
+ * Entry shapes are the caller's to normalize first (`normalizeComputedSelect`
1790
1828
  * — {@link guardQuery} / {@link guardAggregate} do).
1791
1829
  *
1792
1830
  * In aggregate mode (`aggregate = true`) `$select` computed entries have
@@ -1819,7 +1857,7 @@ function guardQuery(meta, adapter, query) {
1819
1857
  if (!query) return;
1820
1858
  guardFilter(meta, adapter, query.filter);
1821
1859
  guardSort(meta, query.controls?.$sort);
1822
- resolveCalendarBuckets(query.controls, meta, false);
1860
+ normalizeComputedSelect(query.controls, meta, false);
1823
1861
  guardPaths(meta, adapter, query);
1824
1862
  }
1825
1863
  /**
@@ -1842,16 +1880,17 @@ function checkHavingKeys(refs) {
1842
1880
  }
1843
1881
  /**
1844
1882
  * Aggregate-path guard: $groupBy / $select / $having encryption refs + filter
1845
- * + $sort, then the path guard, then the adapter's calendar-bucket units
1883
+ * + $sort, then the path guard, then the adapter's aggregate functions
1884
+ * (`AGG_FN_NOT_SUPPORTED`) and calendar-bucket units
1846
1885
  * (`BUCKET_NOT_SUPPORTED`), then the `$having` key rule
1847
1886
  * ({@link checkHavingKeys} — after the path guard so an unknown key still
1848
1887
  * reads `Unknown field`).
1849
1888
  *
1850
1889
  * `buckets` are the query's resolved calendar buckets when the caller already
1851
- * ran `resolveCalendarBuckets` (resolved here otherwise).
1890
+ * ran `normalizeComputedSelect` (resolved here otherwise).
1852
1891
  */
1853
1892
  function guardAggregate(meta, adapter, query, resolved) {
1854
- const buckets = resolved ?? resolveCalendarBuckets(query.controls, meta, true);
1893
+ const buckets = resolved ?? normalizeComputedSelect(query.controls, meta, true);
1855
1894
  guardFilter(meta, adapter, query.filter);
1856
1895
  const controls = query.controls;
1857
1896
  if (meta.encryptedFields.size > 0) {
@@ -1864,11 +1903,26 @@ function guardAggregate(meta, adapter, query, resolved) {
1864
1903
  guardSort(meta, controls.$sort);
1865
1904
  }
1866
1905
  const refs = guardPaths(meta, adapter, query, true);
1906
+ guardAggregateFns(adapter, controls.$select);
1867
1907
  guardBucketUnits(adapter, buckets);
1868
1908
  const having = refs ? checkHavingKeys(refs) : void 0;
1869
1909
  if (having) throw new require_db_error.DbError("INVALID_QUERY", [having]);
1870
1910
  }
1871
1911
  /**
1912
+ * Rejects an aggregate whose (known — the normalizer checked the name)
1913
+ * function this adapter does not render (`aggregateFns()`) with
1914
+ * `AGG_FN_NOT_SUPPORTED`, before anything is translated — an adapter written
1915
+ * before a function existed never receives it.
1916
+ */
1917
+ function guardAggregateFns(adapter, select) {
1918
+ if (!select) return;
1919
+ const fns = adapter.aggregateFns();
1920
+ for (const item of select) if ((0, _uniqu_core.isAggregateExpr)(item) && !fns.has(item.$fn)) throw new require_db_error.DbError("AGG_FN_NOT_SUPPORTED", [{
1921
+ path: "$select",
1922
+ message: `Aggregate function "${item.$fn}" is not supported by this adapter`
1923
+ }]);
1924
+ }
1925
+ /**
1872
1926
  * Rejects a calendar bucket whose unit this adapter cannot group by
1873
1927
  * (`calendarBucketUnits()`; an adapter with none was already answered per
1874
1928
  * source by `bucketSourceVerdict`) with `BUCKET_NOT_SUPPORTED`,
@@ -2375,13 +2429,15 @@ var AtscriptDbReadable = class {
2375
2429
  *
2376
2430
  * Validates:
2377
2431
  * - `$select` computed entries and calendar buckets (the shared normalizer,
2378
- * `resolveCalendarBuckets`: shapes, unit, zone, alias, grouping)
2432
+ * `normalizeComputedSelect`: shapes, unit, zone, alias, grouping)
2379
2433
  * - Plain fields in $select are a subset of $groupBy
2380
2434
  * - When dimensions/measures are defined (strict mode): $groupBy fields
2381
- * must be dimensions, aggregate $field values must be measures (or '*')
2435
+ * must be dimensions, aggregate $field values must be measures (or '*';
2436
+ * a `countDistinct` field may also be a dimension)
2382
2437
  * - the path guard (a bucket source must pass `bucketSourceVerdict` —
2383
2438
  * timestamp type, no JSON ancestor, a dimension in strict mode, an
2384
- * adapter with calendar buckets) and the adapter's calendar-bucket units
2439
+ * adapter with calendar buckets), the adapter's aggregate functions
2440
+ * (`AGG_FN_NOT_SUPPORTED`) and calendar-bucket units
2385
2441
  * (`BUCKET_NOT_SUPPORTED`)
2386
2442
  *
2387
2443
  * Translates field names, delegates to adapter.aggregate(),
@@ -2390,7 +2446,7 @@ var AtscriptDbReadable = class {
2390
2446
  async aggregate(query) {
2391
2447
  this._ensureBuilt();
2392
2448
  const { $groupBy, $select } = query.controls;
2393
- const buckets = resolveCalendarBuckets(query.controls, this._meta, true);
2449
+ const buckets = normalizeComputedSelect(query.controls, this._meta, true);
2394
2450
  if ($select) {
2395
2451
  const groupBySet = new Set($groupBy);
2396
2452
  for (const item of $select) if (typeof item === "string" && !groupBySet.has(item)) throw new require_db_error.DbError("INVALID_QUERY", [{
@@ -2407,8 +2463,16 @@ var AtscriptDbReadable = class {
2407
2463
  path: "$groupBy",
2408
2464
  message: `Field "${field}" is not a dimension`
2409
2465
  }]);
2410
- if ($select) {
2411
- for (const item of $select) if ((0, _uniqu_core.isAggregateExpr)(item) && item.$field !== "*" && !measSet.has(item.$field)) throw new require_db_error.DbError("INVALID_QUERY", [{
2466
+ if ($select) for (const item of $select) {
2467
+ if (!(0, _uniqu_core.isAggregateExpr)(item) || item.$field === "*" || measSet.has(item.$field)) continue;
2468
+ if (item.$fn === "countDistinct") {
2469
+ if (dimSet.has(item.$field)) continue;
2470
+ throw new require_db_error.DbError("INVALID_QUERY", [{
2471
+ path: "$select",
2472
+ message: `Aggregate field "${item.$field}" is not a dimension or measure`
2473
+ }]);
2474
+ }
2475
+ throw new require_db_error.DbError("INVALID_QUERY", [{
2412
2476
  path: "$select",
2413
2477
  message: `Aggregate field "${item.$field}" is not a measure`
2414
2478
  }]);
@@ -2447,6 +2511,10 @@ var AtscriptDbReadable = class {
2447
2511
  calendarBucketUnits() {
2448
2512
  return this.adapter.calendarBucketUnits();
2449
2513
  }
2514
+ /** Aggregate functions the adapter renders (proxies adapter capability). @since 0.1.136 */
2515
+ aggregateFns() {
2516
+ return this.adapter.aggregateFns();
2517
+ }
2450
2518
  /** Whether the adapter can sort by a given field (proxies adapter capability). */
2451
2519
  canSortField(fd) {
2452
2520
  return this.adapter.canSortField(fd);
@@ -2737,7 +2805,7 @@ var AtscriptDbReadable = class {
2737
2805
  * Public entry point for relation loading. Used by adapters for nested $with delegation.
2738
2806
  */
2739
2807
  async loadRelations(rows, withRelations) {
2740
- const { loadRelationsImpl } = await Promise.resolve().then(() => require("./relation-loader-BD4xANQJ.cjs")).then((n) => n.relation_loader_exports);
2808
+ const { loadRelationsImpl } = await Promise.resolve().then(() => require("./relation-loader-6ZB_5KFq.cjs")).then((n) => n.relation_loader_exports);
2741
2809
  return loadRelationsImpl(rows, withRelations, this);
2742
2810
  }
2743
2811
  /**
@@ -3036,6 +3104,20 @@ var BaseDbAdapter = class {
3036
3104
  return EMPTY_BUCKET_UNITS;
3037
3105
  }
3038
3106
  /**
3107
+ * Aggregate functions (`{ $fn, $field }` in an aggregate `$select`) this
3108
+ * adapter renders. The default is `sum`, `count`, `avg`, `min` and `max`;
3109
+ * an adapter that also implements `countDistinct` (distinct non-null
3110
+ * values) returns `ALL_AGGREGATE_FNS`. The core rejects a known function
3111
+ * missing from this set with `AGG_FN_NOT_SUPPORTED` before dispatch, so
3112
+ * `aggregate()` only ever receives functions listed here; moost-db's
3113
+ * `/meta` advertises the set as `aggregateFns`.
3114
+ *
3115
+ * @since 0.1.136
3116
+ */
3117
+ aggregateFns() {
3118
+ return require_aggregate_fns.BASE_AGGREGATE_FNS;
3119
+ }
3120
+ /**
3039
3121
  * Whether this adapter enforces foreign key constraints natively.
3040
3122
  * When `true`, the generic layer skips application-level cascade/setNull
3041
3123
  * on delete — the DB engine handles it (e.g. SQLite `ON DELETE CASCADE`).
@@ -4605,7 +4687,150 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
4605
4687
  }
4606
4688
  };
4607
4689
  //#endregion
4690
+ //#region src/table/view-source.ts
4691
+ const indexCache = /* @__PURE__ */ new WeakMap();
4692
+ function sourceIndex(type) {
4693
+ let idx = indexCache.get(type);
4694
+ if (idx) return idx;
4695
+ idx = {
4696
+ flatMap: /* @__PURE__ */ new Map(),
4697
+ columnMap: /* @__PURE__ */ new Map(),
4698
+ unstored: /* @__PURE__ */ new Set(),
4699
+ jsonRoots: /* @__PURE__ */ new Set(),
4700
+ encrypted: /* @__PURE__ */ new Set(),
4701
+ optional: /* @__PURE__ */ new Set()
4702
+ };
4703
+ if (type.type.kind === "object") {
4704
+ const navFields = /* @__PURE__ */ new Set();
4705
+ const collected = [];
4706
+ idx.flatMap = (0, _atscript_typescript_utils.flattenAnnotatedType)(type, {
4707
+ excludePhantomTypes: true,
4708
+ onField: (path, fieldType, metadata) => {
4709
+ if (isNavRelation(metadata)) navFields.add(path);
4710
+ collected.push([
4711
+ path,
4712
+ fieldType,
4713
+ metadata
4714
+ ]);
4715
+ }
4716
+ });
4717
+ const storage = [];
4718
+ for (const [path, fieldType, metadata] of collected) {
4719
+ if (!path || findAncestorInSet(path, navFields) !== void 0) continue;
4720
+ const column = metadata.get("db.column");
4721
+ if (column) idx.columnMap.set(path, column);
4722
+ if (metadata.has("db.ignore") || navFields.has(path)) idx.unstored.add(path);
4723
+ if (metadata.has("db.encrypted")) storage.push([path, false]);
4724
+ else if (metadata.has("db.json") || resolveDesignType(fieldType) === "array") storage.push([path, true]);
4725
+ }
4726
+ storage.sort(([a], [b]) => a.split(".").length - b.split(".").length);
4727
+ for (const [path, json] of storage) if (findAncestorInSet(path, idx.jsonRoots) === void 0 && findAncestorInSet(path, idx.encrypted) === void 0) (json ? idx.jsonRoots : idx.encrypted).add(path);
4728
+ for (const [path, node] of idx.flatMap) if (path && node.optional) idx.optional.add(path);
4729
+ }
4730
+ indexCache.set(type, idx);
4731
+ return idx;
4732
+ }
4733
+ /** `path` itself if it is in `set`, else its nearest ancestor in `set`. */
4734
+ function selfOrAncestor(path, set) {
4735
+ return set.has(path) ? path : findAncestorInSet(path, set);
4736
+ }
4737
+ /**
4738
+ * Resolves a LOGICAL path of a source table (a view field's chain ref, an
4739
+ * aggregate's field, a predicate operand) to where it is physically stored.
4740
+ * Internal — `AtscriptDbView.resolveRefSource` is the public entry.
4741
+ *
4742
+ * Relational rules (`TableMetadata`'s): the outermost `@db.json` or array
4743
+ * node with segments remaining is the column and the rest becomes
4744
+ * {@link TViewSource.jsonPath}; a flattened leaf is its parent segments
4745
+ * joined with `__` plus its `@db.column` (or segment); a top-level field is
4746
+ * its `@db.column` or name. Nested-object adapters use the document path
4747
+ * (only a top-level key is renamed) and never a JSON path.
4748
+ *
4749
+ * A path the type does not declare resolves to itself — the database
4750
+ * reports the unknown column, as before.
4751
+ * @throws for a path without storage (`@db.ignore`, a navigation relation,
4752
+ * or inside one), and — relational — for a path inside an `@db.encrypted` field.
4753
+ */
4754
+ function resolveViewSource(sourceType, logicalPath, nestedObjects) {
4755
+ const idx = sourceIndex(sourceType);
4756
+ const node = idx.flatMap.get(logicalPath);
4757
+ const jsonRoot = selfOrAncestor(logicalPath, idx.jsonRoots);
4758
+ const optional = selfOrAncestor(logicalPath, idx.optional) !== void 0 || jsonRoot !== void 0 && jsonRoot !== logicalPath;
4759
+ if (!node) return {
4760
+ column: logicalPath,
4761
+ designType: "unknown",
4762
+ optional
4763
+ };
4764
+ const unstored = selfOrAncestor(logicalPath, idx.unstored);
4765
+ if (unstored !== void 0) throw new Error(`"${logicalPath}" has no column — "${unstored}" is @db.ignore or a navigation relation`);
4766
+ const designType = resolveDesignType(node);
4767
+ if (nestedObjects) return {
4768
+ column: documentPath(idx.columnMap, logicalPath),
4769
+ designType,
4770
+ optional
4771
+ };
4772
+ const encrypted = findAncestorInSet(logicalPath, idx.encrypted);
4773
+ if (encrypted !== void 0) throw new Error(`"${logicalPath}" is inside the @db.encrypted field "${encrypted}" — reference "${encrypted}" itself`);
4774
+ if (jsonRoot !== void 0) {
4775
+ const column = relationalColumnName(jsonRoot, idx.columnMap.get(jsonRoot), jsonRoot.includes("."));
4776
+ return jsonRoot === logicalPath ? {
4777
+ column,
4778
+ designType,
4779
+ optional
4780
+ } : {
4781
+ column,
4782
+ jsonPath: logicalPath.slice(jsonRoot.length + 1).split("."),
4783
+ designType,
4784
+ optional
4785
+ };
4786
+ }
4787
+ if (designType === "object" && !idx.encrypted.has(logicalPath)) return {
4788
+ column: logicalPath.replace(/\./g, "__"),
4789
+ designType,
4790
+ flattened: true,
4791
+ optional
4792
+ };
4793
+ return {
4794
+ column: relationalColumnName(logicalPath, idx.columnMap.get(logicalPath), logicalPath.includes(".")),
4795
+ designType,
4796
+ optional
4797
+ };
4798
+ }
4799
+ //#endregion
4608
4800
  //#region src/table/db-view.ts
4801
+ /** The `@db.agg.*` annotations, one per supported aggregate function. */
4802
+ const AGG_KEYS = require_aggregate_fns.SUPPORTED_AGGREGATE_FNS.map((fn) => `db.agg.${fn}`);
4803
+ /**
4804
+ * Reads a view field's `@db.agg.*` annotation. The compiled value is
4805
+ * `{ field?, condition? }`; models compiled by older versions carry `true`
4806
+ * (bare `@db.agg.count`) or a string (the field), which are read the same way.
4807
+ * A missing field or `true` is `'*'` (COUNT(*)).
4808
+ */
4809
+ function readViewAgg(metadata) {
4810
+ for (const key of AGG_KEYS) {
4811
+ const val = metadata?.get(key);
4812
+ if (val === void 0) continue;
4813
+ const aggFn = key.slice(7);
4814
+ if (typeof val === "string") return {
4815
+ aggFn,
4816
+ aggField: val
4817
+ };
4818
+ if (val === null || typeof val !== "object") return {
4819
+ aggFn,
4820
+ aggField: "*"
4821
+ };
4822
+ return {
4823
+ aggFn,
4824
+ aggField: val.field ?? "*",
4825
+ ...val.condition ? { aggFilter: val.condition } : {}
4826
+ };
4827
+ }
4828
+ }
4829
+ const JSON_LEAF_TYPES = new Set([
4830
+ "string",
4831
+ "number",
4832
+ "boolean"
4833
+ ]);
4609
4834
  /**
4610
4835
  * Database view abstraction driven by Atscript `@db.view.*` annotations.
4611
4836
  *
@@ -4621,6 +4846,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
4621
4846
  */
4622
4847
  var AtscriptDbView = class extends AtscriptDbReadable {
4623
4848
  _viewPlan;
4849
+ _columnMappings;
4624
4850
  get isView() {
4625
4851
  return true;
4626
4852
  }
@@ -4646,19 +4872,18 @@ var AtscriptDbView = class extends AtscriptDbReadable {
4646
4872
  const metadata = this._type.metadata;
4647
4873
  const forRef = metadata.get("db.view.for");
4648
4874
  const entryType = typeof forRef === "function" ? forRef : forRef.type;
4649
- const entryTypeResolved = entryType();
4650
- const entryTable = entryTypeResolved?.metadata?.get("db.table") || entryTypeResolved?.id || "";
4875
+ const entryTable = require_nested_writer.tableNameOf(entryType());
4651
4876
  const rawJoins = metadata.get("db.view.joins");
4652
4877
  const joins = [];
4653
4878
  if (rawJoins) for (const join of rawJoins) {
4654
4879
  const targetRef = join.target;
4655
4880
  const targetType = typeof targetRef === "function" ? targetRef : targetRef.type;
4656
4881
  const targetTypeResolved = targetType();
4657
- const targetTable = targetTypeResolved?.metadata?.get("db.table") || targetTypeResolved?.id || "";
4658
4882
  joins.push({
4659
4883
  targetType,
4660
- targetTable,
4661
- condition: join.condition
4884
+ targetTable: require_nested_writer.tableNameOf(targetTypeResolved),
4885
+ condition: join.condition,
4886
+ kind: join.kind === "left" ? "left" : "inner"
4662
4887
  });
4663
4888
  }
4664
4889
  const filter = metadata.get("db.view.filter");
@@ -4674,73 +4899,143 @@ var AtscriptDbView = class extends AtscriptDbReadable {
4674
4899
  };
4675
4900
  return this._viewPlan;
4676
4901
  }
4902
+ /** Whether the adapter stores nested objects natively (document paths, no JSON columns). */
4903
+ get _nested() {
4904
+ return this.adapter.supportsNestedObjects();
4905
+ }
4677
4906
  /**
4678
- * Resolves a query field ref to a quoted `table.column` SQL fragment.
4907
+ * Resolves a view query field ref (join condition, `@db.view.filter`,
4908
+ * conditional-aggregate predicate) to its table name and PHYSICAL source on
4909
+ * this view's adapter — the column (or document path) with `TableMetadata`'s
4910
+ * layout rules, the path inside a JSON column, and whether the value may be
4911
+ * absent. An unqualified ref resolves against the entry table.
4912
+ * @throws for a ref without storage (`@db.ignore`, navigation relation) or
4913
+ * inside an `@db.encrypted` field (relational adapters).
4914
+ * @since 0.1.136
4915
+ */
4916
+ resolveRefSource(ref) {
4917
+ const type = ref.type ? ref.type() : this.viewPlan.entryType();
4918
+ return {
4919
+ table: require_nested_writer.tableNameOf(type),
4920
+ source: resolveViewSource(type, ref.field, this._nested)
4921
+ };
4922
+ }
4923
+ /**
4924
+ * Resolves a query field ref (join condition, `@db.view.filter`) to a
4925
+ * quoted `table.column` SQL fragment — the PHYSICAL column (flattened
4926
+ * `__` name, `@db.column` rename). An unqualified ref
4927
+ * resolves against the entry table.
4679
4928
  *
4680
4929
  * @param ref - The field reference from the query tree.
4681
4930
  * @param qi - Identifier quoting function (e.g. backtick for MySQL, double-quote for SQLite).
4682
4931
  * Defaults to double-quote wrapping for backwards compatibility.
4932
+ * @throws when the ref reads inside a JSON column (not supported in view conditions).
4683
4933
  */
4684
4934
  resolveFieldRef(ref, qi = (n) => `"${n}"`) {
4685
- if (!ref.type) {
4686
- const plan = this.viewPlan;
4687
- return `${qi(plan.entryTable)}.${qi(ref.field)}`;
4688
- }
4689
- const resolved = ref.type();
4690
- return `${qi(resolved?.metadata?.get("db.table") || resolved?.id || "")}.${qi(ref.field)}`;
4935
+ const { table, source } = this.resolveRefSource(ref);
4936
+ if (source.jsonPath) throw new Error(`View "${this.tableName}": "${ref.field}" reads inside a JSON column — JSON paths are not supported in view conditions`);
4937
+ return `${qi(table)}.${qi(source.column)}`;
4691
4938
  }
4692
4939
  /**
4693
- * Maps each view field to its source table and column via ref chain.
4694
- * Fields without refs (inline definitions) map to the entry table with the same name.
4940
+ * Maps each view column to its source table and PHYSICAL source column.
4941
+ *
4942
+ * View fields resolve through their chain ref; fields without a ref read
4943
+ * the entry table under the same name; aggregates read their `@db.agg.*`
4944
+ * field from the entry table (or their ref). Source names are
4945
+ * physical (flattened `__` names, `@db.column`, document paths), `viewColumn`
4946
+ * is the view's own physical name, an object field whose source is a
4947
+ * flattened object expands to one mapping per leaf, and a primitive leaf
4948
+ * inside a JSON column carries `json` (rendered by adapters that support
4949
+ * JSON extraction).
4950
+ *
4951
+ * Computed once per view (the plan and the type are immutable).
4952
+ *
4953
+ * @throws for an object field over a JSON column without `@db.json` on the
4954
+ * view field, a JSON leaf that is not a string / number / boolean, or an
4955
+ * aggregate other than `count` over `'*'`.
4695
4956
  */
4696
4957
  getViewColumnMappings() {
4958
+ this._columnMappings ??= this._buildColumnMappings();
4959
+ return this._columnMappings;
4960
+ }
4961
+ _buildColumnMappings() {
4697
4962
  const plan = this.viewPlan;
4698
4963
  const mappings = [];
4699
4964
  if (this._type.type.kind !== "object") return mappings;
4700
- const aggKeys = [
4701
- "db.agg.sum",
4702
- "db.agg.avg",
4703
- "db.agg.count",
4704
- "db.agg.min",
4705
- "db.agg.max"
4706
- ];
4707
4965
  const ignored = this.ignoredFields;
4966
+ const meta = this.getMetadata();
4967
+ const nested = this._nested;
4968
+ const viewName = (path) => nested ? meta.documentPath(path) : meta.pathToPhysical.get(path);
4969
+ const fail = (field, message) => {
4970
+ throw new Error(`View "${this.tableName}" field "${field}": ${message}`);
4971
+ };
4972
+ const leftJoined = new Set(plan.joins.filter((j) => j.kind === "left").map((j) => j.targetTable));
4708
4973
  for (const [fieldName, fieldType] of this._type.type.props.entries()) {
4709
4974
  if (ignored.has(fieldName)) continue;
4710
- let aggFn;
4711
- let aggField;
4712
- for (const key of aggKeys) {
4713
- const val = fieldType.metadata?.get(key);
4714
- if (val !== void 0) {
4715
- aggFn = key.split(".")[2];
4716
- aggField = typeof val === "string" ? val : "*";
4717
- break;
4718
- }
4719
- }
4975
+ const agg = readViewAgg(fieldType.metadata);
4976
+ const aggField = agg?.aggField;
4977
+ if (aggField === "*" && agg?.aggFn !== "count") fail(fieldName, `aggregate "${agg?.aggFn}" needs a field — only count accepts *`);
4978
+ let sourceType;
4979
+ let sourcePath;
4720
4980
  if (fieldType.ref) {
4721
- const resolved = fieldType.ref.type();
4722
- const sourceTable = resolved?.metadata?.get("db.table") || resolved?.id || "";
4723
- const sourceColumn = fieldType.ref.field || fieldName;
4724
- mappings.push({
4725
- viewColumn: fieldName,
4726
- sourceTable,
4727
- sourceColumn,
4728
- aggFn,
4729
- aggField
4730
- });
4981
+ sourceType = fieldType.ref.type();
4982
+ sourcePath = fieldType.ref.field || fieldName;
4731
4983
  } else {
4732
- const sourceColumn = aggField && aggField !== "*" ? aggField : fieldName;
4984
+ sourceType = plan.entryType();
4985
+ sourcePath = aggField && aggField !== "*" ? aggField : fieldName;
4986
+ }
4987
+ const sourceTable = require_nested_writer.tableNameOf(sourceType);
4988
+ const joinNullable = leftJoined.has(sourceTable);
4989
+ if (aggField === "*" && !fieldType.ref) {
4733
4990
  mappings.push({
4734
- viewColumn: fieldName,
4735
- sourceTable: plan.entryTable,
4736
- sourceColumn,
4737
- aggFn,
4738
- aggField
4991
+ viewColumn: viewName(fieldName) ?? fieldName,
4992
+ viewPath: fieldName,
4993
+ sourceTable,
4994
+ sourceColumn: "*",
4995
+ ...agg
4739
4996
  });
4997
+ continue;
4740
4998
  }
4999
+ const source = resolveViewSource(sourceType, sourcePath, nested);
5000
+ const ownColumn = viewName(fieldName);
5001
+ if (ownColumn === void 0 && !nested) {
5002
+ if (!source.flattened) fail(fieldName, "source is a JSON column — add @db.json to the view field");
5003
+ const prefix = `${fieldName}.`;
5004
+ for (const [viewPath, viewColumn] of meta.pathToPhysical) {
5005
+ if (!viewPath.startsWith(prefix) || ignored.has(viewPath)) continue;
5006
+ const leafPath = `${sourcePath}.${viewPath.slice(prefix.length)}`;
5007
+ const leaf = resolveViewSource(sourceType, leafPath, nested);
5008
+ mappings.push(this._leafMapping(viewPath, viewColumn, sourceTable, leaf, joinNullable));
5009
+ }
5010
+ continue;
5011
+ }
5012
+ if (source.flattened) fail(fieldName, "source is a flattened object — remove @db.json from the view field");
5013
+ const mapping = this._leafMapping(fieldName, ownColumn ?? fieldName, sourceTable, source, joinNullable);
5014
+ mappings.push(agg ? {
5015
+ ...mapping,
5016
+ ...agg
5017
+ } : mapping);
4741
5018
  }
4742
5019
  return mappings;
4743
5020
  }
5021
+ /** One view column over one physical source (a column or a JSON leaf). */
5022
+ _leafMapping(viewPath, viewColumn, sourceTable, source, joinNullable) {
5023
+ const mapping = {
5024
+ viewColumn,
5025
+ viewPath,
5026
+ sourceTable,
5027
+ sourceColumn: source.column
5028
+ };
5029
+ if (joinNullable || source.optional || source.designType === "unknown") mapping.nullable = true;
5030
+ if (source.jsonPath) {
5031
+ if (!JSON_LEAF_TYPES.has(source.designType)) throw new Error(`View "${this.tableName}" field "${viewPath}": JSON extraction supports string, number and boolean leaves only`);
5032
+ mapping.json = {
5033
+ path: source.jsonPath,
5034
+ type: source.designType
5035
+ };
5036
+ }
5037
+ return mapping;
5038
+ }
4744
5039
  };
4745
5040
  /**
4746
5041
  * Structural type guard for views: `true` when the readable reports
@@ -4989,6 +5284,12 @@ Object.defineProperty(exports, "narrowerFilterOps", {
4989
5284
  return narrowerFilterOps;
4990
5285
  }
4991
5286
  });
5287
+ Object.defineProperty(exports, "normalizeComputedSelect", {
5288
+ enumerable: true,
5289
+ get: function() {
5290
+ return normalizeComputedSelect;
5291
+ }
5292
+ });
4992
5293
  Object.defineProperty(exports, "resolveCalendarBuckets", {
4993
5294
  enumerable: true,
4994
5295
  get: function() {