@atscript/moost-db 0.1.130 → 0.1.132
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +191 -68
- package/dist/index.d.cts +93 -13
- package/dist/index.d.mts +93 -13
- package/dist/index.mjs +192 -69
- package/package.json +6 -6
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
|
|
827
|
-
* subclasses can prune the
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
if (
|
|
1280
|
-
|
|
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 (
|
|
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
|
-
|
|
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":
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
-
/**
|
|
1424
|
-
const
|
|
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
|
-
*
|
|
1522
|
-
*
|
|
1523
|
-
*
|
|
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
|
|
1529
|
-
const verdict =
|
|
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
|
|
1533
|
-
const verdict =
|
|
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
|
-
/**
|
|
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
|
|
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() {
|