@atscript/db 0.1.131 → 0.1.132

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 (39) hide show
  1. package/dist/agg.cjs +19 -5
  2. package/dist/agg.d.cts +3 -4
  3. package/dist/agg.d.mts +3 -4
  4. package/dist/agg.mjs +2 -5
  5. package/dist/{db-readable-Cmrg9aLU.d.cts → buckets-BvoYYQ2Q.d.cts} +1339 -1140
  6. package/dist/{db-readable-D8aa-wfP.d.mts → buckets-D2XPrwSC.d.mts} +1339 -1140
  7. package/dist/{db-error-COrO58t5.mjs → db-error-D5uilS_A.mjs} +13 -1
  8. package/dist/{db-error-C4JuLcvb.cjs → db-error-DTkkeu5b.cjs} +18 -0
  9. package/dist/{db-space-B4MjNjY4.d.mts → db-space-BQQEgB3d.d.mts} +1 -1
  10. package/dist/{db-space-rQFgz3-k.d.cts → db-space-Cslq5Dux.d.cts} +1 -1
  11. package/dist/{db-view-DUDnyQ3b.cjs → db-view-CuFAhzwF.cjs} +473 -118
  12. package/dist/{db-view-BYH4HJ-U.mjs → db-view-DVGWWhA3.mjs} +425 -118
  13. package/dist/index.cjs +13 -4
  14. package/dist/index.d.cts +105 -38
  15. package/dist/index.d.mts +105 -38
  16. package/dist/index.mjs +5 -5
  17. package/dist/{nested-writer-CkDo-ZfH.mjs → nested-writer-CqL24ojl.mjs} +1 -1
  18. package/dist/{nested-writer-DxPhmWFz.cjs → nested-writer-wk1EFUNY.cjs} +1 -1
  19. package/dist/ops.cjs +1 -1
  20. package/dist/ops.mjs +1 -1
  21. package/dist/rel.cjs +2 -2
  22. package/dist/rel.d.cts +1 -1
  23. package/dist/rel.d.mts +1 -1
  24. package/dist/rel.mjs +2 -2
  25. package/dist/{relation-loader-CUGcxJ18.mjs → relation-loader-B68R1LET.mjs} +1 -1
  26. package/dist/{relation-loader-C8GOpNYJ.cjs → relation-loader-BD4xANQJ.cjs} +1 -1
  27. package/dist/sync.cjs +1 -1
  28. package/dist/sync.d.cts +2 -2
  29. package/dist/sync.d.mts +2 -2
  30. package/dist/sync.mjs +1 -1
  31. package/dist/{validator-lkCJKuoo.cjs → validator-BtZbcLN2.cjs} +1 -1
  32. package/dist/{validator-wBARmD68.d.cts → validator-CewfnGZj.d.cts} +9 -2
  33. package/dist/{validator-wBARmD68.d.mts → validator-CewfnGZj.d.mts} +9 -2
  34. package/dist/{validator-CeD_fqyW.mjs → validator-Ch7UIQl9.mjs} +1 -1
  35. package/dist/validator.cjs +2 -2
  36. package/dist/validator.d.cts +1 -1
  37. package/dist/validator.d.mts +1 -1
  38. package/dist/validator.mjs +2 -2
  39. package/package.json +3 -3
@@ -1,9 +1,10 @@
1
- const require_db_error = require("./db-error-C4JuLcvb.cjs");
2
- const require_validator = require("./validator-lkCJKuoo.cjs");
3
- const require_agg = require("./agg.cjs");
4
- const require_nested_writer = require("./nested-writer-DxPhmWFz.cjs");
1
+ const require_db_error = require("./db-error-DTkkeu5b.cjs");
2
+ require("./agg.cjs");
3
+ const require_validator = require("./validator-BtZbcLN2.cjs");
4
+ const require_nested_writer = require("./nested-writer-wk1EFUNY.cjs");
5
5
  const require_ops = require("./ops.cjs");
6
6
  let _atscript_typescript_utils = require("@atscript/typescript/utils");
7
+ let _uniqu_core = require("@uniqu/core");
7
8
  let node_async_hooks = require("node:async_hooks");
8
9
  //#region src/logger.ts
