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