@classytic/arc 2.18.2 → 2.18.4

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.
Files changed (153) hide show
  1. package/dist/{BaseController-DPwe89qs.mjs → BaseController-B-uXzf6I.mjs} +403 -216
  2. package/dist/{QueryCache-8YmAQ-zW.d.mts → QueryCache-Dsyv2mLS.d.mts} +1 -1
  3. package/dist/audit/index.d.mts +1 -1
  4. package/dist/auth/index.d.mts +4 -4
  5. package/dist/auth/index.mjs +4 -4
  6. package/dist/auth/redis-session.d.mts +1 -1
  7. package/dist/{betterAuthOpenApi-ClWxaceA.mjs → betterAuthOpenApi-DAAhMZff.mjs} +1 -1
  8. package/dist/{buildHandler-C_SkBNF3.mjs → buildHandler-Cvrc2p7r.mjs} +1 -1
  9. package/dist/cache/index.d.mts +3 -3
  10. package/dist/cache/index.mjs +3 -3
  11. package/dist/cli/commands/describe.d.mts +1 -1
  12. package/dist/cli/commands/describe.mjs +1 -1
  13. package/dist/cli/commands/docs.mjs +2 -2
  14. package/dist/cli/commands/generate.d.mts +1 -1
  15. package/dist/cli/commands/generate.mjs +1 -1
  16. package/dist/cli/commands/init.d.mts +1 -1
  17. package/dist/cli/commands/init.mjs +1 -1
  18. package/dist/cli/commands/introspect.mjs +1 -1
  19. package/dist/cli/index.d.mts +2 -2
  20. package/dist/cli/index.mjs +2 -2
  21. package/dist/core/index.d.mts +2 -2
  22. package/dist/core/index.mjs +3 -3
  23. package/dist/{core-B41eZxyM.mjs → core-C88uIwd3.mjs} +161 -103
  24. package/dist/{createActionRouter-CRaIwB5S.mjs → createActionRouter-DGCCp-kP.mjs} +4 -2
  25. package/dist/{createAggregationRouter-n3O6WdLO.mjs → createAggregationRouter-Ca77b8iK.mjs} +3 -3
  26. package/dist/{createApp-DZvrb587.mjs → createApp-J1iPXY9p.mjs} +11 -11
  27. package/dist/{defineEvent-NPKUJRt8.mjs → defineEvent-DC981g-o.mjs} +1 -1
  28. package/dist/docs/index.d.mts +2 -2
  29. package/dist/docs/index.mjs +2 -2
  30. package/dist/{elevation-6mfTLBC-.mjs → elevation-BzhyVWfn.mjs} +1 -1
  31. package/dist/{eventPlugin-Dxp9JlKK.mjs → eventPlugin-Bw4bUDEO.mjs} +2 -2
  32. package/dist/{eventPlugin-DjEx_J3j.d.mts → eventPlugin-C1W1CRbf.d.mts} +1 -1
  33. package/dist/events/index.d.mts +8 -5
  34. package/dist/events/index.mjs +26 -7
  35. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  36. package/dist/events/transports/redis-stream-entry.mjs +52 -5
  37. package/dist/events/transports/redis.d.mts +1 -1
  38. package/dist/factory/index.d.mts +1 -1
  39. package/dist/factory/index.mjs +3 -3
  40. package/dist/{fastify-augmentation-CL48B6gW.d.mts → fastify-augmentation-BdCl_eNo.d.mts} +2 -2
  41. package/dist/{fields-DqZLp5d6.d.mts → fields-CKLrhyOb.d.mts} +1 -1
  42. package/dist/{generate-CcM24hoq.mjs → generate-BaxDdmJ8.mjs} +1 -1
  43. package/dist/hooks/index.d.mts +1 -1
  44. package/dist/hooks/index.mjs +1 -1
  45. package/dist/idempotency/index.d.mts +2 -2
  46. package/dist/idempotency/index.mjs +1 -1
  47. package/dist/idempotency/redis.d.mts +1 -1
  48. package/dist/{index-CNiryUMN.d.mts → index-Cvo1u9n6.d.mts} +3 -3
  49. package/dist/{index-C000faom.d.mts → index-QYma8mdC.d.mts} +215 -241
  50. package/dist/{index-3L-adFJO.d.mts → index-vcWUGkwf.d.mts} +3 -3
  51. package/dist/index.d.mts +5 -5
  52. package/dist/index.mjs +5 -5
  53. package/dist/{init-DQ_rY1J7.mjs → init-DSsdRfk9.mjs} +3 -3
  54. package/dist/integrations/event-gateway.d.mts +2 -2
  55. package/dist/integrations/event-gateway.mjs +1 -1
  56. package/dist/integrations/index.d.mts +2 -2
  57. package/dist/integrations/jobs.mjs +66 -1
  58. package/dist/integrations/mcp/index.d.mts +4 -4
  59. package/dist/integrations/mcp/index.mjs +1 -1
  60. package/dist/integrations/mcp/testing.d.mts +1 -1
  61. package/dist/integrations/mcp/testing.mjs +1 -1
  62. package/dist/integrations/streamline.d.mts +16 -1
  63. package/dist/integrations/streamline.mjs +33 -2
  64. package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
  65. package/dist/integrations/websocket-redis.d.mts +1 -1
  66. package/dist/integrations/websocket.d.mts +2 -2
  67. package/dist/{loadResourcesFromEntry-DCjYMgfC.mjs → loadResourcesFromEntry-XHNeUWpu.mjs} +1 -1
  68. package/dist/{mcpPlugin-BrepZ0cV.mjs → mcpPlugin-CzKdt3B8.mjs} +9 -9
  69. package/dist/middleware/index.d.mts +1 -1
  70. package/dist/middleware/index.mjs +1 -1
  71. package/dist/{openapi-CLZx9bwS.mjs → openapi-ButnRyOM.mjs} +5 -5
  72. package/dist/permissions/index.d.mts +2 -2
  73. package/dist/permissions/index.mjs +2 -2
  74. package/dist/{permissions-B1Qy55fg.mjs → permissions-3uzwkbQt.mjs} +99 -99
  75. package/dist/pipeline/index.d.mts +1 -1
  76. package/dist/pipeline/index.mjs +1 -1
  77. package/dist/pipeline-D4sqiokR.mjs +336 -0
  78. package/dist/plugins/index.d.mts +5 -5
  79. package/dist/plugins/index.mjs +9 -9
  80. package/dist/plugins/response-cache.mjs +1 -1
  81. package/dist/plugins/tracing-entry.d.mts +1 -1
  82. package/dist/plugins/tracing-entry.mjs +1 -1
  83. package/dist/presets/filesUpload.d.mts +4 -4
  84. package/dist/presets/filesUpload.mjs +2 -2
  85. package/dist/presets/index.d.mts +1 -1
  86. package/dist/presets/index.mjs +1 -1
  87. package/dist/presets/multiTenant.d.mts +1 -1
  88. package/dist/presets/search.d.mts +2 -2
  89. package/dist/presets/search.mjs +1 -1
  90. package/dist/{presets-BYbPobbF.mjs → presets-CF79RHEo.mjs} +1 -1
  91. package/dist/{queryCachePlugin-Do4P_WBC.d.mts → queryCachePlugin-Bd2tYgh5.d.mts} +2 -2
  92. package/dist/{queryCachePlugin-B4XMSSe7.mjs → queryCachePlugin-CnAeFqju.mjs} +3 -3
  93. package/dist/rateLimit-yKvxWAky.mjs +207 -0
  94. package/dist/{redis-cpqVb85w.d.mts → redis-3hqH_Qk_.d.mts} +1 -1
  95. package/dist/{redis-stream-BuiAS3aE.d.mts → redis-stream-BtEUtjxZ.d.mts} +5 -4
  96. package/dist/registerRoute-BEYn0ylk.mjs +108 -0
  97. package/dist/registry/index.d.mts +1 -1
  98. package/dist/registry/index.mjs +2 -2
  99. package/dist/{schemaIR-Bk6Tns82.mjs → schemaIR-CE6USIRi.mjs} +1 -1
  100. package/dist/scope/index.d.mts +2 -2
  101. package/dist/scope/index.mjs +2 -2
  102. package/dist/{sse-jh5OqFjR.mjs → sse-BrY1W6U8.mjs} +11 -2
  103. package/dist/testing/index.d.mts +2 -2
  104. package/dist/testing/index.mjs +12 -2
  105. package/dist/testing/storageContract.d.mts +1 -1
  106. package/dist/types/index.d.mts +4 -4
  107. package/dist/types/storage.d.mts +1 -1
  108. package/dist/{types-DH-7WO64.d.mts → types-BWBcb88c.d.mts} +1 -1
  109. package/dist/{types-DEEWwQFR.d.mts → types-WVlAVp8T.d.mts} +10 -10
  110. package/dist/utils/index.d.mts +3 -3
  111. package/dist/utils/index.mjs +3 -3
  112. package/dist/{utils-BMQK8mRl.mjs → utils-xAQeDyU_.mjs} +1 -1
  113. package/dist/{validate-DFIP2VrM.mjs → validate-0__MuUsK.mjs} +201 -190
  114. package/dist/{versioning-5H4930tj.d.mts → versioning-BSEDGXKC.d.mts} +1 -1
  115. package/dist/{websocket-BZMUP7Gr.d.mts → websocket-BFBT5rH-.d.mts} +1 -1
  116. package/package.json +5 -5
  117. package/skills/arc/references/api-reference.md +1 -1
  118. package/dist/routerShared-Dl0A77Ba.mjs +0 -724
  119. /package/dist/{EventTransport-ClEDfiMS.mjs → EventTransport-7LPXLEUX.mjs} +0 -0
  120. /package/dist/{EventTransport-B1GFiYUy.d.mts → EventTransport-CaaXM5Y1.d.mts} +0 -0
  121. /package/dist/{HookSystem-Cmf7-Etp.mjs → HookSystem-BfCpWbwM.mjs} +0 -0
  122. /package/dist/{ResourceRegistry-CzSzVK1m.mjs → ResourceRegistry-CWmOggxS.mjs} +0 -0
  123. /package/dist/{actionPermissions-Bjmvn7Eb.mjs → actionPermissions-CX9QIMTd.mjs} +0 -0
  124. /package/dist/{caching-8MfrrUYU.mjs → caching-C0DdKiYB.mjs} +0 -0
  125. /package/dist/{elevation-DvwtjDwu.d.mts → elevation-DW90z5bT.d.mts} +0 -0
  126. /package/dist/{errorHandler-wQejMBT3.mjs → errorHandler-Bua97SEt.mjs} +0 -0
  127. /package/dist/{errorHandler-DuqTqeqn.d.mts → errorHandler-_Lp2k9M3.d.mts} +0 -0
  128. /package/dist/{errors-4ZloYFvm.d.mts → errors-DZOemeGa.d.mts} +0 -0
  129. /package/dist/{externalPaths-ClykOQqa.d.mts → externalPaths-DLle0R07.d.mts} +0 -0
  130. /package/dist/{generate-cRnsafMS.d.mts → generate-Dmk8cfvX.d.mts} +0 -0
  131. /package/dist/{init-D-vrTZ1G.d.mts → init-BS4Daqnv.d.mts} +0 -0
  132. /package/dist/{interface-DoE6UoC2.d.mts → interface-CsrGR8LF.d.mts} +0 -0
  133. /package/dist/{interface-BVkZsvEe.d.mts → interface-DL7LlWS5.d.mts} +0 -0
  134. /package/dist/{keys-DopsCuyQ.mjs → keys-B3yyBEyH.mjs} +0 -0
  135. /package/dist/{loadResources-fol6JuvW.mjs → loadResources-CwXt9MCh.mjs} +0 -0
  136. /package/dist/{memory-QOLe11D5.mjs → memory-TdTHCS9-.mjs} +0 -0
  137. /package/dist/{metrics-B-PU4-Yu.mjs → metrics-Da2EUPi9.mjs} +0 -0
  138. /package/dist/{multipartBody-VjVWmZl8.mjs → multipartBody-709YcXi1.mjs} +0 -0
  139. /package/dist/{pipe-DiCyvyPN.mjs → pipe-9Tl-OAus.mjs} +0 -0
  140. /package/dist/{pluralize-B9M8xvy-.mjs → pluralize-BxdUl2P4.mjs} +0 -0
  141. /package/dist/{preloadResources-BOXzPhKU.mjs → preloadResources-B-j-iqo-.mjs} +0 -0
  142. /package/dist/{pushref-registry-Bq_-EVsC.d.mts → pushref-registry-Dqclzjy7.d.mts} +0 -0
  143. /package/dist/{registry-BrlHkrRS.mjs → registry-Br15xdGM.mjs} +0 -0
  144. /package/dist/{replyHelpers-boS5Rx26.mjs → replyHelpers-DXHVxrrj.mjs} +0 -0
  145. /package/dist/{schemaConverter-De34B1ZG.mjs → schemaConverter-DatAaVNj.mjs} +0 -0
  146. /package/dist/{sessionManager-DlmaHUAl.d.mts → sessionManager-CyHpYXoB.d.mts} +0 -0
  147. /package/dist/{storage-BFmamN5m.d.mts → storage-C5q8fuEt.d.mts} +0 -0
  148. /package/dist/{store-helpers-5JgaxieH.mjs → store-helpers-DjSYI2uP.mjs} +0 -0
  149. /package/dist/{tracing-BYQ3hsbM.d.mts → tracing-Bnb1-YVd.d.mts} +0 -0
  150. /package/dist/{typeGuards-BzkXkvVv.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
  151. /package/dist/{types-CfapW2t8.d.mts → types-BJwJN2Pg.d.mts} +0 -0
  152. /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
  153. /package/dist/{versioning-CuIwUQZ2.mjs → versioning-C0K8KQLx.mjs} +0 -0
@@ -1,10 +1,11 @@
1
- import { o as DEFAULT_TENANT_FIELD } from "./constants-TrJVIJl0.mjs";
1
+ import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-TrJVIJl0.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
3
  import { d as createDomainError, f as createError, i as NotFoundError, r as ForbiddenError } from "./errors-j4aJm1Wg.mjs";
4
- import { b as isElevated, c as getOrgId } from "./types-Bi0r0vjG.mjs";
5
- import { I as simpleEqualityMatcher, b as ArcQueryParser, o as scheduleBackground, t as getUserId } from "./utils-BMQK8mRl.mjs";
6
- import { w as BodySanitizer } from "./routerShared-Dl0A77Ba.mjs";
7
- import { t as buildQueryKey } from "./keys-DopsCuyQ.mjs";
4
+ import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-Bi0r0vjG.mjs";
5
+ import { I as simpleEqualityMatcher, b as ArcQueryParser, o as scheduleBackground, t as getUserId } from "./utils-xAQeDyU_.mjs";
6
+ import { F as applyFieldWritePermissions, L as resolveEffectiveRoles } from "./permissions-3uzwkbQt.mjs";
7
+ import { t as getUserRoles } from "./types-D57iXYb8.mjs";
8
+ import { t as buildQueryKey } from "./keys-B3yyBEyH.mjs";
8
9
  //#region src/core/AccessControl.ts
9
10
  const log = arcLog("access-control");
10
11
  var AccessControl = class {
@@ -241,6 +242,337 @@ var AccessControl = class {
241
242
  }
242
243
  };
243
244
  //#endregion
245
+ //#region src/core/BodySanitizer.ts
246
+ /**
247
+ * BodySanitizer - Composable body sanitization logic extracted from BaseController.
248
+ *
249
+ * Strips readonly fields, system-managed fields, and applies field-level
250
+ * write permissions from request bodies before create/update operations.
251
+ *
252
+ * Designed to be used standalone or composed into controllers.
253
+ */
254
+ const DEFAULT_FIELD_WRITE_DENIAL_POLICY = "reject";
255
+ var BodySanitizer = class {
256
+ schemaOptions;
257
+ onFieldWriteDenied;
258
+ constructor(config) {
259
+ this.schemaOptions = config.schemaOptions;
260
+ this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
261
+ }
262
+ /**
263
+ * Strip readonly and system-managed fields from request body.
264
+ * Prevents clients from overwriting _id, timestamps, __v, etc.
265
+ *
266
+ * Also applies field-level write permissions when the request has
267
+ * field permission metadata.
268
+ */
269
+ sanitize(body, _operation, req, meta) {
270
+ let sanitized = { ...body };
271
+ for (const field of SYSTEM_FIELDS) delete sanitized[field];
272
+ const scopeForRules = req ? (meta ?? req.metadata)?._scope ?? PUBLIC_SCOPE : void 0;
273
+ const scopeIsElevated = scopeForRules ? isElevated(scopeForRules) : false;
274
+ const fieldRules = this.schemaOptions.fieldRules ?? {};
275
+ for (const [field, rules] of Object.entries(fieldRules)) {
276
+ const bypass = Boolean(rules.preserveForElevated) && scopeIsElevated;
277
+ if ((rules.systemManaged || rules.readonly) && !bypass) delete sanitized[field];
278
+ if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate) && !bypass) delete sanitized[field];
279
+ }
280
+ if (req) {
281
+ const arcContext = meta ?? req.metadata;
282
+ const scope = arcContext?._scope ?? PUBLIC_SCOPE;
283
+ if (!isElevated(scope)) {
284
+ const fieldPerms = arcContext?.arc?.fields;
285
+ if (fieldPerms) {
286
+ const effectiveRoles = resolveEffectiveRoles(getUserRoles(req.user), isMember(scope) ? scope.orgRoles : []);
287
+ const { body: filtered, deniedFields } = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
288
+ if (deniedFields.length > 0 && this.onFieldWriteDenied === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
289
+ sanitized = filtered;
290
+ }
291
+ }
292
+ }
293
+ return sanitized;
294
+ }
295
+ };
296
+ //#endregion
297
+ //#region src/core/crud/requestPipeline.ts
298
+ /**
299
+ * Build the canonical repo-options bag from the Fastify request.
300
+ *
301
+ * Forwards the cross-kit canonical set (see repo-core's
302
+ * `STANDARD_REPO_OPTION_KEYS`) into every CRUD repo call so kit
303
+ * plugins (multi-tenant, audit, audit-trail, observability) get
304
+ * what they need without per-resource wiring:
305
+ *
306
+ * - **Tenant scope** — `[tenantField]: orgId` from `RequestScope`.
307
+ * Plugin-scoped repos (mongokit's `multiTenantPlugin`) read tenant
308
+ * scope from the TOP of the options bag, not `data.organizationId`.
309
+ * Without this stamping, a tenant-scoped repo throws "Missing
310
+ * 'organizationId' in context" even when arc has injected the
311
+ * tenant into the request body.
312
+ * Multi-field tenancy from `_tenantFields` (populated by
313
+ * `multiTenantPreset`) is merged in.
314
+ *
315
+ * - **Audit attribution** — `userId` + `user` from the authenticated
316
+ * actor. Mongokit's audit-log / audit-trail plugins read these
317
+ * into the `who` column; sqlitekit's audit plugin reads the same
318
+ * names. No host-side forwarding needed.
319
+ *
320
+ * - **Trace correlation** — `requestId` from Fastify's request id
321
+ * for stitching logs / events / downstream calls, plus `traceId`
322
+ * (the 32-hex W3C trace id, canonical repo-option key since
323
+ * repo-core 0.6) when the requestId plugin validated an incoming
324
+ * `traceparent` header.
325
+ *
326
+ * - **`session` is intentionally NOT auto-set.** Sessions are tied
327
+ * to explicit transaction scopes the controller doesn't manage;
328
+ * pass `session` inline at the call site when running inside a
329
+ * `withTransaction` helper.
330
+ */
331
+ function buildTenantRepoOptions(req, tenantField, arcContext) {
332
+ const cached = req._tenantRepoOptions;
333
+ if (cached) return cached;
334
+ const out = {};
335
+ const scope = arcContext?._scope;
336
+ if (tenantField) {
337
+ const orgId = scope ? getOrgId(scope) : void 0;
338
+ if (orgId) out[tenantField] = orgId;
339
+ }
340
+ const presetFields = req._tenantFields;
341
+ if (presetFields && typeof presetFields === "object") {
342
+ for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
343
+ }
344
+ if (tenantField && out[tenantField] == null && scope && isElevated(scope)) out.bypassTenant = true;
345
+ const userId = getUserId(req.user);
346
+ if (userId) out.userId = userId;
347
+ if (req.user) out.user = req.user;
348
+ const requestId = req.id;
349
+ if (requestId) out.requestId = requestId;
350
+ const traceparent = req.traceContext?.traceparent;
351
+ if (traceparent) {
352
+ const traceId = traceparent.split("-")[1];
353
+ if (traceId) out.traceId = traceId;
354
+ }
355
+ Object.freeze(out);
356
+ req._tenantRepoOptions = out;
357
+ return out;
358
+ }
359
+ /**
360
+ * Resolve the repository primary key for mutation calls.
361
+ *
362
+ * When the resource declares a custom `idField` (slug, jobId, UUID), the
363
+ * default behavior is to translate the route id → the fetched doc's `_id`
364
+ * because most Mongo repositories key mutation methods off `_id`.
365
+ *
366
+ * Exception: if the repo exposes a matching `idField` property (e.g.
367
+ * MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
368
+ * repo handles lookup itself — pass the route id through unchanged.
369
+ */
370
+ function resolveMutationRepoId(id, existing, idField, repository) {
371
+ if (idField === "_id") return id;
372
+ if (!existing) return id;
373
+ const repoIdField = repository.idField;
374
+ if (repoIdField && repoIdField === idField) return id;
375
+ return String(existing["_id"] ?? id);
376
+ }
377
+ /**
378
+ * Build the canonical CRUD 404. Maps the denial reason from
379
+ * `AccessControl.fetchDetailed()` into a `NotFoundError` so consumers can
380
+ * distinguish "doc doesn't exist" from "doc filtered by policy/org scope"
381
+ * via the error `details.code` set by the global error handler.
382
+ */
383
+ function buildNotFoundError(reason, resourceName) {
384
+ const code = reason ?? "NOT_FOUND";
385
+ const err = new NotFoundError(resourceName ?? "Resource");
386
+ err.details = {
387
+ ...err.details ?? {},
388
+ code
389
+ };
390
+ return err;
391
+ }
392
+ /** Resolve cache config for a specific operation, merging per-op overrides. */
393
+ function resolveOpCacheConfig(cfg, operation) {
394
+ if (!cfg || cfg.disabled) return null;
395
+ const opOverride = cfg[operation];
396
+ return {
397
+ staleTime: opOverride?.staleTime ?? cfg.staleTime ?? 0,
398
+ gcTime: opOverride?.gcTime ?? cfg.gcTime ?? 60,
399
+ tags: cfg.tags
400
+ };
401
+ }
402
+ /**
403
+ * Extract user/org IDs from request for cache key scoping.
404
+ * Only includes orgId when the resource uses tenant-scoped data (tenantField is set).
405
+ * Universal resources (tenantField: false) get shared cache keys.
406
+ */
407
+ function deriveCacheScope(req, tenantField, arcContext) {
408
+ return {
409
+ userId: getUserId(req.user),
410
+ orgId: tenantField ? (() => {
411
+ const scope = arcContext?._scope;
412
+ return scope ? getOrgId(scope) : void 0;
413
+ })() : void 0
414
+ };
415
+ }
416
+ /** Shared `x-cache` response envelope builder. */
417
+ function buildCacheEnvelope(data, cacheStatus) {
418
+ return {
419
+ data,
420
+ status: 200,
421
+ headers: { "x-cache": cacheStatus }
422
+ };
423
+ }
424
+ /**
425
+ * Run `executeBefore` then `executeAround` (or just the executor if no
426
+ * hooks are wired). Returns the around-phase result directly. Throws an
427
+ * `ArcError` (status 400, code `BEFORE_<OP>_HOOK_ERROR`) when the
428
+ * before-hook fails — the global error handler emits the canonical
429
+ * `ErrorContract` shape.
430
+ *
431
+ * The caller runs `executeAfter` separately via `executeAfterHook` —
432
+ * typically after success-checking the result (delete checks the repo
433
+ * result, update checks `if (!item)`).
434
+ *
435
+ * **Knobs:**
436
+ * - `meta` — passed verbatim into `executeBefore` / `executeAround` opts.
437
+ * - `pipeProcessedData` (default `true`) — whether `executeBefore`'s
438
+ * return value flows into `executeAround` as the data parameter.
439
+ * Set `false` for delete (current behaviour: discards before's
440
+ * return, passes original input to around).
441
+ */
442
+ async function executeHookedOp(ctx, args, executor) {
443
+ const { hooks, resourceName, arcContext, user } = ctx;
444
+ const hookOpts = {
445
+ user,
446
+ context: arcContext,
447
+ ...args.meta ? { meta: args.meta } : {}
448
+ };
449
+ const pipeProcessed = args.pipeProcessedData !== false;
450
+ let processedData = args.input;
451
+ if (hooks && resourceName) try {
452
+ const beforeReturn = await hooks.executeBefore(resourceName, args.op, args.input, hookOpts);
453
+ if (pipeProcessed) processedData = beforeReturn;
454
+ } catch (err) {
455
+ throw createError(400, "Hook execution failed", {
456
+ code: `BEFORE_${args.op.toUpperCase()}_HOOK_ERROR`,
457
+ message: err.message
458
+ });
459
+ }
460
+ let result;
461
+ if (hooks && resourceName) result = await hooks.executeAround(resourceName, args.op, processedData, () => executor(processedData), hookOpts);
462
+ else result = await executor(processedData);
463
+ return result;
464
+ }
465
+ /**
466
+ * Run `executeAfter` for the given op + data. No-op when hooks aren't
467
+ * wired or `resourceName` isn't set. Caller passes the data shape it
468
+ * wants downstream after-handlers to receive — typically the result for
469
+ * create/update, the original input (`existing`) for delete.
470
+ */
471
+ async function executeAfterHook(ctx, op, data, meta) {
472
+ const { hooks, resourceName, arcContext, user } = ctx;
473
+ if (!hooks || !resourceName) return;
474
+ await hooks.executeAfter(resourceName, op, data, {
475
+ user,
476
+ context: arcContext,
477
+ ...meta ? { meta } : {}
478
+ });
479
+ }
480
+ //#endregion
481
+ //#region src/utils/repoFeature.ts
482
+ /**
483
+ * Repository feature-detection helper for arc mixins.
484
+ *
485
+ * Arc's `RepositoryLike` is the minimum contract every adapter
486
+ * implements (`getById`, `getOne`, `create`, `update`, `delete`,
487
+ * `find`). Beyond that, kits MAY expose specialized methods —
488
+ * `createMany`, `updateMany`, `deleteMany`, `getTree`, `getBySlug`,
489
+ * `getDeleted`, `restore`, etc. — that arc mixins detect at call
490
+ * time and surface as 501 when missing.
491
+ *
492
+ * Every mixin used to spell out its own narrowing cast inline:
493
+ *
494
+ * ```ts
495
+ * const repo = this.repository as unknown as {
496
+ * getTree?: (options?: unknown) => Promise<AnyRecord[]>;
497
+ * };
498
+ * ```
499
+ *
500
+ * Centralizing the cast keeps every feature-detection site reading
501
+ * the same shape. The helper does not intersect with `RepositoryLike`
502
+ * on purpose: some standard methods (`createMany`, `updateMany`) live
503
+ * on `StandardRepo` with kit-generic signatures, and intersecting
504
+ * would force the caller's narrowed signature to merge with the wider
505
+ * default, producing mismatched return types. Declare every method
506
+ * you need (base or feature) in `TFeatures`.
507
+ *
508
+ * @example
509
+ * ```ts
510
+ * const repo = withRepoFeatures<{
511
+ * createMany?: (items: unknown[], options?: unknown) => Promise<AnyRecord[]>;
512
+ * }>(this.repository);
513
+ * if (!repo.createMany) throw createError(501, "...");
514
+ * ```
515
+ */
516
+ function withRepoFeatures(repo) {
517
+ return repo;
518
+ }
519
+ //#endregion
520
+ //#region src/core/crud/resourceVerbs.ts
521
+ const isTruthyFlag = (value) => value !== void 0 && value !== "" && value !== "false" && value !== false;
522
+ /**
523
+ * Match a request query against the resource-dispatch verbs. Returns
524
+ * `null` when the request is a regular list query.
525
+ *
526
+ * Verbs (mutually exclusive — first match wins):
527
+ * - `?_count=true` → `{ kind: 'count' }`
528
+ * - `?_distinct=field` → `{ kind: 'distinct', field }`
529
+ * - `?_exists=true` → `{ kind: 'exists' }`
530
+ */
531
+ function matchResourceVerb(query) {
532
+ if (!query) return null;
533
+ if (isTruthyFlag(query._count)) return { kind: "count" };
534
+ const distinctField = query._distinct;
535
+ if (typeof distinctField === "string" && distinctField.length > 0) return {
536
+ kind: "distinct",
537
+ field: distinctField
538
+ };
539
+ if (isTruthyFlag(query._exists)) return { kind: "exists" };
540
+ return null;
541
+ }
542
+ /**
543
+ * Normalizes `repo.exists()` return shapes across adapters. Per
544
+ * StandardRepo's contract, `exists` may return `boolean`, `{ _id }`,
545
+ * or `null` — every truthy non-null shape collapses to `true`.
546
+ */
547
+ function isExistsResultTruthy(result) {
548
+ return result !== null && result !== false && result !== void 0;
549
+ }
550
+ /** `?_count=true` → `repo.count(filter)` */
551
+ async function runCountVerb(repository, resolveScope) {
552
+ const repo = withRepoFeatures(repository);
553
+ if (typeof repo.count !== "function") throw createError(501, "_count is not supported: the resource's storage adapter does not implement repo.count()");
554
+ const { filter, options } = resolveScope();
555
+ return { count: await repo.count(filter, options) };
556
+ }
557
+ /** `?_distinct=field` → `repo.distinct(field, filter)` */
558
+ async function runDistinctVerb(repository, field, resolveScope) {
559
+ const repo = withRepoFeatures(repository);
560
+ if (typeof repo.distinct !== "function") throw createError(501, "_distinct is not supported: the resource's storage adapter does not implement repo.distinct()");
561
+ const { filter, options } = resolveScope();
562
+ return await repo.distinct(field, filter, options);
563
+ }
564
+ /**
565
+ * `?_exists=true` → `repo.exists(filter)`. Returns the RAW repo result —
566
+ * the controller collapses it via its (overridable) `isExistsTruthy`
567
+ * so subclass overrides of the truthiness rule keep working.
568
+ */
569
+ async function runExistsVerb(repository, resolveScope) {
570
+ const repo = withRepoFeatures(repository);
571
+ if (typeof repo.exists !== "function") throw createError(501, "_exists is not supported: the resource's storage adapter does not implement repo.exists()");
572
+ const { filter, options } = resolveScope();
573
+ return await repo.exists(filter, options);
574
+ }
575
+ //#endregion
244
576
  //#region src/core/fieldRulePredicates.ts
