@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 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
- for (const [path, entry] of this.readable.flatMap) if (entry.metadata.has(annotation)) out.add(path);
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 filter = await this.transformFilter(parsed.filter);
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 [filter, rawSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
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 [filter, rawSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
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, rawSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
1549
- const select = this.widenPreferredIdProjection(rawSelect);
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 annotated with
2232
- * `@db.http.path` that has no registered db controller.
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 resolved
2238
- * and are ignored.
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
- if (!model.metadata.has("db.http.path")) continue;
2259
- if (exposed.has(model)) continue;
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 path = model.metadata.get("db.http.path");
2262
- logger.warn(`[moost-db] Model "${model.id ?? path}" declares @db.http.path "${path}" but no registered controller is bound to it. Did you forget to register its controller?`);
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
- /** Minimal logger surface for {@link assertExposed}. */
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 annotated with
919
- * `@db.http.path` that has no registered db controller.
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 resolved
925
- * and are ignored.
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
- /** Minimal logger surface for {@link assertExposed}. */
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 annotated with
919
- * `@db.http.path` that has no registered db controller.
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 resolved
925
- * and are ignored.
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
- for (const [path, entry] of this.readable.flatMap) if (entry.metadata.has(annotation)) out.add(path);
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 filter = await this.transformFilter(parsed.filter);
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 [filter, rawSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
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 [filter, rawSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
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, rawSelect] = await Promise.all([this.transformFilter(parsed.filter), this.transformProjection(controls.$select)]);
1548
- const select = this.widenPreferredIdProjection(rawSelect);
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 annotated with
2231
- * `@db.http.path` that has no registered db controller.
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 resolved
2237
- * and are ignored.
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
- if (!model.metadata.has("db.http.path")) continue;
2258
- if (exposed.has(model)) continue;
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 path = model.metadata.get("db.http.path");
2261
- logger.warn(`[moost-db] Model "${model.id ?? path}" declares @db.http.path "${path}" but no registered controller is bound to it. Did you forget to register its controller?`);
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.122",
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.122"
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.122"
67
+ "@atscript/db": "^0.1.123"
68
68
  },
69
69
  "scripts": {
70
70
  "postinstall": "asc -f dts",