@web-ts-toolkit/access-router 0.48.0 → 0.49.0

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/README.md CHANGED
@@ -59,6 +59,7 @@ const userRouter = acl.createRouter('User', {
59
59
 
60
60
  const fruitRouter = acl.createDataRouter('fruit', {
61
61
  basePath: '/fruit',
62
+ idParam: 'id',
62
63
  idField: 'id',
63
64
  operationAccess: { list: true, read: true },
64
65
  data: [{ id: 'apple', name: 'Apple', public: true }],
@@ -138,6 +139,326 @@ validator sees that transformed envelope, and the service persists only the fina
138
139
  `populate`, `tasks`, and allowed `options`. Missing body options fall back to `returning_all`/`include_permissions`
139
140
  query params; only `includePermissions`/`includeFieldPermissions`/`populateAccess` (plus `returningAll` for update/upsert) are forwarded.
140
141
 
142
+ ## Basic and advanced operation access
143
+
144
+ Configure generated endpoint guards in `operationAccess`. A base rule such as `read`
145
+ covers both variants unless an exact `basicRead` or `advancedRead` rule overrides it.
146
+ Variants authorize **route entry**; field grants remain in `permissionSchema` under
147
+ base names such as `read` and `list`.
148
+
149
+ <!-- doc-example: partial -->
150
+
151
+ ```ts
152
+ import type { ModelRouterOptions } from '@web-ts-toolkit/access-router';
153
+
154
+ // Both list and both read variants inherit their base guards.
155
+ const baseOnly = {
156
+ operationAccess: { list: true, read: true },
157
+ permissionSchema: { name: true },
158
+ } satisfies ModelRouterOptions;
159
+
160
+ // Among top-level routes, deny GET /users/:id only; both advanced read POSTs inherit read: true.
161
+ const advancedReadsOnly = {
162
+ basePath: '/users',
163
+ operationAccess: { list: true, read: true, basicRead: false },
164
+ permissionSchema: { name: true },
165
+ } satisfies ModelRouterOptions;
166
+
167
+ // Keep GET /users, deny POST /users/__query.
168
+ const basicListsOnly = {
169
+ basePath: '/users',
170
+ operationAccess: { list: true, read: true, advancedList: false },
171
+ permissionSchema: { name: true },
172
+ } satisfies ModelRouterOptions;
173
+ // Pass one of these objects to acl.createRouter(UserModel, options).
174
+ ```
175
+
176
+ `basicRead: false` does not affect list, new, count, distinct, or mutation routes;
177
+ their own guards still apply. Subdocument reads follow the field precedence below.
178
+ Denied operations return **HTTP 401 Unauthorized**
179
+ before validation/service dispatch. Every route stays registered in
180
+ `router.router.getEndpoints()` and OpenAPI. Express HEAD fallback uses the
181
+ corresponding GET's basic guard, so HEAD-by-id is denied with basic read.
182
+
183
+ ### Complete operation matrix
184
+
185
+ Paths are relative to `basePath`, with model defaults `idParam: 'id'`,
186
+ `queryRouteSegment: '__query'`, and `mutationRouteSegment: '__mutation'`.
187
+ Changing those route-shape options changes paths, not guard names. For data routers,
188
+ set `idParam: 'id'` explicitly to use the illustrated identifier paths.
189
+
190
+ | Base guard | Basic endpoint → override | Advanced endpoint → override |
191
+ | ----------------- | ------------------------------------------------ | -------------------------------------------------------------- |
192
+ | `list` | `GET /` → `basicList` | `POST /__query` → `advancedList` |
193
+ | `read` | `GET /:id` → `basicRead` | `POST /__query/:id`, `POST /__query/__filter` → `advancedRead` |
194
+ | `create` | `POST /` → `basicCreate` | `POST /__mutation` → `advancedCreate` |
195
+ | `update` | `PATCH /:id` → `basicUpdate` | `PATCH /__mutation/:id` → `advancedUpdate` |
196
+ | `upsert` | `PUT /` → `basicUpsert` | `PUT /__mutation` → `advancedUpsert` |
197
+ | `count` | `GET /count` → `basicCount` | `POST /count` → `advancedCount` |
198
+ | `distinct` | `GET /distinct/:field` → `basicDistinct` | `POST /distinct/:field` → `advancedDistinct` |
199
+ | `subs.<sub>.list` | `GET /:id/<sub>` → `subs.<sub>.basicList` | `POST /:id/<sub>/__query` → `subs.<sub>.advancedList` |
200
+ | `subs.<sub>.read` | `GET /:id/<sub>/:subId` → `subs.<sub>.basicRead` | `POST /:id/<sub>/:subId/__query` → `subs.<sub>.advancedRead` |
201
+
202
+ Model routers support all fourteen top-level keys. Data routers implement only
203
+ list/read: `basicList`, `advancedList`, `basicRead`, and `advancedRead`, including
204
+ both advanced read paths. Each model subdocument field supports four nested
205
+ list/read keys. Filtered count/distinct POSTs are advanced even without `__query`.
206
+
207
+ Unpaired routes retain their base guards:
208
+
209
+ | Endpoint | Guard |
210
+ | --------------------------------------------- | ------------------- |
211
+ | `GET /new` | `new` |
212
+ | `DELETE /:id` | `delete` |
213
+ | `POST /:id/<sub>` | `subs.<sub>.create` |
214
+ | `PATCH /:id/<sub>`, `PATCH /:id/<sub>/:subId` | `subs.<sub>.update` |
215
+ | `DELETE /:id/<sub>/:subId` | `subs.<sub>.delete` |
216
+
217
+ ### Variant and base fallback
218
+
219
+ 1. Read the exact variant from the owning runtime (model lookup includes the
220
+ runtime's exact model-default path).
221
+ 2. Only if that value is `undefined`, use the existing base operation resolution:
222
+ exact base rule → `operationAccess.default` → scalar `operationAccess` shorthand.
223
+ If no valid guard resolves, deny.
224
+
225
+ The selected variant **replaces** the base guard; the two are not combined or both
226
+ evaluated. Explicit false, an empty array, or a guard returning false is terminal.
227
+ Omitted and explicit undefined variants inherit. With no runtime-default variants
228
+ or other fallback configured:
229
+
230
+ | Rules | Basic list | Advanced list | Root list entry |
231
+ | ---------------------------------- | ---------- | ------------- | --------------- |
232
+ | `list: true` | allowed | allowed | allowed |
233
+ | `list: false` | denied | denied | denied |
234
+ | `list: true, basicList: false` | denied | allowed | allowed |
235
+ | `list: true, advancedList: false` | allowed | denied | allowed |
236
+ | `list: false, basicList: true` | allowed | denied | denied |
237
+ | only `basicList: true` | allowed | denied | denied |
238
+ | `list: true, basicList: undefined` | allowed | allowed | allowed |
239
+
240
+ All rule values use `Validation`: `boolean | string | string[] | GuardHook`.
241
+ Space-separated permission strings AND their names; arrays OR their entries.
242
+ Hooks receive `AccessRouterPermissions`, run with `this` bound to the request,
243
+ and may return a promise. Thrown/rejected operational errors retain normal error
244
+ handling rather than permission fallback.
245
+
246
+ <!-- doc-example: partial -->
247
+
248
+ ```ts
249
+ import acl, { type ModelRouterOptions } from '@web-ts-toolkit/access-router';
250
+
251
+ acl.setGlobalOptions({
252
+ globalPermissions(req) {
253
+ return req.headers.user === 'admin' ? ['isAdmin', 'canReadDetails'] : [];
254
+ },
255
+ });
256
+ const permissionBased = {
257
+ operationAccess: {
258
+ list: true,
259
+ read: true,
260
+ advancedList: 'isAdmin',
261
+ advancedRead: ['canReadDetails', 'isAdmin'],
262
+ advancedUpdate: async function (permissions) {
263
+ return permissions.has('isAdmin') && this.headers['x-write-mode'] === 'advanced';
264
+ },
265
+ },
266
+ permissionSchema: { name: { list: true, read: true } },
267
+ } satisfies ModelRouterOptions;
268
+ ```
269
+
270
+ ### Shorthand and model defaults
271
+
272
+ `operationAccess: true` (or another scalar `Validation`) supplies the ordinary
273
+ base fallback. In a rule object, use `default` for that fallback:
274
+
275
+ <!-- doc-example: partial -->
276
+
277
+ ```ts
278
+ import acl, { type ModelRouterOptions } from '@web-ts-toolkit/access-router';
279
+
280
+ const shorthand = { operationAccess: 'canUseApi' } satisfies ModelRouterOptions;
281
+ const withDefault = {
282
+ operationAccess: { default: 'canUseApi', list: true, basicRead: false },
283
+ } satisfies ModelRouterOptions;
284
+
285
+ acl.setDefaultModelOptions({ operationAccess: { list: true, read: true } });
286
+ acl.setDefaultModelOption('operationAccess.advancedRead', 'isAdmin');
287
+ const defaultRead = acl.getDefaultModelOption('operationAccess.advancedRead');
288
+ const defaultsSnapshot = acl.getDefaultModelOptions();
289
+ ```
290
+
291
+ Model options use shallow assignment. Supplying an `operationAccess` object
292
+ replaces that stored object rather than recursively merging default siblings.
293
+ Exact variant lookup separately falls back to the runtime's **same exact path**:
294
+ a runtime-default `advancedRead` can therefore outrank a model's `read`, `default`,
295
+ or scalar shorthand unless the model defines its own `advancedRead`.
296
+
297
+ Defaults copied into a model's stored options stay there until updated. Changing
298
+ runtime defaults does not broadcast replacements to existing model values;
299
+ setting a stored variant to undefined can expose a live runtime-default variant
300
+ before base fallback. `getDefaultModelOption` reads the requested path exactly;
301
+ it does not resolve variant/base aliases or `operationAccess.default`.
302
+ `getDefaultModelOptions()` returns a frozen snapshot. Data routers use only their
303
+ own options and never inherit model defaults.
304
+
305
+ ### Subdocument field precedence
306
+
307
+ For a basic list of `comments`, resolve:
308
+
309
+ 1. Exact `subs.comments.basicList`.
310
+ 2. Exact `subs.comments.list`.
311
+ 3. Defined `subs.comments`: evaluate a scalar guard; an object without the
312
+ applicable key **denies**, even if top-level list/variants allow.
313
+ 4. Only for an absent field rule, top-level exact `basicList`.
314
+ 5. Existing top-level base `list` fallback.
315
+
316
+ The same ordering applies to advanced list and both read variants. No nested
317
+ `.default` fallback is introduced. Exact field lookups retain model-default
318
+ specificity; an inherited defined field also stays closed. The legacy scalar `subs` umbrella is still
319
+ accepted, but **does not guard absent fields**; their fallback bypasses it and
320
+ uses top-level rules. Use explicit per-field rules to guard a field.
321
+
322
+ <!-- doc-example: partial -->
323
+
324
+ ```ts
325
+ import type { ModelRouterOptions } from '@web-ts-toolkit/access-router';
326
+
327
+ const subdocumentRules = {
328
+ operationAccess: {
329
+ list: true,
330
+ read: true,
331
+ subs: {
332
+ comments: { list: true, read: true, basicList: false, advancedRead: 'isAdmin' },
333
+ // Closed object: both items list routes are denied despite top-level list: true.
334
+ items: { read: true },
335
+ attachments: 'isAdmin',
336
+ },
337
+ },
338
+ } satisfies ModelRouterOptions;
339
+ ```
340
+
341
+ ### Live updates and option snapshots
342
+
343
+ Use setters to change request-time policy without reconstructing the router:
344
+
345
+ <!-- doc-example: partial -->
346
+
347
+ ```ts
348
+ import acl from '@web-ts-toolkit/access-router';
349
+
350
+ // User must already be registered with this runtime.
351
+ const router = acl.createRouter('User', {
352
+ basePath: '/users',
353
+ operationAccess: { list: true, read: true },
354
+ permissionSchema: { name: true },
355
+ });
356
+
357
+ router.operationAccess('basicRead', false); // Update one key, keep list/read rules.
358
+ router.operationAccess('subs.comments.basicRead', false);
359
+ router.set('operationAccess.advancedList', 'isAdmin');
360
+ router.setOption('operationAccess.basicRead', undefined); // Restore inheritance.
361
+ acl.setModelOption('User', 'operationAccess.basicRead', false);
362
+
363
+ // All object forms replace the whole operationAccess object, not a deep merge.
364
+ router.operationAccess({ list: true, read: true, basicRead: false });
365
+ router.setOptions({ operationAccess: { list: true, read: true, advancedList: false } });
366
+
367
+ const current = router.runtime.getModelOptions('User');
368
+ const exactVariant = router.runtime.getExactModelOption('User', 'operationAccess.basicRead');
369
+ ```
370
+
371
+ `set({ operationAccess: ... })` and `setOption('operationAccess', ...)` have the
372
+ same shallow replacement semantics. Dotted paths are setter arguments, not flat
373
+ properties in constructor options. Updating a nested path under a scalar shorthand
374
+ starts an object; retain a `default` rule if you want the prior broad fallback.
375
+ Use `set`/`setOption` with undefined to clear a variant; the two-argument property
376
+ helper is intended for defined values.
377
+
378
+ `router.options` is the frozen **construction-time** snapshot. Options getter
379
+ results are also frozen snapshots; fetch again for current stored values. Ordinary
380
+ `getModelOption`/`getDataOption` use generic nested-option fallback, not the
381
+ route-specific variant-to-base resolver. Exact getters inspect the variant path
382
+ (including exact model-default fallback). Request guards read live runtime values.
383
+ Data router setters use the same forms; its owning runtime provides
384
+ `setDataOption`, `getDataOptions`, and `getExactDataOption`.
385
+
386
+ ### Route guards and secondary policy
387
+
388
+ - Field grants, row filters, validation/prepare/decorate hooks, and related target
389
+ checks retain base accesses. A variant grant does not grant fields or bypass
390
+ target authorization.
391
+ - Model `_read`/`_readFilter` misses with `tryList: true` still require **base
392
+ `list`** to retry. `list: false, advancedList: true` cannot grant retry;
393
+ `list: true, basicList: false` cannot block it. Forbidden/BadRequest never retry.
394
+ - Upsert keeps its create/update branch policy; authorizing `advancedUpsert` does
395
+ not also require `advancedCreate` or `advancedUpdate`.
396
+ - Subdocument mutation-response visibility uses base parent/subdocument read/list
397
+ checks. Disabling basic reads does not hide otherwise readable output. Base
398
+ response denial can still yield a successful null/empty response after a write
399
+ (see [Subdocument writes with hidden responses](#subdocument-writes-with-hidden-responses)).
400
+ - Root batches use existing operation names and target base entry checks, with
401
+ entry 401 inside the HTTP 200 batch envelope. Variants are not new root `op`
402
+ values. Direct service calls remain trusted application calls with their
403
+ existing checks; they do not infer basic/advanced access from `req.method`.
404
+
405
+ **Populate selectors:** wire `options.populateAccess` and descriptor `access`
406
+ accept only `'list' | 'read'` (`PopulateAccess` from `/advanced`). The shared Core
407
+ populate boundary also rejects all fourteen reserved variant names in effective
408
+ option/descriptor access, including generic options forwarded by legacy includes
409
+ and subqueries, before target persistence. Other trusted, non-reserved custom
410
+ access strings keep their existing runtime behavior. Invalid selectors return
411
+ controlled BadRequest (direct HTTP 400; affected root entry 400/`bad_request`).
412
+ Legacy read/list includes now propagate target BadRequest instead of silently
413
+ omitting it, so nested invalid selectors cannot disappear from the result.
414
+
415
+ ### Runnable data-router split
416
+
417
+ This database-free example exercises the same four data keys after installation:
418
+
419
+ <!-- doc-example: complete-runtime -->
420
+
421
+ ```ts
422
+ import { once } from 'node:events';
423
+ import express from 'express';
424
+ import { createAccessRuntime } from '@web-ts-toolkit/access-router';
425
+
426
+ const runtime = createAccessRuntime();
427
+ const router = runtime.createDataRouter('VariantFruit', {
428
+ basePath: '/fruit',
429
+ idParam: 'id',
430
+ idField: 'id',
431
+ data: [{ id: 'apple', name: 'Apple' }],
432
+ operationAccess: { list: true, read: true, basicRead: false },
433
+ permissionSchema: { id: true, name: true },
434
+ });
435
+ const app = express();
436
+ app.use(express.json());
437
+ app.use(router.routes);
438
+ const server = app.listen(0, '127.0.0.1');
439
+ try {
440
+ await once(server, 'listening');
441
+ const address = server.address();
442
+ if (!address || typeof address === 'string') throw new Error('Expected a TCP address');
443
+ const url = `http://127.0.0.1:${address.port}/fruit`;
444
+ const basic = await fetch(`${url}/apple`);
445
+ const advanced = await fetch(`${url}/__query/apple`, {
446
+ method: 'POST',
447
+ headers: { 'content-type': 'application/json' },
448
+ body: '{}',
449
+ });
450
+ if (basic.status !== 401 || advanced.status !== 200) throw new Error('Read split failed');
451
+ const fruit = await advanced.json();
452
+ if (fruit.name !== 'Apple') throw new Error('Expected the authorized fruit');
453
+ router.operationAccess('basicRead', true);
454
+ if ((await fetch(`${url}/apple`)).status !== 200) throw new Error('Live update failed');
455
+ } finally {
456
+ await new Promise<void>((resolve, reject) => {
457
+ server.close((err) => (err ? reject(err) : resolve()));
458
+ });
459
+ }
460
+ ```
461
+
141
462
  ## Basic model GET queries
142
463
 
143
464
  `GET /users?select=name&sort=name%20-createdAt` projects `name` and sorts by
@@ -173,6 +494,7 @@ Root entrypoint (`@web-ts-toolkit/access-router`):
173
494
  - `permissionsPlugin` (src/plugins.ts) — Mongoose schema plugin used as `schema.plugin(permissionsPlugin, { modelName })`
174
495
  - logger helpers: `redactFilter`, `redactPayload`, `safeStringify`, `isLevelEnabled` + `OpLogContext` type
175
496
  - option helpers: `setGlobalOptions`, `setGlobalOption`, `getGlobalOptions`, `getGlobalOption`, `setModelOptions`, `setModelOption`, `getModelOptions`, `getModelOption`, `getModelNames`, `getModelJsonSchema`, `setDefaultModelOptions`, `setDefaultModelOption`, `getDefaultModelOptions`, `getDefaultModelOption`
497
+ - route-policy types: `OperationAccess`, `SubOperationAccess`, `Validation`, `GuardHook`, `RouteVariantAccess`, `ModelRouterOptions`, `DefaultModelRouterOptions`, `DataRouterOptions`, `ExtendedModelRouterOptions`, `ExtendedDefaultModelRouterOptions`, and `ExtendedDataRouterOptions` for typed setters; `PermissionSchema` / `FieldOperationAccess` are separate base-operation field grants. These types are also available from `/advanced`.
176
498
 
177
499
  Subpath entrypoints:
178
500
 
@@ -496,6 +818,191 @@ because its output is empty.** Actual write denials and validation/persistence f
496
818
  retain their errors. Response visibility needs at most one extra parent query and
497
819
  resolves applicable row policies once per mutation, rather than once per returned row.
498
820
 
821
+ ## Virtuals (computed fields)
822
+
823
+ Package-level computed output fields ("virtuals") are async, request-aware
824
+ derived fields that behave like first-class response fields. The persisted
825
+ model below has **no** `fullAddress` field; the virtual defines it, the
826
+ permission schema authorizes it, `select` requests it, and the selected output
827
+ keeps it optional (authorization, absent dependencies, `undefined`, and
828
+ fail-closed errors can all omit it).
829
+
830
+ Canonical import shape: default `acl` for the runtime API (named
831
+ `createAccessRuntime` for an isolated runtime); virtual output types live on
832
+ the existing `./advanced` barrel — no new subpaths.
833
+
834
+ <!-- doc-example: partial -->
835
+
836
+ ```ts
837
+ import acl, { type ModelRouterOptions } from '@web-ts-toolkit/access-router';
838
+ import type { SelectedPublicOutput } from '@web-ts-toolkit/access-router/advanced';
839
+
840
+ interface User {
841
+ name: string;
842
+ address: string;
843
+ }
844
+ interface UserVirtuals {
845
+ fullAddress: string;
846
+ }
847
+
848
+ // The persisted model lacks fullAddress; the virtual computes it.
849
+ const options: ModelRouterOptions<User, UserVirtuals> = {
850
+ basePath: '/users',
851
+ permissionSchema: {
852
+ name: { read: true },
853
+ address: { read: 'canViewAddress' },
854
+ fullAddress: { read: 'canViewAddress' },
855
+ },
856
+ virtuals: {
857
+ fullAddress: {
858
+ dependsOn: ['address'],
859
+ read: async function (doc) {
860
+ if (doc.address === undefined) return undefined;
861
+ return `addr:${doc.address}`;
862
+ },
863
+ },
864
+ },
865
+ };
866
+
867
+ // Selected output keeps the virtual optional: present when authorized and
868
+ // computed, omitted otherwise.
869
+ type Selected = SelectedPublicOutput<User, ['name', 'fullAddress'], UserVirtuals>;
870
+ const present: Selected = { name: 'Ada', fullAddress: 'addr:a1' };
871
+ const omitted: Selected = { name: 'Ada' };
872
+ // Pass options to acl.createRouter(UserModel, options); request with
873
+ // select: ['name', 'fullAddress']. Live updates use router.virtuals({...})
874
+ // or router.set('virtuals.fullAddress.read', { get, dependsOn }).
875
+ void [options, present, omitted];
876
+ ```
877
+
878
+ There is one public `select` for persisted and virtual fields; the package
879
+ derives internal fetch vs output plans. `dependsOn` names are top-level
880
+ persisted fields relative to the definition's scope (dotted paths and
881
+ virtual-to-virtual dependencies are rejected). Dependencies are fetched
882
+ internally even when the caller may not receive them, then stripped unless
883
+ independently in the original authorized/requested output. Unknown persisted
884
+ getter fields are type errors (and configuration rejections at runtime).
885
+ Virtual names are never added to persisted `Filter`, sort/distinct inputs, or
886
+ client write types.
887
+
888
+ Lean/hydrated parity: getters always receive an isolated, stable plain-object
889
+ view (`toObject({ virtuals: false })` for documents, recursively isolated
890
+ copies for lean results, with `Date`/`Buffer`/`ObjectId` cloned by their own
891
+ constructors), so package virtuals compute for both lean and hydrated results.
892
+ Only returned field values are committed via document-aware helpers;
893
+ `undefined` omits the field; a throw omits it fail-closed with a structural
894
+ log (no raw values or secret-bearing text). Sibling virtual values are never
895
+ visible regardless of completion order, and mutating getter input cannot change
896
+ response data or lifecycle snapshots. Related documents finalize before parent
897
+ getters run, and embedded children finalize before their parent getter.
898
+
899
+ Coverage: every document-bearing public model output uses its own model's
900
+ definitions, permissions, selection, and effective access — direct
901
+ list/read/create/update/upsert/`new`-template outputs, Mongoose `populate`
902
+ targets, legacy + correlated `include` targets (including nested includes),
903
+ and embedded-subdocument outputs. Embedded scopes mirror
904
+ `permissionSchema.<field>.sub` (for example `contacts.sub.displayName` with
905
+ `virtuals.contacts.sub.displayName`), support nested arrays/single-nested
906
+ objects, and reuse the owning parent's internally computed grants; dedicated
907
+ `listSub`/`readSub`/`createSub`/`updateSub`/`bulkUpdateSub` routes finalize
908
+ visible rows only (hidden rows never run getters; denied post-save mutation
909
+ rows stay `[]`/`null`). Populate and include targets are trimmed with the
910
+ target model's policy — including registered targets without virtual
911
+ definitions. `select` keeps its arbitrary-string grammar (`?select=name,
912
+ fullAddress`, space/repeated query forms, body arrays/objects, and
913
+ `['-fullAddress']` exclusions all work); `select` on `populate` entries and
914
+ embedded fields follows the same rule.
915
+
916
+ Access matrix (`context.operation` always carries the initiating public
917
+ operation; the three accesses carry effective visibility):
918
+
919
+ | Output path | `virtualAccess` | `outputAccess` | grants source |
920
+ | ------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------- | ------------------------------------ |
921
+ | list | `list` | `list` | `list` |
922
+ | read / readFilter (incl. `read` using `list` fallback: all three become `list`) | `read` (or fallback `list`) | same | same |
923
+ | create / create branch of upsert | `create` | `read` | `create` |
924
+ | update / update branch of upsert | `update` | `read` | `update` |
925
+ | new template | `create` | `create` | `create` |
926
+ | populated target | target's effective `read`/`list` | same target access | same target access |
927
+ | included target (legacy + correlated, incl. nested) | include `op` (`read`/`list`) | same target access | same target access |
928
+ | embedded values inside a model output | owning output's getter access | owning output's field-policy access | owning document's internal grants |
929
+ | listSub / readSub | `list`/`read` | `list`/`read` | owning-parent internal `read` grants |
930
+ | createSub / updateSub / bulkUpdateSub response (existing rows post-save) | initiating `create`/`update`/`update` | `read` | owning-parent internal `read` grants |
931
+
932
+ A create-only getter runs only on create/upsert-create responses; an
933
+ update-only getter only on update/upsert-update responses. On other responses
934
+ the name stays a registered virtual: omitted from output and never entering
935
+ persisted projections, write admission, or sort/filter/distinct handling. A
936
+ read-denied virtual (explicit deny, or document grants fail the rule) never
937
+ runs its getter on any path and fetches nothing for it; a document-dependent
938
+ rule defers to post-fetch evaluation with real grants.
939
+
940
+ Output-only database/write restrictions: registered virtual names for any
941
+ access (including bare `permissionSchema: true` rules and inapplicable
942
+ descriptors) are excluded from persisted projections, client create/update
943
+ admission (including whole-object/array grants and permissive/Mixed schemas),
944
+ and database sort/filter/distinct field authorization at the model-aware
945
+ service boundary. `delete` (identity only), `exists`, `distinct`, `count`, and
946
+ `countTrusted` never run getters. No submitted or computed virtual value is
947
+ persisted.
948
+
949
+ Trusted `decorate`/`decorateAll`/tasks boundary: virtuals evaluate before
950
+ `decorate`/`decorateAll`/tasks; internal-only dependency fields are stripped
951
+ before those hooks see the object. Trusted hooks may deliberately construct new
952
+ output from trusted context; no second authorization pass after those hooks is
953
+ promised.
954
+
955
+ Performance (measured, VIRT-11): prefer authorized finalized populate/include
956
+ data over per-row database calls. On a 24-row list with 2 getters at
957
+ `maxHookConcurrency: 4`, a virtual doing `Address.findById` per document issued
958
+ 25 driver reads (1 list + 24 per-row) with peak active getter work 4; the same
959
+ label via `populate: [{ path: 'addrId', select: ['label'] }]` and a getter
960
+ reading the finalized `doc.addrId` object issued 2 driver reads (batched `$in`)
961
+ with identical output and the same peak bound. `maxHookConcurrency` (default
962
+ `10`) is a per-request/per-runtime ceiling on concurrently awaited getter +
963
+ row-finalization orchestration only — not a per-row multiplier, not leaf
964
+ persistence admission, not a process-wide connection limit; trusted getter
965
+ DB/network I/O runs outside the leaf persistence ceiling. Bounds limit
966
+ simultaneous work, not query counts: they do not collapse N+1 into 1.
967
+ Nested/related/embedded work shares the same bounded scheduling (limit-1
968
+ completes without deadlock; input order is preserved). Parent getters see only
969
+ finalized related/embedded outputs and must not rely on a related model's
970
+ stripped private fields.
971
+
972
+ ### Virtuals compatibility notes
973
+
974
+ These notes describe approved external output/validation behavior; they are
975
+ not silent backward compatibility:
976
+
977
+ - Target-model populate trimming now applies to populated documents,
978
+ including registered targets without virtual definitions (previously only
979
+ query-level `select`/`match` restricted populate output, while includes
980
+ trimmed via target `Service.find`/`findOne`). Populated responses may now
981
+ omit fields that previously leaked (for example `alwaysSelect`-forced
982
+ internal fields). This is an intended behavior change.
983
+ - Selection grammar is unchanged: arbitrary select strings remain accepted
984
+ (no stricter signed-field/path grammar was adopted), so there is no
985
+ malformed-path rejection guarantee and no grammar compatibility break.
986
+ Registered-but-inapplicable virtual names remain virtual.
987
+ - Embedded recursion applies the scope's `permissionSchema.<field>.sub` rules
988
+ with children finalized before parents; database fetches retain only the
989
+ needed containers/dependencies (never `.sub` definition paths, and never
990
+ both a container and its child path in one projection, avoiding path
991
+ collisions). An omitted parent container is never exposed just because a
992
+ child virtual uses it internally.
993
+ - Mutation/new selection behavior is unchanged: effective virtual selection is
994
+ computed before evaluation and carried into the shared finalizer; public
995
+ `_create` picks by explicit `select` after decorate/tasks and `_update`
996
+ picks by explicit `select` (else the implicit `returningAll: false` pick);
997
+ `new()` continues to ignore `args.select` (all applicable `create` virtuals
998
+ are considered, subject to explicit exclusion). Any future honoring of
999
+ `new.args.select` is an explicit contract change, not applied here.
1000
+ - Release notes for this feature are generated from conventional commits via
1001
+ `pnpm changelog` (`repo-toolkit-changelog`) into the workspace
1002
+ `CHANGELOG.md`; that generated file is intentionally not hand-edited in this
1003
+ task (per repository constraint). The compatibility notes above live in this
1004
+ README section as the shipped consumer record.
1005
+
499
1006
  ## Persistence concurrency and resource limits
500
1007
 
501
1008
  `requestComplexity.maxBulkConcurrency` (default `10`) bounds simultaneous awaited