@atscript/db 0.1.131 → 0.1.133

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-D8aa-wfP.d.mts → buckets-BENuu_tK.d.mts} +1346 -1141
  6. package/dist/{db-readable-Cmrg9aLU.d.cts → buckets-CTWy7Y1k.d.cts} +1346 -1141
  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-Bv3Ar_Xd.d.mts} +1 -1
  10. package/dist/{db-space-rQFgz3-k.d.cts → db-space-DROLifb-.d.cts} +1 -1
  11. package/dist/{db-view-DUDnyQ3b.cjs → db-view-B5-bQ5AV.cjs} +552 -120
  12. package/dist/{db-view-BYH4HJ-U.mjs → db-view-caPATLmX.mjs} +486 -120
  13. package/dist/index.cjs +16 -4
  14. package/dist/index.d.cts +163 -38
  15. package/dist/index.d.mts +163 -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
- import { n as CasMismatchError, r as DbError } from "./db-error-COrO58t5.mjs";
2
- import { a as forceNavNonOptional, c as getKeyProps, i as dbPlugin, l as isEmptyObject, n as buildPatchPartial, t as buildDbValidator, u as isPlainObject } from "./validator-CeD_fqyW.mjs";
1
+ import { n as CasMismatchError, r as DbError } from "./db-error-D5uilS_A.mjs";
3
2
  import { resolveAlias } from "./agg.mjs";
4
- import { a as batchPatchNestedTo, c as batchReplaceNestedTo, d as preValidateNestedFrom, f as validateBatch, g as findRemoteFK, h as findFKForRelation, i as batchPatchNestedFrom, l as batchReplaceNestedVia, m as remapDeleteFkViolation, n as batchInsertNestedTo, o as batchPatchNestedVia, p as enrichFkViolation, r as batchInsertNestedVia, s as batchReplaceNestedFrom, t as batchInsertNestedFrom, u as checkDepthOverflow } from "./nested-writer-CkDo-ZfH.mjs";
3
+ import { a as forceNavNonOptional, c as getKeyProps, i as dbPlugin, l as isEmptyObject, n as buildPatchPartial, t as buildDbValidator, u as isPlainObject } from "./validator-Ch7UIQl9.mjs";
4
+ import { a as batchPatchNestedTo, c as batchReplaceNestedTo, d as preValidateNestedFrom, f as validateBatch, g as findRemoteFK, h as findFKForRelation, i as batchPatchNestedFrom, l as batchReplaceNestedVia, m as remapDeleteFkViolation, n as batchInsertNestedTo, o as batchPatchNestedVia, p as enrichFkViolation, r as batchInsertNestedVia, s as batchReplaceNestedFrom, t as batchInsertNestedFrom, u as checkDepthOverflow } from "./nested-writer-CqL24ojl.mjs";
5
5
  import { separateCas, separateFieldOps } from "./ops.mjs";
6
6
  import { flattenAnnotatedType, isAnnotatedType } from "@atscript/typescript/utils";
7
+ import { BUCKET_UNITS, isAggregateExpr, isBucketExpr, isPrimitive, resolveBuckets } from "@uniqu/core";
7
8
  import { AsyncLocalStorage } from "node:async_hooks";
8
9
  //#region src/logger.ts
