@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.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
|
|
826
|
-
* subclasses can prune the
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
if (
|
|
1279
|
-
|
|
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 (
|
|
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
|
-
|
|
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":
|
|
1342
|
-
|
|
1343
|
-
|
|
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
|
-
/**
|
|
1423
|
-
const
|
|
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
|
-
*
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
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
|
|
1528
|
-
const verdict =
|
|
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
|
|
1532
|
-
const verdict =
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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.
|
|
47
|
-
"@atscript/db-memory": "^0.1.
|
|
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.
|
|
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.
|
|
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.
|
|
67
|
+
"@atscript/db": "^0.1.132"
|
|
68
68
|
},
|
|
69
69
|
"scripts": {
|
|
70
70
|
"postinstall": "asc -f dts",
|