9
10
  const NoopLogger = {
@@ -14,6 +15,78 @@ const NoopLogger = {
14
15
  debug: () => {}
15
16
  };
16
17
  //#endregion
18
+ //#region src/query/buckets.ts
19
+ /**
20
+ * The one normalizer of `$select` computed entries (since 0.1.132) — uniqu's
21
+ * `resolveBuckets` (entry shapes, unit, time zone canonicalization, week
22
+ * start, alias syntax and uniqueness, "grouped queries only", "must also
23
+ * appear in $groupBy", string `$groupBy` entries) with the table's names as
24
+ * the collision set: a bucket alias may not equal a logical path, a physical
25
+ * column or a navigation field, so a label is never reverse-mapped as a
26
+ * column.
27
+ *
28
+ * Which layer validates what:
29
+ * - **Shapes** (this normalizer) run FIRST at every entry point — the core's
30
+ * read path (`guardQuery`), its aggregate path (`AtscriptDbReadable.aggregate`,
31
+ * which hands the result on to `guardAggregate` and the field mapper), and
32
+ * moost-db's HTTP gate — so both layers answer with the same wording.
33
+ * Everything downstream (`collectQueryPaths`, the field mappers,
34
+ * `UniquSelect`, adapters) assumes normalized input and does not re-check.
35
+ * - **Schema** (timestamp-typed source, JSON ancestor, encryption, physical
36
+ * filterability) is the path guard's (`guardPath` op `bucket`), mirrored by
37
+ * moost-db's capability index; dimensions are the aggregate rules'.
38
+ * - **Adapter capability** (`calendarBucketUnits()`) is `guardAggregate`'s
39
+ * (`BUCKET_NOT_SUPPORTED`); SQL builders only re-assert the inlined
40
+ * literals (defense in depth).
41
+ *
42
+ * `aggregate` defaults to "`$groupBy` is non-empty".
43
+ *
44
+ * @throws DbError `INVALID_QUERY` carrying every issue (`path` `$select` / `$groupBy`).
45
+ */
46
+ function resolveCalendarBuckets(controls, fields, aggregate) {
47
+ const res = (0, _uniqu_core.resolveBuckets)(controls, {
48
+ aggregate,
49
+ isField: (name) => fields.flatMap.has(name) || fields.navFields.has(name) || fields.physicalNames.has(name)
50
+ });
51
+ if (!res.ok) throw new require_db_error.DbError("INVALID_QUERY", res.issues);
52
+ return res.buckets;
53
+ }
54
+ /**
55
+ * Whether a field can be the source of a calendar bucket: a `number` /
56
+ * `integer` leaf carrying the `timestamp` tag (`number.timestamp`,
57
+ * `.created`, `.updated`) that is not `@db.encrypted`. The type is the
58
+ * declaration — no annotation opts a field in. Physical filterability is the
59
+ * caller's (the core path guard and moost-db's capability index both add it).
60
+ */
61
+ function isBucketableField(fd) {
62
+ if (fd.encrypted) return false;
63
+ if (fd.designType !== "number" && fd.designType !== "integer") return false;
64
+ return ((fd.type?.type)?.tags)?.has("timestamp") === true;
65
+ }
66
+ /**
67
+ * Whether a field holds a JSON value — a `@db.json` object / JSON-stored
68
+ * column or an array. The members of `TableMetadata.jsonValueParents` (and
69
+ * moost-db's equivalent set) — see {@link jsonValueAncestor}.
70
+ */
71
+ function isJsonValueField(fd) {
72
+ return fd.storage === "json" || fd.designType === "json" || fd.designType === "array";
73
+ }
74
+ /**
75
+ * The outermost ancestor of `path` in `jsonValueParents` (the paths of the
76
+ * {@link isJsonValueField} descriptors), or `undefined`. A timestamp inside a
77
+ * JSON value is never a bucket source — relational adapters cannot address
78
+ * it and nested-object adapters (which can) must not diverge from them.
79
+ */
80
+ function jsonValueAncestor(path, jsonValueParents) {
81
+ if (jsonValueParents.size === 0) return void 0;
82
+ let pos = path.indexOf(".");
83
+ while (pos !== -1) {
84
+ const ancestor = path.slice(0, pos);
85
+ if (jsonValueParents.has(ancestor)) return ancestor;
86
+ pos = path.indexOf(".", pos + 1);
87
+ }
88
+ }
89
+ //#endregion
17
90
  //#region src/table/table-metadata.ts
18
91
  const INDEX_PREFIX = "atscript__";
19
92
  function indexKey(type, name) {
@@ -124,6 +197,14 @@ var TableMetadata = class {
124
197
  * dotted paths as descriptors).
125
198
  */
126
199
  jsonParents = /* @__PURE__ */ new Set();
200
+ /**
201
+ * Logical paths holding a JSON value (`isJsonValueField`: JSON-stored, `json`
202
+ * or `array` design type; non-ignored, nav-free descriptors) — a timestamp
203
+ * beneath one is never a calendar-bucket source (`jsonValueAncestor`).
204
+ */
205
+ jsonValueParents = /* @__PURE__ */ new Set();
206
+ /** Every field descriptor's `physicalName` — reserved names a bucket alias may not take. */
207
+ physicalNames = /* @__PURE__ */ new Set();
127
208
  _built = false;
128
209
  _identifications;
129
210
  _collateMap = /* @__PURE__ */ new Map();
@@ -135,6 +216,21 @@ var TableMetadata = class {
135
216
  return this._built;
136
217
  }
137
218
  /**
219
+ * Logical field path → its physical path in document storage (nested
220
+ * objects kept inline). `@db.column` renames apply to the annotated key,
221
+ * and a document renames the TOP-LEVEL key only — nested keys are stored
222
+ * as-is — so a dotted path under a renamed top-level object renames its
223
+ * first segment: `profile.bio` under `@db.column 'prof'` → `prof.bio`.
224
+ */
225
+ documentPath(path) {
226
+ const direct = this.columnMap.get(path);
227
+ if (direct !== void 0) return direct;
228
+ const dot = path.indexOf(".");
229
+ if (dot === -1) return path;
230
+ const top = this.columnMap.get(path.slice(0, dot));
231
+ return top === void 0 ? path : top + path.slice(dot);
232
+ }
233
+ /**
138
234
  * Runs the full metadata compilation pipeline. Called once by
139
235
  * `AtscriptDbReadable._ensureBuilt()` on first metadata access.
140
236
  *
@@ -187,7 +283,7 @@ var TableMetadata = class {
187
283
  this._built = true;
188
284
  adapter.onAfterFlatten?.();
189
285
  if (this.nestedObjects && this.flatMap) {
190
- for (const path of this.flatMap.keys()) if (path && !this.ignoredFields.has(path) && !this.navFields.has(path) && findAncestorInSet(path, this.navFields) === void 0) this.allPhysicalFields.push(path);
286
+ for (const path of this.flatMap.keys()) if (path && !this.ignoredFields.has(path) && !this.navFields.has(path) && findAncestorInSet(path, this.navFields) === void 0) this.allPhysicalFields.push(this.documentPath(path));
191
287
  } else for (const [path, physical] of this.pathToPhysical) {
192
288
  if (this.navFields.has(path)) continue;
193
289
  if (findAncestorInSet(path, this.navFields) !== void 0) continue;
@@ -419,19 +515,25 @@ var TableMetadata = class {
419
515
  /** Nearest `@db.encrypted` ancestor of `path` (exclusive), or `undefined`. */
420
516
  /**
421
517
  * Indexes non-ignored descriptors by logical path and retains the JSON-parent
422
- * set. Navigation relations and their descendants are skipped even when the
518
+ * sets (plus every descriptor's physical name). Navigation relations and their descendants are skipped even when the
423
519
  * adapter keeps them as descriptors (nested-object adapters do) — they are
424
520
  * loaded with `$with`, never addressed as columns of this table.
425
521
  */
426
522
  _buildGuardIndexes() {
427
523
  const jsonParents = /* @__PURE__ */ new Set();
524
+ const jsonValueParents = /* @__PURE__ */ new Set();
525
+ const physicalNames = /* @__PURE__ */ new Set();
428
526
  for (const fd of this.fieldDescriptors) {
527
+ physicalNames.add(fd.physicalName);
429
528
  if (fd.ignored) continue;
430
529
  if (this.navFields.has(fd.path) || findAncestorInSet(fd.path, this.navFields) !== void 0) continue;
431
530
  this.descriptorByPath.set(fd.path, fd);
432
531
  if (fd.storage === "json") jsonParents.add(fd.path);
532
+ if (isJsonValueField(fd)) jsonValueParents.add(fd.path);
433
533
  }
434
534
  this.jsonParents = jsonParents;
535
+ this.jsonValueParents = jsonValueParents;
536
+ this.physicalNames = physicalNames;
435
537
  }
436
538
  /**
437
539
  * Indexes `fieldDescriptors` into two lookup maps for unified
@@ -653,6 +755,11 @@ var TableMetadata = class {
653
755
  * `controls.$select` is `UniquSelect | undefined`.
654
756
  *
655
757
  * For exclusion → inclusion inversion, pass `allFields` (physical field names).
758
+ *
759
+ * An array `$select` holds plain field names and computed entries —
760
+ * aggregates (`{ $fn, $field }`, {@link aggregates}) and calendar buckets
761
+ * (`{ $bucket, $field }`, {@link buckets}). Entries arrive normalized
762
+ * (`resolveCalendarBuckets` rejects any other shape before translation).
656
763
  */
657
764
  var UniquSelect = class UniquSelect {
658
765
  static UNRESOLVED = Symbol("unresolved");
@@ -661,17 +768,29 @@ var UniquSelect = class UniquSelect {
661
768
  _array = UniquSelect.UNRESOLVED;
662
769
  _projection = UniquSelect.UNRESOLVED;
663
770
  _aggregates = UniquSelect.UNRESOLVED;
664
- constructor(raw, allFields) {
771
+ /**
772
+ * The calendar buckets of an aggregate `$select`, normalized (canonical
773
+ * zone, week start, alias) with the PHYSICAL source `field` and its
774
+ * descriptor `fd`. `undefined` when there are none. A `$groupBy` key equal
775
+ * to a bucket's `alias` groups by that bucket (aliases never collide with
776
+ * columns). Since 0.1.132.
777
+ */
778
+ buckets;
779
+ /**
780
+ * @param raw - the `$select` value (field paths already physical).
781
+ * @param allFields - physical field names, for exclusion-form inversion.
782
+ * @param buckets - the resolved calendar buckets of the raw `$select`'s
783
+ * `{ $bucket }` entries (the field mappers supply them — physical `field`,
784
+ * source `fd`).
785
+ */
786
+ constructor(raw, allFields, buckets) {
665
787
  this._raw = raw;
666
788
  this._allFields = allFields;
667
- }
668
- /** Type guard: checks if a value is an AggregateExpr ({$fn, $field}). */
669
- static _isAggregateExpr(v) {
670
- return typeof v === "object" && v !== null && "$fn" in v && "$field" in v;
789
+ this.buckets = buckets?.length ? buckets : void 0;
671
790
  }
672
791
  /**
673
792
  * Resolved inclusion array of plain field names (strings only).
674
- * AggregateExpr objects are filtered out.
793
+ * Computed entries (aggregates, calendar buckets) are filtered out.
675
794
  * For exclusion form, inverts using `allFields` from constructor.
676
795
  */
677
796
  get asArray() {
@@ -726,7 +845,7 @@ var UniquSelect = class UniquSelect {
726
845
  this._aggregates = void 0;
727
846
  return;
728
847
  }
729
- const aggs = this._raw.filter((v) => UniquSelect._isAggregateExpr(v));
848
+ const aggs = this._raw.filter(_uniqu_core.isAggregateExpr);
730
849
  this._aggregates = aggs.length > 0 ? aggs : void 0;
731
850
  return this._aggregates;
732
851
  }
@@ -734,6 +853,10 @@ var UniquSelect = class UniquSelect {
734
853
  get hasAggregates() {
735
854
  return !!this.aggregates?.length;
736
855
  }
856
+ /** The calendar bucket whose alias is `key`, if any — how adapters resolve a `$groupBy` / `$having` key. */
857
+ bucketByAlias(key) {
858
+ return this.buckets?.find((b) => b.alias === key);
859
+ }
737
860
  };
738
861
  //#endregion
739
862
  //#region src/strategies/field-mapping.ts
@@ -754,9 +877,83 @@ function toDecimalString(value) {
754
877
  * and `RelationalFieldMapper` (flattened columns, SQL).
755
878
  */
756
879
  var FieldMappingStrategy = class {
880
+ /**
881
+ * Whether {@link physicalPath} can differ from the logical path for this
882
+ * table; `false` lets the path translations hand their input back as-is.
883
+ */
884
+ renamesPaths(_meta) {
885
+ return true;
886
+ }
887
+ /**
888
+ * Translates a grouped query to physical names: the filter and `$having`
889
+ * through {@link translateFilter}, and every field path in `$groupBy`,
890
+ * `$select` (plain and computed `$field`s) and `$sort` through
891
+ * {@link physicalPath}. Computed aliases pass through (a bucket alias never
892
+ * equals a field name).
893
+ *
894
+ * `buckets` are the query's calendar buckets as the core's normalizer
895
+ * resolved them (`resolveCalendarBuckets` — `AtscriptDbReadable.aggregate`
896
+ * runs it before the guards); they reach adapters with `field` made
897
+ * physical and the source descriptor as `fd`.
898
+ */
899
+ translateAggregateQuery(query, meta, buckets) {
900
+ const controls = query.controls;
901
+ const aliases = this.computedAliasSet(controls.$select);
902
+ const physicalBuckets = buckets.map((b) => ({
903
+ ...b,
904
+ field: this.physicalPath(b.field, meta),
905
+ fd: meta.descriptorByPath.get(b.field)
906
+ }));
907
+ const select = controls.$select && this.physicalSelect(controls.$select, meta);
908
+ return {
909
+ filter: this.translateFilter(query.filter ?? {}, meta),
910
+ controls: {
911
+ ...controls,
912
+ $with: void 0,
913
+ $groupBy: this.renamesPaths(meta) ? controls.$groupBy.map((key) => aliases.has(key) ? key : this.physicalPath(key, meta)) : controls.$groupBy,
914
+ $select: select ? new UniquSelect(select, meta.allPhysicalFields, physicalBuckets) : void 0,
915
+ $sort: controls.$sort && this.physicalSort(controls.$sort, meta, aliases),
916
+ $having: controls.$having ? this.translateFilter(controls.$having, meta) : void 0
917
+ },
918
+ insights: query.insights
919
+ };
920
+ }
921
+ /** Output aliases of the computed `$select` entries (aggregates and calendar buckets). */
922
+ computedAliasSet(select) {
923
+ const aliases = /* @__PURE__ */ new Set();
924
+ for (const item of select ?? []) if ((0, _uniqu_core.isAggregateExpr)(item) || (0, _uniqu_core.isBucketExpr)(item)) aliases.add((0, _uniqu_core.resolveAlias)(item));
925
+ return aliases;
926
+ }
927
+ /**
928
+ * `$select` with its field paths made physical: array-form names and
929
+ * computed `$field`s (`'*'` kept), or the keys of the object
930
+ * (inclusion / exclusion) form.
931
+ */
932
+ physicalSelect(select, meta) {
933
+ if (!this.renamesPaths(meta)) return select;
934
+ if (Array.isArray(select)) return select.map((item) => {
935
+ if (typeof item === "string") return this.physicalPath(item, meta);
936
+ if ((0, _uniqu_core.isAggregateExpr)(item) || (0, _uniqu_core.isBucketExpr)(item)) return item.$field === "*" ? item : {
937
+ ...item,
938
+ $field: this.physicalPath(item.$field, meta)
939
+ };
940
+ return item;
941
+ });
942
+ const translated = {};
943
+ for (const [key, flag] of Object.entries(select)) translated[this.physicalPath(key, meta)] = flag;
944
+ return translated;
945
+ }
946
+ /** `$sort` with physical keys; computed `aliases` (grouped queries) pass through. */
947
+ physicalSort(sort, meta, aliases) {
948
+ if (!this.renamesPaths(meta)) return sort;
949
+ const translated = {};
950
+ for (const [key, dir] of Object.entries(sort)) translated[aliases?.has(key) ? key : this.physicalPath(key, meta)] = dir;
951
+ return translated;
952
+ }
757
953
  /**
758
954
  * Recursively walks a filter expression, applying `@db.column` key renames
759
- * via `columnMap` and adapter-specific value formatting via `formatFilterValue`.
955
+ * (document paths — {@link TableMetadata.documentPath}) and adapter-specific
956
+ * value formatting via `formatFilterValue`.
760
957
  *
761
958
  * The relational mapper overrides this to use `leafByLogical` for deeper
762
959
  * key resolution (flattened nested paths).
@@ -769,8 +966,8 @@ var FieldMappingStrategy = class {
769
966
  else if (key === "$not") result[key] = this.translateFilter(value, meta);
770
967
  else if (key.startsWith("$")) result[key] = value;
771
968
  else {
772
- const physical = meta.columnMap.get(key) ?? key;
773
- result[physical] = this.formatFilterValue(physical, value, meta);
969
+ const formatKey = meta.columnMap.get(key) ?? key;
970
+ result[meta.documentPath(key)] = this.formatFilterValue(formatKey, value, meta);
774
971
  }
775
972
  return result;
776
973
  }
@@ -898,29 +1095,31 @@ var DocumentFieldMapper = class extends FieldMappingStrategy {
898
1095
  if (meta.columnMap.size > 0) this.reverseColumnRenames(row, meta);
899
1096
  return row;
900
1097
  }
1098
+ /**
1099
+ * Every field-path position goes through `@db.column` renames
1100
+ * ({@link TableMetadata.documentPath}): filter keys, `$select` fields
1101
+ * (array, inclusion and exclusion forms) and `$sort` keys.
1102
+ */
901
1103
  translateQuery(query, meta) {
902
1104
  const controls = query.controls;
1105
+ const select = controls?.$select && this.physicalSelect(controls.$select, meta);
903
1106
  return {
904
1107
  filter: this.translateFilter(query.filter, meta),
905
1108
  controls: {
906
1109
  ...controls,
907
1110
  $with: void 0,
908
- $select: controls?.$select ? new UniquSelect(controls.$select, meta.allPhysicalFields) : void 0
1111
+ $select: select ? new UniquSelect(select, meta.allPhysicalFields) : void 0,
1112
+ $sort: controls?.$sort && this.physicalSort(controls.$sort, meta)
909
1113
  },
910
1114
  insights: query.insights
911
1115
  };
912
1116
  }
913
- translateAggregateQuery(query, meta) {
914
- const controls = query.controls;
915
- return {
916
- filter: this.translateFilter(query.filter ?? {}, meta),
917
- controls: {
918
- ...controls,
919
- $with: void 0,
920
- $select: controls.$select ? new UniquSelect(controls.$select, meta.allPhysicalFields) : void 0
921
- },
922
- insights: query.insights
923
- };
1117
+ /** A document path with `@db.column` renames ({@link TableMetadata.documentPath}). */
1118
+ physicalPath(logical, meta) {
1119
+ return meta.documentPath(logical);
1120
+ }
1121
+ renamesPaths(meta) {
1122
+ return meta.columnMap.size > 0;
924
1123
  }
925
1124
  prepareForWrite(payload, meta, adapter) {
926
1125
  const data = { ...payload };
@@ -995,46 +1194,9 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
995
1194
  insights: query.insights
996
1195
  };
997
1196
  }
998
- translateAggregateQuery(query, meta) {
999
- const controls = query.controls;
1000
- const filter = meta.requiresMappings ? this.translateFilterWithRename(query.filter ?? {}, meta) : meta.toStorageFormatters ? this.translateFilter(query.filter ?? {}, meta) : query.filter ?? {};
1001
- const groupBy = controls.$groupBy.map((field) => meta.leafByLogical.get(field)?.physicalName ?? field);
1002
- let select;
1003
- if (controls.$select) select = controls.$select.map((item) => {
1004
- if (typeof item === "string") return meta.leafByLogical.get(item)?.physicalName ?? item;
1005
- if (item.$field === "*") return item;
1006
- return {
1007
- ...item,
1008
- $field: meta.leafByLogical.get(item.$field)?.physicalName ?? item.$field
1009
- };
1010
- });
1011
- const aliases = /* @__PURE__ */ new Set();
1012
- if (controls.$select) {
1013
- for (const item of controls.$select) if (typeof item !== "string") aliases.add(require_agg.resolveAlias(item));
1014
- }
1015
- let sort;
1016
- if (controls.$sort) {
1017
- const translated = {};
1018
- for (const [key, dir] of Object.entries(controls.$sort)) if (aliases.has(key)) translated[key] = dir;
1019
- else {
1020
- const physical = meta.leafByLogical.get(key)?.physicalName ?? key;
1021
- translated[physical] = dir;
1022
- }
1023
- sort = translated;
1024
- }
1025
- let having;
1026
- if (controls.$having) having = meta.requiresMappings ? this.translateFilterWithRename(controls.$having, meta) : meta.toStorageFormatters ? this.translateFilter(controls.$having, meta) : controls.$having;
1027
- return {
1028
- filter,
1029
- controls: {
1030
- ...controls,
1031
- $groupBy: groupBy,
1032
- $select: select ? new UniquSelect(select, meta.allPhysicalFields) : void 0,
1033
- $sort: sort,
1034
- $having: having
1035
- },
1036
- insights: query.insights
1037
- };
1197
+ /** The flattened column of a logical path (`contact.email` → `contact__email`). */
1198
+ physicalPath(logical, meta) {
1199
+ return meta.leafByLogical.get(logical)?.physicalName ?? logical;
1038
1200
  }
1039
1201
  /**
1040
1202
  * Overrides the base `translateFilter` to use `leafByLogical` for key resolution
@@ -1196,11 +1358,18 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
1196
1358
  * - `$geoWithin` on a non-geoPoint field → `FILTER_TYPE_MISMATCH`
1197
1359
  * - `$geoWithin` with a malformed circle → `INVALID_QUERY`
1198
1360
  * - `$geoWithin` on an adapter without geo support → `GEO_NOT_SUPPORTED`
1361
+ * - `$exists` with a non-boolean operand → `INVALID_QUERY`
1362
+ * - malformed `$select` computed entries / `$groupBy` entries, calendar
1363
+ * buckets outside grouped queries or with a bad unit / zone / alias →
1364
+ * `INVALID_QUERY` (the shared normalizer, `resolveCalendarBuckets`)
1365
+ * - a calendar bucket over a non-timestamp field → `INVALID_QUERY`; a unit
1366
+ * the adapter's `calendarBucketUnits()` lacks → `BUCKET_NOT_SUPPORTED`
1199
1367
  * - every filter / `$sort` / `$select` / `$groupBy` / `$having` / aggregate
1200
1368
  * path must resolve to physical storage on THIS adapter and pass the
1201
- * adapter's `canFilterField` / `canSortField` → `INVALID_QUERY`
1202
- * (see {@link guardPaths}). Runs after the checks above so `ENC_*` codes
1203
- * keep firing first for encrypted subtrees.
1369
+ * capability its position (for a filter entry: its predicate class, see
1370
+ * {@link canFilterLeaf}) needs → `INVALID_QUERY` (see {@link guardPaths}).
1371
+ * Runs after the checks above so `ENC_*` codes keep firing first for
1372
+ * encrypted subtrees.
1204
1373
  */
1205
1374
  /** Validates a `[lng, lat]` tuple (GeoJSON coordinate order). */
1206
1375
  function assertGeoPoint(point, path) {
@@ -1241,7 +1410,8 @@ function guardGeoWithin(meta, adapter, field, value) {
1241
1410
  }
1242
1411
  /**
1243
1412
  * Walks a filter expression, rejecting encrypted-field references and
1244
- * validating `$geoWithin` operator nodes.
1413
+ * validating operator operands: `$geoWithin` shapes and the boolean
1414
+ * `$exists` operand (this is the one owner of that rule).
1245
1415
  */
1246
1416
  function guardFilter(meta, adapter, filter, encCode = "ENC_FIELD_FILTER") {
1247
1417
  if (!filter || typeof filter !== "object") return;
@@ -1257,8 +1427,12 @@ function guardFilter(meta, adapter, filter, encCode = "ENC_FIELD_FILTER") {
1257
1427
  }
1258
1428
  if (key.startsWith("$")) continue;
1259
1429
  if (hasEncrypted && isEncryptedRef(meta, key)) throw encryptedRefError(encCode, key, "filter on");
1260
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1430
+ if (!(0, _uniqu_core.isPrimitive)(value)) {
1261
1431
  for (const [op, opValue] of Object.entries(value)) if (op === "$geoWithin") guardGeoWithin(meta, adapter, key, opValue);
1432
+ else if (op === "$exists" && typeof opValue !== "boolean") throw new require_db_error.DbError("INVALID_QUERY", [{
1433
+ path: key,
1434
+ message: `$exists on "${key}" expects true or false`
1435
+ }]);
1262
1436
  }
1263
1437
  }
1264
1438
  }
@@ -1273,7 +1447,8 @@ const OP_VERB = {
1273
1447
  select: "select",
1274
1448
  groupBy: "group by",
1275
1449
  having: "filter ($having) on",
1276
- aggregate: "aggregate over"
1450
+ aggregate: "aggregate over",
1451
+ bucket: "bucket"
1277
1452
  };
1278
1453
  function pathError(path, message) {
1279
1454
  return new require_db_error.DbError("INVALID_QUERY", [{
@@ -1313,16 +1488,64 @@ function sortFieldNames(sort) {
1313
1488
  if (typeof sort === "object") return Object.keys(sort);
1314
1489
  return [];
1315
1490
  }
1316
- function collectFilterKeys(filter, out, geo, skip, refs) {
1491
+ /** The operator each non-`compare` predicate class stands for, in `filterOps` order. */
1492
+ const FILTER_PREDICATE_OPS = {
1493
+ exists: "$exists",
1494
+ geo: "$geoWithin"
1495
+ };
1496
+ /** Classifies one filter entry's value (`{ key: value }`); operand validity is `guardFilter`'s. */
1497
+ function filterPredicateOf(value) {
1498
+ if ((0, _uniqu_core.isPrimitive)(value)) return "compare";
1499
+ const ops = value;
1500
+ if ("$geoWithin" in ops) return "geo";
1501
+ const keys = Object.keys(ops);
1502
+ return keys.length === 1 && keys[0] === "$exists" ? "exists" : "compare";
1503
+ }
1504
+ /**
1505
+ * Whether a stored leaf physically supports a filter predicate of this class
1506
+ * — the one rule the core path guard and moost-db's HTTP capability index
1507
+ * both apply:
1508
+ *
1509
+ * - `compare` → `adapter.canFilterField(fd)`;
1510
+ * - `exists` → any stored column: it tests whether the column holds a value
1511
+ * (SQL `IS [NOT] NULL`), never its content, so the scalar veto (JSON /
1512
+ * array storage on relational adapters) does not apply;
1513
+ * - `geo` → a `db.geoPoint` leaf on a geo-searchable adapter.
1514
+ *
1515
+ * `@db.encrypted` vetoes every class.
1516
+ */
1517
+ function canFilterLeaf(fd, predicate, adapter) {
1518
+ if (fd.encrypted) return false;
1519
+ switch (predicate) {
1520
+ case "exists": return true;
1521
+ case "geo": return fd.isGeoPoint === true && adapter.isGeoSearchable();
1522
+ default: return adapter.canFilterField(fd);
1523
+ }
1524
+ }
1525
+ /**
1526
+ * The operators of the non-`compare` predicate classes a leaf physically
1527
+ * accepts — named in a value-comparison rejection by the core guard, and
1528
+ * listed (under the HTTP policy) as `/meta.fields[P].filterOps`.
1529
+ */
1530
+ function narrowerFilterOps(fd, adapter) {
1531
+ const ops = [];
1532
+ for (const [predicate, op] of Object.entries(FILTER_PREDICATE_OPS)) if (canFilterLeaf(fd, predicate, adapter)) ops.push(op);
1533
+ return ops;
1534
+ }
1535
+ /** The rejection suffix naming {@link narrowerFilterOps} — `""` when there are none. */
1536
+ function acceptedOperatorsHint(ops) {
1537
+ return ops.length > 0 ? ` (accepted operators: ${ops.join(", ")})` : "";
1538
+ }
1539
+ function collectFilterKeys(filter, push, skip, refs) {
1317
1540
  if (!filter || typeof filter !== "object" || Array.isArray(filter)) return;
1318
1541
  for (const [key, value] of Object.entries(filter)) {
1319
1542
  if (refs.unsupportedOperator) return;
1320
1543
  if (key === "$and" || key === "$or") {
1321
- if (Array.isArray(value)) for (const child of value) collectFilterKeys(child, out, geo, skip, refs);
1544
+ if (Array.isArray(value)) for (const child of value) collectFilterKeys(child, push, skip, refs);
1322
1545
  continue;
1323
1546
  }
1324
1547
  if (key === "$not") {
1325
- collectFilterKeys(value, out, geo, skip, refs);
1548
+ collectFilterKeys(value, push, skip, refs);
1326
1549
  continue;
1327
1550
  }
1328
1551
  if (key.startsWith("$")) {
@@ -1330,7 +1553,7 @@ function collectFilterKeys(filter, out, geo, skip, refs) {
1330
1553
  return;
1331
1554
  }
1332
1555
  if (skip?.has(key)) continue;
1333
- (geo !== void 0 && value !== null && typeof value === "object" && !Array.isArray(value) && "$geoWithin" in value ? geo : out).push(key);
1556
+ push(key, value);
1334
1557
  }
1335
1558
  }
1336
1559
  /**
@@ -1340,40 +1563,52 @@ function collectFilterKeys(filter, out, geo, skip, refs) {
1340
1563
  * visited — they are validated against their target relation separately.
1341
1564
  *
1342
1565
  * Aggregate mode is `aggregate` when given, else the presence of `$groupBy`.
1343
- * In aggregate mode `$select` entries are aggregate expressions whose
1344
- * `$field` is collected and whose alias (`$as`, else `fn_field` — the core's
1345
- * `resolveAlias`) is exempted from `$sort` / `$having`; outside it non-string
1346
- * `$select` entries are ignored (the projection seal handles them).
1566
+ * In aggregate mode `$select` computed entries are collected by kind — an
1567
+ * aggregate's `$field` into `aggregate`, a calendar bucket's into `bucket` —
1568
+ * and their aliases (`$as`, else uniqu's `resolveAlias`) are exempted from
1569
+ * `$sort` / `$having`; a bucket alias is also dropped from `groupBy`, which
1570
+ * lists grouped fields only.
1571
+ *
1572
+ * Entry shapes are not checked here — see `resolveCalendarBuckets`.
1347
1573
  */
1348
1574
  function collectQueryPaths(query, aggregate) {
1349
1575
  const refs = {
1350
1576
  filter: [],
1351
- geoFilter: [],
1352
1577
  sort: [],
1353
1578
  select: [],
1354
1579
  groupBy: [],
1355
1580
  having: [],
1356
1581
  aggregate: [],
1582
+ bucket: [],
1357
1583
  aggregateMode: false
1358
1584
  };
1359
- collectFilterKeys(query.filter, refs.filter, refs.geoFilter, void 0, refs);
1585
+ collectFilterKeys(query.filter, (path, value) => refs.filter.push({
1586
+ path,
1587
+ predicate: filterPredicateOf(value)
1588
+ }), void 0, refs);
1360
1589
  const controls = query.controls ?? {};
1361
1590
  const rawGroupBy = controls.$groupBy;
1362
1591
  const groupBy = Array.isArray(rawGroupBy) ? rawGroupBy.filter((f) => typeof f === "string") : typeof rawGroupBy === "string" ? [rawGroupBy] : [];
1363
1592
  refs.aggregateMode = aggregate ?? groupBy.length > 0;
1364
- refs.groupBy = groupBy;
1365
1593
  const aliases = /* @__PURE__ */ new Set();
1594
+ const bucketAliases = /* @__PURE__ */ new Set();
1366
1595
  const select = controls.$select;
1367
1596
  if (Array.isArray(select)) {
1368
1597
  for (const item of select) if (typeof item === "string") refs.select.push(item);
1369
- else if (refs.aggregateMode && item && typeof item === "object" && "$field" in item) {
1370
- const expr = item;
1371
- aliases.add(require_agg.resolveAlias(expr));
1372
- if (expr.$field !== "*") refs.aggregate.push(expr.$field);
1598
+ else if (!refs.aggregateMode) continue;
1599
+ else if ((0, _uniqu_core.isAggregateExpr)(item)) {
1600
+ aliases.add((0, _uniqu_core.resolveAlias)(item));
1601
+ if (item.$field !== "*") refs.aggregate.push(item.$field);
1602
+ } else if ((0, _uniqu_core.isBucketExpr)(item)) {
1603
+ const alias = (0, _uniqu_core.resolveAlias)(item);
1604
+ aliases.add(alias);
1605
+ bucketAliases.add(alias);
1606
+ refs.bucket.push(item.$field);
1373
1607
  }
1374
1608
  } else if (select && typeof select === "object") refs.select.push(...Object.keys(select));
1609
+ refs.groupBy = bucketAliases.size > 0 ? groupBy.filter((name) => !bucketAliases.has(name)) : groupBy;
1375
1610
  for (const name of sortFieldNames(controls.$sort)) if (!aliases.has(name)) refs.sort.push(name);
1376
- if (refs.aggregateMode) collectFilterKeys(controls.$having, refs.having, void 0, aliases, refs);
1611
+ if (refs.aggregateMode) collectFilterKeys(controls.$having, (path) => refs.having.push(path), aliases, refs);
1377
1612
  return refs;
1378
1613
  }
1379
1614
  /**
@@ -1424,21 +1659,22 @@ function pathSourceOf(meta) {
1424
1659
  * metadata and adapter capability — the classification of
1425
1660
  * {@link classifyQueryPath} plus the position's physical requirement:
1426
1661
  *
1427
- * - a leaf → physical capability (`canFilterField` / `canSortField`;
1428
- * `$select` always passes);
1662
+ * - a leaf → physical capability (`canSortField` for `$sort`; for a filter
1663
+ * entry {@link canFilterLeaf} of its `predicate` class; `canFilterField`
1664
+ * for `$groupBy` / `$having` / aggregate `$field`s; `$select` always passes);
1665
+ * a calendar-bucket source must also be a timestamp field
1666
+ * (`isBucketableField`);
1429
1667
  * - a nested-object parent → only `$select`, and only when it expands to
1430
1668
  * leaf columns (`selectExpansion`);
1431
1669
  * - everything else is rejected.
1432
1670
  *
1433
- * `geoPredicate` marks a filter entry whose operator is `$geoWithin`: its
1434
- * shape and index support were already validated by {@link guardFilter}, so
1435
- * the adapter's scalar `canFilterField` veto does not apply.
1671
+ * `predicate` is a filter entry's class; other positions leave the default.
1436
1672
  *
1437
1673
  * Messages are the short programmatic forms; the HTTP wording (moost-db's
1438
1674
  * `FieldCapabilityIndex`, with `$with` hints and leaf lists) is what clients
1439
1675
  * see and is authoritative — the HTTP gate always answers first.
1440
1676
  */
1441
- function guardPath(meta, adapter, path, op, geoPredicate = false) {
1677
+ function guardPath(meta, adapter, path, op, predicate = "compare") {
1442
1678
  const verb = OP_VERB[op];
1443
1679
  const { kind, parent } = classifyQueryPath(pathSourceOf(meta), path);
1444
1680
  switch (kind) {
@@ -1446,12 +1682,18 @@ function guardPath(meta, adapter, path, op, geoPredicate = false) {
1446
1682
  case "leaf": {
1447
1683
  if (op === "select") return;
1448
1684
  const fd = meta.descriptorByPath.get(path);
1685
+ if (op === "bucket") {
1686
+ const jsonAncestor = jsonValueAncestor(path, meta.jsonValueParents);
1687
+ if (jsonAncestor !== void 0) throw pathError(path, `Cannot bucket "${path}" — inside JSON-stored column "${jsonAncestor}"`);
1688
+ if (!isBucketableField(fd)) throw pathError(path, notTimestampMessage(path));
1689
+ if (!adapter.canFilterField(fd)) throw pathError(path, `Cannot bucket "${path}" — adapter cannot filter on this storage type`);
1690
+ return;
1691
+ }
1449
1692
  if (op === "sort") {
1450
1693
  if (!adapter.canSortField(fd)) throw pathError(path, `Cannot sort by "${path}" — adapter cannot sort on this storage type`);
1451
1694
  return;
1452
1695
  }
1453
- if (geoPredicate) return;
1454
- if (!adapter.canFilterField(fd)) throw pathError(path, `Cannot ${verb} "${path}" — adapter cannot filter on this storage type`);
1696
+ if (!canFilterLeaf(fd, predicate, adapter)) throw pathError(path, `Cannot ${verb} "${path}" — adapter cannot filter on this storage type${op === "filter" ? acceptedOperatorsHint(narrowerFilterOps(fd, adapter)) : ""}`);
1455
1697
  return;
1456
1698
  }
1457
1699
  case "objectParent":
@@ -1469,9 +1711,13 @@ function guardPath(meta, adapter, path, op, geoPredicate = false) {
1469
1711
  * {@link guardPath}). Adapters may therefore assume every path they receive
1470
1712
  * is physical.
1471
1713
  *
1472
- * In aggregate mode (`aggregate = true`) `$select` entries are aggregate
1473
- * expressions whose `$field` is checked, `$groupBy` fields are checked, and
1474
- * aggregate aliases (`$as` or `fn_field`) are exempt in `$sort` / `$having`.
1714
+ * Entry shapes are the caller's to normalize first (`resolveCalendarBuckets`
1715
+ * — {@link guardQuery} / {@link guardAggregate} do).
1716
+ *
1717
+ * In aggregate mode (`aggregate = true`) `$select` computed entries have
1718
+ * their `$field` checked (aggregates as `aggregate`, calendar buckets as
1719
+ * `bucket`), `$groupBy` fields are checked, and computed aliases (`$as` or
1720
+ * the default) are exempt in `$sort` / `$having`.
1475
1721
  *
1476
1722
  * Returns the collected refs so callers can run further structural rules
1477
1723
  * (see {@link checkHavingKeys}) without walking the query again.
@@ -1480,27 +1726,32 @@ function guardPaths(meta, adapter, query, aggregate = false) {
1480
1726
  if (!query) return;
1481
1727
  const refs = collectQueryPaths(query, aggregate);
1482
1728
  if (refs.unsupportedOperator !== void 0) throw pathError(refs.unsupportedOperator, unsupportedOperatorMessage(refs.unsupportedOperator));
1483
- for (const path of refs.filter) guardPath(meta, adapter, path, "filter");
1484
- for (const path of refs.geoFilter) guardPath(meta, adapter, path, "filter", true);
1729
+ for (const ref of refs.filter) guardPath(meta, adapter, ref.path, "filter", ref.predicate);
1485
1730
  for (const path of refs.sort) guardPath(meta, adapter, path, "sort");
1486
1731
  for (const path of refs.select) guardPath(meta, adapter, path, "select");
1487
1732
  for (const path of refs.aggregate) guardPath(meta, adapter, path, "aggregate");
1733
+ for (const path of refs.bucket) guardPath(meta, adapter, path, "bucket");
1488
1734
  for (const path of refs.groupBy) guardPath(meta, adapter, path, "groupBy");
1489
1735
  for (const path of refs.having) guardPath(meta, adapter, path, "having");
1490
1736
  return refs;
1491
1737
  }
1492
- /** Shared read-path guard: filter + $sort encryption checks, then the path guard. */
1738
+ /**
1739
+ * Shared read-path guard: filter + $sort encryption checks, the `$select`
1740
+ * normalizer (a calendar bucket is invalid outside a grouped query), then
1741
+ * the path guard.
1742
+ */
1493
1743
  function guardQuery(meta, adapter, query) {
1494
1744
  if (!query) return;
1495
1745
  guardFilter(meta, adapter, query.filter);
1496
1746
  guardSort(meta, query.controls?.$sort);
1747
+ resolveCalendarBuckets(query.controls, meta, false);
1497
1748
  guardPaths(meta, adapter, query);
1498
1749
  }
1499
1750
  /**
1500
- * `$having` is a post-aggregation filter, so a key is either an aggregate
1501
- * alias (`$as`, else `fn_field` — already exempt in {@link collectQueryPaths})
1502
- * or a `$groupBy` field (exact logical-path match: `metadata.clicks` grouped
1503
- * stays valid). Any other key — a real but non-grouped column included — is
1751
+ * `$having` is a post-aggregation filter, so a key is either a computed
1752
+ * alias (aggregate or calendar bucket — already exempt in
1753
+ * {@link collectQueryPaths}) or a `$groupBy` field (exact logical-path
1754
+ * match: `metadata.clicks` grouped stays valid). Any other key — a real but non-grouped column included — is
1504
1755
  * rejected here, once, for SDK and HTTP callers alike, instead of by the
1505
1756
  * engine (PostgreSQL / MySQL error, SQLite tolerance, Mongo `[]`). Returns
1506
1757
  * the first offending key as an error entry (`path` = the bare key, as the
@@ -1516,11 +1767,16 @@ function checkHavingKeys(refs) {
1516
1767
  }
1517
1768
  /**
1518
1769
  * Aggregate-path guard: $groupBy / $select / $having encryption refs + filter
1519
- * + $sort, then the path guard, then the `$having` key rule
1770
+ * + $sort, then the path guard, then the adapter's calendar-bucket units
1771
+ * (`BUCKET_NOT_SUPPORTED`), then the `$having` key rule
1520
1772
  * ({@link checkHavingKeys} — after the path guard so an unknown key still
1521
1773
  * reads `Unknown field`).
1774
+ *
1775
+ * `buckets` are the query's resolved calendar buckets when the caller already
1776
+ * ran `resolveCalendarBuckets` (resolved here otherwise).
1522
1777
  */
1523
- function guardAggregate(meta, adapter, query) {
1778
+ function guardAggregate(meta, adapter, query, resolved) {
1779
+ const buckets = resolved ?? resolveCalendarBuckets(query.controls, meta, true);
1524
1780
  guardFilter(meta, adapter, query.filter);
1525
1781
  const controls = query.controls;
1526
1782
  if (meta.encryptedFields.size > 0) {
@@ -1533,9 +1789,27 @@ function guardAggregate(meta, adapter, query) {
1533
1789
  guardSort(meta, controls.$sort);
1534
1790
  }
1535
1791
  const refs = guardPaths(meta, adapter, query, true);
1792
+ guardBucketUnits(adapter, buckets);
1536
1793
  const having = refs ? checkHavingKeys(refs) : void 0;
1537
1794
  if (having) throw new require_db_error.DbError("INVALID_QUERY", [having]);
1538
1795
  }
1796
+ /** The core wording for a bucket over a field that is not `number.timestamp`. */
1797
+ function notTimestampMessage(path) {
1798
+ return `Cannot bucket "${path}" — not a timestamp field (declare it number.timestamp)`;
1799
+ }
1800
+ /**
1801
+ * Rejects a calendar bucket whose unit this adapter cannot group by
1802
+ * (`calendarBucketUnits()`; empty by default) with `BUCKET_NOT_SUPPORTED`,
1803
+ * before anything is translated — third-party adapters get a clean 400,
1804
+ * never an engine error or a silent fallback.
1805
+ */
1806
+ function guardBucketUnits(adapter, buckets) {
1807
+ const units = adapter.calendarBucketUnits();
1808
+ for (const b of buckets) if (!units.has(b.unit)) throw new require_db_error.DbError("BUCKET_NOT_SUPPORTED", [{
1809
+ path: "$select",
1810
+ message: `Calendar bucket "${b.unit}" is not supported by this adapter`
1811
+ }]);
1812
+ }
1539
1813
  //#endregion
1540
1814
  //#region src/table/db-readable.ts
1541
1815
  /**
@@ -2002,9 +2276,14 @@ var AtscriptDbReadable = class {
2002
2276
  * Executes an aggregate query with GROUP BY and aggregate functions.
2003
2277
  *
2004
2278
  * Validates:
2279
+ * - `$select` computed entries and calendar buckets (the shared normalizer,
2280
+ * `resolveCalendarBuckets`: shapes, unit, zone, alias, grouping)
2005
2281
  * - Plain fields in $select are a subset of $groupBy
2006
2282
  * - When dimensions/measures are defined (strict mode): $groupBy fields
2007
- * must be dimensions, aggregate $field values must be measures (or '*')
2283
+ * must be dimensions (a calendar bucket's source field included),
2284
+ * aggregate $field values must be measures (or '*')
2285
+ * - the path guard (a bucket source must be a timestamp field) and the
2286
+ * adapter's calendar-bucket units (`BUCKET_NOT_SUPPORTED`)
2008
2287
  *
2009
2288
  * Translates field names, delegates to adapter.aggregate(),
2010
2289
  * then reverse-maps and applies fromStorage formatters on results.
@@ -2012,6 +2291,7 @@ var AtscriptDbReadable = class {
2012
2291
  async aggregate(query) {
2013
2292
  this._ensureBuilt();
2014
2293
  const { $groupBy, $select } = query.controls;
2294
+ const buckets = resolveCalendarBuckets(query.controls, this._meta, true);
2015
2295
  if ($select) {
2016
2296
  const groupBySet = new Set($groupBy);
2017
2297
  for (const item of $select) if (typeof item === "string" && !groupBySet.has(item)) throw new require_db_error.DbError("INVALID_QUERY", [{
@@ -2023,12 +2303,13 @@ var AtscriptDbReadable = class {
2023
2303
  if (dimensions.length > 0 || measures.length > 0) {
2024
2304
  const dimSet = new Set(dimensions);
2025
2305
  const measSet = new Set(measures);
2026
- for (const field of $groupBy) if (!dimSet.has(field)) throw new require_db_error.DbError("INVALID_QUERY", [{
2306
+ const bucketSource = new Map(buckets.map((b) => [b.alias, b.field]));
2307
+ for (const field of $groupBy.map((key) => bucketSource.get(key) ?? key)) if (!dimSet.has(field)) throw new require_db_error.DbError("INVALID_QUERY", [{
2027
2308
  path: "$groupBy",
2028
2309
  message: `Field "${field}" is not a dimension`
2029
2310
  }]);
2030
2311
  if ($select) {
2031
- for (const item of $select) if (typeof item !== "string" && item.$field !== "*" && !measSet.has(item.$field)) throw new require_db_error.DbError("INVALID_QUERY", [{
2312
+ 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", [{
2032
2313
  path: "$select",
2033
2314
  message: `Aggregate field "${item.$field}" is not a measure`
2034
2315
  }]);
@@ -2038,8 +2319,7 @@ var AtscriptDbReadable = class {
2038
2319
  if ($select && quantityRefByField.size > 0) {
2039
2320
  const groupBySet = new Set($groupBy);
2040
2321
  for (const item of $select) {
2041
- if (typeof item === "string") continue;
2042
- if (item.$field === "*") continue;
2322
+ if (!(0, _uniqu_core.isAggregateExpr)(item) || item.$field === "*") continue;
2043
2323
  const refField = quantityRefByField.get(item.$field);
2044
2324
  if (refField && !groupBySet.has(refField)) throw new require_db_error.DbError("INVALID_QUERY", [{
2045
2325
  path: "$select",
@@ -2052,8 +2332,8 @@ var AtscriptDbReadable = class {
2052
2332
  this._ensureSearchable();
2053
2333
  if (!searchTerm.trim()) return query.controls.$count ? [{ count: 0 }] : [];
2054
2334
  }
2055
- guardAggregate(this._meta, this.adapter, query);
2056
- const dbQuery = this._fieldMapper.translateAggregateQuery(query, this._meta);
2335
+ guardAggregate(this._meta, this.adapter, query, buckets);
2336
+ const dbQuery = this._fieldMapper.translateAggregateQuery(query, this._meta, buckets);
2057
2337
  return (await this.adapter.aggregate(dbQuery)).map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
2058
2338
  }
2059
2339
  /** Whether the underlying adapter supports text search. */
@@ -2064,6 +2344,10 @@ var AtscriptDbReadable = class {
2064
2344
  canFilterField(fd) {
2065
2345
  return this.adapter.canFilterField(fd);
2066
2346
  }
2347
+ /** Calendar-bucket units the adapter can group by (proxies adapter capability; empty = none). */
2348
+ calendarBucketUnits() {
2349
+ return this.adapter.calendarBucketUnits();
2350
+ }
2067
2351
  /** Whether the adapter can sort by a given field (proxies adapter capability). */
2068
2352
  canSortField(fd) {
2069
2353
  return this.adapter.canSortField(fd);
@@ -2350,7 +2634,7 @@ var AtscriptDbReadable = class {
2350
2634
  * Public entry point for relation loading. Used by adapters for nested $with delegation.
2351
2635
  */
2352
2636
  async loadRelations(rows, withRelations) {
2353
- const { loadRelationsImpl } = await Promise.resolve().then(() => require("./relation-loader-C8GOpNYJ.cjs")).then((n) => n.relation_loader_exports);
2637
+ const { loadRelationsImpl } = await Promise.resolve().then(() => require("./relation-loader-BD4xANQJ.cjs")).then((n) => n.relation_loader_exports);
2354
2638
  return loadRelationsImpl(rows, withRelations, this);
2355
2639
  }
2356
2640
  /**
@@ -2415,6 +2699,9 @@ function createFailureCollector(what) {
2415
2699
  //#endregion
2416
2700
  //#region src/base-adapter.ts
2417
2701
  const EMPTY_DEFAULT_FNS = /* @__PURE__ */ new Set();
2702
+ const EMPTY_BUCKET_UNITS = /* @__PURE__ */ new Set();
2703
+ /** Every calendar-bucket unit — what an adapter that renders them all returns from `calendarBucketUnits()`. */
2704
+ const ALL_BUCKET_UNITS = new Set(_uniqu_core.BUCKET_UNITS);
2418
2705
  const txStorage = new node_async_hooks.AsyncLocalStorage();
2419
2706
  /** The innermost open transaction of `owner` in the current async chain. */
2420
2707
  function findTxContext(owner) {
@@ -2629,6 +2916,23 @@ var BaseDbAdapter = class {
2629
2916
  return EMPTY_DEFAULT_FNS;
2630
2917
  }
2631
2918
  /**
2919
+ * Calendar-bucket units (`{ $bucket, $field }` in an aggregate `$select`)
2920
+ * this adapter can group by, over IANA time zones. Empty (the default) =
2921
+ * calendar buckets unsupported: the core rejects them with
2922
+ * `BUCKET_NOT_SUPPORTED` before dispatch, and moost-db's `/meta` advertises
2923
+ * no bucketable field.
2924
+ *
2925
+ * A set rather than a boolean (like {@link nativeDefaultFns}) so a unit can
2926
+ * be adopted adapter by adapter. An adapter that returns a unit must group
2927
+ * by the bucket alias in `$groupBy` — see `controls.$select.buckets`
2928
+ * (`TResolvedBucket`: physical `field`, source `fd`) — and return the
2929
+ * `YYYY-MM-DD` label of the bucket's first local day (null for a null or
2930
+ * out-of-range source, uniqu's `bucketLabel` semantics). Since 0.1.132.
2931
+ */
2932
+ calendarBucketUnits() {
2933
+ return EMPTY_BUCKET_UNITS;
2934
+ }
2935
+ /**
2632
2936
  * Whether this adapter enforces foreign key constraints natively.
2633
2937
  * When `true`, the generic layer skips application-level cascade/setNull
2634
2938
  * on delete — the DB engine handles it (e.g. SQLite `ON DELETE CASCADE`).
@@ -2650,6 +2954,9 @@ var BaseDbAdapter = class {
2650
2954
  * Used by `AsDbReadableController.buildMetaResponse()` to gate the
2651
2955
  * `filterable` flag exposed to UIs — the adapter's answer is a hard gate
2652
2956
  * even when the field carries `@db.column.filterable`.
2957
+ *
2958
+ * Vetoes value comparison only — a sole-`$exists` entry needs just a stored
2959
+ * column (`canFilterLeaf`).
2653
2960
  */
2654
2961
  canFilterField(fd) {
2655
2962
  if (fd.encrypted) return false;
@@ -4345,6 +4652,12 @@ function isAtscriptDbView(readable) {
4345
4652
  return readable.isView;
4346
4653
  }
4347
4654
  //#endregion
4655
+ Object.defineProperty(exports, "ALL_BUCKET_UNITS", {
4656
+ enumerable: true,
4657
+ get: function() {
4658
+ return ALL_BUCKET_UNITS;
4659
+ }
4660
+ });
4348
4661
  Object.defineProperty(exports, "ApplicationIntegrity", {
4349
4662
  enumerable: true,
4350
4663
  get: function() {
@@ -4423,6 +4736,12 @@ Object.defineProperty(exports, "UniquSelect", {
4423
4736
  return UniquSelect;
4424
4737
  }
4425
4738
  });
4739
+ Object.defineProperty(exports, "acceptedOperatorsHint", {
4740
+ enumerable: true,
4741
+ get: function() {
4742
+ return acceptedOperatorsHint;
4743
+ }
4744
+ });
4426
4745
  Object.defineProperty(exports, "assertGeoPoint", {
4427
4746
  enumerable: true,
4428
4747
  get: function() {
@@ -4435,6 +4754,12 @@ Object.defineProperty(exports, "assertNoVersionWrites", {
4435
4754
  return assertNoVersionWrites;
4436
4755
  }
4437
4756
  });
4757
+ Object.defineProperty(exports, "canFilterLeaf", {
4758
+ enumerable: true,
4759
+ get: function() {
4760
+ return canFilterLeaf;
4761
+ }
4762
+ });
4438
4763
  Object.defineProperty(exports, "checkHavingKeys", {
4439
4764
  enumerable: true,
4440
4765
  get: function() {
@@ -4507,6 +4832,12 @@ Object.defineProperty(exports, "isAtscriptDbView", {
4507
4832
  return isAtscriptDbView;
4508
4833
  }
4509
4834
  });
4835
+ Object.defineProperty(exports, "isBucketableField", {
4836
+ enumerable: true,
4837
+ get: function() {
4838
+ return isBucketableField;
4839
+ }
4840
+ });
4510
4841
  Object.defineProperty(exports, "isGeoIndexableType", {
4511
4842
  enumerable: true,
4512
4843
  get: function() {
@@ -4519,6 +4850,30 @@ Object.defineProperty(exports, "isGeoPointType", {
4519
4850
  return isGeoPointType;
4520
4851
  }
4521
4852
  });
4853
+ Object.defineProperty(exports, "isJsonValueField", {
4854
+ enumerable: true,
4855
+ get: function() {
4856
+ return isJsonValueField;
4857
+ }
4858
+ });
4859
+ Object.defineProperty(exports, "jsonValueAncestor", {
4860
+ enumerable: true,
4861
+ get: function() {
4862
+ return jsonValueAncestor;
4863
+ }
4864
+ });
4865
+ Object.defineProperty(exports, "narrowerFilterOps", {
4866
+ enumerable: true,
4867
+ get: function() {
4868
+ return narrowerFilterOps;
4869
+ }
4870
+ });
4871
+ Object.defineProperty(exports, "resolveCalendarBuckets", {
4872
+ enumerable: true,
4873
+ get: function() {
4874
+ return resolveCalendarBuckets;
4875
+ }
4876
+ });
4522
4877
  Object.defineProperty(exports, "resolveDesignType", {
4523
4878
  enumerable: true,
4524
4879
  get: function() {