@atscript/db 0.1.130 → 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-B7eYWS5q.d.cts → buckets-BvoYYQ2Q.d.cts} +1348 -1142
  6. package/dist/{db-readable-Bn1bV_eC.d.mts → buckets-D2XPrwSC.d.mts} +1348 -1142
  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-C2UCnGHd.d.cts → db-space-BQQEgB3d.d.mts} +2 -2
  10. package/dist/{db-space-DdIPYD0Q.d.mts → db-space-Cslq5Dux.d.cts} +2 -2
  11. package/dist/{db-view-hkwQ-fHJ.cjs → db-view-CuFAhzwF.cjs} +487 -123
  12. package/dist/{db-view-BkF2xEGd.mjs → db-view-DVGWWhA3.mjs} +439 -123
  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
- 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,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 = 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 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(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 (isAggregateExpr(item) || isBucketExpr(item)) aliases.add(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 (isAggregateExpr(item) || 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(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 (!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 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 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 (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(resolveAlias(expr));
1372
- if (expr.$field !== "*") refs.aggregate.push(expr.$field);
1598
+ else if (!refs.aggregateMode) continue;
1599
+ else if (isAggregateExpr(item)) {
1600
+ aliases.add(resolveAlias(item));
1601
+ if (item.$field !== "*") refs.aggregate.push(item.$field);
1602
+ } else if (isBucketExpr(item)) {
1603
+ const alias = 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 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 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
  /**
@@ -1680,9 +1954,11 @@ var AtscriptDbReadable = class {
1680
1954
  return this._meta;
1681
1955
  }
1682
1956
  _ensureSearchable() {
1683
- if (!this.adapter.isSearchable()) throw new DbError("INVALID_QUERY", [{
1957
+ if (this.adapter.isSearchable()) return;
1958
+ const hasVectorIndex = this.adapter.getSearchIndexes().some((i) => i.type === "vector");
1959
+ throw new DbError("INVALID_QUERY", [{
1684
1960
  path: "$search",
1685
- message: `Table "${this.tableName}" has no search indexes defined`
1961
+ message: `Table "${this.tableName}" has no text search index` + (hasVectorIndex ? " — a @db.search.vector index only answers vectorSearch()" : "")
1686
1962
  }]);
1687
1963
  }
1688
1964
  /** Engine-agnostic query-time guards (encrypted-field refs, $geoWithin shape). */
@@ -2000,9 +2276,14 @@ var AtscriptDbReadable = class {
2000
2276
  * Executes an aggregate query with GROUP BY and aggregate functions.
2001
2277
  *
2002
2278
  * Validates:
2279
+ * - `$select` computed entries and calendar buckets (the shared normalizer,
2280
+ * `resolveCalendarBuckets`: shapes, unit, zone, alias, grouping)
2003
2281
  * - Plain fields in $select are a subset of $groupBy
2004
2282
  * - When dimensions/measures are defined (strict mode): $groupBy fields
2005
- * 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`)
2006
2287
  *
2007
2288
  * Translates field names, delegates to adapter.aggregate(),
2008
2289
  * then reverse-maps and applies fromStorage formatters on results.
@@ -2010,6 +2291,7 @@ var AtscriptDbReadable = class {
2010
2291
  async aggregate(query) {
2011
2292
  this._ensureBuilt();
2012
2293
  const { $groupBy, $select } = query.controls;
2294
+ const buckets = resolveCalendarBuckets(query.controls, this._meta, true);
2013
2295
  if ($select) {
2014
2296
  const groupBySet = new Set($groupBy);
2015
2297
  for (const item of $select) if (typeof item === "string" && !groupBySet.has(item)) throw new DbError("INVALID_QUERY", [{
@@ -2021,12 +2303,13 @@ var AtscriptDbReadable = class {
2021
2303
  if (dimensions.length > 0 || measures.length > 0) {
2022
2304
  const dimSet = new Set(dimensions);
2023
2305
  const measSet = new Set(measures);
2024
- for (const field of $groupBy) if (!dimSet.has(field)) throw new 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 DbError("INVALID_QUERY", [{
2025
2308
  path: "$groupBy",
2026
2309
  message: `Field "${field}" is not a dimension`
2027
2310
  }]);
2028
2311
  if ($select) {
2029
- for (const item of $select) if (typeof item !== "string" && item.$field !== "*" && !measSet.has(item.$field)) throw new DbError("INVALID_QUERY", [{
2312
+ for (const item of $select) if (isAggregateExpr(item) && item.$field !== "*" && !measSet.has(item.$field)) throw new DbError("INVALID_QUERY", [{
2030
2313
  path: "$select",
2031
2314
  message: `Aggregate field "${item.$field}" is not a measure`
2032
2315
  }]);
@@ -2036,8 +2319,7 @@ var AtscriptDbReadable = class {
2036
2319
  if ($select && quantityRefByField.size > 0) {
2037
2320
  const groupBySet = new Set($groupBy);
2038
2321
  for (const item of $select) {
2039
- if (typeof item === "string") continue;
2040
- if (item.$field === "*") continue;
2322
+ if (!isAggregateExpr(item) || item.$field === "*") continue;
2041
2323
  const refField = quantityRefByField.get(item.$field);
2042
2324
  if (refField && !groupBySet.has(refField)) throw new DbError("INVALID_QUERY", [{
2043
2325
  path: "$select",
@@ -2050,8 +2332,8 @@ var AtscriptDbReadable = class {
2050
2332
  this._ensureSearchable();
2051
2333
  if (!searchTerm.trim()) return query.controls.$count ? [{ count: 0 }] : [];
2052
2334
  }
2053
- guardAggregate(this._meta, this.adapter, query);
2054
- 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);
2055
2337
  return (await this.adapter.aggregate(dbQuery)).map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
2056
2338
  }
2057
2339
  /** Whether the underlying adapter supports text search. */
@@ -2062,6 +2344,10 @@ var AtscriptDbReadable = class {
2062
2344
  canFilterField(fd) {
2063
2345
  return this.adapter.canFilterField(fd);
2064
2346
  }
2347
+ /** Calendar-bucket units the adapter can group by (proxies adapter capability; empty = none). */
2348
+ calendarBucketUnits() {
2349
+ return this.adapter.calendarBucketUnits();
2350
+ }
2065
2351
  /** Whether the adapter can sort by a given field (proxies adapter capability). */
2066
2352
  canSortField(fd) {
2067
2353
  return this.adapter.canSortField(fd);
@@ -2348,7 +2634,7 @@ var AtscriptDbReadable = class {
2348
2634
  * Public entry point for relation loading. Used by adapters for nested $with delegation.
2349
2635
  */
2350
2636
  async loadRelations(rows, withRelations) {
2351
- const { loadRelationsImpl } = await import("./relation-loader-CUGcxJ18.mjs").then((n) => n.n);
2637
+ const { loadRelationsImpl } = await import("./relation-loader-B68R1LET.mjs").then((n) => n.n);
2352
2638
  return loadRelationsImpl(rows, withRelations, this);
2353
2639
  }
2354
2640
  /**
@@ -2413,6 +2699,9 @@ function createFailureCollector(what) {
2413
2699
  //#endregion
2414
2700
  //#region src/base-adapter.ts
2415
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(BUCKET_UNITS);
2416
2705
  const txStorage = new AsyncLocalStorage();
2417
2706
  /** The innermost open transaction of `owner` in the current async chain. */
2418
2707
  function findTxContext(owner) {
@@ -2627,6 +2916,23 @@ var BaseDbAdapter = class {
2627
2916
  return EMPTY_DEFAULT_FNS;
2628
2917
  }
2629
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
+ /**
2630
2936
  * Whether this adapter enforces foreign key constraints natively.
2631
2937
  * When `true`, the generic layer skips application-level cascade/setNull
2632
2938
  * on delete — the DB engine handles it (e.g. SQLite `ON DELETE CASCADE`).
@@ -2648,6 +2954,9 @@ var BaseDbAdapter = class {
2648
2954
  * Used by `AsDbReadableController.buildMetaResponse()` to gate the
2649
2955
  * `filterable` flag exposed to UIs — the adapter's answer is a hard gate
2650
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`).
2651
2960
  */
2652
2961
  canFilterField(fd) {
2653
2962
  if (fd.encrypted) return false;
@@ -2774,11 +3083,18 @@ var BaseDbAdapter = class {
2774
3083
  return [];
2775
3084
  }
2776
3085
  /**
2777
- * Whether this adapter supports text search.
2778
- * Default: `true` when {@link getSearchIndexes} returns any entries.
3086
+ * Whether this adapter can run TEXT search — `search()`, `searchWithCount()`
3087
+ * and the grouped `$search` path all gate on it. Vector capability is a
3088
+ * separate predicate, {@link isVectorSearchable}.
3089
+ *
3090
+ * Default: `true` when {@link getSearchIndexes} lists at least one non-vector
3091
+ * index. Vector entries are published there for the index picker, but a
3092
+ * vector index answers {@link vectorSearch} and nothing else, so counting one
3093
+ * here would claim a capability the adapter does not have. An entry without
3094
+ * `type` counts as text — adapters predating the field only listed text.
2779
3095
  */
2780
3096
  isSearchable() {
2781
- return this.getSearchIndexes().length > 0;
3097
+ return this.getSearchIndexes().some((index) => index.type !== "vector");
2782
3098
  }
2783
3099
  /**
2784
3100
  * Whether this adapter supports vector similarity search.
@@ -4336,4 +4652,4 @@ function isAtscriptDbView(readable) {
4336
4652
  return readable.isView;
4337
4653
  }
4338
4654
  //#endregion
4339
- 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 };
4655
+ export { FieldMappingStrategy as A, NoopLogger as B, guardPaths as C, unsupportedOperatorMessage as D, sortFieldNames as E, isGeoPointType as F, isBucketableField as I, isJsonValueField as L, TableMetadata as M, findAncestorInSet as N, RelationalFieldMapper as O, isGeoIndexableType as P, jsonValueAncestor as R, guardPath as S, narrowerFilterOps as T, checkHavingKeys as _, decomposePatch as a, guardAggregate as b, BaseDbAdapter as c, NativeIntegrity as d, AtscriptDbReadable as f, canFilterLeaf as g, assertGeoPoint as h, assertNoVersionWrites as i, UniquSelect as j, DocumentFieldMapper as k, createFailureCollector as l, acceptedOperatorsHint 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, classifyQueryPath as v, guardQuery as w, guardFilter as x, collectQueryPaths as y, resolveCalendarBuckets as z };