245
577
  /**
246
578
  * True when the field is allowed to appear in client-readable surfaces
@@ -466,6 +798,15 @@ var BaseCrudController = class {
466
798
  _matchesFilter;
467
799
  _presetFields = {};
468
800
  _cacheConfig;
801
+ /**
802
+ * Retained construction-time values so partial `configure()` rebuilds
803
+ * (e.g. `configure({ schemaOptions })`) reuse them instead of silently
804
+ * falling back to the sub-component defaults (`-createdAt` sort,
805
+ * `reject` write-denial) — `defaultSort: false` matters for kits
806
+ * without a `createdAt` column, `strip` for legacy write semantics.
807
+ */
808
+ _defaultSort;
809
+ _onFieldWriteDenied;
469
810
  constructor(repository, options = {}) {
470
811
  this.repository = repository;
471
812
  this.schemaOptions = options.schemaOptions ?? {};
@@ -478,6 +819,8 @@ var BaseCrudController = class {
478
819
  this._matchesFilter = options.matchesFilter;
479
820
  if (options.cache) this._cacheConfig = options.cache;
480
821
  if (options.presetFields) this._presetFields = options.presetFields;
822
+ this._defaultSort = options.defaultSort;
823
+ this._onFieldWriteDenied = options.onFieldWriteDenied;
481
824
  this.accessControl = new AccessControl({
482
825
  tenantField: this.tenantField,
483
826
  idField: this.idField,
@@ -485,13 +828,13 @@ var BaseCrudController = class {
485
828
  });
486
829
  this.bodySanitizer = new BodySanitizer({
487
830
  schemaOptions: this.schemaOptions,
488
- onFieldWriteDenied: options.onFieldWriteDenied
831
+ onFieldWriteDenied: this._onFieldWriteDenied
489
832
  });
490
833
  this.queryResolver = new QueryResolver({
491
834
  queryParser: this.queryParser,
492
835
  maxLimit: this.maxLimit,
493
836
  defaultLimit: this.defaultLimit,
494
- defaultSort: options.defaultSort,
837
+ defaultSort: this._defaultSort,
495
838
  schemaOptions: this.schemaOptions,
496
839
  tenantField: this.tenantField
497
840
  });
@@ -547,8 +890,6 @@ var BaseCrudController = class {
547
890
  let rebuildAccessControl = false;
548
891
  let rebuildBodySanitizer = false;
549
892
  let rebuildQueryResolver = false;
550
- let bodySanitizerOnFieldWriteDenied;
551
- let resolverDefaultSort;
552
893
  if (options.tenantField !== void 0) {
553
894
  this.tenantField = options.tenantField;
554
895
  rebuildAccessControl = true;
@@ -568,7 +909,7 @@ var BaseCrudController = class {
568
909
  rebuildQueryResolver = true;
569
910
  }
570
911
  if (options.onFieldWriteDenied !== void 0) {
571
- bodySanitizerOnFieldWriteDenied = options.onFieldWriteDenied;
912
+ this._onFieldWriteDenied = options.onFieldWriteDenied;
572
913
  rebuildBodySanitizer = true;
573
914
  }
574
915
  if (options.queryParser !== void 0) this.setQueryParser(options.queryParser);
@@ -581,7 +922,7 @@ var BaseCrudController = class {
581
922
  rebuildQueryResolver = true;
582
923
  }
583
924
  if (options.defaultSort !== void 0) {
584
- resolverDefaultSort = options.defaultSort;
925
+ this._defaultSort = options.defaultSort;
585
926
  rebuildQueryResolver = true;
586
927
  }
587
928
  if (options.cache !== void 0) this._cacheConfig = options.cache;
@@ -593,13 +934,13 @@ var BaseCrudController = class {
593
934
  });
594
935
  if (rebuildBodySanitizer) this.bodySanitizer = new BodySanitizer({
595
936
  schemaOptions: this.schemaOptions,
596
- onFieldWriteDenied: bodySanitizerOnFieldWriteDenied
937
+ onFieldWriteDenied: this._onFieldWriteDenied
597
938
  });
598
939
  if (rebuildQueryResolver) this.queryResolver = new QueryResolver({
599
940
  queryParser: this.queryParser,
600
941
  maxLimit: this.maxLimit,
601
942
  defaultLimit: this.defaultLimit,
602
- defaultSort: resolverDefaultSort,
943
+ defaultSort: this._defaultSort,
603
944
  schemaOptions: this.schemaOptions,
604
945
  tenantField: this.tenantField
605
946
  });
@@ -612,62 +953,20 @@ var BaseCrudController = class {
612
953
  return this.tenantField || void 0;
613
954
  }
614
955
  /**
615
- * Build the canonical repo-options bag from the Fastify request.
616
- *
617
- * Forwards the cross-kit canonical set (see repo-core's
618
- * `STANDARD_REPO_OPTION_KEYS`) into every CRUD repo call so kit
619
- * plugins (multi-tenant, audit, audit-trail, observability) get
620
- * what they need without per-resource wiring:
621
- *
622
- * - **Tenant scope** — `[tenantField]: orgId` from `RequestScope`.
623
- * Plugin-scoped repos (mongokit's `multiTenantPlugin`) read tenant
624
- * scope from the TOP of the options bag, not `data.organizationId`.
625
- * Without this stamping, a tenant-scoped repo throws "Missing
626
- * 'organizationId' in context" even when arc has injected the
627
- * tenant into the request body.
628
- * Multi-field tenancy from `_tenantFields` (populated by
629
- * `multiTenantPreset`) is merged in.
630
- *
631
- * - **Audit attribution** — `userId` + `user` from the authenticated
632
- * actor. Mongokit's audit-log / audit-trail plugins read these
633
- * into the `who` column; sqlitekit's audit plugin reads the same
634
- * names. No host-side forwarding needed.
635
- *
636
- * - **Trace correlation** — `requestId` from Fastify's request id
637
- * for stitching logs / events / downstream calls.
638
- *
639
- * - **`session` is intentionally NOT auto-set.** Sessions are tied
640
- * to explicit transaction scopes the controller doesn't manage;
641
- * pass `session` inline at the call site when running inside a
642
- * `withTransaction` helper.
956
+ * Build the canonical repo-options bag (tenant scope, audit
957
+ * attribution, trace correlation) from the Fastify request. Full
958
+ * contract + rationale on `buildTenantRepoOptions` in
959
+ * `crud/requestPipeline.ts` — this delegate exists so host overrides
960
+ * and duck-typed consumers (aggregation router, MCP bridge) keep the
961
+ * same `protected` entry point.
643
962
  *
644
963
  * Method kept named `tenantRepoOptions` for back-compat with hosts
645
964
  * that spread `...this.tenantRepoOptions(req)` (10+ call sites in
646
- * arc, plus host overrides). The bag has always grown over time —
965
+ * arc, plus host overrides). The bag has always grown over time —
647
966
  * hosts that don't want audit forwarding never read those keys.
648
967
  */
649
968
  tenantRepoOptions(req) {
650
- const cached = req._tenantRepoOptions;
651
- if (cached) return cached;
652
- const out = {};
653
- const scope = this.meta(req)?._scope;
654
- if (this.tenantField) {
655
- const orgId = scope ? getOrgId(scope) : void 0;
656
- if (orgId) out[this.tenantField] = orgId;
657
- }
658
- const presetFields = req._tenantFields;
659
- if (presetFields && typeof presetFields === "object") {
660
- for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
661
- }
662
- if (this.tenantField && out[this.tenantField] == null && scope && isElevated(scope)) out.bypassTenant = true;
663
- const userId = getUserId(req.user);
664
- if (userId) out.userId = userId;
665
- if (req.user) out.user = req.user;
666
- const requestId = req.id;
667
- if (requestId) out.requestId = requestId;
668
- Object.freeze(out);
669
- req._tenantRepoOptions = out;
670
- return out;
969
+ return buildTenantRepoOptions(req, this.tenantField, this.meta(req));
671
970
  }
672
971
  /** Extract typed Arc internal metadata from request */
673
972
  meta(req) {
@@ -678,22 +977,13 @@ var BaseCrudController = class {
678
977
  return this.meta(req)?.arc?.hooks ?? null;
679
978
  }
680
979
  /**
681
- * Resolve the repository primary key for mutation calls.
682
- *
683
- * When the resource declares a custom `idField` (slug, jobId, UUID), the
684
- * default behavior is to translate the route id → the fetched doc's `_id`
685
- * because most Mongo repositories key mutation methods off `_id`.
686
- *
687
- * Exception: if the repo exposes a matching `idField` property (e.g.
688
- * MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
689
- * repo handles lookup itself — pass the route id through unchanged.
980
+ * Resolve the repository primary key for mutation calls. Translates a
981
+ * custom route `idField` (slug, jobId, UUID) to the fetched doc's `_id`
982
+ * unless the repo declares a matching `idField` of its own — see
983
+ * `resolveMutationRepoId` in `crud/requestPipeline.ts`.
690
984
  */
691
985
  resolveRepoId(id, existing) {
692
- if (this.idField === "_id") return id;
693
- if (!existing) return id;
694
- const repoIdField = this.repository.idField;
695
- if (repoIdField && repoIdField === this.idField) return id;
696
- return String(existing["_id"] ?? id);
986
+ return resolveMutationRepoId(id, existing, this.idField, this.repository);
697
987
  }
698
988
  /**
699
989
  * Read-side preflight for mutable-target operations (`update`, `delete`).
@@ -732,24 +1022,11 @@ var BaseCrudController = class {
732
1022
  * `details.code` set by the global error handler.
733
1023
  */
734
1024
  throwNotFound(reason = "NOT_FOUND") {
735
- const code = reason ?? "NOT_FOUND";
736
- const err = new NotFoundError(this.resourceName ?? "Resource");
737
- err.details = {
738
- ...err.details ?? {},
739
- code
740
- };
741
- throw err;
1025
+ throw buildNotFoundError(reason, this.resourceName);
742
1026
  }
743
1027
  /** Resolve cache config for a specific operation, merging per-op overrides */
744
1028
  resolveCacheConfig(operation) {
745
- const cfg = this._cacheConfig;
746
- if (!cfg || cfg.disabled) return null;
747
- const opOverride = cfg[operation];
748
- return {
749
- staleTime: opOverride?.staleTime ?? cfg.staleTime ?? 0,
750
- gcTime: opOverride?.gcTime ?? cfg.gcTime ?? 60,
751
- tags: cfg.tags
752
- };
1029
+ return resolveOpCacheConfig(this._cacheConfig, operation);
753
1030
  }
754
1031
  /**
755
1032
  * Extract user/org IDs from request for cache key scoping.
@@ -757,21 +1034,11 @@ var BaseCrudController = class {
757
1034
  * Universal resources (tenantField: false) get shared cache keys.
758
1035
  */
759
1036
  cacheScope(req) {
760
- return {
761
- userId: getUserId(req.user),
762
- orgId: this.tenantField ? (() => {
763
- const scope = this.meta(req)?._scope;
764
- return scope ? getOrgId(scope) : void 0;
765
- })() : void 0
766
- };
1037
+ return deriveCacheScope(req, this.tenantField, this.meta(req));
767
1038
  }
768
1039
  /** Shared `x-cache` response envelope builder. */
769
1040
  cacheResponse(data, cacheStatus) {
770
- return {
771
- data,
772
- status: 200,
773
- headers: { "x-cache": cacheStatus }
774
- };
1041
+ return buildCacheEnvelope(data, cacheStatus);
775
1042
  }
776
1043
  /** Required route-id helper shared by get/update/delete. Throws on missing id. */
777
1044
  requireIdParam(req) {
@@ -785,48 +1052,26 @@ var BaseCrudController = class {
785
1052
  * or `null` — every truthy non-null shape collapses to `true`.
786
1053
  */
787
1054
  isExistsTruthy(result) {
788
- return result !== null && result !== false && result !== void 0;
1055
+ return isExistsResultTruthy(result);
1056
+ }
1057
+ /** Assemble the per-request hook context from the protected accessors. */
1058
+ hookOpContext(req) {
1059
+ return {
1060
+ hooks: this.getHooks(req),
1061
+ resourceName: this.resourceName,
1062
+ arcContext: this.meta(req),
1063
+ user: req.user
1064
+ };
789
1065
  }
790
1066
  /**
791
1067
  * Run `executeBefore` then `executeAround` (or just the executor if no
792
1068
  * hooks are wired). Returns the around-phase result directly. Throws an
793
1069
  * `ArcError` (status 400, code `BEFORE_<OP>_HOOK_ERROR`) when the
794
- * before-hook fails — the global error handler emits the canonical
795
- * `ErrorContract` shape.
796
- *
797
- * The caller runs `executeAfter` separately via `runAfterHook` — typically
798
- * after success-checking the result (delete checks `isDeleteSuccess`,
799
- * update checks `if (!item)`).
800
- *
801
- * **Knobs:**
802
- * - `meta` — passed verbatim into `executeBefore` / `executeAround` opts.
803
- * - `pipeProcessedData` (default `true`) — whether `executeBefore`'s
804
- * return value flows into `executeAround` as the data parameter.
805
- * Set `false` for delete (current behaviour: discards before's
806
- * return, passes original input to around).
1070
+ * before-hook fails. Full contract on `executeHookedOp` in
1071
+ * `crud/requestPipeline.ts`.
807
1072
  */
808
1073
  async runHookedOpUntilResult(req, args, executor) {
809
- const hooks = this.getHooks(req);
810
- const hookOpts = {
811
- user: req.user,
812
- context: this.meta(req),
813
- ...args.meta ? { meta: args.meta } : {}
814
- };
815
- const pipeProcessed = args.pipeProcessedData !== false;
816
- let processedData = args.input;
817
- if (hooks && this.resourceName) try {
818
- const beforeReturn = await hooks.executeBefore(this.resourceName, args.op, args.input, hookOpts);
819
- if (pipeProcessed) processedData = beforeReturn;
820
- } catch (err) {
821
- throw createError(400, "Hook execution failed", {
822
- code: `BEFORE_${args.op.toUpperCase()}_HOOK_ERROR`,
823
- message: err.message
824
- });
825
- }
826
- let result;
827
- if (hooks && this.resourceName) result = await hooks.executeAround(this.resourceName, args.op, processedData, () => executor(processedData), hookOpts);
828
- else result = await executor(processedData);
829
- return result;
1074
+ return executeHookedOp(this.hookOpContext(req), args, executor);
830
1075
  }
831
1076
  /**
832
1077
  * Run `executeAfter` for the given op + data. No-op when hooks aren't
@@ -835,15 +1080,7 @@ var BaseCrudController = class {
835
1080
  * create/update, the original input (`existing`) for delete.
836
1081
  */
837
1082
  async runAfterHook(req, op, data, meta) {
838
- const hooks = this.getHooks(req);
839
- if (!hooks || !this.resourceName) return;
840
- const user = req.user;
841
- const arcContext = this.meta(req);
842
- await hooks.executeAfter(this.resourceName, op, data, {
843
- user,
844
- context: arcContext,
845
- ...meta ? { meta } : {}
846
- });
1083
+ return executeAfterHook(this.hookOpContext(req), op, data, meta);
847
1084
  }
848
1085
  /** Cached `list()` flow with SWR semantics. Returns null when cache is disabled. */
849
1086
  async withListCache(req, options) {
@@ -908,25 +1145,23 @@ var BaseCrudController = class {
908
1145
  * Resource-dispatch verbs router. Returns `null` when the request is
909
1146
  * a regular list query, otherwise returns the dispatch promise.
910
1147
  *
911
- * Verbs (mutually exclusive — first match wins):
912
- * - `?_count=true` → `{ count: number }` via `repo.count()`
913
- * - `?_distinct=field` → `unknown[]` via `repo.distinct(field)`
914
- * - `?_exists=true` → `{ exists: boolean }` via `repo.exists()`
1148
+ * Verbs (mutually exclusive — first match wins):
1149
+ * - `?_count=true` → `{ count: number }` via `repo.count()`
1150
+ * - `?_distinct=field` → `unknown[]` via `repo.distinct(field)`
1151
+ * - `?_exists=true` → `{ exists: boolean }` via `repo.exists()`
915
1152
  *
916
1153
  * All verbs share the resolved filter (parsed query + policy filters
917
1154
  * + tenant scope). Adapters that don't ship the underlying repo
918
1155
  * method get a `501` so failures surface loudly instead of falling
919
- * back to a full table scan.
1156
+ * back to a full table scan. Matching + repo invocation live in
1157
+ * `crud/resourceVerbs.ts`.
920
1158
  */
921
1159
  dispatchResourceVerb(req) {
922
- const query = req.query;
923
- if (!query) return null;
924
- const isTruthyFlag = (value) => value !== void 0 && value !== "" && value !== "false" && value !== false;
925
- if (isTruthyFlag(query._count)) return this.dispatchCount(req);
926
- const distinctField = query._distinct;
927
- if (typeof distinctField === "string" && distinctField.length > 0) return this.dispatchDistinct(req, distinctField);
928
- if (isTruthyFlag(query._exists)) return this.dispatchExists(req);
929
- return null;
1160
+ const verb = matchResourceVerb(req.query);
1161
+ if (!verb) return null;
1162
+ if (verb.kind === "count") return this.dispatchCount(req);
1163
+ if (verb.kind === "distinct") return this.dispatchDistinct(req, verb.field);
1164
+ return this.dispatchExists(req);
930
1165
  }
931
1166
  /** Resolve filter + tenant/audit options for a dispatch verb. */
932
1167
  resolveDispatchScope(req) {
@@ -935,33 +1170,24 @@ var BaseCrudController = class {
935
1170
  options: this.tenantRepoOptions(req)
936
1171
  };
937
1172
  }
938
- /** `?_count=true` → `repo.count(filter)` */
1173
+ /** `?_count=true` → `repo.count(filter)` */
939
1174
  async dispatchCount(req) {
940
- const repo = this.repository;
941
- if (typeof repo.count !== "function") throw createError(501, "_count is not supported: the resource's storage adapter does not implement repo.count()");
942
- const { filter, options } = this.resolveDispatchScope(req);
943
1175
  return {
944
- data: { count: await repo.count(filter, options) },
1176
+ data: await runCountVerb(this.repository, () => this.resolveDispatchScope(req)),
945
1177
  status: 200
946
1178
  };
947
1179
  }
948
- /** `?_distinct=field` → `repo.distinct(field, filter)` */
1180
+ /** `?_distinct=field` → `repo.distinct(field, filter)` */
949
1181
  async dispatchDistinct(req, field) {
950
1182
  if (!this.isFieldExposedForRead(field)) throw createError(400, `_distinct field "${field}" is not allowed (hidden or system-managed)`);
951
- const repo = this.repository;
952
- if (typeof repo.distinct !== "function") throw createError(501, "_distinct is not supported: the resource's storage adapter does not implement repo.distinct()");
953
- const { filter, options } = this.resolveDispatchScope(req);
954
1183
  return {
955
- data: await repo.distinct(field, filter, options),
1184
+ data: await runDistinctVerb(this.repository, field, () => this.resolveDispatchScope(req)),
956
1185
  status: 200
957
1186
  };
958
1187
  }
959
- /** `?_exists=true` → `repo.exists(filter)` */
1188
+ /** `?_exists=true` → `repo.exists(filter)` */
960
1189
  async dispatchExists(req) {
961
- const repo = this.repository;
962
- if (typeof repo.exists !== "function") throw createError(501, "_exists is not supported: the resource's storage adapter does not implement repo.exists()");
963
- const { filter, options } = this.resolveDispatchScope(req);
964
- const result = await repo.exists(filter, options);
1190
+ const result = await runExistsVerb(this.repository, () => this.resolveDispatchScope(req));
965
1191
  return {
966
1192
  data: { exists: this.isExistsTruthy(result) },
967
1193
  status: 200
@@ -1109,45 +1335,6 @@ var BaseCrudController = class {
1109
1335
  }
1110
1336
  };
1111
1337
  //#endregion
1112
- //#region src/utils/repoFeature.ts
1113
- /**
1114
- * Repository feature-detection helper for arc mixins.
1115
- *
1116
- * Arc's `RepositoryLike` is the minimum contract every adapter
1117
- * implements (`getById`, `getOne`, `create`, `update`, `delete`,
1118
- * `find`). Beyond that, kits MAY expose specialized methods —
1119
- * `createMany`, `updateMany`, `deleteMany`, `getTree`, `getBySlug`,
1120
- * `getDeleted`, `restore`, etc. — that arc mixins detect at call
1121
- * time and surface as 501 when missing.
1122
- *
1123
- * Every mixin used to spell out its own narrowing cast inline:
1124
- *
1125
- * ```ts
1126
- * const repo = this.repository as unknown as {
1127
- * getTree?: (options?: unknown) => Promise<AnyRecord[]>;
1128
- * };
1129
- * ```
1130
- *
1131
- * Centralizing the cast keeps every feature-detection site reading
1132
- * the same shape. The helper does not intersect with `RepositoryLike`
1133
- * on purpose: some standard methods (`createMany`, `updateMany`) live
1134
- * on `StandardRepo` with kit-generic signatures, and intersecting
1135
- * would force the caller's narrowed signature to merge with the wider
1136
- * default, producing mismatched return types. Declare every method
1137
- * you need (base or feature) in `TFeatures`.
1138
- *
1139
- * @example
1140
- * ```ts
1141
- * const repo = withRepoFeatures<{
1142
- * createMany?: (items: unknown[], options?: unknown) => Promise<AnyRecord[]>;
1143
- * }>(this.repository);
1144
- * if (!repo.createMany) throw createError(501, "...");
1145
- * ```
1146
- */
1147
- function withRepoFeatures(repo) {
1148
- return repo;
1149
- }
1150
- //#endregion
1151
1338
  //#region src/core/mixins/bulk.ts
1152
1339
  /**
1153
1340
  * BulkMixin — `bulkCreate` / `bulkUpdate` / `bulkDelete` endpoints.
@@ -1489,4 +1676,4 @@ function TreeMixin(Base) {
1489
1676
  */
1490
1677
  var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
1491
1678
  //#endregion
1492
- export { BulkMixin as a, collectReadBlockedFields as c, SlugMixin as i, isFieldReadable as l, TreeMixin as n, BaseCrudController as o, SoftDeleteMixin as r, QueryResolver as s, BaseController as t, AccessControl as u };
1679
+ export { BulkMixin as a, collectReadBlockedFields as c, DEFAULT_FIELD_WRITE_DENIAL_POLICY as d, AccessControl as f, SlugMixin as i, isFieldReadable as l, TreeMixin as n, BaseCrudController as o, SoftDeleteMixin as r, QueryResolver as s, BaseController as t, BodySanitizer as u };