@atscript/moost-db 0.1.122 → 0.1.123
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 +140 -23
- package/dist/index.d.cts +59 -8
- package/dist/index.d.mts +59 -8
- package/dist/index.mjs +140 -23
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -625,7 +625,7 @@ let AsReadableController = class AsReadableController {
|
|
|
625
625
|
key,
|
|
626
626
|
value
|
|
627
627
|
};
|
|
628
|
-
if (key === "db.json" || key === "db.patch.strategy" || key.startsWith("db.default") || key === "db.http.path") return {
|
|
628
|
+
if (key === "db.json" || key === "db.patch.strategy" || key.startsWith("db.default") || key === "db.http.path" || key === "db.writeOnly") return {
|
|
629
629
|
key,
|
|
630
630
|
value
|
|
631
631
|
};
|
|
@@ -1089,6 +1089,18 @@ const GEO_CONTROLS = [
|
|
|
1089
1089
|
let AsDbReadableController = class AsDbReadableController extends AsReadableController {
|
|
1090
1090
|
/** Reference to the underlying readable (table or view). */
|
|
1091
1091
|
readable;
|
|
1092
|
+
/**
|
|
1093
|
+
* The bound readable as a writable table. The canonical readable-controller
|
|
1094
|
+
* posture is "generic reads + named `@DbAction` mutations" — action handlers
|
|
1095
|
+
* write through this instead of re-importing the DbSpace module for a
|
|
1096
|
+
* module-scope `getTable`. Throws when the controller is bound to a view
|
|
1097
|
+
* (or any non-table readable).
|
|
1098
|
+
*/
|
|
1099
|
+
get table() {
|
|
1100
|
+
const readable = this.readable;
|
|
1101
|
+
if (readable && (readable.isView || typeof readable.insertOne !== "function")) throw new Error(`${this.constructor.name} is bound to a ${readable.isView ? "view" : "non-table readable"} ("${readable.tableName}") — .table is only available for table-bound controllers.`);
|
|
1102
|
+
return readable;
|
|
1103
|
+
}
|
|
1092
1104
|
_gates;
|
|
1093
1105
|
_preferredIdSet;
|
|
1094
1106
|
_overlayIsNoOp;
|
|
@@ -1096,11 +1108,17 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1096
1108
|
_quantityRefByPath;
|
|
1097
1109
|
/** Paths the adapter vetoes for filtering (e.g. JSON storage on SQL). Symmetric with `/meta` `filterable: false`. */
|
|
1098
1110
|
_adapterNonFilterable;
|
|
1111
|
+
/** `@db.column.searchable` paths — the `$search` fallback when the adapter has no native search. */
|
|
1112
|
+
_searchFallbackFields;
|
|
1113
|
+
/** `@db.writeOnly` paths — settable in writes, sealed out of every read surface. */
|
|
1114
|
+
_writeOnlySet;
|
|
1099
1115
|
constructor(app, readable) {
|
|
1100
1116
|
const resolved = readable ?? resolveBoundReadable(new.target);
|
|
1101
1117
|
super(resolved.type, resolved.tableName, app, resolved.isView ? "view" : "table");
|
|
1102
1118
|
this.readable = resolved;
|
|
1103
1119
|
this._adapterNonFilterable = this._collectAdapterNonFilterable();
|
|
1120
|
+
this._writeOnlySet = this._collectAnnotated("db.writeOnly");
|
|
1121
|
+
this._searchFallbackFields = this._collectSearchFallbackFields();
|
|
1104
1122
|
this._gates = this._buildGates();
|
|
1105
1123
|
this._preferredIdSet = new Set(resolved.preferredId ?? []);
|
|
1106
1124
|
this._quantityRefByPath = this._collectQuantityRefs();
|
|
@@ -1140,11 +1158,25 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1140
1158
|
annotation: "@db.column.sortable"
|
|
1141
1159
|
};
|
|
1142
1160
|
}
|
|
1161
|
+
const writeOnly = this._writeOnlySet;
|
|
1162
|
+
if (writeOnly.size > 0) {
|
|
1163
|
+
const prevFilter = gates.filter;
|
|
1164
|
+
gates.filter = {
|
|
1165
|
+
predicate: (f) => !writeOnly.has(f) && (prevFilter ? prevFilter.predicate(f) : true),
|
|
1166
|
+
annotation: prevFilter?.annotation ?? "@db.column.filterable (field is @db.writeOnly)"
|
|
1167
|
+
};
|
|
1168
|
+
const prevSort = gates.sort;
|
|
1169
|
+
gates.sort = {
|
|
1170
|
+
predicate: (f) => !writeOnly.has(f) && (prevSort ? prevSort.predicate(f) : true),
|
|
1171
|
+
annotation: prevSort?.annotation ?? "@db.column.sortable (field is @db.writeOnly)"
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1143
1174
|
return gates;
|
|
1144
1175
|
}
|
|
1145
1176
|
_collectAnnotated(annotation) {
|
|
1146
1177
|
const out = /* @__PURE__ */ new Set();
|
|
1147
|
-
|
|
1178
|
+
if (!this.readable.flatMap) return out;
|
|
1179
|
+
for (const [path, entry] of this.readable.flatMap) if (entry?.metadata?.has?.(annotation)) out.add(path);
|
|
1148
1180
|
return out;
|
|
1149
1181
|
}
|
|
1150
1182
|
hasField(path) {
|
|
@@ -1369,6 +1401,72 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1369
1401
|
widenedSelect: resolvedProjection === null ? null : this._widenSelectForActions(envelopes, resolvedProjection)
|
|
1370
1402
|
};
|
|
1371
1403
|
}
|
|
1404
|
+
/** `@db.column.searchable` paths, minus anything the adapter can't filter (JSON storage, encrypted). */
|
|
1405
|
+
_collectSearchFallbackFields() {
|
|
1406
|
+
const out = [];
|
|
1407
|
+
if (!this.readable.fieldDescriptors) return out;
|
|
1408
|
+
for (const fd of this.readable.fieldDescriptors) {
|
|
1409
|
+
if (fd.ignored) continue;
|
|
1410
|
+
if (!fd.type?.metadata?.has?.("db.column.searchable")) continue;
|
|
1411
|
+
if (this._adapterNonFilterable.has(fd.path)) continue;
|
|
1412
|
+
if (this._writeOnlySet.has(fd.path)) continue;
|
|
1413
|
+
out.push(fd.path);
|
|
1414
|
+
}
|
|
1415
|
+
return out;
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Removes `@db.writeOnly` fields from any `$select` shape — and forces an
|
|
1419
|
+
* exclusion when no projection was requested — so sealed values never leave
|
|
1420
|
+
* the database on a read. Runs AFTER `transformProjection` so permission
|
|
1421
|
+
* overlays compose (they see the wire `$select`; this guarantees the seal on
|
|
1422
|
+
* whatever they return).
|
|
1423
|
+
*/
|
|
1424
|
+
_sealProjection(select) {
|
|
1425
|
+
const writeOnly = this._writeOnlySet;
|
|
1426
|
+
if (writeOnly.size === 0) return select;
|
|
1427
|
+
const exclusion = () => {
|
|
1428
|
+
const out = {};
|
|
1429
|
+
for (const f of writeOnly) out[f] = 0;
|
|
1430
|
+
return out;
|
|
1431
|
+
};
|
|
1432
|
+
if (select === void 0) return exclusion();
|
|
1433
|
+
if (Array.isArray(select)) {
|
|
1434
|
+
const kept = select.filter((item) => typeof item === "string" ? !writeOnly.has(item) : !writeOnly.has(item.$field ?? ""));
|
|
1435
|
+
return kept.length > 0 ? kept : exclusion();
|
|
1436
|
+
}
|
|
1437
|
+
const entries = Object.entries(select);
|
|
1438
|
+
if (entries.length > 0 && (entries[0][1] === 1 || entries[0][1] === true)) {
|
|
1439
|
+
const out = {};
|
|
1440
|
+
for (const [k, v] of entries) if (!writeOnly.has(k)) out[k] = v;
|
|
1441
|
+
return Object.keys(out).length > 0 ? out : exclusion();
|
|
1442
|
+
}
|
|
1443
|
+
const out = { ...select };
|
|
1444
|
+
for (const f of writeOnly) out[f] = 0;
|
|
1445
|
+
return out;
|
|
1446
|
+
}
|
|
1447
|
+
/** First `@db.writeOnly` field referenced by `$groupBy` / aggregate `$select`, or undefined. */
|
|
1448
|
+
_findWriteOnlyInAggregate(groupBy, select) {
|
|
1449
|
+
if (this._writeOnlySet.size === 0) return void 0;
|
|
1450
|
+
for (const f of groupBy) if (this._writeOnlySet.has(f)) return f;
|
|
1451
|
+
if (Array.isArray(select)) for (const item of select) {
|
|
1452
|
+
const field = typeof item === "string" ? item : item.$field;
|
|
1453
|
+
if (field && this._writeOnlySet.has(field)) return field;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Merges the `$search` fallback into the filter: a case-insensitive literal
|
|
1458
|
+
* substring match OR'd across the `@db.column.searchable` fields, `$and`-combined
|
|
1459
|
+
* with the existing filter. Applies only when the adapter has no native search
|
|
1460
|
+
* (native wins) and the request isn't a vector search (`$vector` consumes the term).
|
|
1461
|
+
*/
|
|
1462
|
+
applySearchFallback(filter, controls) {
|
|
1463
|
+
const term = controls.$search;
|
|
1464
|
+
if (!term || controls.$vector !== void 0) return filter;
|
|
1465
|
+
if (this.readable.isSearchable() || this._searchFallbackFields.length === 0) return filter;
|
|
1466
|
+
const rx = `/${term.replace(/[.*+?^${}()|[\]\\/]/g, String.raw`\$&`)}/i`;
|
|
1467
|
+
const fragment = { $or: this._searchFallbackFields.map((f) => ({ [f]: { $regex: rx } })) };
|
|
1468
|
+
return filter && Object.keys(filter).length > 0 ? { $and: [filter, fragment] } : fragment;
|
|
1469
|
+
}
|
|
1372
1470
|
async _resolveReadStrategy(controls) {
|
|
1373
1471
|
const searchTerm = controls.$search;
|
|
1374
1472
|
const indexName = controls.$index;
|
|
@@ -1439,14 +1537,18 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1439
1537
|
const gateError = this.checkGates(parsed.filter, controls, this._gates);
|
|
1440
1538
|
if (gateError) return gateError;
|
|
1441
1539
|
if (groupBy?.length) {
|
|
1442
|
-
const
|
|
1540
|
+
const sealed = this._findWriteOnlyInAggregate(groupBy, controls.$select);
|
|
1541
|
+
if (sealed) return new _moostjs_event_http.HttpError(400, `Field "${sealed}" is @db.writeOnly and cannot be aggregated`);
|
|
1542
|
+
const filter = this.applySearchFallback(await this.transformFilter(parsed.filter), controls);
|
|
1443
1543
|
return this.readable.aggregate({
|
|
1444
1544
|
filter,
|
|
1445
1545
|
controls,
|
|
1446
1546
|
insights: parsed.insights
|
|
1447
1547
|
});
|
|
1448
1548
|
}
|
|
1449
|
-
const [
|
|
1549
|
+
const [transformedFilter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
|
|
1550
|
+
const filter = this.applySearchFallback(transformedFilter, controls);
|
|
1551
|
+
const rawSelect = this._sealProjection(transformedSelect);
|
|
1450
1552
|
if (controls.$count) return this.readable.count({
|
|
1451
1553
|
filter,
|
|
1452
1554
|
controls: {
|
|
@@ -1488,7 +1590,9 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1488
1590
|
const page = Math.max(Number(controls.$page || 1), 1);
|
|
1489
1591
|
const size = Math.max(Number(controls.$size || 10), 1);
|
|
1490
1592
|
const skip = (page - 1) * size;
|
|
1491
|
-
const [
|
|
1593
|
+
const [transformedFilter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
|
|
1594
|
+
const filter = this.applySearchFallback(transformedFilter, controls);
|
|
1595
|
+
const rawSelect = this._sealProjection(transformedSelect);
|
|
1492
1596
|
const select = this.widenPreferredIdProjection(rawSelect);
|
|
1493
1597
|
if (select instanceof _moostjs_event_http.HttpError) return select;
|
|
1494
1598
|
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
@@ -1545,8 +1649,8 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1545
1649
|
}
|
|
1546
1650
|
const gateError = this.checkGates(parsed.filter, controls, this._gates);
|
|
1547
1651
|
if (gateError) return gateError;
|
|
1548
|
-
const [filter,
|
|
1549
|
-
const select = this.widenPreferredIdProjection(
|
|
1652
|
+
const [filter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
|
|
1653
|
+
const select = this.widenPreferredIdProjection(this._sealProjection(transformedSelect));
|
|
1550
1654
|
if (select instanceof _moostjs_event_http.HttpError) return select;
|
|
1551
1655
|
const paginated = controls.$page !== void 0 || controls.$size !== void 0;
|
|
1552
1656
|
const page = Math.max(Number(controls.$page || 1), 1);
|
|
@@ -1605,7 +1709,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1605
1709
|
const error = this.validateParsed(parsed, "getOne");
|
|
1606
1710
|
if (error) return error;
|
|
1607
1711
|
const rawSelect = await this.transformProjection(parsed.controls.$select);
|
|
1608
|
-
const select = this.widenPreferredIdProjection(rawSelect);
|
|
1712
|
+
const select = this.widenPreferredIdProjection(this._sealProjection(rawSelect));
|
|
1609
1713
|
if (select instanceof _moostjs_event_http.HttpError) return select;
|
|
1610
1714
|
return this._findByIdAndAugment(id, parsed.controls, select);
|
|
1611
1715
|
}
|
|
@@ -1620,7 +1724,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1620
1724
|
const { parsed } = this.parseControlsOnlyFromUrl(url);
|
|
1621
1725
|
this._coerceActionsControl(parsed.controls);
|
|
1622
1726
|
const rawSelect = await this.transformProjection(parsed.controls.$select);
|
|
1623
|
-
const select = this.widenPreferredIdProjection(rawSelect);
|
|
1727
|
+
const select = this.widenPreferredIdProjection(this._sealProjection(rawSelect));
|
|
1624
1728
|
if (select instanceof _moostjs_event_http.HttpError) return select;
|
|
1625
1729
|
return this._findByIdAndAugment(idObj, parsed.controls, select);
|
|
1626
1730
|
}
|
|
@@ -1686,9 +1790,14 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1686
1790
|
};
|
|
1687
1791
|
if (fd.encrypted) fields[fd.path].encrypted = true;
|
|
1688
1792
|
if (geoIndexedPhysical.has(fd.physicalName)) fields[fd.path].geo = true;
|
|
1793
|
+
if (this._writeOnlySet.has(fd.path)) {
|
|
1794
|
+
fields[fd.path].writeOnly = true;
|
|
1795
|
+
fields[fd.path].filterable = false;
|
|
1796
|
+
fields[fd.path].sortable = false;
|
|
1797
|
+
}
|
|
1689
1798
|
}
|
|
1690
1799
|
return {
|
|
1691
|
-
searchable: this.readable.isSearchable(),
|
|
1800
|
+
searchable: this.readable.isSearchable() || this._searchFallbackFields.length > 0,
|
|
1692
1801
|
vectorSearchable: this.readable.isVectorSearchable(),
|
|
1693
1802
|
geoSearchable: this._isGeoSearchable(),
|
|
1694
1803
|
searchIndexes: this.readable.getSearchIndexes(),
|
|
@@ -1789,10 +1898,6 @@ function liftVersionToCas(payload, versionColumn) {
|
|
|
1789
1898
|
return true;
|
|
1790
1899
|
}
|
|
1791
1900
|
let AsDbController = class AsDbController extends AsDbReadableController {
|
|
1792
|
-
/** Reference to the underlying table (typed for write access). */
|
|
1793
|
-
get table() {
|
|
1794
|
-
return this.readable;
|
|
1795
|
-
}
|
|
1796
1901
|
constructor(app, table) {
|
|
1797
1902
|
super(app, table);
|
|
1798
1903
|
}
|
|
@@ -2228,26 +2333,37 @@ AsJsonValueHelpController = __decorate([(0, moost.Inherit)(), __decorateMetadata
|
|
|
2228
2333
|
//#endregion
|
|
2229
2334
|
//#region src/assert-exposed.ts
|
|
2230
2335
|
/**
|
|
2231
|
-
* Dev-mode wiring assertion: warns for every model
|
|
2232
|
-
*
|
|
2336
|
+
* Dev-mode wiring assertion: warns for every model that should have a
|
|
2337
|
+
* registered db controller but doesn't.
|
|
2338
|
+
*
|
|
2339
|
+
* Default mode audits models annotated with `@db.http.path`. With
|
|
2340
|
+
* `{ all: true }` every passed model is a candidate (minus `exclude`) — the
|
|
2341
|
+
* right mode for repos that mount everything through decorator prefixes
|
|
2342
|
+
* (`@TableController(Model, 'db/x')`) and keep a generated model manifest.
|
|
2233
2343
|
*
|
|
2234
2344
|
* Call AFTER `await app.init()` (controller bindings are collected during
|
|
2235
2345
|
* init). Detection reads the binding metadata written by `@TableController` /
|
|
2236
2346
|
* `@ReadableController` / `@ViewController`, so it covers the model-token and
|
|
2237
|
-
* instance forms; lazy-factory bindings can't name their model until
|
|
2238
|
-
* and are
|
|
2347
|
+
* instance forms; **lazy-factory bindings can't name their model until
|
|
2348
|
+
* resolved and are invisible to the check** — with `all: true` such models
|
|
2349
|
+
* will warn even though they are exposed; list them in `exclude`.
|
|
2239
2350
|
*
|
|
2240
2351
|
* Returns the list of unexposed models so callers can escalate (e.g. throw in
|
|
2241
2352
|
* CI):
|
|
2242
2353
|
*
|
|
2243
2354
|
* ```ts
|
|
2244
2355
|
* await app.init()
|
|
2245
|
-
* const missing = assertExposed(app, atscriptModels
|
|
2356
|
+
* const missing = assertExposed(app, atscriptModels, {
|
|
2357
|
+
* all: true,
|
|
2358
|
+
* exclude: [EmbeddingCache], // internal on purpose
|
|
2359
|
+
* })
|
|
2246
2360
|
* if (missing.length && process.env.CI) throw new Error("unexposed models")
|
|
2247
2361
|
* ```
|
|
2248
2362
|
*/
|
|
2249
2363
|
function assertExposed(app, models, options) {
|
|
2250
2364
|
const logger = options?.logger ?? console;
|
|
2365
|
+
const auditAll = options?.all === true;
|
|
2366
|
+
const excluded = new Set(options?.exclude ?? []);
|
|
2251
2367
|
const exposed = /* @__PURE__ */ new Set();
|
|
2252
2368
|
for (const overview of app.getControllersOverview()) {
|
|
2253
2369
|
const model = findReadableBinding(overview.type)?.model;
|
|
@@ -2255,11 +2371,12 @@ function assertExposed(app, models, options) {
|
|
|
2255
2371
|
}
|
|
2256
2372
|
const missing = [];
|
|
2257
2373
|
for (const model of models) {
|
|
2258
|
-
|
|
2259
|
-
if (
|
|
2374
|
+
const httpPath = model.metadata.get("db.http.path");
|
|
2375
|
+
if (!auditAll && httpPath === void 0) continue;
|
|
2376
|
+
if (excluded.has(model) || exposed.has(model)) continue;
|
|
2260
2377
|
missing.push(model);
|
|
2261
|
-
const
|
|
2262
|
-
logger.warn(`[moost-db] Model "${
|
|
2378
|
+
const name = model.id ?? httpPath ?? "(unnamed model)";
|
|
2379
|
+
logger.warn(httpPath === void 0 ? `[moost-db] Model "${name}" has no registered controller bound to it. Register its controller, or list it in assertExposed's \`exclude\` if it is internal on purpose.` : `[moost-db] Model "${name}" declares @db.http.path "${httpPath}" but no registered controller is bound to it. Did you forget to register its controller?`);
|
|
2263
2380
|
}
|
|
2264
2381
|
return missing;
|
|
2265
2382
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -174,6 +174,14 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
|
|
|
174
174
|
declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscriptAnnotatedType, DataType = TAtscriptDataType<T>> extends AsReadableController<T, DataType> {
|
|
175
175
|
/** Reference to the underlying readable (table or view). */
|
|
176
176
|
protected readable: AtscriptDbReadable<T>;
|
|
177
|
+
/**
|
|
178
|
+
* The bound readable as a writable table. The canonical readable-controller
|
|
179
|
+
* posture is "generic reads + named `@DbAction` mutations" — action handlers
|
|
180
|
+
* write through this instead of re-importing the DbSpace module for a
|
|
181
|
+
* module-scope `getTable`. Throws when the controller is bound to a view
|
|
182
|
+
* (or any non-table readable).
|
|
183
|
+
*/
|
|
184
|
+
protected get table(): AtscriptDbTable<T>;
|
|
177
185
|
private readonly _gates;
|
|
178
186
|
private readonly _preferredIdSet;
|
|
179
187
|
private readonly _overlayIsNoOp;
|
|
@@ -181,6 +189,10 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
|
|
|
181
189
|
private readonly _quantityRefByPath;
|
|
182
190
|
/** Paths the adapter vetoes for filtering (e.g. JSON storage on SQL). Symmetric with `/meta` `filterable: false`. */
|
|
183
191
|
private readonly _adapterNonFilterable;
|
|
192
|
+
/** `@db.column.searchable` paths — the `$search` fallback when the adapter has no native search. */
|
|
193
|
+
private readonly _searchFallbackFields;
|
|
194
|
+
/** `@db.writeOnly` paths — settable in writes, sealed out of every read surface. */
|
|
195
|
+
private readonly _writeOnlySet;
|
|
184
196
|
constructor(app: Moost, readable?: AtscriptDbReadable<T>);
|
|
185
197
|
private _collectAdapterNonFilterable;
|
|
186
198
|
private _collectQuantityRefs;
|
|
@@ -239,6 +251,25 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
|
|
|
239
251
|
/** Returns a widened `$select` only when at least one `requiredFields` entry is missing; `null` means "no widening needed". */
|
|
240
252
|
private _widenSelectForActions;
|
|
241
253
|
private _prepareAugmentation;
|
|
254
|
+
/** `@db.column.searchable` paths, minus anything the adapter can't filter (JSON storage, encrypted). */
|
|
255
|
+
private _collectSearchFallbackFields;
|
|
256
|
+
/**
|
|
257
|
+
* Removes `@db.writeOnly` fields from any `$select` shape — and forces an
|
|
258
|
+
* exclusion when no projection was requested — so sealed values never leave
|
|
259
|
+
* the database on a read. Runs AFTER `transformProjection` so permission
|
|
260
|
+
* overlays compose (they see the wire `$select`; this guarantees the seal on
|
|
261
|
+
* whatever they return).
|
|
262
|
+
*/
|
|
263
|
+
private _sealProjection;
|
|
264
|
+
/** First `@db.writeOnly` field referenced by `$groupBy` / aggregate `$select`, or undefined. */
|
|
265
|
+
private _findWriteOnlyInAggregate;
|
|
266
|
+
/**
|
|
267
|
+
* Merges the `$search` fallback into the filter: a case-insensitive literal
|
|
268
|
+
* substring match OR'd across the `@db.column.searchable` fields, `$and`-combined
|
|
269
|
+
* with the existing filter. Applies only when the adapter has no native search
|
|
270
|
+
* (native wins) and the request isn't a vector search (`$vector` consumes the term).
|
|
271
|
+
*/
|
|
272
|
+
protected applySearchFallback(filter: FilterExpr | undefined, controls: Record<string, unknown>): FilterExpr | undefined;
|
|
242
273
|
private _resolveReadStrategy;
|
|
243
274
|
/**
|
|
244
275
|
* Shared `query` / `pages` pipeline: prepare actions augmentation + read
|
|
@@ -321,8 +352,6 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
|
|
|
321
352
|
* ```
|
|
322
353
|
*/
|
|
323
354
|
declare class AsDbController<T extends TAtscriptAnnotatedType = TAtscriptAnnotatedType, DataType = TAtscriptDataType<T>> extends AsDbReadableController<T, DataType> {
|
|
324
|
-
/** Reference to the underlying table (typed for write access). */
|
|
325
|
-
protected get table(): AtscriptDbTable<T>;
|
|
326
355
|
constructor(app: Moost, table?: AtscriptDbTable<T>);
|
|
327
356
|
protected buildCrud(): TCrudPermissions$1;
|
|
328
357
|
/**
|
|
@@ -908,28 +937,50 @@ declare function findReadableBinding(ctor: Function | undefined): TReadableBindi
|
|
|
908
937
|
declare function resolveBoundReadable(ctor: Function | undefined): AtscriptDbReadable<any>;
|
|
909
938
|
//#endregion
|
|
910
939
|
//#region src/assert-exposed.d.ts
|
|
911
|
-
/**
|
|
940
|
+
/** Options for {@link assertExposed}. */
|
|
912
941
|
interface TAssertExposedOptions {
|
|
913
942
|
logger?: {
|
|
914
943
|
warn: (...args: unknown[]) => void;
|
|
915
944
|
};
|
|
945
|
+
/**
|
|
946
|
+
* Audit EVERY passed model, not only those annotated `@db.http.path`.
|
|
947
|
+
* Use with a generated model manifest so exposure completeness is guarded
|
|
948
|
+
* the same way sync completeness is. Deliberately-internal collections go
|
|
949
|
+
* in {@link exclude}.
|
|
950
|
+
*/
|
|
951
|
+
all?: boolean;
|
|
952
|
+
/**
|
|
953
|
+
* Models exempt from the audit (internal-on-purpose collections). Only
|
|
954
|
+
* meaningful with {@link all} — the default `@db.http.path` mode is already
|
|
955
|
+
* opt-in per model.
|
|
956
|
+
*/
|
|
957
|
+
exclude?: readonly TAtscriptAnnotatedType[];
|
|
916
958
|
}
|
|
917
959
|
/**
|
|
918
|
-
* Dev-mode wiring assertion: warns for every model
|
|
919
|
-
*
|
|
960
|
+
* Dev-mode wiring assertion: warns for every model that should have a
|
|
961
|
+
* registered db controller but doesn't.
|
|
962
|
+
*
|
|
963
|
+
* Default mode audits models annotated with `@db.http.path`. With
|
|
964
|
+
* `{ all: true }` every passed model is a candidate (minus `exclude`) — the
|
|
965
|
+
* right mode for repos that mount everything through decorator prefixes
|
|
966
|
+
* (`@TableController(Model, 'db/x')`) and keep a generated model manifest.
|
|
920
967
|
*
|
|
921
968
|
* Call AFTER `await app.init()` (controller bindings are collected during
|
|
922
969
|
* init). Detection reads the binding metadata written by `@TableController` /
|
|
923
970
|
* `@ReadableController` / `@ViewController`, so it covers the model-token and
|
|
924
|
-
* instance forms; lazy-factory bindings can't name their model until
|
|
925
|
-
* and are
|
|
971
|
+
* instance forms; **lazy-factory bindings can't name their model until
|
|
972
|
+
* resolved and are invisible to the check** — with `all: true` such models
|
|
973
|
+
* will warn even though they are exposed; list them in `exclude`.
|
|
926
974
|
*
|
|
927
975
|
* Returns the list of unexposed models so callers can escalate (e.g. throw in
|
|
928
976
|
* CI):
|
|
929
977
|
*
|
|
930
978
|
* ```ts
|
|
931
979
|
* await app.init()
|
|
932
|
-
* const missing = assertExposed(app, atscriptModels
|
|
980
|
+
* const missing = assertExposed(app, atscriptModels, {
|
|
981
|
+
* all: true,
|
|
982
|
+
* exclude: [EmbeddingCache], // internal on purpose
|
|
983
|
+
* })
|
|
933
984
|
* if (missing.length && process.env.CI) throw new Error("unexposed models")
|
|
934
985
|
* ```
|
|
935
986
|
*/
|
package/dist/index.d.mts
CHANGED
|
@@ -174,6 +174,14 @@ declare abstract class AsReadableController<T extends TAtscriptAnnotatedType = T
|
|
|
174
174
|
declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscriptAnnotatedType, DataType = TAtscriptDataType<T>> extends AsReadableController<T, DataType> {
|
|
175
175
|
/** Reference to the underlying readable (table or view). */
|
|
176
176
|
protected readable: AtscriptDbReadable<T>;
|
|
177
|
+
/**
|
|
178
|
+
* The bound readable as a writable table. The canonical readable-controller
|
|
179
|
+
* posture is "generic reads + named `@DbAction` mutations" — action handlers
|
|
180
|
+
* write through this instead of re-importing the DbSpace module for a
|
|
181
|
+
* module-scope `getTable`. Throws when the controller is bound to a view
|
|
182
|
+
* (or any non-table readable).
|
|
183
|
+
*/
|
|
184
|
+
protected get table(): AtscriptDbTable<T>;
|
|
177
185
|
private readonly _gates;
|
|
178
186
|
private readonly _preferredIdSet;
|
|
179
187
|
private readonly _overlayIsNoOp;
|
|
@@ -181,6 +189,10 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
|
|
|
181
189
|
private readonly _quantityRefByPath;
|
|
182
190
|
/** Paths the adapter vetoes for filtering (e.g. JSON storage on SQL). Symmetric with `/meta` `filterable: false`. */
|
|
183
191
|
private readonly _adapterNonFilterable;
|
|
192
|
+
/** `@db.column.searchable` paths — the `$search` fallback when the adapter has no native search. */
|
|
193
|
+
private readonly _searchFallbackFields;
|
|
194
|
+
/** `@db.writeOnly` paths — settable in writes, sealed out of every read surface. */
|
|
195
|
+
private readonly _writeOnlySet;
|
|
184
196
|
constructor(app: Moost, readable?: AtscriptDbReadable<T>);
|
|
185
197
|
private _collectAdapterNonFilterable;
|
|
186
198
|
private _collectQuantityRefs;
|
|
@@ -239,6 +251,25 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
|
|
|
239
251
|
/** Returns a widened `$select` only when at least one `requiredFields` entry is missing; `null` means "no widening needed". */
|
|
240
252
|
private _widenSelectForActions;
|
|
241
253
|
private _prepareAugmentation;
|
|
254
|
+
/** `@db.column.searchable` paths, minus anything the adapter can't filter (JSON storage, encrypted). */
|
|
255
|
+
private _collectSearchFallbackFields;
|
|
256
|
+
/**
|
|
257
|
+
* Removes `@db.writeOnly` fields from any `$select` shape — and forces an
|
|
258
|
+
* exclusion when no projection was requested — so sealed values never leave
|
|
259
|
+
* the database on a read. Runs AFTER `transformProjection` so permission
|
|
260
|
+
* overlays compose (they see the wire `$select`; this guarantees the seal on
|
|
261
|
+
* whatever they return).
|
|
262
|
+
*/
|
|
263
|
+
private _sealProjection;
|
|
264
|
+
/** First `@db.writeOnly` field referenced by `$groupBy` / aggregate `$select`, or undefined. */
|
|
265
|
+
private _findWriteOnlyInAggregate;
|
|
266
|
+
/**
|
|
267
|
+
* Merges the `$search` fallback into the filter: a case-insensitive literal
|
|
268
|
+
* substring match OR'd across the `@db.column.searchable` fields, `$and`-combined
|
|
269
|
+
* with the existing filter. Applies only when the adapter has no native search
|
|
270
|
+
* (native wins) and the request isn't a vector search (`$vector` consumes the term).
|
|
271
|
+
*/
|
|
272
|
+
protected applySearchFallback(filter: FilterExpr | undefined, controls: Record<string, unknown>): FilterExpr | undefined;
|
|
242
273
|
private _resolveReadStrategy;
|
|
243
274
|
/**
|
|
244
275
|
* Shared `query` / `pages` pipeline: prepare actions augmentation + read
|
|
@@ -321,8 +352,6 @@ declare class AsDbReadableController<T extends TAtscriptAnnotatedType = TAtscrip
|
|
|
321
352
|
* ```
|
|
322
353
|
*/
|
|
323
354
|
declare class AsDbController<T extends TAtscriptAnnotatedType = TAtscriptAnnotatedType, DataType = TAtscriptDataType<T>> extends AsDbReadableController<T, DataType> {
|
|
324
|
-
/** Reference to the underlying table (typed for write access). */
|
|
325
|
-
protected get table(): AtscriptDbTable<T>;
|
|
326
355
|
constructor(app: Moost, table?: AtscriptDbTable<T>);
|
|
327
356
|
protected buildCrud(): TCrudPermissions$1;
|
|
328
357
|
/**
|
|
@@ -908,28 +937,50 @@ declare function findReadableBinding(ctor: Function | undefined): TReadableBindi
|
|
|
908
937
|
declare function resolveBoundReadable(ctor: Function | undefined): AtscriptDbReadable<any>;
|
|
909
938
|
//#endregion
|
|
910
939
|
//#region src/assert-exposed.d.ts
|
|
911
|
-
/**
|
|
940
|
+
/** Options for {@link assertExposed}. */
|
|
912
941
|
interface TAssertExposedOptions {
|
|
913
942
|
logger?: {
|
|
914
943
|
warn: (...args: unknown[]) => void;
|
|
915
944
|
};
|
|
945
|
+
/**
|
|
946
|
+
* Audit EVERY passed model, not only those annotated `@db.http.path`.
|
|
947
|
+
* Use with a generated model manifest so exposure completeness is guarded
|
|
948
|
+
* the same way sync completeness is. Deliberately-internal collections go
|
|
949
|
+
* in {@link exclude}.
|
|
950
|
+
*/
|
|
951
|
+
all?: boolean;
|
|
952
|
+
/**
|
|
953
|
+
* Models exempt from the audit (internal-on-purpose collections). Only
|
|
954
|
+
* meaningful with {@link all} — the default `@db.http.path` mode is already
|
|
955
|
+
* opt-in per model.
|
|
956
|
+
*/
|
|
957
|
+
exclude?: readonly TAtscriptAnnotatedType[];
|
|
916
958
|
}
|
|
917
959
|
/**
|
|
918
|
-
* Dev-mode wiring assertion: warns for every model
|
|
919
|
-
*
|
|
960
|
+
* Dev-mode wiring assertion: warns for every model that should have a
|
|
961
|
+
* registered db controller but doesn't.
|
|
962
|
+
*
|
|
963
|
+
* Default mode audits models annotated with `@db.http.path`. With
|
|
964
|
+
* `{ all: true }` every passed model is a candidate (minus `exclude`) — the
|
|
965
|
+
* right mode for repos that mount everything through decorator prefixes
|
|
966
|
+
* (`@TableController(Model, 'db/x')`) and keep a generated model manifest.
|
|
920
967
|
*
|
|
921
968
|
* Call AFTER `await app.init()` (controller bindings are collected during
|
|
922
969
|
* init). Detection reads the binding metadata written by `@TableController` /
|
|
923
970
|
* `@ReadableController` / `@ViewController`, so it covers the model-token and
|
|
924
|
-
* instance forms; lazy-factory bindings can't name their model until
|
|
925
|
-
* and are
|
|
971
|
+
* instance forms; **lazy-factory bindings can't name their model until
|
|
972
|
+
* resolved and are invisible to the check** — with `all: true` such models
|
|
973
|
+
* will warn even though they are exposed; list them in `exclude`.
|
|
926
974
|
*
|
|
927
975
|
* Returns the list of unexposed models so callers can escalate (e.g. throw in
|
|
928
976
|
* CI):
|
|
929
977
|
*
|
|
930
978
|
* ```ts
|
|
931
979
|
* await app.init()
|
|
932
|
-
* const missing = assertExposed(app, atscriptModels
|
|
980
|
+
* const missing = assertExposed(app, atscriptModels, {
|
|
981
|
+
* all: true,
|
|
982
|
+
* exclude: [EmbeddingCache], // internal on purpose
|
|
983
|
+
* })
|
|
933
984
|
* if (missing.length && process.env.CI) throw new Error("unexposed models")
|
|
934
985
|
* ```
|
|
935
986
|
*/
|
package/dist/index.mjs
CHANGED
|
@@ -624,7 +624,7 @@ let AsReadableController = class AsReadableController {
|
|
|
624
624
|
key,
|
|
625
625
|
value
|
|
626
626
|
};
|
|
627
|
-
if (key === "db.json" || key === "db.patch.strategy" || key.startsWith("db.default") || key === "db.http.path") return {
|
|
627
|
+
if (key === "db.json" || key === "db.patch.strategy" || key.startsWith("db.default") || key === "db.http.path" || key === "db.writeOnly") return {
|
|
628
628
|
key,
|
|
629
629
|
value
|
|
630
630
|
};
|
|
@@ -1088,6 +1088,18 @@ const GEO_CONTROLS = [
|
|
|
1088
1088
|
let AsDbReadableController = class AsDbReadableController extends AsReadableController {
|
|
1089
1089
|
/** Reference to the underlying readable (table or view). */
|
|
1090
1090
|
readable;
|
|
1091
|
+
/**
|
|
1092
|
+
* The bound readable as a writable table. The canonical readable-controller
|
|
1093
|
+
* posture is "generic reads + named `@DbAction` mutations" — action handlers
|
|
1094
|
+
* write through this instead of re-importing the DbSpace module for a
|
|
1095
|
+
* module-scope `getTable`. Throws when the controller is bound to a view
|
|
1096
|
+
* (or any non-table readable).
|
|
1097
|
+
*/
|
|
1098
|
+
get table() {
|
|
1099
|
+
const readable = this.readable;
|
|
1100
|
+
if (readable && (readable.isView || typeof readable.insertOne !== "function")) throw new Error(`${this.constructor.name} is bound to a ${readable.isView ? "view" : "non-table readable"} ("${readable.tableName}") — .table is only available for table-bound controllers.`);
|
|
1101
|
+
return readable;
|
|
1102
|
+
}
|
|
1091
1103
|
_gates;
|
|
1092
1104
|
_preferredIdSet;
|
|
1093
1105
|
_overlayIsNoOp;
|
|
@@ -1095,11 +1107,17 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1095
1107
|
_quantityRefByPath;
|
|
1096
1108
|
/** Paths the adapter vetoes for filtering (e.g. JSON storage on SQL). Symmetric with `/meta` `filterable: false`. */
|
|
1097
1109
|
_adapterNonFilterable;
|
|
1110
|
+
/** `@db.column.searchable` paths — the `$search` fallback when the adapter has no native search. */
|
|
1111
|
+
_searchFallbackFields;
|
|
1112
|
+
/** `@db.writeOnly` paths — settable in writes, sealed out of every read surface. */
|
|
1113
|
+
_writeOnlySet;
|
|
1098
1114
|
constructor(app, readable) {
|
|
1099
1115
|
const resolved = readable ?? resolveBoundReadable(new.target);
|
|
1100
1116
|
super(resolved.type, resolved.tableName, app, resolved.isView ? "view" : "table");
|
|
1101
1117
|
this.readable = resolved;
|
|
1102
1118
|
this._adapterNonFilterable = this._collectAdapterNonFilterable();
|
|
1119
|
+
this._writeOnlySet = this._collectAnnotated("db.writeOnly");
|
|
1120
|
+
this._searchFallbackFields = this._collectSearchFallbackFields();
|
|
1103
1121
|
this._gates = this._buildGates();
|
|
1104
1122
|
this._preferredIdSet = new Set(resolved.preferredId ?? []);
|
|
1105
1123
|
this._quantityRefByPath = this._collectQuantityRefs();
|
|
@@ -1139,11 +1157,25 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1139
1157
|
annotation: "@db.column.sortable"
|
|
1140
1158
|
};
|
|
1141
1159
|
}
|
|
1160
|
+
const writeOnly = this._writeOnlySet;
|
|
1161
|
+
if (writeOnly.size > 0) {
|
|
1162
|
+
const prevFilter = gates.filter;
|
|
1163
|
+
gates.filter = {
|
|
1164
|
+
predicate: (f) => !writeOnly.has(f) && (prevFilter ? prevFilter.predicate(f) : true),
|
|
1165
|
+
annotation: prevFilter?.annotation ?? "@db.column.filterable (field is @db.writeOnly)"
|
|
1166
|
+
};
|
|
1167
|
+
const prevSort = gates.sort;
|
|
1168
|
+
gates.sort = {
|
|
1169
|
+
predicate: (f) => !writeOnly.has(f) && (prevSort ? prevSort.predicate(f) : true),
|
|
1170
|
+
annotation: prevSort?.annotation ?? "@db.column.sortable (field is @db.writeOnly)"
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1142
1173
|
return gates;
|
|
1143
1174
|
}
|
|
1144
1175
|
_collectAnnotated(annotation) {
|
|
1145
1176
|
const out = /* @__PURE__ */ new Set();
|
|
1146
|
-
|
|
1177
|
+
if (!this.readable.flatMap) return out;
|
|
1178
|
+
for (const [path, entry] of this.readable.flatMap) if (entry?.metadata?.has?.(annotation)) out.add(path);
|
|
1147
1179
|
return out;
|
|
1148
1180
|
}
|
|
1149
1181
|
hasField(path) {
|
|
@@ -1368,6 +1400,72 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1368
1400
|
widenedSelect: resolvedProjection === null ? null : this._widenSelectForActions(envelopes, resolvedProjection)
|
|
1369
1401
|
};
|
|
1370
1402
|
}
|
|
1403
|
+
/** `@db.column.searchable` paths, minus anything the adapter can't filter (JSON storage, encrypted). */
|
|
1404
|
+
_collectSearchFallbackFields() {
|
|
1405
|
+
const out = [];
|
|
1406
|
+
if (!this.readable.fieldDescriptors) return out;
|
|
1407
|
+
for (const fd of this.readable.fieldDescriptors) {
|
|
1408
|
+
if (fd.ignored) continue;
|
|
1409
|
+
if (!fd.type?.metadata?.has?.("db.column.searchable")) continue;
|
|
1410
|
+
if (this._adapterNonFilterable.has(fd.path)) continue;
|
|
1411
|
+
if (this._writeOnlySet.has(fd.path)) continue;
|
|
1412
|
+
out.push(fd.path);
|
|
1413
|
+
}
|
|
1414
|
+
return out;
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Removes `@db.writeOnly` fields from any `$select` shape — and forces an
|
|
1418
|
+
* exclusion when no projection was requested — so sealed values never leave
|
|
1419
|
+
* the database on a read. Runs AFTER `transformProjection` so permission
|
|
1420
|
+
* overlays compose (they see the wire `$select`; this guarantees the seal on
|
|
1421
|
+
* whatever they return).
|
|
1422
|
+
*/
|
|
1423
|
+
_sealProjection(select) {
|
|
1424
|
+
const writeOnly = this._writeOnlySet;
|
|
1425
|
+
if (writeOnly.size === 0) return select;
|
|
1426
|
+
const exclusion = () => {
|
|
1427
|
+
const out = {};
|
|
1428
|
+
for (const f of writeOnly) out[f] = 0;
|
|
1429
|
+
return out;
|
|
1430
|
+
};
|
|
1431
|
+
if (select === void 0) return exclusion();
|
|
1432
|
+
if (Array.isArray(select)) {
|
|
1433
|
+
const kept = select.filter((item) => typeof item === "string" ? !writeOnly.has(item) : !writeOnly.has(item.$field ?? ""));
|
|
1434
|
+
return kept.length > 0 ? kept : exclusion();
|
|
1435
|
+
}
|
|
1436
|
+
const entries = Object.entries(select);
|
|
1437
|
+
if (entries.length > 0 && (entries[0][1] === 1 || entries[0][1] === true)) {
|
|
1438
|
+
const out = {};
|
|
1439
|
+
for (const [k, v] of entries) if (!writeOnly.has(k)) out[k] = v;
|
|
1440
|
+
return Object.keys(out).length > 0 ? out : exclusion();
|
|
1441
|
+
}
|
|
1442
|
+
const out = { ...select };
|
|
1443
|
+
for (const f of writeOnly) out[f] = 0;
|
|
1444
|
+
return out;
|
|
1445
|
+
}
|
|
1446
|
+
/** First `@db.writeOnly` field referenced by `$groupBy` / aggregate `$select`, or undefined. */
|
|
1447
|
+
_findWriteOnlyInAggregate(groupBy, select) {
|
|
1448
|
+
if (this._writeOnlySet.size === 0) return void 0;
|
|
1449
|
+
for (const f of groupBy) if (this._writeOnlySet.has(f)) return f;
|
|
1450
|
+
if (Array.isArray(select)) for (const item of select) {
|
|
1451
|
+
const field = typeof item === "string" ? item : item.$field;
|
|
1452
|
+
if (field && this._writeOnlySet.has(field)) return field;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Merges the `$search` fallback into the filter: a case-insensitive literal
|
|
1457
|
+
* substring match OR'd across the `@db.column.searchable` fields, `$and`-combined
|
|
1458
|
+
* with the existing filter. Applies only when the adapter has no native search
|
|
1459
|
+
* (native wins) and the request isn't a vector search (`$vector` consumes the term).
|
|
1460
|
+
*/
|
|
1461
|
+
applySearchFallback(filter, controls) {
|
|
1462
|
+
const term = controls.$search;
|
|
1463
|
+
if (!term || controls.$vector !== void 0) return filter;
|
|
1464
|
+
if (this.readable.isSearchable() || this._searchFallbackFields.length === 0) return filter;
|
|
1465
|
+
const rx = `/${term.replace(/[.*+?^${}()|[\]\\/]/g, String.raw`\$&`)}/i`;
|
|
1466
|
+
const fragment = { $or: this._searchFallbackFields.map((f) => ({ [f]: { $regex: rx } })) };
|
|
1467
|
+
return filter && Object.keys(filter).length > 0 ? { $and: [filter, fragment] } : fragment;
|
|
1468
|
+
}
|
|
1371
1469
|
async _resolveReadStrategy(controls) {
|
|
1372
1470
|
const searchTerm = controls.$search;
|
|
1373
1471
|
const indexName = controls.$index;
|
|
@@ -1438,14 +1536,18 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1438
1536
|
const gateError = this.checkGates(parsed.filter, controls, this._gates);
|
|
1439
1537
|
if (gateError) return gateError;
|
|
1440
1538
|
if (groupBy?.length) {
|
|
1441
|
-
const
|
|
1539
|
+
const sealed = this._findWriteOnlyInAggregate(groupBy, controls.$select);
|
|
1540
|
+
if (sealed) return new HttpError(400, `Field "${sealed}" is @db.writeOnly and cannot be aggregated`);
|
|
1541
|
+
const filter = this.applySearchFallback(await this.transformFilter(parsed.filter), controls);
|
|
1442
1542
|
return this.readable.aggregate({
|
|
1443
1543
|
filter,
|
|
1444
1544
|
controls,
|
|
1445
1545
|
insights: parsed.insights
|
|
1446
1546
|
});
|
|
1447
1547
|
}
|
|
1448
|
-
const [
|
|
1548
|
+
const [transformedFilter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
|
|
1549
|
+
const filter = this.applySearchFallback(transformedFilter, controls);
|
|
1550
|
+
const rawSelect = this._sealProjection(transformedSelect);
|
|
1449
1551
|
if (controls.$count) return this.readable.count({
|
|
1450
1552
|
filter,
|
|
1451
1553
|
controls: {
|
|
@@ -1487,7 +1589,9 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1487
1589
|
const page = Math.max(Number(controls.$page || 1), 1);
|
|
1488
1590
|
const size = Math.max(Number(controls.$size || 10), 1);
|
|
1489
1591
|
const skip = (page - 1) * size;
|
|
1490
|
-
const [
|
|
1592
|
+
const [transformedFilter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
|
|
1593
|
+
const filter = this.applySearchFallback(transformedFilter, controls);
|
|
1594
|
+
const rawSelect = this._sealProjection(transformedSelect);
|
|
1491
1595
|
const select = this.widenPreferredIdProjection(rawSelect);
|
|
1492
1596
|
if (select instanceof HttpError) return select;
|
|
1493
1597
|
const threshold = controls.$threshold ? Number(controls.$threshold) : void 0;
|
|
@@ -1544,8 +1648,8 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1544
1648
|
}
|
|
1545
1649
|
const gateError = this.checkGates(parsed.filter, controls, this._gates);
|
|
1546
1650
|
if (gateError) return gateError;
|
|
1547
|
-
const [filter,
|
|
1548
|
-
const select = this.widenPreferredIdProjection(
|
|
1651
|
+
const [filter, transformedSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
|
|
1652
|
+
const select = this.widenPreferredIdProjection(this._sealProjection(transformedSelect));
|
|
1549
1653
|
if (select instanceof HttpError) return select;
|
|
1550
1654
|
const paginated = controls.$page !== void 0 || controls.$size !== void 0;
|
|
1551
1655
|
const page = Math.max(Number(controls.$page || 1), 1);
|
|
@@ -1604,7 +1708,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1604
1708
|
const error = this.validateParsed(parsed, "getOne");
|
|
1605
1709
|
if (error) return error;
|
|
1606
1710
|
const rawSelect = await this.transformProjection(parsed.controls.$select);
|
|
1607
|
-
const select = this.widenPreferredIdProjection(rawSelect);
|
|
1711
|
+
const select = this.widenPreferredIdProjection(this._sealProjection(rawSelect));
|
|
1608
1712
|
if (select instanceof HttpError) return select;
|
|
1609
1713
|
return this._findByIdAndAugment(id, parsed.controls, select);
|
|
1610
1714
|
}
|
|
@@ -1619,7 +1723,7 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1619
1723
|
const { parsed } = this.parseControlsOnlyFromUrl(url);
|
|
1620
1724
|
this._coerceActionsControl(parsed.controls);
|
|
1621
1725
|
const rawSelect = await this.transformProjection(parsed.controls.$select);
|
|
1622
|
-
const select = this.widenPreferredIdProjection(rawSelect);
|
|
1726
|
+
const select = this.widenPreferredIdProjection(this._sealProjection(rawSelect));
|
|
1623
1727
|
if (select instanceof HttpError) return select;
|
|
1624
1728
|
return this._findByIdAndAugment(idObj, parsed.controls, select);
|
|
1625
1729
|
}
|
|
@@ -1685,9 +1789,14 @@ let AsDbReadableController = class AsDbReadableController extends AsReadableCont
|
|
|
1685
1789
|
};
|
|
1686
1790
|
if (fd.encrypted) fields[fd.path].encrypted = true;
|
|
1687
1791
|
if (geoIndexedPhysical.has(fd.physicalName)) fields[fd.path].geo = true;
|
|
1792
|
+
if (this._writeOnlySet.has(fd.path)) {
|
|
1793
|
+
fields[fd.path].writeOnly = true;
|
|
1794
|
+
fields[fd.path].filterable = false;
|
|
1795
|
+
fields[fd.path].sortable = false;
|
|
1796
|
+
}
|
|
1688
1797
|
}
|
|
1689
1798
|
return {
|
|
1690
|
-
searchable: this.readable.isSearchable(),
|
|
1799
|
+
searchable: this.readable.isSearchable() || this._searchFallbackFields.length > 0,
|
|
1691
1800
|
vectorSearchable: this.readable.isVectorSearchable(),
|
|
1692
1801
|
geoSearchable: this._isGeoSearchable(),
|
|
1693
1802
|
searchIndexes: this.readable.getSearchIndexes(),
|
|
@@ -1788,10 +1897,6 @@ function liftVersionToCas(payload, versionColumn) {
|
|
|
1788
1897
|
return true;
|
|
1789
1898
|
}
|
|
1790
1899
|
let AsDbController = class AsDbController extends AsDbReadableController {
|
|
1791
|
-
/** Reference to the underlying table (typed for write access). */
|
|
1792
|
-
get table() {
|
|
1793
|
-
return this.readable;
|
|
1794
|
-
}
|
|
1795
1900
|
constructor(app, table) {
|
|
1796
1901
|
super(app, table);
|
|
1797
1902
|
}
|
|
@@ -2227,26 +2332,37 @@ AsJsonValueHelpController = __decorate([Inherit(), __decorateMetadata("design:pa
|
|
|
2227
2332
|
//#endregion
|
|
2228
2333
|
//#region src/assert-exposed.ts
|
|
2229
2334
|
/**
|
|
2230
|
-
* Dev-mode wiring assertion: warns for every model
|
|
2231
|
-
*
|
|
2335
|
+
* Dev-mode wiring assertion: warns for every model that should have a
|
|
2336
|
+
* registered db controller but doesn't.
|
|
2337
|
+
*
|
|
2338
|
+
* Default mode audits models annotated with `@db.http.path`. With
|
|
2339
|
+
* `{ all: true }` every passed model is a candidate (minus `exclude`) — the
|
|
2340
|
+
* right mode for repos that mount everything through decorator prefixes
|
|
2341
|
+
* (`@TableController(Model, 'db/x')`) and keep a generated model manifest.
|
|
2232
2342
|
*
|
|
2233
2343
|
* Call AFTER `await app.init()` (controller bindings are collected during
|
|
2234
2344
|
* init). Detection reads the binding metadata written by `@TableController` /
|
|
2235
2345
|
* `@ReadableController` / `@ViewController`, so it covers the model-token and
|
|
2236
|
-
* instance forms; lazy-factory bindings can't name their model until
|
|
2237
|
-
* and are
|
|
2346
|
+
* instance forms; **lazy-factory bindings can't name their model until
|
|
2347
|
+
* resolved and are invisible to the check** — with `all: true` such models
|
|
2348
|
+
* will warn even though they are exposed; list them in `exclude`.
|
|
2238
2349
|
*
|
|
2239
2350
|
* Returns the list of unexposed models so callers can escalate (e.g. throw in
|
|
2240
2351
|
* CI):
|
|
2241
2352
|
*
|
|
2242
2353
|
* ```ts
|
|
2243
2354
|
* await app.init()
|
|
2244
|
-
* const missing = assertExposed(app, atscriptModels
|
|
2355
|
+
* const missing = assertExposed(app, atscriptModels, {
|
|
2356
|
+
* all: true,
|
|
2357
|
+
* exclude: [EmbeddingCache], // internal on purpose
|
|
2358
|
+
* })
|
|
2245
2359
|
* if (missing.length && process.env.CI) throw new Error("unexposed models")
|
|
2246
2360
|
* ```
|
|
2247
2361
|
*/
|
|
2248
2362
|
function assertExposed(app, models, options) {
|
|
2249
2363
|
const logger = options?.logger ?? console;
|
|
2364
|
+
const auditAll = options?.all === true;
|
|
2365
|
+
const excluded = new Set(options?.exclude ?? []);
|
|
2250
2366
|
const exposed = /* @__PURE__ */ new Set();
|
|
2251
2367
|
for (const overview of app.getControllersOverview()) {
|
|
2252
2368
|
const model = findReadableBinding(overview.type)?.model;
|
|
@@ -2254,11 +2370,12 @@ function assertExposed(app, models, options) {
|
|
|
2254
2370
|
}
|
|
2255
2371
|
const missing = [];
|
|
2256
2372
|
for (const model of models) {
|
|
2257
|
-
|
|
2258
|
-
if (
|
|
2373
|
+
const httpPath = model.metadata.get("db.http.path");
|
|
2374
|
+
if (!auditAll && httpPath === void 0) continue;
|
|
2375
|
+
if (excluded.has(model) || exposed.has(model)) continue;
|
|
2259
2376
|
missing.push(model);
|
|
2260
|
-
const
|
|
2261
|
-
logger.warn(`[moost-db] Model "${
|
|
2377
|
+
const name = model.id ?? httpPath ?? "(unnamed model)";
|
|
2378
|
+
logger.warn(httpPath === void 0 ? `[moost-db] Model "${name}" has no registered controller bound to it. Register its controller, or list it in assertExposed's \`exclude\` if it is internal on purpose.` : `[moost-db] Model "${name}" declares @db.http.path "${httpPath}" but no registered controller is bound to it. Did you forget to register its controller?`);
|
|
2262
2379
|
}
|
|
2263
2380
|
return missing;
|
|
2264
2381
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atscript/moost-db",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.123",
|
|
4
4
|
"description": "Generic database controller for Moost with Atscript.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"annotations",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@uniqu/url": "^0.1.7",
|
|
47
|
-
"@atscript/db-memory": "^0.1.
|
|
47
|
+
"@atscript/db-memory": "^0.1.123"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@atscript/core": "^0.1.85",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@wooksjs/event-core": "^0.7.21",
|
|
65
65
|
"@wooksjs/http-body": "^0.7.21",
|
|
66
66
|
"moost": "^0.6.33",
|
|
67
|
-
"@atscript/db": "^0.1.
|
|
67
|
+
"@atscript/db": "^0.1.123"
|
|
68
68
|
},
|
|
69
69
|
"scripts": {
|
|
70
70
|
"postinstall": "asc -f dts",
|