9
10
  const NoopLogger = {
@@ -14,6 +15,83 @@ 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
+ * - **The source field** (encryption, JSON ancestor, timestamp type,
36
+ * physical filterability, strict-mode dimension, an adapter with calendar
37
+ * buckets) is `bucketSourceVerdict` — ONE function, called by the path
38
+ * guard (`guardPath` op `bucket`) and by moost-db's capability index, so
39
+ * `/meta.fields[P].bucketable` and the core cannot disagree.
40
+ * - **The unit** (`calendarBucketUnits()` lacks it) is `guardAggregate`'s
41
+ * (`BUCKET_NOT_SUPPORTED`); SQL builders only re-assert the inlined
42
+ * literals (defense in depth).
43
+ *
44
+ * `aggregate` defaults to "`$groupBy` is non-empty".
45
+ *
46
+ * @throws DbError `INVALID_QUERY` carrying every issue (`path` `$select` / `$groupBy`).
47
+ */
48
+ function resolveCalendarBuckets(controls, fields, aggregate) {
49
+ const res = resolveBuckets(controls, {
50
+ aggregate,
51
+ isField: (name) => fields.flatMap.has(name) || fields.navFields.has(name) || fields.physicalNames.has(name)
52
+ });
53
+ if (!res.ok) throw new DbError("INVALID_QUERY", res.issues);
54
+ return res.buckets;
55
+ }
56
+ /**
57
+ * Whether a field's TYPE allows it to be the source of a calendar bucket: a
58
+ * `number` / `integer` leaf carrying the `timestamp` tag
59
+ * (`number.timestamp`, `.created`, `.updated`) that is not
60
+ * `@db.encrypted`. The type is the declaration — no annotation opts a field
61
+ * in.
62
+ *
63
+ * @deprecated since 0.1.133 — the type rule alone; use `bucketSourceVerdict`,
64
+ * the full bucket-source rule the core and moost-db apply.
65
+ */
66
+ function isBucketableField(fd) {
67
+ if (fd.encrypted) return false;
68
+ if (fd.designType !== "number" && fd.designType !== "integer") return false;
69
+ return ((fd.type?.type)?.tags)?.has("timestamp") === true;
70
+ }
71
+ /**
72
+ * Whether a field holds a JSON value — a `@db.json` object / JSON-stored
73
+ * column or an array. The members of `TableMetadata.jsonValueParents` (and
74
+ * moost-db's equivalent set) — see {@link jsonValueAncestor}.
75
+ */
76
+ function isJsonValueField(fd) {
77
+ return fd.storage === "json" || fd.designType === "json" || fd.designType === "array";
78
+ }
79
+ /**
80
+ * The outermost ancestor of `path` in `jsonValueParents` (the paths of the
81
+ * {@link isJsonValueField} descriptors), or `undefined`. A timestamp inside a
82
+ * JSON value is never a bucket source — relational adapters cannot address
83
+ * it and nested-object adapters (which can) must not diverge from them.
84
+ */
85
+ function jsonValueAncestor(path, jsonValueParents) {
86
+ if (jsonValueParents.size === 0) return void 0;
87
+ let pos = path.indexOf(".");
88
+ while (pos !== -1) {
89
+ const ancestor = path.slice(0, pos);
90
+ if (jsonValueParents.has(ancestor)) return ancestor;
91
+ pos = path.indexOf(".", pos + 1);
92
+ }
93
+ }
94
+ //#endregion
17
95
  //#region src/table/table-metadata.ts
18
96
  const INDEX_PREFIX = "atscript__";
19
97
  function indexKey(type, name) {
@@ -124,6 +202,14 @@ var TableMetadata = class {
124
202
  * dotted paths as descriptors).
125
203
  */
126
204
  jsonParents = /* @__PURE__ */ new Set();
205
+ /**
206
+ * Logical paths holding a JSON value (`isJsonValueField`: JSON-stored, `json`
207
+ * or `array` design type; non-ignored, nav-free descriptors) — a timestamp
208
+ * beneath one is never a calendar-bucket source (`jsonValueAncestor`).
209
+ */
210
+ jsonValueParents = /* @__PURE__ */ new Set();
211
+ /** Every field descriptor's `physicalName` — reserved names a bucket alias may not take. */
212
+ physicalNames = /* @__PURE__ */ new Set();
127
213
  _built = false;
128
214
  _identifications;
129
215
  _collateMap = /* @__PURE__ */ new Map();
@@ -135,6 +221,21 @@ var TableMetadata = class {
135
221
  return this._built;
136
222
  }
137
223
  /**
224
+ * Logical field path → its physical path in document storage (nested
225
+ * objects kept inline). `@db.column` renames apply to the annotated key,
226
+ * and a document renames the TOP-LEVEL key only — nested keys are stored
227
+ * as-is — so a dotted path under a renamed top-level object renames its
228
+ * first segment: `profile.bio` under `@db.column 'prof'` → `prof.bio`.
229
+ */
230
+ documentPath(path) {
231
+ const direct = this.columnMap.get(path);
232
+ if (direct !== void 0) return direct;
233
+ const dot = path.indexOf(".");
234
+ if (dot === -1) return path;
235
+ const top = this.columnMap.get(path.slice(0, dot));
236
+ return top === void 0 ? path : top + path.slice(dot);
237
+ }
238
+ /**
138
239
  * Runs the full metadata compilation pipeline. Called once by
139
240
  * `AtscriptDbReadable._ensureBuilt()` on first metadata access.
140
241
  *
@@ -187,7 +288,7 @@ var TableMetadata = class {
187
288
  this._built = true;
188
289
  adapter.onAfterFlatten?.();
189
290
  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);
291
+ 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
292
  } else for (const [path, physical] of this.pathToPhysical) {
192
293
  if (this.navFields.has(path)) continue;
193
294
  if (findAncestorInSet(path, this.navFields) !== void 0) continue;
@@ -419,19 +520,25 @@ var TableMetadata = class {
419
520
  /** Nearest `@db.encrypted` ancestor of `path` (exclusive), or `undefined`. */
420
521
  /**
421
522
  * Indexes non-ignored descriptors by logical path and retains the JSON-parent
422
- * set. Navigation relations and their descendants are skipped even when the
523
+ * sets (plus every descriptor's physical name). Navigation relations and their descendants are skipped even when the
423
524
  * adapter keeps them as descriptors (nested-object adapters do) — they are
424
525
  * loaded with `$with`, never addressed as columns of this table.
425
526
  */
426
527
  _buildGuardIndexes() {
427
528
  const jsonParents = /* @__PURE__ */ new Set();
529
+ const jsonValueParents = /* @__PURE__ */ new Set();
530
+ const physicalNames = /* @__PURE__ */ new Set();
428
531
  for (const fd of this.fieldDescriptors) {
532
+ physicalNames.add(fd.physicalName);
429
533
  if (fd.ignored) continue;
430
534
  if (this.navFields.has(fd.path) || findAncestorInSet(fd.path, this.navFields) !== void 0) continue;
431
535
  this.descriptorByPath.set(fd.path, fd);
432
536
  if (fd.storage === "json") jsonParents.add(fd.path);
537
+ if (isJsonValueField(fd)) jsonValueParents.add(fd.path);
433
538
  }
434
539
  this.jsonParents = jsonParents;
540
+ this.jsonValueParents = jsonValueParents;
541
+ this.physicalNames = physicalNames;
435
542
  }
436
543
  /**
437
544
  * Indexes `fieldDescriptors` into two lookup maps for unified
@@ -653,6 +760,11 @@ var TableMetadata = class {
653
760
  * `controls.$select` is `UniquSelect | undefined`.
654
761
  *
655
762
  * For exclusion → inclusion inversion, pass `allFields` (physical field names).
763
+ *
764
+ * An array `$select` holds plain field names and computed entries —
765
+ * aggregates (`{ $fn, $field }`, {@link aggregates}) and calendar buckets
766
+ * (`{ $bucket, $field }`, {@link buckets}). Entries arrive normalized
767
+ * (`resolveCalendarBuckets` rejects any other shape before translation).
656
768
  */
657
769
  var UniquSelect = class UniquSelect {
658
770
  static UNRESOLVED = Symbol("unresolved");
@@ -661,17 +773,29 @@ var UniquSelect = class UniquSelect {
661
773
  _array = UniquSelect.UNRESOLVED;
662
774
  _projection = UniquSelect.UNRESOLVED;
663
775
  _aggregates = UniquSelect.UNRESOLVED;
664
- constructor(raw, allFields) {
776
+ /**
777
+ * The calendar buckets of an aggregate `$select`, normalized (canonical
778
+ * zone, week start, alias) with the PHYSICAL source `field` and its
779
+ * descriptor `fd`. `undefined` when there are none. A `$groupBy` key equal
780
+ * to a bucket's `alias` groups by that bucket (aliases never collide with
781
+ * columns). Since 0.1.132.
782
+ */
783
+ buckets;
784
+ /**
785
+ * @param raw - the `$select` value (field paths already physical).
786
+ * @param allFields - physical field names, for exclusion-form inversion.
787
+ * @param buckets - the resolved calendar buckets of the raw `$select`'s
788
+ * `{ $bucket }` entries (the field mappers supply them — physical `field`,
789
+ * source `fd`).
790
+ */
791
+ constructor(raw, allFields, buckets) {
665
792
  this._raw = raw;
666
793
  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;
794
+ this.buckets = buckets?.length ? buckets : void 0;
671
795
  }
672
796
  /**
673
797
  * Resolved inclusion array of plain field names (strings only).
674
- * AggregateExpr objects are filtered out.
798
+ * Computed entries (aggregates, calendar buckets) are filtered out.
675
799
  * For exclusion form, inverts using `allFields` from constructor.
676
800
  */
677
801
  get asArray() {
@@ -726,7 +850,7 @@ var UniquSelect = class UniquSelect {
726
850
  this._aggregates = void 0;
727
851
  return;
728
852
  }
729
- const aggs = this._raw.filter((v) => UniquSelect._isAggregateExpr(v));
853
+ const aggs = this._raw.filter(isAggregateExpr);
730
854
  this._aggregates = aggs.length > 0 ? aggs : void 0;
731
855
  return this._aggregates;
732
856
  }
@@ -734,6 +858,10 @@ var UniquSelect = class UniquSelect {
734
858
  get hasAggregates() {
735
859
  return !!this.aggregates?.length;
736
860
  }
861
+ /** The calendar bucket whose alias is `key`, if any — how adapters resolve a `$groupBy` / `$having` key. */
862
+ bucketByAlias(key) {
863
+ return this.buckets?.find((b) => b.alias === key);
864
+ }
737
865
  };
738
866
  //#endregion
739
867
  //#region src/strategies/field-mapping.ts
@@ -754,9 +882,83 @@ function toDecimalString(value) {
754
882
  * and `RelationalFieldMapper` (flattened columns, SQL).
755
883
  */
756
884
  var FieldMappingStrategy = class {
885
+ /**
886
+ * Whether {@link physicalPath} can differ from the logical path for this
887
+ * table; `false` lets the path translations hand their input back as-is.
888
+ */
889
+ renamesPaths(_meta) {
890
+ return true;
891
+ }
892
+ /**
893
+ * Translates a grouped query to physical names: the filter and `$having`
894
+ * through {@link translateFilter}, and every field path in `$groupBy`,
895
+ * `$select` (plain and computed `$field`s) and `$sort` through
896
+ * {@link physicalPath}. Computed aliases pass through (a bucket alias never
897
+ * equals a field name).
898
+ *
899
+ * `buckets` are the query's calendar buckets as the core's normalizer
900
+ * resolved them (`resolveCalendarBuckets` — `AtscriptDbReadable.aggregate`
901
+ * runs it before the guards); they reach adapters with `field` made
902
+ * physical and the source descriptor as `fd`.
903
+ */
904
+ translateAggregateQuery(query, meta, buckets) {
905
+ const controls = query.controls;
906
+ const aliases = this.computedAliasSet(controls.$select);
907
+ const physicalBuckets = buckets.map((b) => ({
908
+ ...b,
909
+ field: this.physicalPath(b.field, meta),
910
+ fd: meta.descriptorByPath.get(b.field)
911
+ }));
912
+ const select = controls.$select && this.physicalSelect(controls.$select, meta);
913
+ return {
914
+ filter: this.translateFilter(query.filter ?? {}, meta),
915
+ controls: {
916
+ ...controls,
917
+ $with: void 0,
918
+ $groupBy: this.renamesPaths(meta) ? controls.$groupBy.map((key) => aliases.has(key) ? key : this.physicalPath(key, meta)) : controls.$groupBy,
919
+ $select: select ? new UniquSelect(select, meta.allPhysicalFields, physicalBuckets) : void 0,
920
+ $sort: controls.$sort && this.physicalSort(controls.$sort, meta, aliases),
921
+ $having: controls.$having ? this.translateFilter(controls.$having, meta) : void 0
922
+ },
923
+ insights: query.insights
924
+ };
925
+ }
926
+ /** Output aliases of the computed `$select` entries (aggregates and calendar buckets). */
927
+ computedAliasSet(select) {
928
+ const aliases = /* @__PURE__ */ new Set();
929
+ for (const item of select ?? []) if (isAggregateExpr(item) || isBucketExpr(item)) aliases.add(resolveAlias(item));
930
+ return aliases;
931
+ }
932
+ /**
933
+ * `$select` with its field paths made physical: array-form names and
934
+ * computed `$field`s (`'*'` kept), or the keys of the object
935
+ * (inclusion / exclusion) form.
936
+ */
937
+ physicalSelect(select, meta) {
938
+ if (!this.renamesPaths(meta)) return select;
939
+ if (Array.isArray(select)) return select.map((item) => {
940
+ if (typeof item === "string") return this.physicalPath(item, meta);
941
+ if (isAggregateExpr(item) || isBucketExpr(item)) return item.$field === "*" ? item : {
942
+ ...item,
943
+ $field: this.physicalPath(item.$field, meta)
944
+ };
945
+ return item;
946
+ });
947
+ const translated = {};
948
+ for (const [key, flag] of Object.entries(select)) translated[this.physicalPath(key, meta)] = flag;
949
+ return translated;
950
+ }
951
+ /** `$sort` with physical keys; computed `aliases` (grouped queries) pass through. */
952
+ physicalSort(sort, meta, aliases) {
953
+ if (!this.renamesPaths(meta)) return sort;
954
+ const translated = {};
955
+ for (const [key, dir] of Object.entries(sort)) translated[aliases?.has(key) ? key : this.physicalPath(key, meta)] = dir;
956
+ return translated;
957
+ }
757
958
  /**
758
959
  * Recursively walks a filter expression, applying `@db.column` key renames
759
- * via `columnMap` and adapter-specific value formatting via `formatFilterValue`.
960
+ * (document paths — {@link TableMetadata.documentPath}) and adapter-specific
961
+ * value formatting via `formatFilterValue`.
760
962
  *
761
963
  * The relational mapper overrides this to use `leafByLogical` for deeper
762
964
  * key resolution (flattened nested paths).
@@ -769,8 +971,8 @@ var FieldMappingStrategy = class {
769
971
  else if (key === "$not") result[key] = this.translateFilter(value, meta);
770
972
  else if (key.startsWith("$")) result[key] = value;
771
973
  else {
772
- const physical = meta.columnMap.get(key) ?? key;
773
- result[physical] = this.formatFilterValue(physical, value, meta);
974
+ const formatKey = meta.columnMap.get(key) ?? key;
975
+ result[meta.documentPath(key)] = this.formatFilterValue(formatKey, value, meta);
774
976
  }
775
977
  return result;
776
978
  }
@@ -898,29 +1100,31 @@ var DocumentFieldMapper = class extends FieldMappingStrategy {
898
1100
  if (meta.columnMap.size > 0) this.reverseColumnRenames(row, meta);
899
1101
  return row;
900
1102
  }
1103
+ /**
1104
+ * Every field-path position goes through `@db.column` renames
1105
+ * ({@link TableMetadata.documentPath}): filter keys, `$select` fields
1106
+ * (array, inclusion and exclusion forms) and `$sort` keys.
1107
+ */
901
1108
  translateQuery(query, meta) {
902
1109
  const controls = query.controls;
1110
+ const select = controls?.$select && this.physicalSelect(controls.$select, meta);
903
1111
  return {
904
1112
  filter: this.translateFilter(query.filter, meta),
905
1113
  controls: {
906
1114
  ...controls,
907
1115
  $with: void 0,
908
- $select: controls?.$select ? new UniquSelect(controls.$select, meta.allPhysicalFields) : void 0
1116
+ $select: select ? new UniquSelect(select, meta.allPhysicalFields) : void 0,
1117
+ $sort: controls?.$sort && this.physicalSort(controls.$sort, meta)
909
1118
  },
910
1119
  insights: query.insights
911
1120
  };
912
1121
  }
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
- };
1122
+ /** A document path with `@db.column` renames ({@link TableMetadata.documentPath}). */
1123
+ physicalPath(logical, meta) {
1124
+ return meta.documentPath(logical);
1125
+ }
1126
+ renamesPaths(meta) {
1127
+ return meta.columnMap.size > 0;
924
1128
  }
925
1129
  prepareForWrite(payload, meta, adapter) {
926
1130
  const data = { ...payload };
@@ -995,46 +1199,9 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
995
1199
  insights: query.insights
996
1200
  };
997
1201
  }
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(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
- };
1202
+ /** The flattened column of a logical path (`contact.email` → `contact__email`). */
1203
+ physicalPath(logical, meta) {
1204
+ return meta.leafByLogical.get(logical)?.physicalName ?? logical;
1038
1205
  }
1039
1206
  /**
1040
1207
  * Overrides the base `translateFilter` to use `leafByLogical` for key resolution
@@ -1196,11 +1363,20 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
1196
1363
  * - `$geoWithin` on a non-geoPoint field → `FILTER_TYPE_MISMATCH`
1197
1364
  * - `$geoWithin` with a malformed circle → `INVALID_QUERY`
1198
1365
  * - `$geoWithin` on an adapter without geo support → `GEO_NOT_SUPPORTED`
1366
+ * - `$exists` with a non-boolean operand → `INVALID_QUERY`
1367
+ * - malformed `$select` computed entries / `$groupBy` entries, calendar
1368
+ * buckets outside grouped queries or with a bad unit / zone / alias →
1369
+ * `INVALID_QUERY` (the shared normalizer, `resolveCalendarBuckets`)
1370
+ * - a calendar bucket over a field that is not a bucket source
1371
+ * (`bucketSourceVerdict`) → `INVALID_QUERY`, or `BUCKET_NOT_SUPPORTED`
1372
+ * when the adapter has no calendar buckets at all; a unit the adapter's
1373
+ * `calendarBucketUnits()` lacks → `BUCKET_NOT_SUPPORTED`
1199
1374
  * - every filter / `$sort` / `$select` / `$groupBy` / `$having` / aggregate
1200
1375
  * 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.
1376
+ * capability its position (for a filter entry: its predicate class, see
1377
+ * {@link canFilterLeaf}) needs → `INVALID_QUERY` (see {@link guardPaths}).
1378
+ * Runs after the checks above so `ENC_*` codes keep firing first for
1379
+ * encrypted subtrees.
1204
1380
  */
1205
1381
  /** Validates a `[lng, lat]` tuple (GeoJSON coordinate order). */
1206
1382
  function assertGeoPoint(point, path) {
@@ -1241,7 +1417,8 @@ function guardGeoWithin(meta, adapter, field, value) {
1241
1417
  }
1242
1418
  /**
1243
1419
  * Walks a filter expression, rejecting encrypted-field references and
1244
- * validating `$geoWithin` operator nodes.
1420
+ * validating operator operands: `$geoWithin` shapes and the boolean
1421
+ * `$exists` operand (this is the one owner of that rule).
1245
1422
  */
1246
1423
  function guardFilter(meta, adapter, filter, encCode = "ENC_FIELD_FILTER") {
1247
1424
  if (!filter || typeof filter !== "object") return;
@@ -1257,8 +1434,12 @@ function guardFilter(meta, adapter, filter, encCode = "ENC_FIELD_FILTER") {
1257
1434
  }
1258
1435
  if (key.startsWith("$")) continue;
1259
1436
  if (hasEncrypted && isEncryptedRef(meta, key)) throw encryptedRefError(encCode, key, "filter on");
1260
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1437
+ if (!isPrimitive(value)) {
1261
1438
  for (const [op, opValue] of Object.entries(value)) if (op === "$geoWithin") guardGeoWithin(meta, adapter, key, opValue);
1439
+ else if (op === "$exists" && typeof opValue !== "boolean") throw new DbError("INVALID_QUERY", [{
1440
+ path: key,
1441
+ message: `$exists on "${key}" expects true or false`
1442
+ }]);
1262
1443
  }
1263
1444
  }
1264
1445
  }
@@ -1273,10 +1454,11 @@ const OP_VERB = {
1273
1454
  select: "select",
1274
1455
  groupBy: "group by",
1275
1456
  having: "filter ($having) on",
1276
- aggregate: "aggregate over"
1457
+ aggregate: "aggregate over",
1458
+ bucket: "bucket"
1277
1459
  };
1278
- function pathError(path, message) {
1279
- return new DbError("INVALID_QUERY", [{
1460
+ function pathError(path, message, code = "INVALID_QUERY") {
1461
+ return new DbError(code, [{
1280
1462
  path,
1281
1463
  message
1282
1464
  }]);
@@ -1313,16 +1495,120 @@ function sortFieldNames(sort) {
1313
1495
  if (typeof sort === "object") return Object.keys(sort);
1314
1496
  return [];
1315
1497
  }
1316
- function collectFilterKeys(filter, out, geo, skip, refs) {
1498
+ /**
1499
+ * The reason clause for a leaf the adapter cannot filter (value comparison).
1500
+ * @internal Shared wording for moost-db's capability index — not consumer API.
1501
+ */
1502
+ const ADAPTER_FILTER_REASON = "adapter cannot filter on this storage type";
1503
+ /**
1504
+ * The reason clause for an `@db.encrypted` leaf in a comparing / ordering position.
1505
+ * @internal Shared wording for moost-db's capability index — not consumer API.
1506
+ */
1507
+ const ENCRYPTED_REASON = "field is @db.encrypted (ciphertext cannot be compared or ordered)";
1508
+ /** The operator each non-`compare` predicate class stands for, in `filterOps` order. */
1509
+ const FILTER_PREDICATE_OPS = {
1510
+ exists: "$exists",
1511
+ geo: "$geoWithin"
1512
+ };
1513
+ /** Classifies one filter entry's value (`{ key: value }`); operand validity is `guardFilter`'s. */
1514
+ function filterPredicateOf(value) {
1515
+ if (isPrimitive(value)) return "compare";
1516
+ const ops = value;
1517
+ if ("$geoWithin" in ops) return "geo";
1518
+ const keys = Object.keys(ops);
1519
+ return keys.length === 1 && keys[0] === "$exists" ? "exists" : "compare";
1520
+ }
1521
+ /**
1522
+ * Whether a stored leaf physically supports a filter predicate of this class
1523
+ * — the one rule the core path guard and moost-db's HTTP capability index
1524
+ * both apply:
1525
+ *
1526
+ * - `compare` → `adapter.canFilterField(fd)`;
1527
+ * - `exists` → any stored column: it tests whether the column holds a value
1528
+ * (SQL `IS [NOT] NULL`), never its content, so the scalar veto (JSON /
1529
+ * array storage on relational adapters) does not apply;
1530
+ * - `geo` → a `db.geoPoint` leaf on a geo-searchable adapter.
1531
+ *
1532
+ * `@db.encrypted` vetoes every class.
1533
+ */
1534
+ function canFilterLeaf(fd, predicate, adapter) {
1535
+ if (fd.encrypted) return false;
1536
+ switch (predicate) {
1537
+ case "exists": return true;
1538
+ case "geo": return fd.isGeoPoint === true && adapter.isGeoSearchable();
1539
+ default: return adapter.canFilterField(fd);
1540
+ }
1541
+ }
1542
+ /**
1543
+ * The operators of the non-`compare` predicate classes a leaf physically
1544
+ * accepts — named in a value-comparison rejection by the core guard, and
1545
+ * listed (under the HTTP policy) as `/meta.fields[P].filterOps`.
1546
+ */
1547
+ function narrowerFilterOps(fd, adapter) {
1548
+ const ops = [];
1549
+ for (const [predicate, op] of Object.entries(FILTER_PREDICATE_OPS)) if (canFilterLeaf(fd, predicate, adapter)) ops.push(op);
1550
+ return ops;
1551
+ }
1552
+ /** The rejection suffix naming {@link narrowerFilterOps} — `""` when there are none. */
1553
+ function acceptedOperatorsHint(ops) {
1554
+ return ops.length > 0 ? ` (accepted operators: ${ops.join(", ")})` : "";
1555
+ }
1556
+ /**
1557
+ * Strict mode: a table declaring dimensions or measures groups only by
1558
+ * dimensions (a calendar bucket's source included) and aggregates only
1559
+ * measures.
1560
+ */
1561
+ function isStrictTable(table) {
1562
+ return table.dimensions.length > 0 || table.measures.length > 0;
1563
+ }
1564
+ function rejectSource(code, reason) {
1565
+ return {
1566
+ ok: false,
1567
+ code,
1568
+ reason
1569
+ };
1570
+ }
1571
+ /**
1572
+ * Whether the stored leaf `fd` may be the source of a calendar bucket
1573
+ * (since 0.1.133) — every schema and adapter rule, once, for the core path
1574
+ * guard ({@link guardPath} op `bucket`) and moost-db's capability index
1575
+ * (`/meta.fields[P].bucketable` and the HTTP gate) alike. First failing rule
1576
+ * wins, in this order:
1577
+ *
1578
+ * 1. `encrypted` — `@db.encrypted` (ciphertext has no calendar);
1579
+ * 2. `jsonDescendant` — inside a JSON value (`jsonValueAncestor`):
1580
+ * relational adapters cannot address it and nested-object adapters must
1581
+ * not diverge from them;
1582
+ * 3. `notTimestamp` — not a `number.timestamp` leaf;
1583
+ * 4. `notFilterable` — the adapter cannot filter (so cannot group) the storage;
1584
+ * 5. `notDimension` — a strict table ({@link isStrictTable}) and the field
1585
+ * is not a dimension;
1586
+ * 6. `noBuckets` — the adapter reports no `calendarBucketUnits()`.
1587
+ *
1588
+ * Whether the adapter supports the requested UNIT is a per-query rule, not
1589
+ * a field's (the core's `BUCKET_NOT_SUPPORTED`). Caller-specific policy
1590
+ * layers on top: moost-db rejects `@db.writeOnly` sources before asking.
1591
+ */
1592
+ function bucketSourceVerdict(fd, table, adapter) {
1593
+ if (fd.encrypted) return rejectSource("encrypted", ENCRYPTED_REASON);
1594
+ const jsonAncestor = jsonValueAncestor(fd.path, table.jsonValueParents);
1595
+ if (jsonAncestor !== void 0) return rejectSource("jsonDescendant", `inside JSON-stored column "${jsonAncestor}"`);
1596
+ if (!isBucketableField(fd)) return rejectSource("notTimestamp", "not a timestamp field (declare it number.timestamp)");
1597
+ if (!adapter.canFilterField(fd)) return rejectSource("notFilterable", ADAPTER_FILTER_REASON);
1598
+ if (isStrictTable(table) && !table.dimensions.includes(fd.path)) return rejectSource("notDimension", "not a dimension");
1599
+ if (adapter.calendarBucketUnits().size === 0) return rejectSource("noBuckets", "adapter has no calendar buckets");
1600
+ return { ok: true };
1601
+ }
1602
+ function collectFilterKeys(filter, push, skip, refs) {
1317
1603
  if (!filter || typeof filter !== "object" || Array.isArray(filter)) return;
1318
1604
  for (const [key, value] of Object.entries(filter)) {
1319
1605
  if (refs.unsupportedOperator) return;
1320
1606
  if (key === "$and" || key === "$or") {
1321
- if (Array.isArray(value)) for (const child of value) collectFilterKeys(child, out, geo, skip, refs);
1607
+ if (Array.isArray(value)) for (const child of value) collectFilterKeys(child, push, skip, refs);
1322
1608
  continue;
1323
1609
  }
1324
1610
  if (key === "$not") {
1325
- collectFilterKeys(value, out, geo, skip, refs);
1611
+ collectFilterKeys(value, push, skip, refs);
1326
1612
  continue;
1327
1613
  }
1328
1614
  if (key.startsWith("$")) {
@@ -1330,7 +1616,7 @@ function collectFilterKeys(filter, out, geo, skip, refs) {
1330
1616
  return;
1331
1617
  }
1332
1618
  if (skip?.has(key)) continue;
1333
- (geo !== void 0 && value !== null && typeof value === "object" && !Array.isArray(value) && "$geoWithin" in value ? geo : out).push(key);
1619
+ push(key, value);
1334
1620
  }
1335
1621
  }
1336
1622
  /**
@@ -1340,40 +1626,52 @@ function collectFilterKeys(filter, out, geo, skip, refs) {
1340
1626
  * visited — they are validated against their target relation separately.
1341
1627
  *
1342
1628
  * 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).
1629
+ * In aggregate mode `$select` computed entries are collected by kind — an
1630
+ * aggregate's `$field` into `aggregate`, a calendar bucket's into `bucket` —
1631
+ * and their aliases (`$as`, else uniqu's `resolveAlias`) are exempted from
1632
+ * `$sort` / `$having`; a bucket alias is also dropped from `groupBy`, which
1633
+ * lists grouped fields only.
1634
+ *
1635
+ * Entry shapes are not checked here — see `resolveCalendarBuckets`.
1347
1636
  */
1348
1637
  function collectQueryPaths(query, aggregate) {
1349
1638
  const refs = {
1350
1639
  filter: [],
1351
- geoFilter: [],
1352
1640
  sort: [],
1353
1641
  select: [],
1354
1642
  groupBy: [],
1355
1643
  having: [],
1356
1644
  aggregate: [],
1645
+ bucket: [],
1357
1646
  aggregateMode: false
1358
1647
  };
1359
- collectFilterKeys(query.filter, refs.filter, refs.geoFilter, void 0, refs);
1648
+ collectFilterKeys(query.filter, (path, value) => refs.filter.push({
1649
+ path,
1650
+ predicate: filterPredicateOf(value)
1651
+ }), void 0, refs);
1360
1652
  const controls = query.controls ?? {};
1361
1653
  const rawGroupBy = controls.$groupBy;
1362
1654
  const groupBy = Array.isArray(rawGroupBy) ? rawGroupBy.filter((f) => typeof f === "string") : typeof rawGroupBy === "string" ? [rawGroupBy] : [];
1363
1655
  refs.aggregateMode = aggregate ?? groupBy.length > 0;
1364
- refs.groupBy = groupBy;
1365
1656
  const aliases = /* @__PURE__ */ new Set();
1657
+ const bucketAliases = /* @__PURE__ */ new Set();
1366
1658
  const select = controls.$select;
1367
1659
  if (Array.isArray(select)) {
1368
1660
  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(resolveAlias(expr));
1372
- if (expr.$field !== "*") refs.aggregate.push(expr.$field);
1661
+ else if (!refs.aggregateMode) continue;
1662
+ else if (isAggregateExpr(item)) {
1663
+ aliases.add(resolveAlias(item));
1664
+ if (item.$field !== "*") refs.aggregate.push(item.$field);
1665
+ } else if (isBucketExpr(item)) {
1666
+ const alias = resolveAlias(item);
1667
+ aliases.add(alias);
1668
+ bucketAliases.add(alias);
1669
+ refs.bucket.push(item.$field);
1373
1670
  }
1374
1671
  } else if (select && typeof select === "object") refs.select.push(...Object.keys(select));
1672
+ refs.groupBy = bucketAliases.size > 0 ? groupBy.filter((name) => !bucketAliases.has(name)) : groupBy;
1375
1673
  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);
1674
+ if (refs.aggregateMode) collectFilterKeys(controls.$having, (path) => refs.having.push(path), aliases, refs);
1377
1675
  return refs;
1378
1676
  }
1379
1677
  /**
@@ -1424,21 +1722,22 @@ function pathSourceOf(meta) {
1424
1722
  * metadata and adapter capability — the classification of
1425
1723
  * {@link classifyQueryPath} plus the position's physical requirement:
1426
1724
  *
1427
- * - a leaf → physical capability (`canFilterField` / `canSortField`;
1428
- * `$select` always passes);
1725
+ * - a leaf → physical capability (`canSortField` for `$sort`; for a filter
1726
+ * entry {@link canFilterLeaf} of its `predicate` class; `canFilterField`
1727
+ * for `$groupBy` / `$having` / aggregate `$field`s; `$select` always passes);
1728
+ * a calendar-bucket source must pass `bucketSourceVerdict` (the rules
1729
+ * moost-db's capability index applies too);
1429
1730
  * - a nested-object parent → only `$select`, and only when it expands to
1430
1731
  * leaf columns (`selectExpansion`);
1431
1732
  * - everything else is rejected.
1432
1733
  *
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.
1734
+ * `predicate` is a filter entry's class; other positions leave the default.
1436
1735
  *
1437
1736
  * Messages are the short programmatic forms; the HTTP wording (moost-db's
1438
1737
  * `FieldCapabilityIndex`, with `$with` hints and leaf lists) is what clients
1439
1738
  * see and is authoritative — the HTTP gate always answers first.
1440
1739
  */
1441
- function guardPath(meta, adapter, path, op, geoPredicate = false) {
1740
+ function guardPath(meta, adapter, path, op, predicate = "compare") {
1442
1741
  const verb = OP_VERB[op];
1443
1742
  const { kind, parent } = classifyQueryPath(pathSourceOf(meta), path);
1444
1743
  switch (kind) {
@@ -1446,12 +1745,16 @@ function guardPath(meta, adapter, path, op, geoPredicate = false) {
1446
1745
  case "leaf": {
1447
1746
  if (op === "select") return;
1448
1747
  const fd = meta.descriptorByPath.get(path);
1748
+ if (op === "bucket") {
1749
+ const verdict = bucketSourceVerdict(fd, meta, adapter);
1750
+ if (!verdict.ok) throw pathError(path, `Cannot bucket "${path}" — ${verdict.reason}`, verdict.code === "noBuckets" ? "BUCKET_NOT_SUPPORTED" : "INVALID_QUERY");
1751
+ return;
1752
+ }
1449
1753
  if (op === "sort") {
1450
1754
  if (!adapter.canSortField(fd)) throw pathError(path, `Cannot sort by "${path}" — adapter cannot sort on this storage type`);
1451
1755
  return;
1452
1756
  }
1453
- if (geoPredicate) return;
1454
- if (!adapter.canFilterField(fd)) throw pathError(path, `Cannot ${verb} "${path}" — adapter cannot filter on this storage type`);
1757
+ if (!canFilterLeaf(fd, predicate, adapter)) throw pathError(path, `Cannot ${verb} "${path}" — ${ADAPTER_FILTER_REASON}${op === "filter" ? acceptedOperatorsHint(narrowerFilterOps(fd, adapter)) : ""}`);
1455
1758
  return;
1456
1759
  }
1457
1760
  case "objectParent":
@@ -1469,9 +1772,13 @@ function guardPath(meta, adapter, path, op, geoPredicate = false) {
1469
1772
  * {@link guardPath}). Adapters may therefore assume every path they receive
1470
1773
  * is physical.
1471
1774
  *
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`.
1775
+ * Entry shapes are the caller's to normalize first (`resolveCalendarBuckets`
1776
+ * — {@link guardQuery} / {@link guardAggregate} do).
1777
+ *
1778
+ * In aggregate mode (`aggregate = true`) `$select` computed entries have
1779
+ * their `$field` checked (aggregates as `aggregate`, calendar buckets as
1780
+ * `bucket`), `$groupBy` fields are checked, and computed aliases (`$as` or
1781
+ * the default) are exempt in `$sort` / `$having`.
1475
1782
  *
1476
1783
  * Returns the collected refs so callers can run further structural rules
1477
1784
  * (see {@link checkHavingKeys}) without walking the query again.
@@ -1480,27 +1787,32 @@ function guardPaths(meta, adapter, query, aggregate = false) {
1480
1787
  if (!query) return;
1481
1788
  const refs = collectQueryPaths(query, aggregate);
1482
1789
  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);
1790
+ for (const ref of refs.filter) guardPath(meta, adapter, ref.path, "filter", ref.predicate);
1485
1791
  for (const path of refs.sort) guardPath(meta, adapter, path, "sort");
1486
1792
  for (const path of refs.select) guardPath(meta, adapter, path, "select");
1487
1793
  for (const path of refs.aggregate) guardPath(meta, adapter, path, "aggregate");
1794
+ for (const path of refs.bucket) guardPath(meta, adapter, path, "bucket");
1488
1795
  for (const path of refs.groupBy) guardPath(meta, adapter, path, "groupBy");
1489
1796
  for (const path of refs.having) guardPath(meta, adapter, path, "having");
1490
1797
  return refs;
1491
1798
  }
1492
- /** Shared read-path guard: filter + $sort encryption checks, then the path guard. */
1799
+ /**
1800
+ * Shared read-path guard: filter + $sort encryption checks, the `$select`
1801
+ * normalizer (a calendar bucket is invalid outside a grouped query), then
1802
+ * the path guard.
1803
+ */
1493
1804
  function guardQuery(meta, adapter, query) {
1494
1805
  if (!query) return;
1495
1806
  guardFilter(meta, adapter, query.filter);
1496
1807
  guardSort(meta, query.controls?.$sort);
1808
+ resolveCalendarBuckets(query.controls, meta, false);
1497
1809
  guardPaths(meta, adapter, query);
1498
1810
  }
1499
1811
  /**
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
1812
+ * `$having` is a post-aggregation filter, so a key is either a computed
1813
+ * alias (aggregate or calendar bucket — already exempt in
1814
+ * {@link collectQueryPaths}) or a `$groupBy` field (exact logical-path
1815
+ * match: `metadata.clicks` grouped stays valid). Any other key — a real but non-grouped column included — is
1504
1816
  * rejected here, once, for SDK and HTTP callers alike, instead of by the
1505
1817
  * engine (PostgreSQL / MySQL error, SQLite tolerance, Mongo `[]`). Returns
1506
1818
  * the first offending key as an error entry (`path` = the bare key, as the
@@ -1516,11 +1828,16 @@ function checkHavingKeys(refs) {
1516
1828
  }
1517
1829
  /**
1518
1830
  * Aggregate-path guard: $groupBy / $select / $having encryption refs + filter
1519
- * + $sort, then the path guard, then the `$having` key rule
1831
+ * + $sort, then the path guard, then the adapter's calendar-bucket units
1832
+ * (`BUCKET_NOT_SUPPORTED`), then the `$having` key rule
1520
1833
  * ({@link checkHavingKeys} — after the path guard so an unknown key still
1521
1834
  * reads `Unknown field`).
1835
+ *
1836
+ * `buckets` are the query's resolved calendar buckets when the caller already
1837
+ * ran `resolveCalendarBuckets` (resolved here otherwise).
1522
1838
  */
1523
- function guardAggregate(meta, adapter, query) {
1839
+ function guardAggregate(meta, adapter, query, resolved) {
1840
+ const buckets = resolved ?? resolveCalendarBuckets(query.controls, meta, true);
1524
1841
  guardFilter(meta, adapter, query.filter);
1525
1842
  const controls = query.controls;
1526
1843
  if (meta.encryptedFields.size > 0) {
@@ -1533,9 +1850,24 @@ function guardAggregate(meta, adapter, query) {
1533
1850
  guardSort(meta, controls.$sort);
1534
1851
  }
1535
1852
  const refs = guardPaths(meta, adapter, query, true);
1853
+ guardBucketUnits(adapter, buckets);
1536
1854
  const having = refs ? checkHavingKeys(refs) : void 0;
1537
1855
  if (having) throw new DbError("INVALID_QUERY", [having]);
1538
1856
  }
1857
+ /**
1858
+ * Rejects a calendar bucket whose unit this adapter cannot group by
1859
+ * (`calendarBucketUnits()`; an adapter with none was already answered per
1860
+ * source by `bucketSourceVerdict`) with `BUCKET_NOT_SUPPORTED`,
1861
+ * before anything is translated — third-party adapters get a clean 400,
1862
+ * never an engine error or a silent fallback.
1863
+ */
1864
+ function guardBucketUnits(adapter, buckets) {
1865
+ const units = adapter.calendarBucketUnits();
1866
+ for (const b of buckets) if (!units.has(b.unit)) throw new DbError("BUCKET_NOT_SUPPORTED", [{
1867
+ path: "$select",
1868
+ message: `Calendar bucket "${b.unit}" is not supported by this adapter`
1869
+ }]);
1870
+ }
1539
1871
  //#endregion
1540
1872
  //#region src/table/db-readable.ts
1541
1873
  /**
@@ -2002,9 +2334,15 @@ var AtscriptDbReadable = class {
2002
2334
  * Executes an aggregate query with GROUP BY and aggregate functions.
2003
2335
  *
2004
2336
  * Validates:
2337
+ * - `$select` computed entries and calendar buckets (the shared normalizer,
2338
+ * `resolveCalendarBuckets`: shapes, unit, zone, alias, grouping)
2005
2339
  * - Plain fields in $select are a subset of $groupBy
2006
2340
  * - When dimensions/measures are defined (strict mode): $groupBy fields
2007
2341
  * must be dimensions, aggregate $field values must be measures (or '*')
2342
+ * - the path guard (a bucket source must pass `bucketSourceVerdict` —
2343
+ * timestamp type, no JSON ancestor, a dimension in strict mode, an
2344
+ * adapter with calendar buckets) and the adapter's calendar-bucket units
2345
+ * (`BUCKET_NOT_SUPPORTED`)
2008
2346
  *
2009
2347
  * Translates field names, delegates to adapter.aggregate(),
2010
2348
  * then reverse-maps and applies fromStorage formatters on results.
@@ -2012,6 +2350,7 @@ var AtscriptDbReadable = class {
2012
2350
  async aggregate(query) {
2013
2351
  this._ensureBuilt();
2014
2352
  const { $groupBy, $select } = query.controls;
2353
+ const buckets = resolveCalendarBuckets(query.controls, this._meta, true);
2015
2354
  if ($select) {
2016
2355
  const groupBySet = new Set($groupBy);
2017
2356
  for (const item of $select) if (typeof item === "string" && !groupBySet.has(item)) throw new DbError("INVALID_QUERY", [{
@@ -2020,15 +2359,16 @@ var AtscriptDbReadable = class {
2020
2359
  }]);
2021
2360
  }
2022
2361
  const { dimensions, measures } = this._meta;
2023
- if (dimensions.length > 0 || measures.length > 0) {
2362
+ if (isStrictTable(this._meta)) {
2024
2363
  const dimSet = new Set(dimensions);
2025
2364
  const measSet = new Set(measures);
2026
- for (const field of $groupBy) if (!dimSet.has(field)) throw new DbError("INVALID_QUERY", [{
2365
+ const bucketAliases = new Set(buckets.map((b) => b.alias));
2366
+ for (const field of $groupBy) if (!dimSet.has(field) && !bucketAliases.has(field)) throw new DbError("INVALID_QUERY", [{
2027
2367
  path: "$groupBy",
2028
2368
  message: `Field "${field}" is not a dimension`
2029
2369
  }]);
2030
2370
  if ($select) {
2031
- for (const item of $select) if (typeof item !== "string" && item.$field !== "*" && !measSet.has(item.$field)) throw new DbError("INVALID_QUERY", [{
2371
+ for (const item of $select) if (isAggregateExpr(item) && item.$field !== "*" && !measSet.has(item.$field)) throw new DbError("INVALID_QUERY", [{
2032
2372
  path: "$select",
2033
2373
  message: `Aggregate field "${item.$field}" is not a measure`
2034
2374
  }]);
@@ -2038,8 +2378,7 @@ var AtscriptDbReadable = class {
2038
2378
  if ($select && quantityRefByField.size > 0) {
2039
2379
  const groupBySet = new Set($groupBy);
2040
2380
  for (const item of $select) {
2041
- if (typeof item === "string") continue;
2042
- if (item.$field === "*") continue;
2381
+ if (!isAggregateExpr(item) || item.$field === "*") continue;
2043
2382
  const refField = quantityRefByField.get(item.$field);
2044
2383
  if (refField && !groupBySet.has(refField)) throw new DbError("INVALID_QUERY", [{
2045
2384
  path: "$select",
@@ -2052,8 +2391,8 @@ var AtscriptDbReadable = class {
2052
2391
  this._ensureSearchable();
2053
2392
  if (!searchTerm.trim()) return query.controls.$count ? [{ count: 0 }] : [];
2054
2393
  }
2055
- guardAggregate(this._meta, this.adapter, query);
2056
- const dbQuery = this._fieldMapper.translateAggregateQuery(query, this._meta);
2394
+ guardAggregate(this._meta, this.adapter, query, buckets);
2395
+ const dbQuery = this._fieldMapper.translateAggregateQuery(query, this._meta, buckets);
2057
2396
  return (await this.adapter.aggregate(dbQuery)).map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
2058
2397
  }
2059
2398
  /** Whether the underlying adapter supports text search. */
@@ -2064,6 +2403,10 @@ var AtscriptDbReadable = class {
2064
2403
  canFilterField(fd) {
2065
2404
  return this.adapter.canFilterField(fd);
2066
2405
  }
2406
+ /** Calendar-bucket units the adapter can group by (proxies adapter capability; empty = none). */
2407
+ calendarBucketUnits() {
2408
+ return this.adapter.calendarBucketUnits();
2409
+ }
2067
2410
  /** Whether the adapter can sort by a given field (proxies adapter capability). */
2068
2411
  canSortField(fd) {
2069
2412
  return this.adapter.canSortField(fd);
@@ -2350,7 +2693,7 @@ var AtscriptDbReadable = class {
2350
2693
  * Public entry point for relation loading. Used by adapters for nested $with delegation.
2351
2694
  */
2352
2695
  async loadRelations(rows, withRelations) {
2353
- const { loadRelationsImpl } = await import("./relation-loader-CUGcxJ18.mjs").then((n) => n.n);
2696
+ const { loadRelationsImpl } = await import("./relation-loader-B68R1LET.mjs").then((n) => n.n);
2354
2697
  return loadRelationsImpl(rows, withRelations, this);
2355
2698
  }
2356
2699
  /**
@@ -2415,6 +2758,9 @@ function createFailureCollector(what) {
2415
2758
  //#endregion
2416
2759
  //#region src/base-adapter.ts
2417
2760
  const EMPTY_DEFAULT_FNS = /* @__PURE__ */ new Set();
2761
+ const EMPTY_BUCKET_UNITS = /* @__PURE__ */ new Set();
2762
+ /** Every calendar-bucket unit — what an adapter that renders them all returns from `calendarBucketUnits()`. */
2763
+ const ALL_BUCKET_UNITS = new Set(BUCKET_UNITS);
2418
2764
  const txStorage = new AsyncLocalStorage();
2419
2765
  /** The innermost open transaction of `owner` in the current async chain. */
2420
2766
  function findTxContext(owner) {
@@ -2629,6 +2975,23 @@ var BaseDbAdapter = class {
2629
2975
  return EMPTY_DEFAULT_FNS;
2630
2976
  }
2631
2977
  /**
2978
+ * Calendar-bucket units (`{ $bucket, $field }` in an aggregate `$select`)
2979
+ * this adapter can group by, over IANA time zones. Empty (the default) =
2980
+ * calendar buckets unsupported: the core rejects them with
2981
+ * `BUCKET_NOT_SUPPORTED` before dispatch, and moost-db's `/meta` advertises
2982
+ * no bucketable field.
2983
+ *
2984
+ * A set rather than a boolean (like {@link nativeDefaultFns}) so a unit can
2985
+ * be adopted adapter by adapter. An adapter that returns a unit must group
2986
+ * by the bucket alias in `$groupBy` — see `controls.$select.buckets`
2987
+ * (`TResolvedBucket`: physical `field`, source `fd`) — and return the
2988
+ * `YYYY-MM-DD` label of the bucket's first local day (null for a null or
2989
+ * out-of-range source, uniqu's `bucketLabel` semantics). Since 0.1.132.
2990
+ */
2991
+ calendarBucketUnits() {
2992
+ return EMPTY_BUCKET_UNITS;
2993
+ }
2994
+ /**
2632
2995
  * Whether this adapter enforces foreign key constraints natively.
2633
2996
  * When `true`, the generic layer skips application-level cascade/setNull
2634
2997
  * on delete — the DB engine handles it (e.g. SQLite `ON DELETE CASCADE`).
@@ -2650,6 +3013,9 @@ var BaseDbAdapter = class {
2650
3013
  * Used by `AsDbReadableController.buildMetaResponse()` to gate the
2651
3014
  * `filterable` flag exposed to UIs — the adapter's answer is a hard gate
2652
3015
  * even when the field carries `@db.column.filterable`.
3016
+ *
3017
+ * Vetoes value comparison only — a sole-`$exists` entry needs just a stored
3018
+ * column (`canFilterLeaf`).
2653
3019
  */
2654
3020
  canFilterField(fd) {
2655
3021
  if (fd.encrypted) return false;
@@ -4345,4 +4711,4 @@ function isAtscriptDbView(readable) {
4345
4711
  return readable.isView;
4346
4712
  }
4347
4713
  //#endregion
4348
- export { isGeoIndexableType as A, unsupportedOperatorMessage as C, UniquSelect as D, FieldMappingStrategy as E, NoopLogger as M, TableMetadata as O, sortFieldNames as S, DocumentFieldMapper as T, guardAggregate as _, decomposePatch as a, guardPaths as b, createFailureCollector as c, AtscriptDbReadable as d, resolveDesignType as f, collectQueryPaths as g, classifyQueryPath as h, assertNoVersionWrites as i, isGeoPointType as j, findAncestorInSet as k, IntegrityStrategy as l, checkHavingKeys as m, isAtscriptDbView as n, ApplicationIntegrity as o, assertGeoPoint as p, AtscriptDbTable as r, BaseDbAdapter as s, AtscriptDbView as t, NativeIntegrity as u, guardFilter as v, RelationalFieldMapper as w, guardQuery as x, guardPath as y };
4714
+ export { unsupportedOperatorMessage as A, isJsonValueField as B, guardAggregate as C, guardQuery as D, guardPaths as E, TableMetadata as F, resolveCalendarBuckets as H, findAncestorInSet as I, isGeoIndexableType as L, DocumentFieldMapper as M, FieldMappingStrategy as N, narrowerFilterOps as O, UniquSelect as P, isGeoPointType as R, collectQueryPaths as S, guardPath as T, NoopLogger as U, jsonValueAncestor as V, assertGeoPoint as _, decomposePatch as a, checkHavingKeys as b, BaseDbAdapter as c, NativeIntegrity as d, AtscriptDbReadable as f, acceptedOperatorsHint as g, ENCRYPTED_REASON as h, assertNoVersionWrites as i, RelationalFieldMapper as j, sortFieldNames as k, createFailureCollector as l, ADAPTER_FILTER_REASON as m, isAtscriptDbView as n, ApplicationIntegrity as o, resolveDesignType as p, AtscriptDbTable as r, ALL_BUCKET_UNITS as s, AtscriptDbView as t, IntegrityStrategy as u, bucketSourceVerdict as v, guardFilter as w, classifyQueryPath as x, canFilterLeaf as y, isBucketableField as z };