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