@atscript/moost-db 0.1.131 → 0.1.132

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,12 +1155,16 @@ 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 ADAPTER_FILTER = "adapter cannot filter on this storage type";
1159
+ const REASON_ADAPTER_FILTER = `${ADAPTER_FILTER}.`;
1143
1160
  const REASON_ADAPTER_SORT = "adapter cannot sort on this storage type.";
1144
1161
  const REASON_WRITE_ONLY = "field is @db.writeOnly.";
1145
1162
  const REASON_ENCRYPTED = "field is @db.encrypted (ciphertext cannot be compared or ordered).";
1146
1163
  const REASON_ANNOTATION_FILTER = "add @db.column.filterable to enable.";
1147
1164
  const REASON_ANNOTATION_SORT = "add @db.column.sortable to enable.";
1165
+ const REASON_NOT_TIMESTAMP = "not a timestamp field (declare it number.timestamp).";
1166
+ const REASON_NOT_DIMENSION = "not a dimension.";
1167
+ const REASON_NO_BUCKETS = "adapter has no calendar buckets.";
1148
1168
  /** Sentence subject per op ("Filtering on field …"). */
1149
1169
  const OP_SUBJECT = {
1150
1170
  filter: "Filtering on",
@@ -1152,7 +1172,8 @@ const OP_SUBJECT = {
1152
1172
  select: "Selecting",
1153
1173
  groupBy: "Grouping by",
1154
1174
  having: "Filtering ($having) on",
1155
- aggregate: "Aggregating over"
1175
+ aggregate: "Aggregating over",
1176
+ bucket: "Bucketing"
1156
1177
  };
1157
1178
  /** Lower-case verb per op ("… cannot filter JSON paths"). */
1158
1179
  const OP_VERB = {
@@ -1161,7 +1182,8 @@ const OP_VERB = {
1161
1182
  select: "select",
1162
1183
  groupBy: "group by",
1163
1184
  having: "filter ($having) on",
1164
- aggregate: "aggregate over"
1185
+ aggregate: "aggregate over",
1186
+ bucket: "bucket"
1165
1187
  };
1166
1188
  function leafHint(leaves) {
1167
1189
  if (leaves.length === 0) return "no leaf fields";
@@ -1169,17 +1191,22 @@ function leafHint(leaves) {
1169
1191
  return leaves.length > 5 ? `${shown}, …` : shown;
1170
1192
  }
1171
1193
  /**
1172
- * Capability index of one readable, built once per controller.
1194
+ * Capability index of one readable.
1173
1195
  *
1174
1196
  * - {@link entries} feeds `/meta.fields` (listed leaves in descriptor order);
1175
1197
  * - {@link check} is the request gate: same inputs, same answer.
1176
1198
  *
1199
+ * Built from the adapter's capabilities as they are NOW. Some are only known
1200
+ * after schema sync (PostgreSQL learns PostGIS there), so owners rebuild the
1201
+ * index when {@link adapterSignature} changes (see `AsDbReadableController`'s
1202
+ * `capabilities` getter) instead of keeping a constructor-time snapshot.
1203
+ *
1177
1204
  * Paths outside the index are classified by the core's `classifyQueryPath`
1178
1205
  * (the same rules the core backstop applies) — navigation path, nested-object
1179
1206
  * parent, JSON descendant (relational adapters), encrypted descendant,
1180
1207
  * unknown — so the 400 names the storage reason and the alternative.
1181
1208
  */
1182
- var FieldCapabilityIndex = class {
1209
+ var FieldCapabilityIndex = class FieldCapabilityIndex {
1183
1210
  filterableManual;
1184
1211
  sortableManual;
1185
1212
  /** Navigation relations (`@db.rel.to/from/via`), incl. nested ones. */
@@ -1190,9 +1217,32 @@ var FieldCapabilityIndex = class {
1190
1217
  jsonParents;
1191
1218
  /** Descriptors carrying `@db.encrypted` (the ciphertext column on relational adapters). */
1192
1219
  encryptedFields;
1220
+ /** Every field descriptor's `physicalName` — names a calendar-bucket alias may not take. */
1221
+ physicalNames;
1222
+ /** Calendar-bucket units the adapter groups by, in `BUCKET_UNITS` order (`/meta.bucketUnits`). */
1223
+ bucketUnits;
1224
+ /** The adapter-level capabilities this index was built against — see {@link adapterSignature}. */
1225
+ signature;
1226
+ /**
1227
+ * The adapter-level capabilities that can change after construction (geo
1228
+ * support, calendar-bucket units): an index whose {@link signature}
1229
+ * differs from this is stale. Any new adapter-level input the index reads
1230
+ * must be added here.
1231
+ */
1232
+ static adapterSignature(source) {
1233
+ return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}`;
1234
+ }
1193
1235
  _entries = /* @__PURE__ */ new Map();
1194
1236
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
1195
1237
  _objectParents = /* @__PURE__ */ new Map();
1238
+ /**
1239
+ * Declared dimensions when the table is strict (declares dimensions or
1240
+ * measures), else `undefined` — the core rule: a grouping source, a
1241
+ * bucketed field included, must then be a dimension.
1242
+ */
1243
+ _dimensions;
1244
+ /** Paths of every JSON-value descriptor (`isJsonValueField`) — see `jsonValueAncestor`. */
1245
+ _jsonValueParents;
1196
1246
  /** Listed leaves — the {@link TQueryPathSource} view for `classifyQueryPath`. */
1197
1247
  get leaves() {
1198
1248
  return this._entries;
@@ -1202,12 +1252,22 @@ var FieldCapabilityIndex = class {
1202
1252
  return this._objectParents;
1203
1253
  }
1204
1254
  constructor(source, writeOnly) {
1205
- const canFilter = (fd) => source.canFilterField(fd);
1206
- const canSort = (fd) => source.canSortField(fd);
1207
1255
  const tableMeta = source.type.metadata;
1208
1256
  this.filterableManual = tableMeta.get("db.table.filterable") === "manual";
1209
1257
  this.sortableManual = tableMeta.get("db.table.sortable") === "manual";
1210
1258
  this.writeOnly = writeOnly;
1259
+ this.signature = FieldCapabilityIndex.adapterSignature(source);
1260
+ const units = source.calendarBucketUnits();
1261
+ this.bucketUnits = _uniqu_core.BUCKET_UNITS.filter((unit) => units.has(unit));
1262
+ this._dimensions = source.dimensions.length > 0 || source.measures.length > 0 ? new Set(source.dimensions) : void 0;
1263
+ const physicalNames = /* @__PURE__ */ new Set();
1264
+ const jsonValueParents = /* @__PURE__ */ new Set();
1265
+ for (const fd of source.fieldDescriptors) {
1266
+ physicalNames.add(fd.physicalName);
1267
+ if ((0, _atscript_db.isJsonValueField)(fd)) jsonValueParents.add(fd.path);
1268
+ }
1269
+ this.physicalNames = physicalNames;
1270
+ this._jsonValueParents = jsonValueParents;
1211
1271
  const nav = new Set(source.navFields);
1212
1272
  if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
1213
1273
  this.navFields = nav;
@@ -1229,7 +1289,7 @@ var FieldCapabilityIndex = class {
1229
1289
  this._objectParents.set(fd.path, []);
1230
1290
  continue;
1231
1291
  }
1232
- this._entries.set(fd.path, this._buildEntry(fd, canFilter, canSort, annotated));
1292
+ this._entries.set(fd.path, this._buildEntry(fd, source, annotated));
1233
1293
  }
1234
1294
  const ignored = source.ignoredFields;
1235
1295
  for (const [path, entry] of flatMap) {
@@ -1249,49 +1309,49 @@ var FieldCapabilityIndex = class {
1249
1309
  this.jsonParents = jsonParents;
1250
1310
  this.encryptedFields = encrypted;
1251
1311
  }
1252
- _buildEntry(fd, canFilter, canSort, annotated) {
1253
- const physicalFilter = canFilter(fd);
1254
- const physicalSort = canSort(fd);
1312
+ _buildEntry(fd, source, annotated) {
1255
1313
  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
- }
1314
+ const filterPolicyBlocked = this.filterableManual && !annotated(fd, "db.column.filterable");
1315
+ const verdict = (predicate, policy) => {
1316
+ if (isWriteOnly) return REASON_WRITE_ONLY;
1317
+ if (fd.encrypted) return REASON_ENCRYPTED;
1318
+ if (!(0, _atscript_db.canFilterLeaf)(fd, predicate, source)) return REASON_ADAPTER_FILTER;
1319
+ if (policy && filterPolicyBlocked) return REASON_ANNOTATION_FILTER;
1320
+ };
1321
+ const filterBy = {
1322
+ compare: verdict("compare", true),
1323
+ exists: verdict("exists", true),
1324
+ geo: verdict("geo", true)
1325
+ };
1326
+ const filterOps = filterBy.compare === REASON_ADAPTER_FILTER && !filterPolicyBlocked ? (0, _atscript_db.narrowerFilterOps)(fd, source) : [];
1327
+ if (filterOps.length > 0) filterBy.compare = `${ADAPTER_FILTER}${(0, _atscript_db.acceptedOperatorsHint)(filterOps)}.`;
1328
+ const physicalReason = verdict("compare", false);
1329
+ let sortReason = source.canSortField(fd) ? void 0 : REASON_ADAPTER_SORT;
1330
+ if (fd.encrypted) sortReason = REASON_ENCRYPTED;
1331
+ if (isWriteOnly) sortReason = REASON_WRITE_ONLY;
1332
+ if (!sortReason && this.sortableManual && !annotated(fd, "db.column.sortable")) sortReason = REASON_ANNOTATION_SORT;
1333
+ let bucketReason = physicalReason;
1334
+ const jsonAncestor = (0, _atscript_db.jsonValueAncestor)(fd.path, this._jsonValueParents);
1335
+ if (!bucketReason && jsonAncestor !== void 0) bucketReason = `inside JSON-stored column "${jsonAncestor}".`;
1336
+ if (!bucketReason && !(0, _atscript_db.isBucketableField)(fd)) bucketReason = REASON_NOT_TIMESTAMP;
1337
+ if (!bucketReason && this._dimensions && !this._dimensions.has(fd.path)) bucketReason = REASON_NOT_DIMENSION;
1338
+ if (!bucketReason && this.bucketUnits.length === 0) bucketReason = REASON_NO_BUCKETS;
1283
1339
  const cap = {
1284
- filterable,
1285
- sortable,
1340
+ filterable: filterBy.compare === void 0,
1341
+ sortable: sortReason === void 0,
1286
1342
  selectable: true,
1287
- indexed: fd.isIndexed === true
1343
+ indexed: fd.isIndexed === true,
1344
+ bucketable: bucketReason === void 0
1288
1345
  };
1289
- if (filterReason) cap.filterReason = filterReason;
1346
+ if (filterOps.length > 0) cap.filterOps = filterOps;
1347
+ if (filterBy.compare) cap.filterReason = filterBy.compare;
1290
1348
  if (sortReason) cap.sortReason = sortReason;
1349
+ if (bucketReason) cap.bucketReason = bucketReason;
1291
1350
  return {
1292
1351
  fd,
1293
1352
  cap,
1294
- physicalFilterable: physicalFilter && !isWriteOnly && !fd.encrypted,
1353
+ filterBy,
1354
+ physicalFilterable: physicalReason === void 0,
1295
1355
  physicalReason
1296
1356
  };
1297
1357
  }
@@ -1319,8 +1379,11 @@ var FieldCapabilityIndex = class {
1319
1379
  * an untyped descendant of a JSON column (`address.nope`) is reported as
1320
1380
  * `Unknown field`, not as "inside JSON-stored column" — clients pin that
1321
1381
  * wording, so do not "align" it with the core backstop's text.
1382
+ *
1383
+ * `predicate` is a filter entry's class (`collectQueryPaths` records it per
1384
+ * occurrence); it only matters for `op === "filter"` on a listed leaf.
1322
1385
  */
1323
- check(path, op, exists) {
1386
+ check(path, op, exists, predicate = "compare") {
1324
1387
  const { kind, parent } = (0, _atscript_db.classifyQueryPath)(this, path);
1325
1388
  if (kind === "nav") {
1326
1389
  if (parent === void 0) return {
@@ -1339,14 +1402,21 @@ 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}`
@@ -1420,14 +1490,14 @@ const GEO_CONTROLS = [
1420
1490
  ];
1421
1491
  //#endregion
1422
1492
  //#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",
1493
+ /** Gate positions checked after the filter entries, in order; `refs[op]` are their paths. */
1494
+ const PATH_OPS = [
1426
1495
  "sort",
1427
1496
  "select",
1428
1497
  "groupBy",
1429
1498
  "having",
1430
- "aggregate"
1499
+ "aggregate",
1500
+ "bucket"
1431
1501
  ];
1432
1502
  let AsDbReadableController = class AsDbReadableController extends AsReadableController {
1433
1503
  /** Reference to the underlying readable (table or view). */
@@ -1448,8 +1518,25 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1448
1518
  * Per-path capability index (since 0.1.128): the ONE input both `/meta.fields`
1449
1519
  * and the request gate ({@link checkCapabilities}) are computed from, so
1450
1520
  * metadata and runtime can never diverge.
1521
+ *
1522
+ * Built on first use and rebuilt whenever the adapter-level capabilities
1523
+ * change (`FieldCapabilityIndex.adapterSignature`: geo support, calendar
1524
+ * buckets) — PostgreSQL learns PostGIS only during schema sync, which may
1525
+ * run after this controller is constructed, so a constructor-time snapshot
1526
+ * would keep advertising (and gating) the pre-sync answer (since 0.1.132).
1451
1527
  */
1452
- capabilities;
1528
+ get capabilities() {
1529
+ const current = this._capabilities;
1530
+ if (current && current.signature === FieldCapabilityIndex.adapterSignature(this.readable)) return current;
1531
+ const index = new FieldCapabilityIndex(this.readable, this._writeOnlySet);
1532
+ this._capabilities = index;
1533
+ return index;
1534
+ }
1535
+ _capabilities;
1536
+ /** `/meta` is a projection of {@link capabilities}: a rebuilt index rebuilds the cached envelope. */
1537
+ metaCacheKey() {
1538
+ return this.capabilities;
1539
+ }
1453
1540
  /** Bound once: the field-existence check the gate hands to `capabilities.check`. */
1454
1541
  _exists = (path) => this.hasField(path);
1455
1542
  _preferredIdSet;
@@ -1471,7 +1558,6 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1471
1558
  super(resolved.type, resolved.tableName, app, resolved.isView ? "view" : "table");
1472
1559
  this.readable = resolved;
1473
1560
  this._writeOnlySet = this._collectAnnotated("db.writeOnly");
1474
- this.capabilities = new FieldCapabilityIndex(resolved, this._writeOnlySet);
1475
1561
  this._invertibleFields = this._collectInvertibleFields();
1476
1562
  this._searchFallbackFields = this._collectSearchFallbackFields();
1477
1563
  this._preferredIdSet = new Set(resolved.preferredId ?? []);
@@ -1518,19 +1604,23 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1518
1604
  *
1519
1605
  * Rejections use the structured envelope `{ message, statusCode: 400,
1520
1606
  * 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.
1607
+ *
1608
+ * Expects normalized `$select` computed entries ({@link checkComputedSelect}
1609
+ * ran first); a bucket's source is checked like any other path (op
1610
+ * `bucket`). After the per-path checks the core `$having` rule runs
1611
+ * (`checkHavingKeys`: aliases or `$groupBy` fields only), so a readable mock
1612
+ * and a real table answer alike.
1524
1613
  */
1525
1614
  checkCapabilities(parsed) {
1615
+ const capabilities = this.capabilities;
1526
1616
  const refs = (0, _atscript_db.collectQueryPaths)(parsed);
1527
1617
  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);
1618
+ for (const { path, predicate } of refs.filter) {
1619
+ const verdict = capabilities.check(path, "filter", this._exists, predicate);
1530
1620
  if (verdict) return badRequest(verdict.path, verdict.message);
1531
1621
  }
1532
- for (const path of refs.geoFilter) {
1533
- const verdict = this.capabilities.check(path, "filter", this._exists);
1622
+ for (const op of PATH_OPS) for (const path of refs[op]) {
1623
+ const verdict = capabilities.check(path, op, this._exists);
1534
1624
  if (verdict) return badRequest(verdict.path, verdict.message);
1535
1625
  }
1536
1626
  const having = (0, _atscript_db.checkHavingKeys)(refs);
@@ -1538,6 +1628,31 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1538
1628
  return this.checkGates(parsed);
1539
1629
  }
1540
1630
  /**
1631
+ * The core's shared normalizer of `$select` computed entries
1632
+ * (`resolveCalendarBuckets`) as a 400 with the core's wording and `path`
1633
+ * (`$select` / `$groupBy`): entry shapes, calendar-bucket unit / zone /
1634
+ * week start / alias, "grouped queries only", "must also appear in
1635
+ * $groupBy", alias collisions with this table's fields. Runs once per
1636
+ * request, before {@link checkCapabilities}: at the head of
1637
+ * {@link validateParsed} — ahead of the controls DTO, which would otherwise
1638
+ * answer a bucket in a non-grouped query with a generic type mismatch — or
1639
+ * explicitly on the endpoint that skips it (`geo`).
1640
+ */
1641
+ checkComputedSelect(controls) {
1642
+ const capabilities = this.capabilities;
1643
+ try {
1644
+ (0, _atscript_db.resolveCalendarBuckets)(controls, {
1645
+ flatMap: this.readable.flatMap,
1646
+ physicalNames: capabilities.physicalNames,
1647
+ navFields: capabilities.navFields
1648
+ });
1649
+ } catch (error) {
1650
+ if (!(error instanceof _atscript_db.DbError)) throw error;
1651
+ const [issue] = error.errors;
1652
+ return badRequest(issue.path, issue.message);
1653
+ }
1654
+ }
1655
+ /**
1541
1656
  * Root-path existence moved into {@link checkCapabilities}; the insights map
1542
1657
  * only serves `$with` sub-controls here — the URL parser flattens
1543
1658
  * `$with=assignee($select=name)` into the insight `assignee.name`, which is
@@ -1553,8 +1668,10 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1553
1668
  if (!this.hasField(key)) return `Unknown field "${key}"`;
1554
1669
  }
1555
1670
  }
1556
- /** Validates $with relations against the readable. */
1671
+ /** {@link checkComputedSelect} (before the controls DTO), then $with relations against the readable. */
1557
1672
  validateParsed(parsed, type) {
1673
+ const computedError = this.checkComputedSelect(parsed.controls);
1674
+ if (computedError) return computedError;
1558
1675
  const baseError = super.validateParsed(parsed, type);
1559
1676
  if (baseError) return baseError;
1560
1677
  const withRelations = parsed.controls.$with;
@@ -2026,6 +2143,8 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2026
2143
  const insightsError = this.validateInsights(parsed.insights);
2027
2144
  if (insightsError) return new _moostjs_event_http.HttpError(400, insightsError);
2028
2145
  }
2146
+ const computedError = this.checkComputedSelect(controls);
2147
+ if (computedError) return computedError;
2029
2148
  const gateError = this.checkCapabilities(parsed);
2030
2149
  if (gateError) return gateError;
2031
2150
  const [filter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
@@ -2158,16 +2277,19 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2158
2277
  if (this.readable.indexes instanceof Map) {
2159
2278
  for (const index of this.readable.indexes.values()) if (index.type === "geo") for (const f of index.fields) geoIndexedPhysical.add(f.name);
2160
2279
  }
2280
+ const capabilities = this.capabilities;
2161
2281
  const fields = {};
2162
- for (const [path, cap, fd] of this.capabilities.entries()) {
2282
+ for (const [path, cap, fd] of capabilities.entries()) {
2163
2283
  const entry = {
2164
2284
  sortable: cap.sortable,
2165
2285
  filterable: cap.filterable
2166
2286
  };
2287
+ if (cap.filterOps) entry.filterOps = [...cap.filterOps];
2167
2288
  if (cap.indexed) entry.indexed = true;
2168
2289
  if (fd.encrypted) entry.encrypted = true;
2169
2290
  if (geoIndexedPhysical.has(fd.physicalName)) entry.geo = true;
2170
2291
  if (this._writeOnlySet.has(path)) entry.writeOnly = true;
2292
+ if (cap.bucketable) entry.bucketable = true;
2171
2293
  fields[path] = entry;
2172
2294
  }
2173
2295
  return {
@@ -2182,7 +2304,8 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2182
2304
  type: this.getSerializedType(),
2183
2305
  actions: this.buildActions(),
2184
2306
  crud: this.buildCrud(),
2185
- versionColumn: this.readable.versionColumn
2307
+ versionColumn: this.readable.versionColumn,
2308
+ ...capabilities.bucketUnits.length > 0 && { bucketUnits: [...capabilities.bucketUnits] }
2186
2309
  };
2187
2310
  }
2188
2311
  buildCrud() {