@atscript/moost-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.
package/dist/index.cjs CHANGED
@@ -5,6 +5,7 @@ let _moostjs_event_http = require("@moostjs/event-http");
5
5
  let moost = require("moost");
6
6
  let _uniqu_url = require("@uniqu/url");
7
7
  let _atscript_db = require("@atscript/db");
8
+ let _uniqu_core = require("@uniqu/core");
8
9
  let _atscript_db_memory = require("@atscript/db-memory");
9
10
  let _wooksjs_event_core = require("@wooksjs/event-core");
10
11
  let _wooksjs_http_body = require("@wooksjs/http-body");
@@ -38,7 +39,8 @@ function badRequest(path, message, top = message) {
38
39
  const dbErrorCodeToStatus = {
39
40
  CONFLICT: 409,
40
41
  CAS_MISMATCH: 409,
41
- TX_WAIT_TIMEOUT: 503
42
+ TX_WAIT_TIMEOUT: 503,
43
+ BUCKET_TZ_UNAVAILABLE: 501
42
44
  };
43
45
  function transformValidationError(error, reply) {
44
46
  if (error instanceof _atscript_typescript_utils.ValidatorError) reply(errorEnvelope(400, error.message, error.errors));
@@ -630,6 +632,8 @@ let AsReadableController = class AsReadableController {
630
632
  _serializedType;
631
633
  /** Cached full meta response (computed lazily on first meta() call). */
632
634
  _metaResponse;
635
+ /** {@link metaCacheKey} the cached response was built for. */
636
+ _metaResponseKey;
633
637
  /** Cached serialized form schemas keyed by `FormType.name` — populated lazily by {@link metaForm}. */
634
638
  _formSchemas = /* @__PURE__ */ new Map();
635
639
  constructor(boundType, controllerName, app, kindTag = "readable") {
@@ -823,14 +827,26 @@ let AsReadableController = class AsReadableController {
823
827
  }
824
828
  /**
825
829
  * **GET /meta** — returns the bound interface's metadata envelope. The
826
- * static envelope is cached; {@link applyMetaOverlay} runs per request so
827
- * subclasses can prune the response by principal.
830
+ * static envelope is cached (rebuilt when {@link metaCacheKey} changes);
831
+ * {@link applyMetaOverlay} runs per request so subclasses can prune the
832
+ * response by principal.
828
833
  */
829
834
  async meta() {
830
- if (!this._metaResponse) this._metaResponse = this.buildMetaResponse();
835
+ const key = this.metaCacheKey();
836
+ if (!this._metaResponse || key !== this._metaResponseKey) {
837
+ this._metaResponse = this.buildMetaResponse();
838
+ this._metaResponseKey = key;
839
+ }
831
840
  return this.applyMetaOverlay(this._metaResponse);
832
841
  }
833
842
  /**
843
+ * Identity of the inputs the cached `/meta` envelope is built from — a new
844
+ * value rebuilds it. Default: constant (built once). The DB readable
845
+ * controller returns its capability index, which is rebuilt when the
846
+ * adapter's capabilities change (since 0.1.132).
847
+ */
848
+ metaCacheKey() {}
849
+ /**
834
850
  * **GET /meta/form/:name** — returns the serialized schema of a form
835
851
  * referenced by an action's `inputForm` field. The form name is the
836
852
  * compiled `.as` class's `.name`, registered when an action's parameter is
@@ -1139,10 +1155,10 @@ function resolveBoundReadable(ctor) {
1139
1155
  }
1140
1156
  //#endregion
1141
1157
  //#region src/meta/field-capabilities.ts
1142
- const REASON_ADAPTER_FILTER = "adapter cannot filter on this storage type.";
1158
+ const REASON_ADAPTER_FILTER = `${_atscript_db.ADAPTER_FILTER_REASON}.`;
1143
1159
  const REASON_ADAPTER_SORT = "adapter cannot sort on this storage type.";
1144
1160
  const REASON_WRITE_ONLY = "field is @db.writeOnly.";
1145
- const REASON_ENCRYPTED = "field is @db.encrypted (ciphertext cannot be compared or ordered).";
1161
+ const REASON_ENCRYPTED = `${_atscript_db.ENCRYPTED_REASON}.`;
1146
1162
  const REASON_ANNOTATION_FILTER = "add @db.column.filterable to enable.";
1147
1163
  const REASON_ANNOTATION_SORT = "add @db.column.sortable to enable.";
1148
1164
  /** Sentence subject per op ("Filtering on field …"). */
@@ -1152,7 +1168,8 @@ const OP_SUBJECT = {
1152
1168
  select: "Selecting",
1153
1169
  groupBy: "Grouping by",
1154
1170
  having: "Filtering ($having) on",
1155
- aggregate: "Aggregating over"
1171
+ aggregate: "Aggregating over",
1172
+ bucket: "Bucketing"
1156
1173
  };
1157
1174
  /** Lower-case verb per op ("… cannot filter JSON paths"). */
1158
1175
  const OP_VERB = {
@@ -1161,7 +1178,8 @@ const OP_VERB = {
1161
1178
  select: "select",
1162
1179
  groupBy: "group by",
1163
1180
  having: "filter ($having) on",
1164
- aggregate: "aggregate over"
1181
+ aggregate: "aggregate over",
1182
+ bucket: "bucket"
1165
1183
  };
1166
1184
  function leafHint(leaves) {
1167
1185
  if (leaves.length === 0) return "no leaf fields";
@@ -1169,17 +1187,22 @@ function leafHint(leaves) {
1169
1187
  return leaves.length > 5 ? `${shown}, …` : shown;
1170
1188
  }
1171
1189
  /**
1172
- * Capability index of one readable, built once per controller.
1190
+ * Capability index of one readable.
1173
1191
  *
1174
1192
  * - {@link entries} feeds `/meta.fields` (listed leaves in descriptor order);
1175
1193
  * - {@link check} is the request gate: same inputs, same answer.
1176
1194
  *
1195
+ * Built from the adapter's capabilities as they are NOW. Some are only known
1196
+ * after schema sync (PostgreSQL learns PostGIS there), so owners rebuild the
1197
+ * index when {@link adapterSignature} changes (see `AsDbReadableController`'s
1198
+ * `capabilities` getter) instead of keeping a constructor-time snapshot.
1199
+ *
1177
1200
  * Paths outside the index are classified by the core's `classifyQueryPath`
1178
1201
  * (the same rules the core backstop applies) — navigation path, nested-object
1179
1202
  * parent, JSON descendant (relational adapters), encrypted descendant,
1180
1203
  * unknown — so the 400 names the storage reason and the alternative.
1181
1204
  */
1182
- var FieldCapabilityIndex = class {
1205
+ var FieldCapabilityIndex = class FieldCapabilityIndex {
1183
1206
  filterableManual;
1184
1207
  sortableManual;
1185
1208
  /** Navigation relations (`@db.rel.to/from/via`), incl. nested ones. */
@@ -1190,9 +1213,26 @@ var FieldCapabilityIndex = class {
1190
1213
  jsonParents;
1191
1214
  /** Descriptors carrying `@db.encrypted` (the ciphertext column on relational adapters). */
1192
1215
  encryptedFields;
1216
+ /** Every field descriptor's `physicalName` — names a calendar-bucket alias may not take. */
1217
+ physicalNames;
1218
+ /** Calendar-bucket units the adapter groups by, in `BUCKET_UNITS` order (`/meta.bucketUnits`). */
1219
+ bucketUnits;
1220
+ /** The adapter-level capabilities this index was built against — see {@link adapterSignature}. */
1221
+ signature;
1222
+ /**
1223
+ * The adapter-level capabilities that can change after construction (geo
1224
+ * support, calendar-bucket units): an index whose {@link signature}
1225
+ * differs from this is stale. Any new adapter-level input the index reads
1226
+ * must be added here.
1227
+ */
1228
+ static adapterSignature(source) {
1229
+ return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}`;
1230
+ }
1193
1231
  _entries = /* @__PURE__ */ new Map();
1194
1232
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
1195
1233
  _objectParents = /* @__PURE__ */ new Map();
1234
+ /** What `bucketSourceVerdict` reads of the table (JSON-value parents, dimensions, measures). */
1235
+ _bucketTable;
1196
1236
  /** Listed leaves — the {@link TQueryPathSource} view for `classifyQueryPath`. */
1197
1237
  get leaves() {
1198
1238
  return this._entries;
@@ -1202,12 +1242,25 @@ var FieldCapabilityIndex = class {
1202
1242
  return this._objectParents;
1203
1243
  }
1204
1244
  constructor(source, writeOnly) {
1205
- const canFilter = (fd) => source.canFilterField(fd);
1206
- const canSort = (fd) => source.canSortField(fd);
1207
1245
  const tableMeta = source.type.metadata;
1208
1246
  this.filterableManual = tableMeta.get("db.table.filterable") === "manual";
1209
1247
  this.sortableManual = tableMeta.get("db.table.sortable") === "manual";
1210
1248
  this.writeOnly = writeOnly;
1249
+ this.signature = FieldCapabilityIndex.adapterSignature(source);
1250
+ const units = source.calendarBucketUnits();
1251
+ this.bucketUnits = _uniqu_core.BUCKET_UNITS.filter((unit) => units.has(unit));
1252
+ const physicalNames = /* @__PURE__ */ new Set();
1253
+ const jsonValueParents = /* @__PURE__ */ new Set();
1254
+ for (const fd of source.fieldDescriptors) {
1255
+ physicalNames.add(fd.physicalName);
1256
+ if ((0, _atscript_db.isJsonValueField)(fd)) jsonValueParents.add(fd.path);
1257
+ }
1258
+ this.physicalNames = physicalNames;
1259
+ this._bucketTable = {
1260
+ jsonValueParents,
1261
+ dimensions: source.dimensions,
1262
+ measures: source.measures
1263
+ };
1211
1264
  const nav = new Set(source.navFields);
1212
1265
  if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
1213
1266
  this.navFields = nav;
@@ -1229,7 +1282,7 @@ var FieldCapabilityIndex = class {
1229
1282
  this._objectParents.set(fd.path, []);
1230
1283
  continue;
1231
1284
  }
1232
- this._entries.set(fd.path, this._buildEntry(fd, canFilter, canSort, annotated));
1285
+ this._entries.set(fd.path, this._buildEntry(fd, source, annotated));
1233
1286
  }
1234
1287
  const ignored = source.ignoredFields;
1235
1288
  for (const [path, entry] of flatMap) {
@@ -1249,49 +1302,45 @@ var FieldCapabilityIndex = class {
1249
1302
  this.jsonParents = jsonParents;
1250
1303
  this.encryptedFields = encrypted;
1251
1304
  }
1252
- _buildEntry(fd, canFilter, canSort, annotated) {
1253
- const physicalFilter = canFilter(fd);
1254
- const physicalSort = canSort(fd);
1305
+ _buildEntry(fd, source, annotated) {
1255
1306
  const isWriteOnly = this.writeOnly.has(fd.path);
1256
- let filterable = physicalFilter;
1257
- let sortable = physicalSort;
1258
- let filterReason = physicalFilter ? void 0 : REASON_ADAPTER_FILTER;
1259
- let sortReason = physicalSort ? void 0 : REASON_ADAPTER_SORT;
1260
- let physicalReason = physicalFilter ? void 0 : REASON_ADAPTER_FILTER;
1261
- if (fd.encrypted) {
1262
- filterable = false;
1263
- sortable = false;
1264
- filterReason = REASON_ENCRYPTED;
1265
- sortReason = REASON_ENCRYPTED;
1266
- physicalReason = REASON_ENCRYPTED;
1267
- }
1268
- if (isWriteOnly) {
1269
- filterable = false;
1270
- sortable = false;
1271
- filterReason = REASON_WRITE_ONLY;
1272
- sortReason = REASON_WRITE_ONLY;
1273
- physicalReason = REASON_WRITE_ONLY;
1274
- }
1275
- if (filterable && this.filterableManual && !annotated(fd, "db.column.filterable")) {
1276
- filterable = false;
1277
- filterReason = REASON_ANNOTATION_FILTER;
1278
- }
1279
- if (sortable && this.sortableManual && !annotated(fd, "db.column.sortable")) {
1280
- sortable = false;
1281
- sortReason = REASON_ANNOTATION_SORT;
1282
- }
1307
+ const filterPolicyBlocked = this.filterableManual && !annotated(fd, "db.column.filterable");
1308
+ const verdict = (predicate, policy) => {
1309
+ if (isWriteOnly) return REASON_WRITE_ONLY;
1310
+ if (fd.encrypted) return REASON_ENCRYPTED;
1311
+ if (!(0, _atscript_db.canFilterLeaf)(fd, predicate, source)) return REASON_ADAPTER_FILTER;
1312
+ if (policy && filterPolicyBlocked) return REASON_ANNOTATION_FILTER;
1313
+ };
1314
+ const filterBy = {
1315
+ compare: verdict("compare", true),
1316
+ exists: verdict("exists", true),
1317
+ geo: verdict("geo", true)
1318
+ };
1319
+ const filterOps = filterBy.compare === REASON_ADAPTER_FILTER && !filterPolicyBlocked ? (0, _atscript_db.narrowerFilterOps)(fd, source) : [];
1320
+ if (filterOps.length > 0) filterBy.compare = `${_atscript_db.ADAPTER_FILTER_REASON}${(0, _atscript_db.acceptedOperatorsHint)(filterOps)}.`;
1321
+ const physicalReason = verdict("compare", false);
1322
+ let sortReason = source.canSortField(fd) ? void 0 : REASON_ADAPTER_SORT;
1323
+ if (fd.encrypted) sortReason = REASON_ENCRYPTED;
1324
+ if (isWriteOnly) sortReason = REASON_WRITE_ONLY;
1325
+ if (!sortReason && this.sortableManual && !annotated(fd, "db.column.sortable")) sortReason = REASON_ANNOTATION_SORT;
1326
+ const bucket = isWriteOnly ? void 0 : (0, _atscript_db.bucketSourceVerdict)(fd, this._bucketTable, source);
1327
+ const bucketReason = !bucket ? REASON_WRITE_ONLY : bucket.ok ? void 0 : `${bucket.reason}.`;
1283
1328
  const cap = {
1284
- filterable,
1285
- sortable,
1329
+ filterable: filterBy.compare === void 0,
1330
+ sortable: sortReason === void 0,
1286
1331
  selectable: true,
1287
- indexed: fd.isIndexed === true
1332
+ indexed: fd.isIndexed === true,
1333
+ bucketable: bucketReason === void 0
1288
1334
  };
1289
- if (filterReason) cap.filterReason = filterReason;
1335
+ if (filterOps.length > 0) cap.filterOps = filterOps;
1336
+ if (filterBy.compare) cap.filterReason = filterBy.compare;
1290
1337
  if (sortReason) cap.sortReason = sortReason;
1338
+ if (bucketReason) cap.bucketReason = bucketReason;
1291
1339
  return {
1292
1340
  fd,
1293
1341
  cap,
1294
- physicalFilterable: physicalFilter && !isWriteOnly && !fd.encrypted,
1342
+ filterBy,
1343
+ physicalFilterable: physicalReason === void 0,
1295
1344
  physicalReason
1296
1345
  };
1297
1346
  }
@@ -1309,18 +1358,32 @@ var FieldCapabilityIndex = class {
1309
1358
  }
1310
1359
  /**
1311
1360
  * Gate check for one path in one position. Returns `undefined` when the
1312
- * path is accepted. Order: navigation paths first (a nav path "exists" on
1313
- * the target table but is never a column here), then a listed leaf's
1314
- * capability (no existence lookup needed — every listed leaf is a real
1315
- * field), then `exists` (the readable's `isValidFieldPath`) and, for paths
1316
- * that exist but are not leaves, the storage classification.
1361
+ * path is accepted.
1362
+ *
1363
+ * `exists` runs FIRST, for every path (since 0.1.133): it is the
1364
+ * controller's `hasField`, the visibility hook subclasses narrow per
1365
+ * request (e.g. a projection-scoped viewer). A path it rejects answers
1366
+ * `Unknown field "x"` exactly like a nonexistent one — never a capability
1367
+ * or navigation hint, which would reveal the field and let a filter or
1368
+ * sort on it act as a value oracle. Before 0.1.133 listed leaves and
1369
+ * navigation paths skipped it.
1370
+ *
1371
+ * Then: navigation paths (a nav path exists on the target table but is
1372
+ * never a column here), a listed leaf's capability, and for other paths
1373
+ * the storage classification. Existence also runs BEFORE the JSON /
1374
+ * encrypted classification: an untyped descendant of a JSON column
1375
+ * (`address.nope`) is reported as `Unknown field`, not as "inside
1376
+ * JSON-stored column" — clients pin that wording, so do not "align" it
1377
+ * with the core backstop's text.
1317
1378
  *
1318
- * Existence deliberately runs BEFORE the JSON / encrypted classification:
1319
- * an untyped descendant of a JSON column (`address.nope`) is reported as
1320
- * `Unknown field`, not as "inside JSON-stored column" — clients pin that
1321
- * wording, so do not "align" it with the core backstop's text.
1379
+ * `predicate` is a filter entry's class (`collectQueryPaths` records it per
1380
+ * occurrence); it only matters for `op === "filter"` on a listed leaf.
1322
1381
  */
1323
- check(path, op, exists) {
1382
+ check(path, op, exists, predicate = "compare") {
1383
+ if (!exists(path)) return {
1384
+ path,
1385
+ message: `Unknown field "${path}"`
1386
+ };
1324
1387
  const { kind, parent } = (0, _atscript_db.classifyQueryPath)(this, path);
1325
1388
  if (kind === "nav") {
1326
1389
  if (parent === void 0) return {
@@ -1339,24 +1402,27 @@ var FieldCapabilityIndex = class {
1339
1402
  path,
1340
1403
  message: `Selecting field "${path}" is not permitted.`
1341
1404
  };
1342
- case "filter": return entry.cap.filterable ? void 0 : {
1343
- path,
1344
- message: `Filtering on field "${path}" is not permitted — ${entry.cap.filterReason}`
1345
- };
1405
+ case "filter": {
1406
+ const reason = entry.filterBy[predicate];
1407
+ return reason === void 0 ? void 0 : {
1408
+ path,
1409
+ message: `Filtering on field "${path}" is not permitted — ${reason}`
1410
+ };
1411
+ }
1346
1412
  case "sort": return entry.cap.sortable ? void 0 : {
1347
1413
  path,
1348
1414
  message: `Sorting on field "${path}" is not permitted — ${entry.cap.sortReason}`
1349
1415
  };
1416
+ case "bucket": return entry.cap.bucketable ? void 0 : {
1417
+ path,
1418
+ message: `Bucketing field "${path}" is not permitted — ${entry.cap.bucketReason}`
1419
+ };
1350
1420
  default: return entry.physicalFilterable ? void 0 : {
1351
1421
  path,
1352
1422
  message: `${OP_SUBJECT[op]} field "${path}" is not permitted — ${entry.physicalReason}`
1353
1423
  };
1354
1424
  }
1355
1425
  }
1356
- if (!exists(path)) return {
1357
- path,
1358
- message: `Unknown field "${path}"`
1359
- };
1360
1426
  switch (kind) {
1361
1427
  case "objectParent":
1362
1428
  if (op === "select") return void 0;
@@ -1420,14 +1486,14 @@ const GEO_CONTROLS = [
1420
1486
  ];
1421
1487
  //#endregion
1422
1488
  //#region src/as-db-readable.controller.ts
1423
- /** The gate positions in check order; `refs[op]` are the paths collected for each. */
1424
- const OPS = [
1425
- "filter",
1489
+ /** Gate positions checked after the filter entries, in order; `refs[op]` are their paths. */
1490
+ const PATH_OPS = [
1426
1491
  "sort",
1427
1492
  "select",
1428
1493
  "groupBy",
1429
1494
  "having",
1430
- "aggregate"
1495
+ "aggregate",
1496
+ "bucket"
1431
1497
  ];
1432
1498
  let AsDbReadableController = class AsDbReadableController extends AsReadableController {
1433
1499
  /** Reference to the underlying readable (table or view). */
@@ -1448,9 +1514,26 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1448
1514
  * Per-path capability index (since 0.1.128): the ONE input both `/meta.fields`
1449
1515
  * and the request gate ({@link checkCapabilities}) are computed from, so
1450
1516
  * metadata and runtime can never diverge.
1517
+ *
1518
+ * Built on first use and rebuilt whenever the adapter-level capabilities
1519
+ * change (`FieldCapabilityIndex.adapterSignature`: geo support, calendar
1520
+ * buckets) — PostgreSQL learns PostGIS only during schema sync, which may
1521
+ * run after this controller is constructed, so a constructor-time snapshot
1522
+ * would keep advertising (and gating) the pre-sync answer (since 0.1.132).
1451
1523
  */
1452
- capabilities;
1453
- /** Bound once: the field-existence check the gate hands to `capabilities.check`. */
1524
+ get capabilities() {
1525
+ const current = this._capabilities;
1526
+ if (current && current.signature === FieldCapabilityIndex.adapterSignature(this.readable)) return current;
1527
+ const index = new FieldCapabilityIndex(this.readable, this._writeOnlySet);
1528
+ this._capabilities = index;
1529
+ return index;
1530
+ }
1531
+ _capabilities;
1532
+ /** `/meta` is a projection of {@link capabilities}: a rebuilt index rebuilds the cached envelope. */
1533
+ metaCacheKey() {
1534
+ return this.capabilities;
1535
+ }
1536
+ /** Bound once: the visibility check ({@link hasField}) the gate hands to `capabilities.check`. */
1454
1537
  _exists = (path) => this.hasField(path);
1455
1538
  _preferredIdSet;
1456
1539
  _overlayIsNoOp;
@@ -1471,7 +1554,6 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1471
1554
  super(resolved.type, resolved.tableName, app, resolved.isView ? "view" : "table");
1472
1555
  this.readable = resolved;
1473
1556
  this._writeOnlySet = this._collectAnnotated("db.writeOnly");
1474
- this.capabilities = new FieldCapabilityIndex(resolved, this._writeOnlySet);
1475
1557
  this._invertibleFields = this._collectInvertibleFields();
1476
1558
  this._searchFallbackFields = this._collectSearchFallbackFields();
1477
1559
  this._preferredIdSet = new Set(resolved.preferredId ?? []);
@@ -1504,6 +1586,20 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1504
1586
  for (const [path, entry] of this.readable.flatMap) if (entry?.metadata?.has?.(annotation)) out.add(path);
1505
1587
  return out;
1506
1588
  }
1589
+ /**
1590
+ * THE field-visibility hook: every gated path consults it before any
1591
+ * capability check (since 0.1.133) — filter keys (inside `$and` / `$or` /
1592
+ * `$not`, existence predicates included), `$sort`, `$select`,
1593
+ * `$groupBy`, `$having` keys, aggregate and calendar-bucket `$field`s,
1594
+ * `$with` relation names and sub-query paths, and the `$search` fallback
1595
+ * fields. A path it rejects is answered exactly like a nonexistent one
1596
+ * (`Unknown field "x"` / `Unknown relation "x"`), so override it to hide
1597
+ * fields per request (read scopes). The default accepts every real path
1598
+ * (`isValidFieldPath`). `/meta` does NOT consult it — prune hidden fields
1599
+ * there with `applyMetaOverlay`. Native text search and vector search
1600
+ * (`$vector` names an index) run inside the engine over its indexes, out of
1601
+ * this hook's reach — keep hidden fields out of those indexes.
1602
+ */
1507
1603
  hasField(path) {
1508
1604
  if (typeof this.readable.isValidFieldPath === "function") return this.readable.isValidFieldPath(path);
1509
1605
  return this.readable.flatMap.has(path);
@@ -1518,19 +1614,23 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1518
1614
  *
1519
1615
  * Rejections use the structured envelope `{ message, statusCode: 400,
1520
1616
  * errors: [{ path, message }] }` — `path` is the offending logical path.
1521
- * After the per-path checks the core `$having` rule runs (`checkHavingKeys`:
1522
- * aliases or `$groupBy` fields only), so a readable mock and a real table
1523
- * answer alike.
1617
+ *
1618
+ * Expects normalized `$select` computed entries ({@link checkComputedSelect}
1619
+ * ran first); a bucket's source is checked like any other path (op
1620
+ * `bucket`). After the per-path checks the core `$having` rule runs
1621
+ * (`checkHavingKeys`: aliases or `$groupBy` fields only), so a readable mock
1622
+ * and a real table answer alike.
1524
1623
  */
1525
1624
  checkCapabilities(parsed) {
1625
+ const capabilities = this.capabilities;
1526
1626
  const refs = (0, _atscript_db.collectQueryPaths)(parsed);
1527
1627
  if (refs.unsupportedOperator !== void 0) return badRequest(refs.unsupportedOperator, (0, _atscript_db.unsupportedOperatorMessage)(refs.unsupportedOperator));
1528
- for (const op of OPS) for (const path of refs[op]) {
1529
- const verdict = this.capabilities.check(path, op, this._exists);
1628
+ for (const { path, predicate } of refs.filter) {
1629
+ const verdict = capabilities.check(path, "filter", this._exists, predicate);
1530
1630
  if (verdict) return badRequest(verdict.path, verdict.message);
1531
1631
  }
1532
- for (const path of refs.geoFilter) {
1533
- const verdict = this.capabilities.check(path, "filter", this._exists);
1632
+ for (const op of PATH_OPS) for (const path of refs[op]) {
1633
+ const verdict = capabilities.check(path, op, this._exists);
1534
1634
  if (verdict) return badRequest(verdict.path, verdict.message);
1535
1635
  }
1536
1636
  const having = (0, _atscript_db.checkHavingKeys)(refs);
@@ -1538,6 +1638,31 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1538
1638
  return this.checkGates(parsed);
1539
1639
  }
1540
1640
  /**
1641
+ * The core's shared normalizer of `$select` computed entries
1642
+ * (`resolveCalendarBuckets`) as a 400 with the core's wording and `path`
1643
+ * (`$select` / `$groupBy`): entry shapes, calendar-bucket unit / zone /
1644
+ * week start / alias, "grouped queries only", "must also appear in
1645
+ * $groupBy", alias collisions with this table's fields. Runs once per
1646
+ * request, before {@link checkCapabilities}: at the head of
1647
+ * {@link validateParsed} — ahead of the controls DTO, which would otherwise
1648
+ * answer a bucket in a non-grouped query with a generic type mismatch — or
1649
+ * explicitly on the endpoint that skips it (`geo`).
1650
+ */
1651
+ checkComputedSelect(controls) {
1652
+ const capabilities = this.capabilities;
1653
+ try {
1654
+ (0, _atscript_db.resolveCalendarBuckets)(controls, {
1655
+ flatMap: this.readable.flatMap,
1656
+ physicalNames: capabilities.physicalNames,
1657
+ navFields: capabilities.navFields
1658
+ });
1659
+ } catch (error) {
1660
+ if (!(error instanceof _atscript_db.DbError)) throw error;
1661
+ const [issue] = error.errors;
1662
+ return badRequest(issue.path, issue.message);
1663
+ }
1664
+ }
1665
+ /**
1541
1666
  * Root-path existence moved into {@link checkCapabilities}; the insights map
1542
1667
  * only serves `$with` sub-controls here — the URL parser flattens
1543
1668
  * `$with=assignee($select=name)` into the insight `assignee.name`, which is
@@ -1553,14 +1678,22 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1553
1678
  if (!this.hasField(key)) return `Unknown field "${key}"`;
1554
1679
  }
1555
1680
  }
1556
- /** Validates $with relations against the readable. */
1681
+ /** {@link checkComputedSelect} (before the controls DTO), then $with relations against the readable. */
1557
1682
  validateParsed(parsed, type) {
1683
+ const computedError = this.checkComputedSelect(parsed.controls);
1684
+ if (computedError) return computedError;
1558
1685
  const baseError = super.validateParsed(parsed, type);
1559
1686
  if (baseError) return baseError;
1560
1687
  const withRelations = parsed.controls.$with;
1561
1688
  if (withRelations?.length) {
1562
1689
  const relations = this.readable.relations;
1563
- for (const rel of withRelations) if (!rel.name.includes(".") && !relations.has(rel.name)) return badRequest("$with", `Unknown relation "${rel.name}"`, `Unknown relation "${rel.name}" in $with. Available relations: ${[...relations.keys()].join(", ") || "(none)"}`);
1690
+ for (const rel of withRelations) {
1691
+ const dot = rel.name.indexOf(".");
1692
+ if (!(dot === -1 ? relations.has(rel.name) && this.hasField(rel.name) : this.hasField(rel.name.slice(0, dot)))) {
1693
+ const visible = [...relations.keys()].filter((name) => this.hasField(name));
1694
+ return badRequest("$with", `Unknown relation "${rel.name}"`, `Unknown relation "${rel.name}" in $with. Available relations: ${visible.join(", ") || "(none)"}`);
1695
+ }
1696
+ }
1564
1697
  }
1565
1698
  }
1566
1699
  /**
@@ -1797,10 +1930,11 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1797
1930
  /** First `@db.writeOnly` field referenced by `$groupBy` / aggregate `$select`, or undefined. */
1798
1931
  _findWriteOnlyInAggregate(groupBy, select) {
1799
1932
  if (this._writeOnlySet.size === 0) return void 0;
1800
- for (const f of groupBy) if (this._writeOnlySet.has(f)) return f;
1933
+ const sealed = (f) => this._writeOnlySet.has(f) && this.hasField(f);
1934
+ for (const f of groupBy) if (sealed(f)) return f;
1801
1935
  if (Array.isArray(select)) for (const item of select) {
1802
1936
  const field = typeof item === "string" ? item : item.$field;
1803
- if (field && this._writeOnlySet.has(field)) return field;
1937
+ if (field && sealed(field)) return field;
1804
1938
  }
1805
1939
  }
1806
1940
  /**
@@ -1812,9 +1946,11 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1812
1946
  applySearchFallback(filter, controls) {
1813
1947
  const term = controls.$search;
1814
1948
  if (!term || controls.$vector !== void 0) return filter;
1815
- if (this.readable.isSearchable() || this._searchFallbackFields.length === 0) return filter;
1949
+ if (this.readable.isSearchable()) return filter;
1950
+ const fields = this._searchFallbackFields.filter((f) => this.hasField(f));
1951
+ if (fields.length === 0) return filter;
1816
1952
  const rx = `/${term.replace(/[.*+?^${}()|[\]\\/]/g, String.raw`\$&`)}/i`;
1817
- const fragment = { $or: this._searchFallbackFields.map((f) => ({ [f]: { $regex: rx } })) };
1953
+ const fragment = { $or: fields.map((f) => ({ [f]: { $regex: rx } })) };
1818
1954
  return filter && Object.keys(filter).length > 0 ? { $and: [filter, fragment] } : fragment;
1819
1955
  }
1820
1956
  /**
@@ -2026,6 +2162,8 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2026
2162
  const insightsError = this.validateInsights(parsed.insights);
2027
2163
  if (insightsError) return new _moostjs_event_http.HttpError(400, insightsError);
2028
2164
  }
2165
+ const computedError = this.checkComputedSelect(controls);
2166
+ if (computedError) return computedError;
2029
2167
  const gateError = this.checkCapabilities(parsed);
2030
2168
  if (gateError) return gateError;
2031
2169
  const [filter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
@@ -2158,16 +2296,19 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2158
2296
  if (this.readable.indexes instanceof Map) {
2159
2297
  for (const index of this.readable.indexes.values()) if (index.type === "geo") for (const f of index.fields) geoIndexedPhysical.add(f.name);
2160
2298
  }
2299
+ const capabilities = this.capabilities;
2161
2300
  const fields = {};
2162
- for (const [path, cap, fd] of this.capabilities.entries()) {
2301
+ for (const [path, cap, fd] of capabilities.entries()) {
2163
2302
  const entry = {
2164
2303
  sortable: cap.sortable,
2165
2304
  filterable: cap.filterable
2166
2305
  };
2306
+ if (cap.filterOps) entry.filterOps = [...cap.filterOps];
2167
2307
  if (cap.indexed) entry.indexed = true;
2168
2308
  if (fd.encrypted) entry.encrypted = true;
2169
2309
  if (geoIndexedPhysical.has(fd.physicalName)) entry.geo = true;
2170
2310
  if (this._writeOnlySet.has(path)) entry.writeOnly = true;
2311
+ if (cap.bucketable) entry.bucketable = true;
2171
2312
  fields[path] = entry;
2172
2313
  }
2173
2314
  return {
@@ -2182,7 +2323,8 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2182
2323
  type: this.getSerializedType(),
2183
2324
  actions: this.buildActions(),
2184
2325
  crud: this.buildCrud(),
2185
- versionColumn: this.readable.versionColumn
2326
+ versionColumn: this.readable.versionColumn,
2327
+ ...capabilities.bucketUnits.length > 0 && { bucketUnits: [...capabilities.bucketUnits] }
2186
2328
  };
2187
2329
  }
2188
2330
  buildCrud() {