@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.mjs CHANGED
@@ -3,7 +3,8 @@ import { ValidatorError, defineAnnotatedType, isAnnotatedType, serializeAnnotate
3
3
  import { Body, Delete, Get, HttpError, Patch, Post, Put, Query, Url } from "@moostjs/event-http";
4
4
  import { ApplyDecorators, Controller, Inherit, Inject, Intercept, Moost, Optional, Param, Provide, Resolve, TInterceptorPriority, defineBeforeInterceptor, defineInterceptor, getMoostMate, useControllerContext } from "moost";
5
5
  import { parseUrl } from "@uniqu/url";
6
- import { DbError, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, findAncestorInSet, isPlainObject, reconcileCas, unsupportedOperatorMessage } from "@atscript/db";
6
+ import { DbError, acceptedOperatorsHint, canFilterLeaf, checkHavingKeys, classifyQueryPath, collectQueryPaths, collectQueryPaths as collectQueryPaths$1, findAncestorInSet, isBucketableField, isJsonValueField, isPlainObject, jsonValueAncestor, narrowerFilterOps, reconcileCas, resolveCalendarBuckets, unsupportedOperatorMessage } from "@atscript/db";
7
+ import { BUCKET_UNITS } from "@uniqu/core";
7
8
  import { buildMemoryPredicate, projectRow, sortRows } from "@atscript/db-memory";
8
9
  import { cached, current, defineWook, key } from "@wooksjs/event-core";
9
10
  import { useBody } from "@wooksjs/http-body";
@@ -37,7 +38,8 @@ function badRequest(path, message, top = message) {
37
38
  const dbErrorCodeToStatus = {
38
39
  CONFLICT: 409,
39
40
  CAS_MISMATCH: 409,
40
- TX_WAIT_TIMEOUT: 503
41
+ TX_WAIT_TIMEOUT: 503,
42
+ BUCKET_TZ_UNAVAILABLE: 501
41
43
  };
42
44
  function transformValidationError(error, reply) {
43
45
  if (error instanceof ValidatorError) reply(errorEnvelope(400, error.message, error.errors));
@@ -629,6 +631,8 @@ let AsReadableController = class AsReadableController {
629
631
  _serializedType;
630
632
  /** Cached full meta response (computed lazily on first meta() call). */
631
633
  _metaResponse;
634
+ /** {@link metaCacheKey} the cached response was built for. */
635
+ _metaResponseKey;
632
636
  /** Cached serialized form schemas keyed by `FormType.name` — populated lazily by {@link metaForm}. */
633
637
  _formSchemas = /* @__PURE__ */ new Map();
634
638
  constructor(boundType, controllerName, app, kindTag = "readable") {
@@ -822,14 +826,26 @@ let AsReadableController = class AsReadableController {
822
826
  }
823
827
  /**
824
828
  * **GET /meta** — returns the bound interface's metadata envelope. The
825
- * static envelope is cached; {@link applyMetaOverlay} runs per request so
826
- * subclasses can prune the response by principal.
829
+ * static envelope is cached (rebuilt when {@link metaCacheKey} changes);
830
+ * {@link applyMetaOverlay} runs per request so subclasses can prune the
831
+ * response by principal.
827
832
  */
828
833
  async meta() {
829
- if (!this._metaResponse) this._metaResponse = this.buildMetaResponse();
834
+ const key = this.metaCacheKey();
835
+ if (!this._metaResponse || key !== this._metaResponseKey) {
836
+ this._metaResponse = this.buildMetaResponse();
837
+ this._metaResponseKey = key;
838
+ }
830
839
  return this.applyMetaOverlay(this._metaResponse);
831
840
  }
832
841
  /**
842
+ * Identity of the inputs the cached `/meta` envelope is built from — a new
843
+ * value rebuilds it. Default: constant (built once). The DB readable
844
+ * controller returns its capability index, which is rebuilt when the
845
+ * adapter's capabilities change (since 0.1.132).
846
+ */
847
+ metaCacheKey() {}
848
+ /**
833
849
  * **GET /meta/form/:name** — returns the serialized schema of a form
834
850
  * referenced by an action's `inputForm` field. The form name is the
835
851
  * compiled `.as` class's `.name`, registered when an action's parameter is
@@ -1138,12 +1154,16 @@ function resolveBoundReadable(ctor) {
1138
1154
  }
1139
1155
  //#endregion
1140
1156
  //#region src/meta/field-capabilities.ts
1141
- const REASON_ADAPTER_FILTER = "adapter cannot filter on this storage type.";
1157
+ const ADAPTER_FILTER = "adapter cannot filter on this storage type";
1158
+ const REASON_ADAPTER_FILTER = `${ADAPTER_FILTER}.`;
1142
1159
  const REASON_ADAPTER_SORT = "adapter cannot sort on this storage type.";
1143
1160
  const REASON_WRITE_ONLY = "field is @db.writeOnly.";
1144
1161
  const REASON_ENCRYPTED = "field is @db.encrypted (ciphertext cannot be compared or ordered).";
1145
1162
  const REASON_ANNOTATION_FILTER = "add @db.column.filterable to enable.";
1146
1163
  const REASON_ANNOTATION_SORT = "add @db.column.sortable to enable.";
1164
+ const REASON_NOT_TIMESTAMP = "not a timestamp field (declare it number.timestamp).";
1165
+ const REASON_NOT_DIMENSION = "not a dimension.";
1166
+ const REASON_NO_BUCKETS = "adapter has no calendar buckets.";
1147
1167
  /** Sentence subject per op ("Filtering on field …"). */
1148
1168
  const OP_SUBJECT = {
1149
1169
  filter: "Filtering on",
@@ -1151,7 +1171,8 @@ const OP_SUBJECT = {
1151
1171
  select: "Selecting",
1152
1172
  groupBy: "Grouping by",
1153
1173
  having: "Filtering ($having) on",
1154
- aggregate: "Aggregating over"
1174
+ aggregate: "Aggregating over",
1175
+ bucket: "Bucketing"
1155
1176
  };
1156
1177
  /** Lower-case verb per op ("… cannot filter JSON paths"). */
1157
1178
  const OP_VERB = {
@@ -1160,7 +1181,8 @@ const OP_VERB = {
1160
1181
  select: "select",
1161
1182
  groupBy: "group by",
1162
1183
  having: "filter ($having) on",
1163
- aggregate: "aggregate over"
1184
+ aggregate: "aggregate over",
1185
+ bucket: "bucket"
1164
1186
  };
1165
1187
  function leafHint(leaves) {
1166
1188
  if (leaves.length === 0) return "no leaf fields";
@@ -1168,17 +1190,22 @@ function leafHint(leaves) {
1168
1190
  return leaves.length > 5 ? `${shown}, …` : shown;
1169
1191
  }
1170
1192
  /**
1171
- * Capability index of one readable, built once per controller.
1193
+ * Capability index of one readable.
1172
1194
  *
1173
1195
  * - {@link entries} feeds `/meta.fields` (listed leaves in descriptor order);
1174
1196
  * - {@link check} is the request gate: same inputs, same answer.
1175
1197
  *
1198
+ * Built from the adapter's capabilities as they are NOW. Some are only known
1199
+ * after schema sync (PostgreSQL learns PostGIS there), so owners rebuild the
1200
+ * index when {@link adapterSignature} changes (see `AsDbReadableController`'s
1201
+ * `capabilities` getter) instead of keeping a constructor-time snapshot.
1202
+ *
1176
1203
  * Paths outside the index are classified by the core's `classifyQueryPath`
1177
1204
  * (the same rules the core backstop applies) — navigation path, nested-object
1178
1205
  * parent, JSON descendant (relational adapters), encrypted descendant,
1179
1206
  * unknown — so the 400 names the storage reason and the alternative.
1180
1207
  */
1181
- var FieldCapabilityIndex = class {
1208
+ var FieldCapabilityIndex = class FieldCapabilityIndex {
1182
1209
  filterableManual;
1183
1210
  sortableManual;
1184
1211
  /** Navigation relations (`@db.rel.to/from/via`), incl. nested ones. */
@@ -1189,9 +1216,32 @@ var FieldCapabilityIndex = class {
1189
1216
  jsonParents;
1190
1217
  /** Descriptors carrying `@db.encrypted` (the ciphertext column on relational adapters). */
1191
1218
  encryptedFields;
1219
+ /** Every field descriptor's `physicalName` — names a calendar-bucket alias may not take. */
1220
+ physicalNames;
1221
+ /** Calendar-bucket units the adapter groups by, in `BUCKET_UNITS` order (`/meta.bucketUnits`). */
1222
+ bucketUnits;
1223
+ /** The adapter-level capabilities this index was built against — see {@link adapterSignature}. */
1224
+ signature;
1225
+ /**
1226
+ * The adapter-level capabilities that can change after construction (geo
1227
+ * support, calendar-bucket units): an index whose {@link signature}
1228
+ * differs from this is stale. Any new adapter-level input the index reads
1229
+ * must be added here.
1230
+ */
1231
+ static adapterSignature(source) {
1232
+ return `${source.isGeoSearchable()}|${[...source.calendarBucketUnits()].join(",")}`;
1233
+ }
1192
1234
  _entries = /* @__PURE__ */ new Map();
1193
1235
  /** Nested-object parents (never listed, always selectable) → their listed leaves. */
1194
1236
  _objectParents = /* @__PURE__ */ new Map();
1237
+ /**
1238
+ * Declared dimensions when the table is strict (declares dimensions or
1239
+ * measures), else `undefined` — the core rule: a grouping source, a
1240
+ * bucketed field included, must then be a dimension.
1241
+ */
1242
+ _dimensions;
1243
+ /** Paths of every JSON-value descriptor (`isJsonValueField`) — see `jsonValueAncestor`. */
1244
+ _jsonValueParents;
1195
1245
  /** Listed leaves — the {@link TQueryPathSource} view for `classifyQueryPath`. */
1196
1246
  get leaves() {
1197
1247
  return this._entries;
@@ -1201,12 +1251,22 @@ var FieldCapabilityIndex = class {
1201
1251
  return this._objectParents;
1202
1252
  }
1203
1253
  constructor(source, writeOnly) {
1204
- const canFilter = (fd) => source.canFilterField(fd);
1205
- const canSort = (fd) => source.canSortField(fd);
1206
1254
  const tableMeta = source.type.metadata;
1207
1255
  this.filterableManual = tableMeta.get("db.table.filterable") === "manual";
1208
1256
  this.sortableManual = tableMeta.get("db.table.sortable") === "manual";
1209
1257
  this.writeOnly = writeOnly;
1258
+ this.signature = FieldCapabilityIndex.adapterSignature(source);
1259
+ const units = source.calendarBucketUnits();
1260
+ this.bucketUnits = BUCKET_UNITS.filter((unit) => units.has(unit));
1261
+ this._dimensions = source.dimensions.length > 0 || source.measures.length > 0 ? new Set(source.dimensions) : void 0;
1262
+ const physicalNames = /* @__PURE__ */ new Set();
1263
+ const jsonValueParents = /* @__PURE__ */ new Set();
1264
+ for (const fd of source.fieldDescriptors) {
1265
+ physicalNames.add(fd.physicalName);
1266
+ if (isJsonValueField(fd)) jsonValueParents.add(fd.path);
1267
+ }
1268
+ this.physicalNames = physicalNames;
1269
+ this._jsonValueParents = jsonValueParents;
1210
1270
  const nav = new Set(source.navFields);
1211
1271
  if (nav.size === 0) for (const name of source.relations.keys()) nav.add(name);
1212
1272
  this.navFields = nav;
@@ -1228,7 +1288,7 @@ var FieldCapabilityIndex = class {
1228
1288
  this._objectParents.set(fd.path, []);
1229
1289
  continue;
1230
1290
  }
1231
- this._entries.set(fd.path, this._buildEntry(fd, canFilter, canSort, annotated));
1291
+ this._entries.set(fd.path, this._buildEntry(fd, source, annotated));
1232
1292
  }
1233
1293
  const ignored = source.ignoredFields;
1234
1294
  for (const [path, entry] of flatMap) {
@@ -1248,49 +1308,49 @@ var FieldCapabilityIndex = class {
1248
1308
  this.jsonParents = jsonParents;
1249
1309
  this.encryptedFields = encrypted;
1250
1310
  }
1251
- _buildEntry(fd, canFilter, canSort, annotated) {
1252
- const physicalFilter = canFilter(fd);
1253
- const physicalSort = canSort(fd);
1311
+ _buildEntry(fd, source, annotated) {
1254
1312
  const isWriteOnly = this.writeOnly.has(fd.path);
1255
- let filterable = physicalFilter;
1256
- let sortable = physicalSort;
1257
- let filterReason = physicalFilter ? void 0 : REASON_ADAPTER_FILTER;
1258
- let sortReason = physicalSort ? void 0 : REASON_ADAPTER_SORT;
1259
- let physicalReason = physicalFilter ? void 0 : REASON_ADAPTER_FILTER;
1260
- if (fd.encrypted) {
1261
- filterable = false;
1262
- sortable = false;
1263
- filterReason = REASON_ENCRYPTED;
1264
- sortReason = REASON_ENCRYPTED;
1265
- physicalReason = REASON_ENCRYPTED;
1266
- }
1267
- if (isWriteOnly) {
1268
- filterable = false;
1269
- sortable = false;
1270
- filterReason = REASON_WRITE_ONLY;
1271
- sortReason = REASON_WRITE_ONLY;
1272
- physicalReason = REASON_WRITE_ONLY;
1273
- }
1274
- if (filterable && this.filterableManual && !annotated(fd, "db.column.filterable")) {
1275
- filterable = false;
1276
- filterReason = REASON_ANNOTATION_FILTER;
1277
- }
1278
- if (sortable && this.sortableManual && !annotated(fd, "db.column.sortable")) {
1279
- sortable = false;
1280
- sortReason = REASON_ANNOTATION_SORT;
1281
- }
1313
+ const filterPolicyBlocked = this.filterableManual && !annotated(fd, "db.column.filterable");
1314
+ const verdict = (predicate, policy) => {
1315
+ if (isWriteOnly) return REASON_WRITE_ONLY;
1316
+ if (fd.encrypted) return REASON_ENCRYPTED;
1317
+ if (!canFilterLeaf(fd, predicate, source)) return REASON_ADAPTER_FILTER;
1318
+ if (policy && filterPolicyBlocked) return REASON_ANNOTATION_FILTER;
1319
+ };
1320
+ const filterBy = {
1321
+ compare: verdict("compare", true),
1322
+ exists: verdict("exists", true),
1323
+ geo: verdict("geo", true)
1324
+ };
1325
+ const filterOps = filterBy.compare === REASON_ADAPTER_FILTER && !filterPolicyBlocked ? narrowerFilterOps(fd, source) : [];
1326
+ if (filterOps.length > 0) filterBy.compare = `${ADAPTER_FILTER}${acceptedOperatorsHint(filterOps)}.`;
1327
+ const physicalReason = verdict("compare", false);
1328
+ let sortReason = source.canSortField(fd) ? void 0 : REASON_ADAPTER_SORT;
1329
+ if (fd.encrypted) sortReason = REASON_ENCRYPTED;
1330
+ if (isWriteOnly) sortReason = REASON_WRITE_ONLY;
1331
+ if (!sortReason && this.sortableManual && !annotated(fd, "db.column.sortable")) sortReason = REASON_ANNOTATION_SORT;
1332
+ let bucketReason = physicalReason;
1333
+ const jsonAncestor = jsonValueAncestor(fd.path, this._jsonValueParents);
1334
+ if (!bucketReason && jsonAncestor !== void 0) bucketReason = `inside JSON-stored column "${jsonAncestor}".`;
1335
+ if (!bucketReason && !isBucketableField(fd)) bucketReason = REASON_NOT_TIMESTAMP;
1336
+ if (!bucketReason && this._dimensions && !this._dimensions.has(fd.path)) bucketReason = REASON_NOT_DIMENSION;
1337
+ if (!bucketReason && this.bucketUnits.length === 0) bucketReason = REASON_NO_BUCKETS;
1282
1338
  const cap = {
1283
- filterable,
1284
- sortable,
1339
+ filterable: filterBy.compare === void 0,
1340
+ sortable: sortReason === void 0,
1285
1341
  selectable: true,
1286
- indexed: fd.isIndexed === true
1342
+ indexed: fd.isIndexed === true,
1343
+ bucketable: bucketReason === void 0
1287
1344
  };
1288
- if (filterReason) cap.filterReason = filterReason;
1345
+ if (filterOps.length > 0) cap.filterOps = filterOps;
1346
+ if (filterBy.compare) cap.filterReason = filterBy.compare;
1289
1347
  if (sortReason) cap.sortReason = sortReason;
1348
+ if (bucketReason) cap.bucketReason = bucketReason;
1290
1349
  return {
1291
1350
  fd,
1292
1351
  cap,
1293
- physicalFilterable: physicalFilter && !isWriteOnly && !fd.encrypted,
1352
+ filterBy,
1353
+ physicalFilterable: physicalReason === void 0,
1294
1354
  physicalReason
1295
1355
  };
1296
1356
  }
@@ -1318,8 +1378,11 @@ var FieldCapabilityIndex = class {
1318
1378
  * an untyped descendant of a JSON column (`address.nope`) is reported as
1319
1379
  * `Unknown field`, not as "inside JSON-stored column" — clients pin that
1320
1380
  * wording, so do not "align" it with the core backstop's text.
1381
+ *
1382
+ * `predicate` is a filter entry's class (`collectQueryPaths` records it per
1383
+ * occurrence); it only matters for `op === "filter"` on a listed leaf.
1321
1384
  */
1322
- check(path, op, exists) {
1385
+ check(path, op, exists, predicate = "compare") {
1323
1386
  const { kind, parent } = classifyQueryPath(this, path);
1324
1387
  if (kind === "nav") {
1325
1388
  if (parent === void 0) return {
@@ -1338,14 +1401,21 @@ var FieldCapabilityIndex = class {
1338
1401
  path,
1339
1402
  message: `Selecting field "${path}" is not permitted.`
1340
1403
  };
1341
- case "filter": return entry.cap.filterable ? void 0 : {
1342
- path,
1343
- message: `Filtering on field "${path}" is not permitted — ${entry.cap.filterReason}`
1344
- };
1404
+ case "filter": {
1405
+ const reason = entry.filterBy[predicate];
1406
+ return reason === void 0 ? void 0 : {
1407
+ path,
1408
+ message: `Filtering on field "${path}" is not permitted — ${reason}`
1409
+ };
1410
+ }
1345
1411
  case "sort": return entry.cap.sortable ? void 0 : {
1346
1412
  path,
1347
1413
  message: `Sorting on field "${path}" is not permitted — ${entry.cap.sortReason}`
1348
1414
  };
1415
+ case "bucket": return entry.cap.bucketable ? void 0 : {
1416
+ path,
1417
+ message: `Bucketing field "${path}" is not permitted — ${entry.cap.bucketReason}`
1418
+ };
1349
1419
  default: return entry.physicalFilterable ? void 0 : {
1350
1420
  path,
1351
1421
  message: `${OP_SUBJECT[op]} field "${path}" is not permitted — ${entry.physicalReason}`
@@ -1419,14 +1489,14 @@ const GEO_CONTROLS = [
1419
1489
  ];
1420
1490
  //#endregion
1421
1491
  //#region src/as-db-readable.controller.ts
1422
- /** The gate positions in check order; `refs[op]` are the paths collected for each. */
1423
- const OPS = [
1424
- "filter",
1492
+ /** Gate positions checked after the filter entries, in order; `refs[op]` are their paths. */
1493
+ const PATH_OPS = [
1425
1494
  "sort",
1426
1495
  "select",
1427
1496
  "groupBy",
1428
1497
  "having",
1429
- "aggregate"
1498
+ "aggregate",
1499
+ "bucket"
1430
1500
  ];
1431
1501
  let AsDbReadableController = class AsDbReadableController extends AsReadableController {
1432
1502
  /** Reference to the underlying readable (table or view). */
@@ -1447,8 +1517,25 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1447
1517
  * Per-path capability index (since 0.1.128): the ONE input both `/meta.fields`
1448
1518
  * and the request gate ({@link checkCapabilities}) are computed from, so
1449
1519
  * metadata and runtime can never diverge.
1520
+ *
1521
+ * Built on first use and rebuilt whenever the adapter-level capabilities
1522
+ * change (`FieldCapabilityIndex.adapterSignature`: geo support, calendar
1523
+ * buckets) — PostgreSQL learns PostGIS only during schema sync, which may
1524
+ * run after this controller is constructed, so a constructor-time snapshot
1525
+ * would keep advertising (and gating) the pre-sync answer (since 0.1.132).
1450
1526
  */
1451
- capabilities;
1527
+ get capabilities() {
1528
+ const current = this._capabilities;
1529
+ if (current && current.signature === FieldCapabilityIndex.adapterSignature(this.readable)) return current;
1530
+ const index = new FieldCapabilityIndex(this.readable, this._writeOnlySet);
1531
+ this._capabilities = index;
1532
+ return index;
1533
+ }
1534
+ _capabilities;
1535
+ /** `/meta` is a projection of {@link capabilities}: a rebuilt index rebuilds the cached envelope. */
1536
+ metaCacheKey() {
1537
+ return this.capabilities;
1538
+ }
1452
1539
  /** Bound once: the field-existence check the gate hands to `capabilities.check`. */
1453
1540
  _exists = (path) => this.hasField(path);
1454
1541
  _preferredIdSet;
@@ -1470,7 +1557,6 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1470
1557
  super(resolved.type, resolved.tableName, app, resolved.isView ? "view" : "table");
1471
1558
  this.readable = resolved;
1472
1559
  this._writeOnlySet = this._collectAnnotated("db.writeOnly");
1473
- this.capabilities = new FieldCapabilityIndex(resolved, this._writeOnlySet);
1474
1560
  this._invertibleFields = this._collectInvertibleFields();
1475
1561
  this._searchFallbackFields = this._collectSearchFallbackFields();
1476
1562
  this._preferredIdSet = new Set(resolved.preferredId ?? []);
@@ -1517,19 +1603,23 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1517
1603
  *
1518
1604
  * Rejections use the structured envelope `{ message, statusCode: 400,
1519
1605
  * errors: [{ path, message }] }` — `path` is the offending logical path.
1520
- * After the per-path checks the core `$having` rule runs (`checkHavingKeys`:
1521
- * aliases or `$groupBy` fields only), so a readable mock and a real table
1522
- * answer alike.
1606
+ *
1607
+ * Expects normalized `$select` computed entries ({@link checkComputedSelect}
1608
+ * ran first); a bucket's source is checked like any other path (op
1609
+ * `bucket`). After the per-path checks the core `$having` rule runs
1610
+ * (`checkHavingKeys`: aliases or `$groupBy` fields only), so a readable mock
1611
+ * and a real table answer alike.
1523
1612
  */
1524
1613
  checkCapabilities(parsed) {
1614
+ const capabilities = this.capabilities;
1525
1615
  const refs = collectQueryPaths$1(parsed);
1526
1616
  if (refs.unsupportedOperator !== void 0) return badRequest(refs.unsupportedOperator, unsupportedOperatorMessage(refs.unsupportedOperator));
1527
- for (const op of OPS) for (const path of refs[op]) {
1528
- const verdict = this.capabilities.check(path, op, this._exists);
1617
+ for (const { path, predicate } of refs.filter) {
1618
+ const verdict = capabilities.check(path, "filter", this._exists, predicate);
1529
1619
  if (verdict) return badRequest(verdict.path, verdict.message);
1530
1620
  }
1531
- for (const path of refs.geoFilter) {
1532
- const verdict = this.capabilities.check(path, "filter", this._exists);
1621
+ for (const op of PATH_OPS) for (const path of refs[op]) {
1622
+ const verdict = capabilities.check(path, op, this._exists);
1533
1623
  if (verdict) return badRequest(verdict.path, verdict.message);
1534
1624
  }
1535
1625
  const having = checkHavingKeys(refs);
@@ -1537,6 +1627,31 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1537
1627
  return this.checkGates(parsed);
1538
1628
  }
1539
1629
  /**
1630
+ * The core's shared normalizer of `$select` computed entries
1631
+ * (`resolveCalendarBuckets`) as a 400 with the core's wording and `path`
1632
+ * (`$select` / `$groupBy`): entry shapes, calendar-bucket unit / zone /
1633
+ * week start / alias, "grouped queries only", "must also appear in
1634
+ * $groupBy", alias collisions with this table's fields. Runs once per
1635
+ * request, before {@link checkCapabilities}: at the head of
1636
+ * {@link validateParsed} — ahead of the controls DTO, which would otherwise
1637
+ * answer a bucket in a non-grouped query with a generic type mismatch — or
1638
+ * explicitly on the endpoint that skips it (`geo`).
1639
+ */
1640
+ checkComputedSelect(controls) {
1641
+ const capabilities = this.capabilities;
1642
+ try {
1643
+ resolveCalendarBuckets(controls, {
1644
+ flatMap: this.readable.flatMap,
1645
+ physicalNames: capabilities.physicalNames,
1646
+ navFields: capabilities.navFields
1647
+ });
1648
+ } catch (error) {
1649
+ if (!(error instanceof DbError)) throw error;
1650
+ const [issue] = error.errors;
1651
+ return badRequest(issue.path, issue.message);
1652
+ }
1653
+ }
1654
+ /**
1540
1655
  * Root-path existence moved into {@link checkCapabilities}; the insights map
1541
1656
  * only serves `$with` sub-controls here — the URL parser flattens
1542
1657
  * `$with=assignee($select=name)` into the insight `assignee.name`, which is
@@ -1552,8 +1667,10 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
1552
1667
  if (!this.hasField(key)) return `Unknown field "${key}"`;
1553
1668
  }
1554
1669
  }
1555
- /** Validates $with relations against the readable. */
1670
+ /** {@link checkComputedSelect} (before the controls DTO), then $with relations against the readable. */
1556
1671
  validateParsed(parsed, type) {
1672
+ const computedError = this.checkComputedSelect(parsed.controls);
1673
+ if (computedError) return computedError;
1557
1674
  const baseError = super.validateParsed(parsed, type);
1558
1675
  if (baseError) return baseError;
1559
1676
  const withRelations = parsed.controls.$with;
@@ -2025,6 +2142,8 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2025
2142
  const insightsError = this.validateInsights(parsed.insights);
2026
2143
  if (insightsError) return new HttpError(400, insightsError);
2027
2144
  }
2145
+ const computedError = this.checkComputedSelect(controls);
2146
+ if (computedError) return computedError;
2028
2147
  const gateError = this.checkCapabilities(parsed);
2029
2148
  if (gateError) return gateError;
2030
2149
  const [filter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
@@ -2157,16 +2276,19 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2157
2276
  if (this.readable.indexes instanceof Map) {
2158
2277
  for (const index of this.readable.indexes.values()) if (index.type === "geo") for (const f of index.fields) geoIndexedPhysical.add(f.name);
2159
2278
  }
2279
+ const capabilities = this.capabilities;
2160
2280
  const fields = {};
2161
- for (const [path, cap, fd] of this.capabilities.entries()) {
2281
+ for (const [path, cap, fd] of capabilities.entries()) {
2162
2282
  const entry = {
2163
2283
  sortable: cap.sortable,
2164
2284
  filterable: cap.filterable
2165
2285
  };
2286
+ if (cap.filterOps) entry.filterOps = [...cap.filterOps];
2166
2287
  if (cap.indexed) entry.indexed = true;
2167
2288
  if (fd.encrypted) entry.encrypted = true;
2168
2289
  if (geoIndexedPhysical.has(fd.physicalName)) entry.geo = true;
2169
2290
  if (this._writeOnlySet.has(path)) entry.writeOnly = true;
2291
+ if (cap.bucketable) entry.bucketable = true;
2170
2292
  fields[path] = entry;
2171
2293
  }
2172
2294
  return {
@@ -2181,7 +2303,8 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
2181
2303
  type: this.getSerializedType(),
2182
2304
  actions: this.buildActions(),
2183
2305
  crud: this.buildCrud(),
2184
- versionColumn: this.readable.versionColumn
2306
+ versionColumn: this.readable.versionColumn,
2307
+ ...capabilities.bucketUnits.length > 0 && { bucketUnits: [...capabilities.bucketUnits] }
2185
2308
  };
2186
2309
  }
2187
2310
  buildCrud() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/moost-db",
3
- "version": "0.1.131",
3
+ "version": "0.1.132",
4
4
  "description": "Generic database controller for Moost with Atscript.",
5
5
  "keywords": [
6
6
  "annotations",
@@ -43,14 +43,14 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@uniqu/url": "^0.1.8",
47
- "@atscript/db-memory": "^0.1.131"
46
+ "@uniqu/url": "^0.1.9",
47
+ "@atscript/db-memory": "^0.1.132"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@atscript/core": "^0.1.92",
51
51
  "@atscript/typescript": "^0.1.92",
52
52
  "@moostjs/event-http": "^0.6.37",
53
- "@uniqu/core": "^0.1.8",
53
+ "@uniqu/core": "^0.1.9",
54
54
  "@wooksjs/event-core": "^0.7.23",
55
55
  "@wooksjs/event-http": "^0.7.23",
56
56
  "@wooksjs/http-body": "^0.7.23",
@@ -60,11 +60,11 @@
60
60
  "peerDependencies": {
61
61
  "@atscript/typescript": "^0.1.92",
62
62
  "@moostjs/event-http": "^0.6.37",
63
- "@uniqu/core": "^0.1.8",
63
+ "@uniqu/core": "^0.1.9",
64
64
  "@wooksjs/event-core": "^0.7.23",
65
65
  "@wooksjs/http-body": "^0.7.23",
66
66
  "moost": "^0.6.37",
67
- "@atscript/db": "^0.1.131"
67
+ "@atscript/db": "^0.1.132"
68
68
  },
69
69
  "scripts": {
70
70
  "postinstall": "asc -f dts",