@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.cjs +230 -88
- package/dist/index.d.cts +126 -25
- package/dist/index.d.mts +126 -25
- package/dist/index.mjs +231 -89
- 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 { 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
|
|
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,10 +1154,10 @@ function resolveBoundReadable(ctor) {
|
|
|
1138
1154
|
}
|
|
1139
1155
|
//#endregion
|
|
1140
1156
|
//#region src/meta/field-capabilities.ts
|
|
1141
|
-
const REASON_ADAPTER_FILTER =
|
|
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 =
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
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
|
-
*
|
|
1318
|
-
*
|
|
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":
|
|
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}`
|
|
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
|
-
/**
|
|
1423
|
-
const
|
|
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
|
-
|
|
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
|
-
*
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
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
|
|
1528
|
-
const verdict =
|
|
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
|
|
1532
|
-
const verdict =
|
|
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
|
-
/**
|
|
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)
|
|
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
|
-
|
|
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 &&
|
|
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()
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
47
|
-
"@atscript/db-memory": "^0.1.
|
|
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.
|
|
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.
|
|
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.
|
|
67
|
+
"@atscript/db": "^0.1.133"
|
|
68
68
|
},
|
|
69
69
|
"scripts": {
|
|
70
70
|
"postinstall": "asc -f dts",
|