@classytic/arc 2.11.4 → 2.13.1

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 (166) hide show
  1. package/README.md +16 -12
  2. package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
  3. package/dist/EventTransport-CT_52aWU.d.mts +34 -0
  4. package/dist/EventTransport-DLWoUMHy.mjs +103 -0
  5. package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
  6. package/dist/audit/index.d.mts +2 -2
  7. package/dist/audit/index.mjs +1 -1
  8. package/dist/auth/audit.d.mts +199 -0
  9. package/dist/auth/audit.mjs +288 -0
  10. package/dist/auth/index.d.mts +3 -3
  11. package/dist/auth/index.mjs +117 -191
  12. package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
  13. package/dist/buildHandler-olo-gt94.mjs +610 -0
  14. package/dist/cache/index.mjs +3 -3
  15. package/dist/cli/commands/describe.d.mts +89 -13
  16. package/dist/cli/commands/describe.mjs +56 -2
  17. package/dist/cli/commands/docs.mjs +2 -2
  18. package/dist/cli/commands/generate.mjs +147 -48
  19. package/dist/cli/commands/init.d.mts +13 -0
  20. package/dist/cli/commands/init.mjs +130 -87
  21. package/dist/cli/commands/introspect.mjs +8 -1
  22. package/dist/context/index.mjs +1 -1
  23. package/dist/core/index.d.mts +3 -3
  24. package/dist/core/index.mjs +5 -5
  25. package/dist/core-D72ia0EH.mjs +1399 -0
  26. package/dist/{createActionRouter-CIKOcNA7.mjs → createActionRouter-CEvzKcy8.mjs} +7 -20
  27. package/dist/createAggregationRouter-CyecOxnO.mjs +114 -0
  28. package/dist/{createApp-C9bRrqlX.mjs → createApp-XX2-N0Yd.mjs} +28 -22
  29. package/dist/{defineEvent-D1Ky9M1D.mjs → defineEvent-D5h7EvAx.mjs} +1 -1
  30. package/dist/docs/index.d.mts +1 -1
  31. package/dist/docs/index.mjs +2 -2
  32. package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
  33. package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
  34. package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
  35. package/dist/errors-j4aJm1Wg.mjs +184 -0
  36. package/dist/{eventPlugin-Cts2-Tfj.mjs → eventPlugin-CaKTYkYM.mjs} +28 -4
  37. package/dist/{eventPlugin-DDJoNEPL.d.mts → eventPlugin-qXpqTebY.d.mts} +24 -1
  38. package/dist/events/index.d.mts +6 -6
  39. package/dist/events/index.mjs +11 -35
  40. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  41. package/dist/events/transports/redis.d.mts +1 -1
  42. package/dist/factory/index.d.mts +2 -2
  43. package/dist/factory/index.mjs +2 -2
  44. package/dist/{fields-BRjxOAFp.d.mts → fields-COhcH3fk.d.mts} +23 -2
  45. package/dist/hooks/index.d.mts +1 -1
  46. package/dist/hooks/index.mjs +1 -1
  47. package/dist/idempotency/index.d.mts +1 -1
  48. package/dist/idempotency/index.mjs +1 -20
  49. package/dist/idempotency/redis.mjs +1 -1
  50. package/dist/{index-rHjXmJar.d.mts → index-BTqLEvhu.d.mts} +163 -3
  51. package/dist/{index-CXXRbnf8.d.mts → index-BtW7qYwa.d.mts} +660 -326
  52. package/dist/{index-m8mOOlFW.d.mts → index-Ds61mrJE.d.mts} +50 -4
  53. package/dist/{index-D9t1KNaB.d.mts → index-Dz5IKsrE.d.mts} +360 -219
  54. package/dist/index.d.mts +6 -7
  55. package/dist/index.mjs +9 -10
  56. package/dist/integrations/event-gateway.d.mts +1 -1
  57. package/dist/integrations/event-gateway.mjs +1 -1
  58. package/dist/integrations/index.d.mts +1 -1
  59. package/dist/integrations/mcp/index.d.mts +2 -2
  60. package/dist/integrations/mcp/index.mjs +1 -1
  61. package/dist/integrations/mcp/testing.d.mts +1 -1
  62. package/dist/integrations/mcp/testing.mjs +1 -1
  63. package/dist/integrations/streamline.d.mts +60 -11
  64. package/dist/integrations/streamline.mjs +75 -85
  65. package/dist/integrations/websocket.mjs +2 -8
  66. package/dist/middleware/index.d.mts +1 -1
  67. package/dist/middleware/index.mjs +2 -2
  68. package/dist/migrations/index.d.mts +23 -3
  69. package/dist/migrations/index.mjs +0 -7
  70. package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
  71. package/dist/{openapi-D7G1V7ex.mjs → openapi-CiOMVW1p.mjs} +143 -13
  72. package/dist/org/index.d.mts +2 -2
  73. package/dist/org/index.mjs +1 -1
  74. package/dist/permissions/index.d.mts +3 -3
  75. package/dist/permissions/index.mjs +3 -3
  76. package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
  77. package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
  78. package/dist/pipeline/index.d.mts +1 -1
  79. package/dist/pipeline/index.mjs +1 -1
  80. package/dist/plugins/index.d.mts +16 -31
  81. package/dist/plugins/index.mjs +33 -13
  82. package/dist/plugins/response-cache.mjs +1 -1
  83. package/dist/plugins/tracing-entry.mjs +1 -1
  84. package/dist/presets/filesUpload.d.mts +4 -4
  85. package/dist/presets/filesUpload.mjs +6 -9
  86. package/dist/presets/index.d.mts +1 -1
  87. package/dist/presets/index.mjs +1 -1
  88. package/dist/presets/multiTenant.d.mts +1 -1
  89. package/dist/presets/multiTenant.mjs +2 -2
  90. package/dist/presets/search.d.mts +2 -2
  91. package/dist/presets/search.mjs +6 -8
  92. package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
  93. package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
  94. package/dist/{redis-stream-xTGxB2bm.d.mts → redis-stream-D6HzR1Z_.d.mts} +1 -1
  95. package/dist/registry/index.d.mts +1 -1
  96. package/dist/registry/index.mjs +2 -2
  97. package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
  98. package/dist/{resourceToTools-CxNmI6xF.mjs → resourceToTools-C5coh64w.mjs} +224 -71
  99. package/dist/{routerShared-BqLRb5l7.mjs → routerShared-D6_fEGHh.mjs} +40 -36
  100. package/dist/{schemaIR-Dy2p4MxS.mjs → schemaIR-7Vl611Qs.mjs} +1 -1
  101. package/dist/schemas/index.d.mts +100 -30
  102. package/dist/schemas/index.mjs +86 -29
  103. package/dist/scim/index.d.mts +264 -0
  104. package/dist/scim/index.mjs +963 -0
  105. package/dist/scope/index.d.mts +3 -3
  106. package/dist/scope/index.mjs +4 -4
  107. package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
  108. package/dist/{store-helpers-Cp4uKC1U.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
  109. package/dist/testing/index.d.mts +2 -8
  110. package/dist/testing/index.mjs +16 -24
  111. package/dist/types/index.d.mts +4 -4
  112. package/dist/{types-D7KpfiL1.d.mts → types-BvqwCCSx.d.mts} +73 -25
  113. package/dist/{types-DDyTPc6y.d.mts → types-CTYvcwHe.d.mts} +195 -1
  114. package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
  115. package/dist/{types-BQ9TJQNy.d.mts → types-DQHFc8PM.d.mts} +1 -1
  116. package/dist/utils/index.d.mts +2 -2
  117. package/dist/utils/index.mjs +5 -5
  118. package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
  119. package/dist/{versioning-DsglKfM_.d.mts → versioning-DTTvc80y.d.mts} +1 -1
  120. package/package.json +24 -34
  121. package/skills/arc/SKILL.md +147 -51
  122. package/skills/arc/references/agent-auth.md +238 -0
  123. package/skills/arc/references/api-reference.md +187 -0
  124. package/skills/arc/references/auth.md +354 -7
  125. package/skills/arc/references/enterprise-auth.md +94 -0
  126. package/skills/arc/references/events.md +8 -6
  127. package/skills/arc/references/mcp.md +2 -2
  128. package/skills/arc/references/multi-tenancy.md +11 -2
  129. package/skills/arc/references/production.md +10 -9
  130. package/skills/arc/references/scim.md +247 -0
  131. package/skills/arc/references/testing.md +1 -1
  132. package/skills/arc-code-review/SKILL.md +141 -0
  133. package/skills/arc-code-review/references/anti-patterns.md +911 -0
  134. package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
  135. package/skills/arc-code-review/references/migration-recipes.md +700 -0
  136. package/skills/arc-code-review/references/mongokit-migration.md +386 -0
  137. package/skills/arc-code-review/references/scaffolding.md +230 -0
  138. package/skills/arc-code-review/references/severity.md +127 -0
  139. package/dist/EventTransport-BFQjw9pB.mjs +0 -133
  140. package/dist/EventTransport-CYNUXdCJ.d.mts +0 -293
  141. package/dist/adapters/index.d.mts +0 -3
  142. package/dist/adapters/index.mjs +0 -2
  143. package/dist/adapters-DUUiiimH.mjs +0 -964
  144. package/dist/auth/mongoose.d.mts +0 -191
  145. package/dist/auth/mongoose.mjs +0 -73
  146. package/dist/core-CbcQRIch.mjs +0 -1054
  147. package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
  148. package/dist/errorHandler-DEWmGWPz.d.mts +0 -114
  149. package/dist/errors-D5c-5BJL.mjs +0 -232
  150. package/dist/index-Rg8axYPz.d.mts +0 -370
  151. /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
  152. /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
  153. /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
  154. /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
  155. /package/dist/{elevation-BQQXZ_VR.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
  156. /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
  157. /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
  158. /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
  159. /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
  160. /package/dist/{pluralize-CWP6MB39.mjs → pluralize-DQgqgifU.mjs} +0 -0
  161. /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
  162. /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
  163. /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
  164. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
  165. /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
  166. /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
@@ -1,11 +1,11 @@
1
- import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-BhY1OHoH.mjs";
1
+ import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-Cxde4rpC.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
- import { _ as isElevated, n as PUBLIC_SCOPE, o as getOrgId, v as isMember } from "./types-AOD8fxIw.mjs";
4
- import { M as simpleEqualityMatcher, j as getUserId, k as ArcQueryParser } from "./utils-CcYTj09l.mjs";
5
- import { t as buildQueryKey } from "./keys-CARyUjiR.mjs";
6
- import { M as applyFieldWritePermissions, P as resolveEffectiveRoles } from "./permissions-gd_aUWrR.mjs";
7
- import { t as getUserRoles } from "./types-DV9WDfeg.mjs";
8
- import { r as ForbiddenError } from "./errors-D5c-5BJL.mjs";
3
+ import { d as createDomainError, f as createError, i as NotFoundError, r as ForbiddenError } from "./errors-j4aJm1Wg.mjs";
4
+ import { b as isMember, c as getOrgId, n as PUBLIC_SCOPE, y as isElevated } from "./types-C_s5moIu.mjs";
5
+ import { N as simpleEqualityMatcher, _ as ArcQueryParser, r as scheduleBackground, t as getUserId } from "./utils-_h9B3c57.mjs";
6
+ import { d as applyFieldWritePermissions, p as resolveEffectiveRoles } from "./permissions-ohQyv50e.mjs";
7
+ import { t as getUserRoles } from "./types-D57iXYb8.mjs";
8
+ import { t as buildQueryKey } from "./keys-CGcCbNyu.mjs";
9
9
  //#region src/core/AccessControl.ts
10
10
  const log = arcLog("access-control");
11
11
  var AccessControl = class {
@@ -195,7 +195,10 @@ var AccessControl = class {
195
195
  };
196
196
  }
197
197
  if (this.idField !== "_id") {
198
- if (typeof repository.getOne !== "function") throw new Error(`Resource with idField="${this.idField}" requires repository.getOne() to look up by custom field. Arc's BaseController cannot fall back to getById() because it would query by _id.`);
198
+ if (typeof repository.getOne !== "function") throw createDomainError("arc.adapter.capability_required", `Resource with idField="${this.idField}" requires repository.getOne() to look up by custom field. Arc's BaseController cannot fall back to getById() because it would query by _id.`, 501, {
199
+ capability: "getOne",
200
+ idField: this.idField
201
+ });
199
202
  }
200
203
  const item = await repository.getById(id, queryOptions);
201
204
  if (!item) return {
@@ -311,6 +314,15 @@ var QueryResolver = class {
311
314
  this.tenantField = config.tenantField !== void 0 ? config.tenantField : DEFAULT_TENANT_FIELD;
312
315
  }
313
316
  /**
317
+ * Swap the underlying parser. Mutates in place so the resolver instance
318
+ * stays referentially stable (hosts capturing a `queryResolver` ref via
319
+ * `defineResource({ controller })` keep that ref valid). Single source of
320
+ * truth — pairs with `BaseCrudController.setQueryParser()`.
321
+ */
322
+ setParser(parser) {
323
+ this.queryParser = parser;
324
+ }
325
+ /**
314
326
  * Resolve a request into parsed query options -- ONE parse per request.
315
327
  * Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
316
328
  */
@@ -416,11 +428,6 @@ var QueryResolver = class {
416
428
  //#endregion
417
429
  //#region src/core/BaseCrudController.ts
418
430
  /**
419
- * Portable "run on next tick" scheduler. `setImmediate` is Node-only — not
420
- * available in Bun workers, Deno, Cloudflare Workers, or edge runtimes.
421
- */
422
- const scheduleBackground = typeof setImmediate === "function" ? (cb) => void setImmediate(cb) : (cb) => queueMicrotask(cb);
423
- /**
424
431
  * Framework-agnostic CRUD controller implementing IController.
425
432
  *
426
433
  * Composes AccessControl, BodySanitizer, and QueryResolver. All shared
@@ -436,8 +443,6 @@ var BaseCrudController = class {
436
443
  queryParser;
437
444
  maxLimit;
438
445
  defaultLimit;
439
- /** `undefined` means "no default sort" (caller passed `false`). */
440
- defaultSort;
441
446
  resourceName;
442
447
  tenantField;
443
448
  idField = "_id";
@@ -448,7 +453,7 @@ var BaseCrudController = class {
448
453
  /**
449
454
  * Composable query resolution (parsing, pagination, sort, select/populate).
450
455
  *
451
- * Not `readonly` `setQueryParser()` rebuilds this resolver to swap in a
456
+ * Not `readonly` — `setQueryParser()` rebuilds this resolver to swap in a
452
457
  * different parser (e.g. mongokit's `QueryParser`). `defineResource` calls
453
458
  * it automatically when a resource supplies both `controller` and
454
459
  * `queryParser`.
@@ -463,7 +468,6 @@ var BaseCrudController = class {
463
468
  this.queryParser = options.queryParser ?? getDefaultQueryParser();
464
469
  this.maxLimit = options.maxLimit ?? 100;
465
470
  this.defaultLimit = options.defaultLimit ?? 20;
466
- this.defaultSort = options.defaultSort === false ? void 0 : options.defaultSort ?? "-createdAt";
467
471
  this.resourceName = options.resourceName;
468
472
  this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
469
473
  this.idField = options.idField ?? repository?.idField ?? "_id";
@@ -494,29 +498,19 @@ var BaseCrudController = class {
494
498
  this.delete = this.delete.bind(this);
495
499
  }
496
500
  /**
497
- * Swap the controller's query parser. Rebuilds the internal `QueryResolver`
498
- * with the new parser while preserving every other config.
501
+ * Swap the controller's query parser. Mutates the existing `QueryResolver`
502
+ * in place via `QueryResolver.setParser()` the resolver instance stays
503
+ * referentially stable, and there is no second copy of `defaultSort` /
504
+ * `tenantField` / `schemaOptions` for the swap to drift away from.
499
505
  *
500
506
  * Closes the v2.10.9 gap where `defineResource({ controller, queryParser })`
501
- * forwarded the parser only to auto-constructed controllers; user-supplied
502
- * controllers kept their default `ArcQueryParser`. `defineResource` calls
503
- * this via duck-typing when both `controller` and `queryParser` are
504
- * supplied — controllers that don't implement `setQueryParser` are left
505
- * untouched.
506
- *
507
- * Idempotent + safe to call repeatedly. Does NOT touch `maxLimit` or
508
- * `defaultLimit` — those are construction-time decisions.
507
+ * forwarded the parser only to auto-constructed controllers. `defineResource`
508
+ * calls this via duck-typing when both `controller` and `queryParser` are
509
+ * supplied; controllers that don't implement it are left untouched.
509
510
  */
510
511
  setQueryParser(queryParser) {
511
512
  this.queryParser = queryParser;
512
- this.queryResolver = new QueryResolver({
513
- queryParser: this.queryParser,
514
- maxLimit: this.maxLimit,
515
- defaultLimit: this.defaultLimit,
516
- defaultSort: this.defaultSort,
517
- schemaOptions: this.schemaOptions,
518
- tenantField: this.tenantField
519
- });
513
+ this.queryResolver.setParser(queryParser);
520
514
  }
521
515
  /**
522
516
  * Get the tenant field name if multi-tenant scoping is enabled.
@@ -526,22 +520,46 @@ var BaseCrudController = class {
526
520
  return this.tenantField || void 0;
527
521
  }
528
522
  /**
529
- * Build top-level tenant options to thread into the repository call.
523
+ * Build the canonical repo-options bag from the Fastify request.
524
+ *
525
+ * Forwards the cross-kit canonical set (see repo-core's
526
+ * `STANDARD_REPO_OPTION_KEYS`) into every CRUD repo call so kit
527
+ * plugins (multi-tenant, audit, audit-trail, observability) get
528
+ * what they need without per-resource wiring:
529
+ *
530
+ * - **Tenant scope** — `[tenantField]: orgId` from `RequestScope`.
531
+ * Plugin-scoped repos (mongokit's `multiTenantPlugin`) read tenant
532
+ * scope from the TOP of the options bag, not `data.organizationId`.
533
+ * Without this stamping, a tenant-scoped repo throws "Missing
534
+ * 'organizationId' in context" even when arc has injected the
535
+ * tenant into the request body.
536
+ * Multi-field tenancy from `_tenantFields` (populated by
537
+ * `multiTenantPreset`) is merged in.
530
538
  *
531
- * Plugin-scoped repos (mongokit's `multiTenantPlugin`) read tenant scope
532
- * from the TOP of the operation context — `context.organizationId`, not
533
- * `context.data.organizationId`. Without this stamping, a tenant-scoped
534
- * repo throws "Missing 'organizationId' in context" even when arc has
535
- * injected the tenant into the request body.
539
+ * - **Audit attribution** — `userId` + `user` from the authenticated
540
+ * actor. Mongokit's audit-log / audit-trail plugins read these
541
+ * into the `who` column; sqlitekit's audit plugin reads the same
542
+ * names. No host-side forwarding needed.
536
543
  *
537
- * Returns `{ [tenantField]: orgId }` for tenant-scoped + org-carrying
538
- * requests, `{}` otherwise. Merges multi-field tenancy from
539
- * `_tenantFields` (populated by `multiTenantPreset`).
544
+ * - **Trace correlation** — `requestId` from Fastify's request id
545
+ * for stitching logs / events / downstream calls.
546
+ *
547
+ * - **`session` is intentionally NOT auto-set.** Sessions are tied
548
+ * to explicit transaction scopes the controller doesn't manage;
549
+ * pass `session` inline at the call site when running inside a
550
+ * `withTransaction` helper.
551
+ *
552
+ * Method kept named `tenantRepoOptions` for back-compat with hosts
553
+ * that spread `...this.tenantRepoOptions(req)` (10+ call sites in
554
+ * arc, plus host overrides). The bag has always grown over time —
555
+ * hosts that don't want audit forwarding never read those keys.
540
556
  */
541
557
  tenantRepoOptions(req) {
558
+ const cached = req._tenantRepoOptions;
559
+ if (cached) return cached;
542
560
  const out = {};
561
+ const scope = this.meta(req)?._scope;
543
562
  if (this.tenantField) {
544
- const scope = this.meta(req)?._scope;
545
563
  const orgId = scope ? getOrgId(scope) : void 0;
546
564
  if (orgId) out[this.tenantField] = orgId;
547
565
  }
@@ -549,6 +567,13 @@ var BaseCrudController = class {
549
567
  if (presetFields && typeof presetFields === "object") {
550
568
  for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
551
569
  }
570
+ const userId = getUserId(req.user);
571
+ if (userId) out.userId = userId;
572
+ if (req.user) out.user = req.user;
573
+ const requestId = req.id;
574
+ if (requestId) out.requestId = requestId;
575
+ Object.freeze(out);
576
+ req._tenantRepoOptions = out;
552
577
  return out;
553
578
  }
554
579
  /** Extract typed Arc internal metadata from request */
@@ -563,12 +588,12 @@ var BaseCrudController = class {
563
588
  * Resolve the repository primary key for mutation calls.
564
589
  *
565
590
  * When the resource declares a custom `idField` (slug, jobId, UUID), the
566
- * default behavior is to translate the route id the fetched doc's `_id`
591
+ * default behavior is to translate the route id → the fetched doc's `_id`
567
592
  * because most Mongo repositories key mutation methods off `_id`.
568
593
  *
569
594
  * Exception: if the repo exposes a matching `idField` property (e.g.
570
595
  * MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
571
- * repo handles lookup itself pass the route id through unchanged.
596
+ * repo handles lookup itself — pass the route id through unchanged.
572
597
  */
573
598
  resolveRepoId(id, existing) {
574
599
  if (this.idField === "_id") return id;
@@ -578,24 +603,50 @@ var BaseCrudController = class {
578
603
  return String(existing["_id"] ?? id);
579
604
  }
580
605
  /**
581
- * Centralized 404 response builder. Maps the denial reason from
582
- * `fetchDetailed()` into a structured `details.code` so consumers can
583
- * distinguish "doc doesn't exist" from "doc filtered by policy/org scope"
584
- * without parsing error strings.
606
+ * Read-side preflight for mutable-target operations (`update`, `delete`).
607
+ *
608
+ * Bundles the four steps that every mutation must do before touching the
609
+ * repo: (1) extract `:id`, (2) fetch under access control + tenant scope,
610
+ * (3) verify ownership, (4) translate the route id to the repo's primary
611
+ * key. Returning `{id, existing, repoId}` keeps the call sites a single
612
+ * line and makes drift between `update` and `delete` structurally
613
+ * impossible — there is one preflight, one denial-reason mapping, one
614
+ * ownership check.
615
+ *
616
+ * Pass `extraFetchOptions` for callers (e.g. soft-delete restore) that
617
+ * need to widen the fetch (`{ includeDeleted: true }`).
585
618
  */
586
- notFoundResponse(reason = "NOT_FOUND") {
587
- const code = reason ?? "NOT_FOUND";
619
+ async loadMutableTarget(req, extraFetchOptions) {
620
+ const id = this.requireIdParam(req);
621
+ const baseOptions = this.tenantRepoOptions(req);
622
+ const fetchOptions = extraFetchOptions ? {
623
+ ...baseOptions,
624
+ ...extraFetchOptions
625
+ } : baseOptions;
626
+ const { doc, reason } = await this.accessControl.fetchDetailed(id, req, this.repository, fetchOptions);
627
+ if (!doc) this.throwNotFound(reason);
628
+ if (!this.accessControl.checkOwnership(doc, req)) throw new ForbiddenError("You do not have permission to modify this resource");
588
629
  return {
589
- success: false,
590
- error: {
591
- NOT_FOUND: "Resource not found",
592
- POLICY_FILTERED: "Resource not found",
593
- ORG_SCOPE_DENIED: "Resource not found"
594
- }[code] ?? "Resource not found",
595
- status: 404,
596
- details: { code }
630
+ id,
631
+ existing: doc,
632
+ repoId: this.resolveRepoId(id, doc)
597
633
  };
598
634
  }
635
+ /**
636
+ * Centralized 404 thrower. Maps the denial reason from `fetchDetailed()`
637
+ * into a `NotFoundError` so consumers can distinguish "doc doesn't
638
+ * exist" from "doc filtered by policy/org scope" via the error
639
+ * `details.code` set by the global error handler.
640
+ */
641
+ throwNotFound(reason = "NOT_FOUND") {
642
+ const code = reason ?? "NOT_FOUND";
643
+ const err = new NotFoundError(this.resourceName ?? "Resource");
644
+ err.details = {
645
+ ...err.details ?? {},
646
+ code
647
+ };
648
+ throw err;
649
+ }
599
650
  /** Resolve cache config for a specific operation, merging per-op overrides */
600
651
  resolveCacheConfig(operation) {
601
652
  const cfg = this._cacheConfig;
@@ -621,47 +672,217 @@ var BaseCrudController = class {
621
672
  })() : void 0
622
673
  };
623
674
  }
624
- async list(req) {
625
- const options = this.queryResolver.resolve(req, this.meta(req));
675
+ /** Shared `x-cache` response envelope builder. */
676
+ cacheResponse(data, cacheStatus) {
677
+ return {
678
+ data,
679
+ status: 200,
680
+ headers: { "x-cache": cacheStatus }
681
+ };
682
+ }
683
+ /** Required route-id helper shared by get/update/delete. Throws on missing id. */
684
+ requireIdParam(req) {
685
+ const id = req.params.id;
686
+ if (!id) throw createError(400, "ID parameter is required");
687
+ return id;
688
+ }
689
+ /**
690
+ * Normalizes `repo.exists()` return shapes across adapters. Per
691
+ * StandardRepo's contract, `exists` may return `boolean`, `{ _id }`,
692
+ * or `null` — every truthy non-null shape collapses to `true`.
693
+ */
694
+ isExistsTruthy(result) {
695
+ return result !== null && result !== false && result !== void 0;
696
+ }
697
+ /**
698
+ * Run `executeBefore` then `executeAround` (or just the executor if no
699
+ * hooks are wired). Returns the around-phase result directly. Throws an
700
+ * `ArcError` (status 400, code `BEFORE_<OP>_HOOK_ERROR`) when the
701
+ * before-hook fails — the global error handler emits the canonical
702
+ * `ErrorContract` shape.
703
+ *
704
+ * The caller runs `executeAfter` separately via `runAfterHook` — typically
705
+ * after success-checking the result (delete checks `isDeleteSuccess`,
706
+ * update checks `if (!item)`).
707
+ *
708
+ * **Knobs:**
709
+ * - `meta` — passed verbatim into `executeBefore` / `executeAround` opts.
710
+ * - `pipeProcessedData` (default `true`) — whether `executeBefore`'s
711
+ * return value flows into `executeAround` as the data parameter.
712
+ * Set `false` for delete (current behaviour: discards before's
713
+ * return, passes original input to around).
714
+ */
715
+ async runHookedOpUntilResult(req, args, executor) {
716
+ const hooks = this.getHooks(req);
717
+ const hookOpts = {
718
+ user: req.user,
719
+ context: this.meta(req),
720
+ ...args.meta ? { meta: args.meta } : {}
721
+ };
722
+ const pipeProcessed = args.pipeProcessedData !== false;
723
+ let processedData = args.input;
724
+ if (hooks && this.resourceName) try {
725
+ const beforeReturn = await hooks.executeBefore(this.resourceName, args.op, args.input, hookOpts);
726
+ if (pipeProcessed) processedData = beforeReturn;
727
+ } catch (err) {
728
+ throw createError(400, "Hook execution failed", {
729
+ code: `BEFORE_${args.op.toUpperCase()}_HOOK_ERROR`,
730
+ message: err.message
731
+ });
732
+ }
733
+ let result;
734
+ if (hooks && this.resourceName) result = await hooks.executeAround(this.resourceName, args.op, processedData, () => executor(processedData), hookOpts);
735
+ else result = await executor(processedData);
736
+ return result;
737
+ }
738
+ /**
739
+ * Run `executeAfter` for the given op + data. No-op when hooks aren't
740
+ * wired or `resourceName` isn't set. Caller passes the data shape it
741
+ * wants downstream after-handlers to receive — typically the result for
742
+ * create/update, the original input (`existing`) for delete.
743
+ */
744
+ async runAfterHook(req, op, data, meta) {
745
+ const hooks = this.getHooks(req);
746
+ if (!hooks || !this.resourceName) return;
747
+ const user = req.user;
748
+ const arcContext = this.meta(req);
749
+ await hooks.executeAfter(this.resourceName, op, data, {
750
+ user,
751
+ context: arcContext,
752
+ ...meta ? { meta } : {}
753
+ });
754
+ }
755
+ /** Cached `list()` flow with SWR semantics. Returns null when cache is disabled. */
756
+ async withListCache(req, options) {
626
757
  const cacheConfig = this.resolveCacheConfig("list");
627
758
  const qc = req.server?.queryCache;
628
- if (cacheConfig && qc) {
629
- const version = await qc.getResourceVersion(this.resourceName);
630
- const { userId, orgId } = this.cacheScope(req);
631
- const key = buildQueryKey(this.resourceName, "list", version, options, userId, orgId);
632
- const { data, status } = await qc.get(key);
633
- if (status === "fresh") return {
634
- success: true,
635
- data,
636
- status: 200,
637
- headers: { "x-cache": "HIT" }
638
- };
639
- if (status === "stale") {
640
- scheduleBackground(() => {
641
- this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
642
- });
643
- return {
644
- success: true,
645
- data,
646
- status: 200,
647
- headers: { "x-cache": "STALE" }
648
- };
649
- }
650
- const result = await this.executeListQuery(options, req);
651
- await qc.set(key, result, cacheConfig);
652
- return {
653
- success: true,
654
- data: result,
655
- status: 200,
656
- headers: { "x-cache": "MISS" }
657
- };
759
+ if (!cacheConfig || !qc) return null;
760
+ const version = await qc.getResourceVersion(this.resourceName);
761
+ const { userId, orgId } = this.cacheScope(req);
762
+ const key = buildQueryKey(this.resourceName, "list", version, options, userId, orgId);
763
+ const { data, status } = await qc.get(key);
764
+ if (status === "fresh") return this.cacheResponse(data, "HIT");
765
+ if (status === "stale") {
766
+ scheduleBackground(() => {
767
+ this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
768
+ });
769
+ return this.cacheResponse(data, "STALE");
658
770
  }
771
+ const result = await this.executeListQuery(options, req);
772
+ await qc.set(key, result, cacheConfig);
773
+ return this.cacheResponse(result, "MISS");
774
+ }
775
+ /** Cached `get()` flow with SWR semantics. Returns null when cache is disabled. */
776
+ async withGetCache(req, id, options) {
777
+ const cacheConfig = this.resolveCacheConfig("byId");
778
+ const qc = req.server?.queryCache;
779
+ if (!cacheConfig || !qc) return null;
780
+ const version = await qc.getResourceVersion(this.resourceName);
781
+ const { userId, orgId } = this.cacheScope(req);
782
+ const key = buildQueryKey(this.resourceName, "get", version, {
783
+ id,
784
+ ...options
785
+ }, userId, orgId);
786
+ const { data, status } = await qc.get(key);
787
+ if (status === "fresh") return this.cacheResponse(data, "HIT");
788
+ if (status === "stale") {
789
+ scheduleBackground(() => {
790
+ this.executeGetQuery(id, options, req).then(({ doc: fresh }) => {
791
+ if (fresh) qc.set(key, fresh, cacheConfig);
792
+ }).catch(() => {});
793
+ });
794
+ return this.cacheResponse(data, "STALE");
795
+ }
796
+ const { doc, reason } = await this.executeGetQuery(id, options, req);
797
+ if (!doc) this.throwNotFound(reason);
798
+ await qc.set(key, doc, cacheConfig);
799
+ return this.cacheResponse(doc, "MISS");
800
+ }
801
+ async list(req) {
802
+ const dispatch = this.dispatchResourceVerb(req);
803
+ if (dispatch) return dispatch;
804
+ const options = this.queryResolver.resolve(req, this.meta(req));
805
+ const cached = await this.withListCache(req, options);
806
+ if (cached) return cached;
659
807
  return {
660
- success: true,
661
808
  data: await this.executeListQuery(options, req),
662
809
  status: 200
663
810
  };
664
811
  }
812
+ /**
813
+ * Resource-dispatch verbs router. Returns `null` when the request is
814
+ * a regular list query, otherwise returns the dispatch promise.
815
+ *
816
+ * Verbs (mutually exclusive — first match wins):
817
+ * - `?_count=true` → `{ count: number }` via `repo.count()`
818
+ * - `?_distinct=field` → `unknown[]` via `repo.distinct(field)`
819
+ * - `?_exists=true` → `{ exists: boolean }` via `repo.exists()`
820
+ *
821
+ * All verbs share the resolved filter (parsed query + policy filters
822
+ * + tenant scope). Adapters that don't ship the underlying repo
823
+ * method get a `501` so failures surface loudly instead of falling
824
+ * back to a full table scan.
825
+ */
826
+ dispatchResourceVerb(req) {
827
+ const query = req.query;
828
+ if (!query) return null;
829
+ const isTruthyFlag = (value) => value !== void 0 && value !== "" && value !== "false" && value !== false;
830
+ if (isTruthyFlag(query._count)) return this.dispatchCount(req);
831
+ const distinctField = query._distinct;
832
+ if (typeof distinctField === "string" && distinctField.length > 0) return this.dispatchDistinct(req, distinctField);
833
+ if (isTruthyFlag(query._exists)) return this.dispatchExists(req);
834
+ return null;
835
+ }
836
+ /** Resolve filter + tenant/audit options for a dispatch verb. */
837
+ resolveDispatchScope(req) {
838
+ return {
839
+ filter: this.queryResolver.resolve(req, this.meta(req)).filters ?? {},
840
+ options: this.tenantRepoOptions(req)
841
+ };
842
+ }
843
+ /** `?_count=true` → `repo.count(filter)` */
844
+ async dispatchCount(req) {
845
+ const repo = this.repository;
846
+ if (typeof repo.count !== "function") throw createError(501, "_count is not supported: the resource's storage adapter does not implement repo.count()");
847
+ const { filter, options } = this.resolveDispatchScope(req);
848
+ return {
849
+ data: { count: await repo.count(filter, options) },
850
+ status: 200
851
+ };
852
+ }
853
+ /** `?_distinct=field` → `repo.distinct(field, filter)` */
854
+ async dispatchDistinct(req, field) {
855
+ if (!this.isFieldExposedForRead(field)) throw createError(400, `_distinct field "${field}" is not allowed (hidden or system-managed)`);
856
+ const repo = this.repository;
857
+ if (typeof repo.distinct !== "function") throw createError(501, "_distinct is not supported: the resource's storage adapter does not implement repo.distinct()");
858
+ const { filter, options } = this.resolveDispatchScope(req);
859
+ return {
860
+ data: await repo.distinct(field, filter, options),
861
+ status: 200
862
+ };
863
+ }
864
+ /** `?_exists=true` → `repo.exists(filter)` */
865
+ async dispatchExists(req) {
866
+ const repo = this.repository;
867
+ if (typeof repo.exists !== "function") throw createError(501, "_exists is not supported: the resource's storage adapter does not implement repo.exists()");
868
+ const { filter, options } = this.resolveDispatchScope(req);
869
+ const result = await repo.exists(filter, options);
870
+ return {
871
+ data: { exists: this.isExistsTruthy(result) },
872
+ status: 200
873
+ };
874
+ }
875
+ /**
876
+ * True when `field` is safe to expose via `_distinct`. Mirrors the
877
+ * `select` allowlist — fields marked `hidden` or `systemManaged` in
878
+ * `schemaOptions.fieldRules` are NOT exposed (would leak password
879
+ * hashes, internal flags, etc).
880
+ */
881
+ isFieldExposedForRead(field) {
882
+ const rules = this.schemaOptions.fieldRules?.[field];
883
+ if (!rules) return true;
884
+ return !(rules.hidden || rules.systemManaged);
885
+ }
665
886
  /** Execute list query through hooks (extracted for cache revalidation) */
666
887
  async executeListQuery(options, req) {
667
888
  const hooks = this.getHooks(req);
@@ -676,59 +897,16 @@ var BaseCrudController = class {
676
897
  }) : await repoGetAll();
677
898
  }
678
899
  async get(req) {
679
- const id = req.params.id;
680
- if (!id) return {
681
- success: false,
682
- error: "ID parameter is required",
683
- status: 400
684
- };
900
+ const id = this.requireIdParam(req);
685
901
  const options = {
686
902
  ...this.queryResolver.resolve(req, this.meta(req)),
687
903
  ...this.tenantRepoOptions(req)
688
904
  };
689
- const cacheConfig = this.resolveCacheConfig("byId");
690
- const qc = req.server?.queryCache;
691
- if (cacheConfig && qc) {
692
- const version = await qc.getResourceVersion(this.resourceName);
693
- const { userId, orgId } = this.cacheScope(req);
694
- const key = buildQueryKey(this.resourceName, "get", version, {
695
- id,
696
- ...options
697
- }, userId, orgId);
698
- const { data, status } = await qc.get(key);
699
- if (status === "fresh") return {
700
- success: true,
701
- data,
702
- status: 200,
703
- headers: { "x-cache": "HIT" }
704
- };
705
- if (status === "stale") {
706
- scheduleBackground(() => {
707
- this.executeGetQuery(id, options, req).then(({ doc: fresh }) => {
708
- if (fresh) qc.set(key, fresh, cacheConfig);
709
- }).catch(() => {});
710
- });
711
- return {
712
- success: true,
713
- data,
714
- status: 200,
715
- headers: { "x-cache": "STALE" }
716
- };
717
- }
718
- const { doc: cached, reason: cacheReason } = await this.executeGetQuery(id, options, req);
719
- if (!cached) return this.notFoundResponse(cacheReason);
720
- await qc.set(key, cached, cacheConfig);
721
- return {
722
- success: true,
723
- data: cached,
724
- status: 200,
725
- headers: { "x-cache": "MISS" }
726
- };
727
- }
905
+ const cached = await this.withGetCache(req, id, options);
906
+ if (cached) return cached;
728
907
  const { doc, reason } = await this.executeGetQuery(id, options, req);
729
- if (!doc) return this.notFoundResponse(reason);
908
+ if (!doc) this.throwNotFound(reason);
730
909
  return {
731
- success: true,
732
910
  data: doc,
733
911
  status: 200
734
912
  };
@@ -760,188 +938,71 @@ var BaseCrudController = class {
760
938
  if (this.tenantField && createOrgId) data[this.tenantField] = createOrgId;
761
939
  const userId = getUserId(req.user);
762
940
  if (userId) data.createdBy = userId;
763
- const hooks = this.getHooks(req);
764
941
  const user = req.user;
765
- let processedData = data;
766
- if (hooks && this.resourceName) try {
767
- processedData = await hooks.executeBefore(this.resourceName, "create", data, {
768
- user,
769
- context: arcContext
770
- });
771
- } catch (err) {
772
- return {
773
- success: false,
774
- error: "Hook execution failed",
775
- details: {
776
- code: "BEFORE_CREATE_HOOK_ERROR",
777
- message: err.message
778
- },
779
- status: 400
780
- };
781
- }
782
- const repoCreate = async () => this.repository.create(processedData, {
942
+ const item = await this.runHookedOpUntilResult(req, {
943
+ op: "create",
944
+ input: data
945
+ }, async (processed) => this.repository.create(processed, {
783
946
  user,
784
947
  context: arcContext,
785
948
  ...this.tenantRepoOptions(req)
786
- });
787
- let item;
788
- if (hooks && this.resourceName) {
789
- item = await hooks.executeAround(this.resourceName, "create", processedData, repoCreate, {
790
- user,
791
- context: arcContext
792
- });
793
- await hooks.executeAfter(this.resourceName, "create", item, {
794
- user,
795
- context: arcContext
796
- });
797
- } else item = await repoCreate();
949
+ }));
950
+ await this.runAfterHook(req, "create", item);
798
951
  return {
799
- success: true,
800
952
  data: item,
801
953
  status: 201,
802
954
  meta: { message: "Created successfully" }
803
955
  };
804
956
  }
805
957
  async update(req) {
806
- const id = req.params.id;
807
- if (!id) return {
808
- success: false,
809
- error: "ID parameter is required",
810
- status: 400
811
- };
812
958
  const arcContext = this.meta(req);
813
959
  const data = this.bodySanitizer.sanitize(req.body ?? {}, "update", req, arcContext);
814
960
  const user = req.user;
815
961
  const userId = getUserId(user);
816
962
  if (userId) data.updatedBy = userId;
817
- const { doc: existing, reason: updateReason } = await this.accessControl.fetchDetailed(id, req, this.repository, this.tenantRepoOptions(req));
818
- if (!existing) return this.notFoundResponse(updateReason);
819
- if (!this.accessControl.checkOwnership(existing, req)) return {
820
- success: false,
821
- error: "You do not have permission to modify this resource",
822
- details: { code: "OWNERSHIP_DENIED" },
823
- status: 403
963
+ const { id, existing, repoId } = await this.loadMutableTarget(req);
964
+ const hookMeta = {
965
+ id,
966
+ existing
824
967
  };
825
- const repoId = this.resolveRepoId(id, existing);
826
- const hooks = this.getHooks(req);
827
- let processedData = data;
828
- if (hooks && this.resourceName) try {
829
- processedData = await hooks.executeBefore(this.resourceName, "update", data, {
830
- user,
831
- context: arcContext,
832
- meta: {
833
- id,
834
- existing
835
- }
836
- });
837
- } catch (err) {
838
- return {
839
- success: false,
840
- error: "Hook execution failed",
841
- details: {
842
- code: "BEFORE_UPDATE_HOOK_ERROR",
843
- message: err.message
844
- },
845
- status: 400
846
- };
847
- }
848
- const repoUpdate = async () => this.repository.update(repoId, processedData, {
968
+ const item = await this.runHookedOpUntilResult(req, {
969
+ op: "update",
970
+ input: data,
971
+ meta: hookMeta
972
+ }, async (processed) => this.repository.update(repoId, processed, {
849
973
  user,
850
974
  context: arcContext,
851
975
  ...this.tenantRepoOptions(req)
852
- });
853
- let item;
854
- if (hooks && this.resourceName) {
855
- item = await hooks.executeAround(this.resourceName, "update", processedData, repoUpdate, {
856
- user,
857
- context: arcContext,
858
- meta: {
859
- id,
860
- existing
861
- }
862
- });
863
- if (item) await hooks.executeAfter(this.resourceName, "update", item, {
864
- user,
865
- context: arcContext,
866
- meta: {
867
- id,
868
- existing
869
- }
870
- });
871
- } else item = await repoUpdate();
872
- if (!item) return this.notFoundResponse("NOT_FOUND");
976
+ }));
977
+ if (!item) this.throwNotFound("NOT_FOUND");
978
+ await this.runAfterHook(req, "update", item, hookMeta);
873
979
  return {
874
- success: true,
875
980
  data: item,
876
981
  status: 200,
877
982
  meta: { message: "Updated successfully" }
878
983
  };
879
984
  }
880
985
  async delete(req) {
881
- const id = req.params.id;
882
- if (!id) return {
883
- success: false,
884
- error: "ID parameter is required",
885
- status: 400
886
- };
887
986
  const arcContext = this.meta(req);
888
987
  const user = req.user;
889
- const { doc: existing, reason: deleteReason } = await this.accessControl.fetchDetailed(id, req, this.repository, this.tenantRepoOptions(req));
890
- if (!existing) return this.notFoundResponse(deleteReason);
891
- if (!this.accessControl.checkOwnership(existing, req)) return {
892
- success: false,
893
- error: "You do not have permission to delete this resource",
894
- details: { code: "OWNERSHIP_DENIED" },
895
- status: 403
896
- };
897
- const repoId = this.resolveRepoId(id, existing);
898
- const hooks = this.getHooks(req);
899
- if (hooks && this.resourceName) try {
900
- await hooks.executeBefore(this.resourceName, "delete", existing, {
901
- user,
902
- context: arcContext,
903
- meta: { id }
904
- });
905
- } catch (err) {
906
- return {
907
- success: false,
908
- error: "Hook execution failed",
909
- details: {
910
- code: "BEFORE_DELETE_HOOK_ERROR",
911
- message: err.message
912
- },
913
- status: 400
914
- };
915
- }
988
+ const { id, existing, repoId } = await this.loadMutableTarget(req);
989
+ const hookMeta = { id };
916
990
  const deleteMode = req.query?.hard === "true" || req.query?.hard === true || req.body?.mode === "hard" ? "hard" : void 0;
917
- const repoDelete = async () => this.repository.delete(repoId, {
991
+ const result = await this.runHookedOpUntilResult(req, {
992
+ op: "delete",
993
+ input: existing,
994
+ meta: hookMeta,
995
+ pipeProcessedData: false
996
+ }, async () => this.repository.delete(repoId, {
918
997
  user,
919
998
  context: arcContext,
920
999
  ...this.tenantRepoOptions(req),
921
1000
  ...deleteMode ? { mode: deleteMode } : {}
922
- });
923
- let result;
924
- if (hooks && this.resourceName) result = await hooks.executeAround(this.resourceName, "delete", existing, repoDelete, {
925
- user,
926
- context: arcContext,
927
- meta: { id }
928
- });
929
- else result = await repoDelete();
930
- if (!(() => {
931
- if (typeof result !== "object" || result === null) return !!result;
932
- const r = result;
933
- if (typeof r.success === "boolean") return r.success;
934
- if (typeof r.deletedCount === "number") return r.deletedCount > 0;
935
- return true;
936
- })()) return this.notFoundResponse("NOT_FOUND");
937
- if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "delete", existing, {
938
- user,
939
- context: arcContext,
940
- meta: { id }
941
- });
942
- const deleteResult = typeof result === "object" && result !== null ? result : {};
1001
+ }));
1002
+ if (!result) this.throwNotFound("NOT_FOUND");
1003
+ await this.runAfterHook(req, "delete", existing, hookMeta);
1004
+ const deleteResult = result;
943
1005
  return {
944
- success: true,
945
1006
  data: {
946
1007
  message: deleteResult.message || "Deleted successfully",
947
1008
  ...id ? { id } : {},
@@ -974,17 +1035,9 @@ function BulkMixin(Base) {
974
1035
  return class BulkController extends Base {
975
1036
  async bulkCreate(req) {
976
1037
  const repo = this.repository;
977
- if (!repo.createMany) return {
978
- success: false,
979
- error: "Repository does not support createMany",
980
- status: 501
981
- };
1038
+ if (!repo.createMany) throw createError(501, "Repository does not support createMany");
982
1039
  const rawItems = req.body?.items;
983
- if (!Array.isArray(rawItems) || rawItems.length === 0) return {
984
- success: false,
985
- error: "Bulk create requires a non-empty items array",
986
- status: 400
987
- };
1040
+ if (!Array.isArray(rawItems) || rawItems.length === 0) throw createError(400, "Bulk create requires a non-empty items array");
988
1041
  const items = rawItems;
989
1042
  const arcContext = this.meta(req);
990
1043
  const user = req.user;
@@ -993,20 +1046,10 @@ function BulkMixin(Base) {
993
1046
  if (this.tenantField) {
994
1047
  const scope = arcContext?._scope;
995
1048
  if (scope) {
996
- if (scope.kind === "public") return {
997
- success: false,
998
- error: "Organization context required to bulk-create resources",
999
- details: { code: "ORG_CONTEXT_REQUIRED" },
1000
- status: 403
1001
- };
1049
+ if (scope.kind === "public") throw createError(403, "Organization context required to bulk-create resources", { code: "ORG_CONTEXT_REQUIRED" });
1002
1050
  if (!isElevated(scope)) {
1003
1051
  const orgId = getOrgId(scope);
1004
- if (!orgId) return {
1005
- success: false,
1006
- error: "Organization context required to bulk-create resources",
1007
- details: { code: "ORG_CONTEXT_REQUIRED" },
1008
- status: 403
1009
- };
1052
+ if (!orgId) throw createError(403, "Organization context required to bulk-create resources", { code: "ORG_CONTEXT_REQUIRED" });
1010
1053
  const tenantField = this.tenantField;
1011
1054
  scopedItems = sanitizedItems.map((item) => ({
1012
1055
  ...item,
@@ -1023,7 +1066,6 @@ function BulkMixin(Base) {
1023
1066
  const inserted = created.length;
1024
1067
  const skipped = requested - inserted;
1025
1068
  return {
1026
- success: true,
1027
1069
  data: created,
1028
1070
  status: skipped === 0 ? 201 : inserted === 0 ? 422 : 207,
1029
1071
  meta: {
@@ -1116,49 +1158,21 @@ function BulkMixin(Base) {
1116
1158
  }
1117
1159
  async bulkUpdate(req) {
1118
1160
  const repo = this.repository;
1119
- if (!repo.updateMany) return {
1120
- success: false,
1121
- error: "Repository does not support updateMany",
1122
- status: 501
1123
- };
1161
+ if (!repo.updateMany) throw createError(501, "Repository does not support updateMany");
1124
1162
  const body = req.body;
1125
- if (!body.filter || Object.keys(body.filter).length === 0) return {
1126
- success: false,
1127
- error: "Bulk update requires a non-empty filter",
1128
- status: 400
1129
- };
1130
- if (!body.data || Object.keys(body.data).length === 0) return {
1131
- success: false,
1132
- error: "Bulk update requires non-empty data",
1133
- status: 400
1134
- };
1163
+ if (!body.filter || Object.keys(body.filter).length === 0) throw createError(400, "Bulk update requires a non-empty filter");
1164
+ if (!body.data || Object.keys(body.data).length === 0) throw createError(400, "Bulk update requires non-empty data");
1135
1165
  const scopedFilter = this.buildBulkFilter(body.filter, req);
1136
- if (scopedFilter === null) return {
1137
- success: false,
1138
- error: "Organization context required for bulk update",
1139
- details: { code: "ORG_CONTEXT_REQUIRED" },
1140
- status: 403
1141
- };
1166
+ if (scopedFilter === null) throw createError(403, "Organization context required for bulk update", { code: "ORG_CONTEXT_REQUIRED" });
1142
1167
  const arcContext = this.meta(req);
1143
1168
  const user = req.user;
1144
1169
  const { sanitized, stripped, mixedShape } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
1145
- if (mixedShape) return {
1146
- success: false,
1147
- error: "Bulk update payload cannot mix operator keys ($set, $inc, ...) with flat fields. Pick one shape.",
1148
- details: { code: "MIXED_UPDATE_SHAPE" },
1149
- status: 400
1150
- };
1151
- if (Object.keys(sanitized).length === 0) return {
1152
- success: false,
1153
- error: "Bulk update payload contained only protected fields",
1154
- details: {
1155
- code: "ALL_FIELDS_STRIPPED",
1156
- stripped
1157
- },
1158
- status: 400
1159
- };
1170
+ if (mixedShape) throw createError(400, "Bulk update payload cannot mix operator keys ($set, $inc, ...) with flat fields. Pick one shape.", { code: "MIXED_UPDATE_SHAPE" });
1171
+ if (Object.keys(sanitized).length === 0) throw createError(400, "Bulk update payload contained only protected fields", {
1172
+ code: "ALL_FIELDS_STRIPPED",
1173
+ stripped
1174
+ });
1160
1175
  return {
1161
- success: true,
1162
1176
  data: await repo.updateMany(scopedFilter, sanitized, {
1163
1177
  user,
1164
1178
  context: arcContext
@@ -1183,33 +1197,16 @@ function BulkMixin(Base) {
1183
1197
  */
1184
1198
  async bulkDelete(req) {
1185
1199
  const repo = this.repository;
1186
- if (!repo.deleteMany) return {
1187
- success: false,
1188
- error: "Repository does not support deleteMany",
1189
- status: 501
1190
- };
1200
+ if (!repo.deleteMany) throw createError(501, "Repository does not support deleteMany");
1191
1201
  const body = req.body;
1192
1202
  let userFilter;
1193
1203
  if (body.ids && body.ids.length > 0) {
1194
- if (body.filter && Object.keys(body.filter).length > 0) return {
1195
- success: false,
1196
- error: "Bulk delete accepts either `ids` or `filter`, not both",
1197
- status: 400
1198
- };
1204
+ if (body.filter && Object.keys(body.filter).length > 0) throw createError(400, "Bulk delete accepts either `ids` or `filter`, not both");
1199
1205
  userFilter = { [this.idField]: { $in: body.ids } };
1200
1206
  } else if (body.filter && Object.keys(body.filter).length > 0) userFilter = body.filter;
1201
- else return {
1202
- success: false,
1203
- error: "Bulk delete requires a non-empty `filter` or `ids` array",
1204
- status: 400
1205
- };
1207
+ else throw createError(400, "Bulk delete requires a non-empty `filter` or `ids` array");
1206
1208
  const scopedFilter = this.buildBulkFilter(userFilter, req);
1207
- if (scopedFilter === null) return {
1208
- success: false,
1209
- error: "Organization context required for bulk delete",
1210
- details: { code: "ORG_CONTEXT_REQUIRED" },
1211
- status: 403
1212
- };
1209
+ if (scopedFilter === null) throw createError(403, "Organization context required for bulk delete", { code: "ORG_CONTEXT_REQUIRED" });
1213
1210
  const hardHint = req.query?.hard === "true" || req.query?.hard === true || body.mode === "hard";
1214
1211
  const arcContext = this.meta(req);
1215
1212
  const options = {
@@ -1218,7 +1215,6 @@ function BulkMixin(Base) {
1218
1215
  };
1219
1216
  if (hardHint) options.mode = "hard";
1220
1217
  return {
1221
- success: true,
1222
1218
  data: await repo.deleteMany(scopedFilter, options),
1223
1219
  status: 200
1224
1220
  };
@@ -1245,14 +1241,17 @@ function SlugMixin(Base) {
1245
1241
  ...options?.filter ?? {}
1246
1242
  };
1247
1243
  item = await repo.getOne(filter, options);
1248
- } else return {
1249
- success: false,
1250
- error: "Slug lookup not implemented repository needs getBySlug() or getOne()",
1251
- status: 501
1252
- };
1253
- if (!this.accessControl.validateItemAccess(item, req)) return this.notFoundResponse(item ? "POLICY_FILTERED" : "NOT_FOUND");
1244
+ } else throw createError(501, "Slug lookup not implemented — repository needs getBySlug() or getOne()");
1245
+ if (!this.accessControl.validateItemAccess(item, req)) {
1246
+ const code = item ? "POLICY_FILTERED" : "NOT_FOUND";
1247
+ const err = new NotFoundError(this.resourceName ?? "Resource");
1248
+ err.details = {
1249
+ ...err.details ?? {},
1250
+ code
1251
+ };
1252
+ throw err;
1253
+ }
1254
1254
  return {
1255
- success: true,
1256
1255
  data: item,
1257
1256
  status: 200
1258
1257
  };
@@ -1265,39 +1264,21 @@ function SoftDeleteMixin(Base) {
1265
1264
  return class SoftDeleteController extends Base {
1266
1265
  async getDeleted(req) {
1267
1266
  const repo = this.repository;
1268
- if (!repo.getDeleted) return {
1269
- success: false,
1270
- error: "Soft delete not implemented",
1271
- status: 501
1272
- };
1267
+ if (!repo.getDeleted) throw createError(501, "Soft delete not implemented");
1273
1268
  const parsed = this.queryResolver.resolve(req, this.meta(req));
1274
1269
  return {
1275
- success: true,
1276
1270
  data: await repo.getDeleted(parsed, parsed),
1277
1271
  status: 200
1278
1272
  };
1279
1273
  }
1280
1274
  async restore(req) {
1281
1275
  const repo = this.repository;
1282
- if (!repo.restore) return {
1283
- success: false,
1284
- error: "Restore not implemented",
1285
- status: 501
1286
- };
1276
+ if (!repo.restore) throw createError(501, "Restore not implemented");
1287
1277
  const id = req.params.id;
1288
- if (!id) return {
1289
- success: false,
1290
- error: "ID parameter is required",
1291
- status: 400
1292
- };
1278
+ if (!id) throw createError(400, "ID parameter is required");
1293
1279
  const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
1294
- if (!existing) return this.notFoundResponse("NOT_FOUND");
1295
- if (!this.accessControl.checkOwnership(existing, req)) return {
1296
- success: false,
1297
- error: "You do not have permission to restore this resource",
1298
- details: { code: "OWNERSHIP_DENIED" },
1299
- status: 403
1300
- };
1280
+ if (!existing) throw new NotFoundError(this.resourceName ?? "Resource");
1281
+ if (!this.accessControl.checkOwnership(existing, req)) throw new ForbiddenError("You do not have permission to restore this resource");
1301
1282
  const arcContext = this.meta(req);
1302
1283
  const user = req.user;
1303
1284
  const repoId = this.resolveRepoId(id, existing);
@@ -1309,15 +1290,10 @@ function SoftDeleteMixin(Base) {
1309
1290
  meta: { id }
1310
1291
  });
1311
1292
  } catch (err) {
1312
- return {
1313
- success: false,
1314
- error: "Hook execution failed",
1315
- details: {
1316
- code: "BEFORE_RESTORE_HOOK_ERROR",
1317
- message: err.message
1318
- },
1319
- status: 400
1320
- };
1293
+ throw createError(400, "Hook execution failed", {
1294
+ code: "BEFORE_RESTORE_HOOK_ERROR",
1295
+ message: err.message
1296
+ });
1321
1297
  }
1322
1298
  const repoRestore = () => repo.restore(repoId);
1323
1299
  let item;
@@ -1327,14 +1303,13 @@ function SoftDeleteMixin(Base) {
1327
1303
  meta: { id }
1328
1304
  });
1329
1305
  else item = await repoRestore();
1330
- if (!item) return this.notFoundResponse("NOT_FOUND");
1306
+ if (!item) throw new NotFoundError(this.resourceName ?? "Resource");
1331
1307
  if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "restore", item, {
1332
1308
  user,
1333
1309
  context: arcContext,
1334
1310
  meta: { id }
1335
1311
  });
1336
1312
  return {
1337
- success: true,
1338
1313
  data: item,
1339
1314
  status: 200,
1340
1315
  meta: { message: "Restored successfully" }
@@ -1348,30 +1323,20 @@ function TreeMixin(Base) {
1348
1323
  return class TreeController extends Base {
1349
1324
  async getTree(req) {
1350
1325
  const repo = this.repository;
1351
- if (!repo.getTree) return {
1352
- success: false,
1353
- error: "Tree structure not implemented",
1354
- status: 501
1355
- };
1326
+ if (!repo.getTree) throw createError(501, "Tree structure not implemented");
1356
1327
  const options = this.queryResolver.resolve(req, this.meta(req));
1357
1328
  return {
1358
- success: true,
1359
1329
  data: await repo.getTree(options),
1360
1330
  status: 200
1361
1331
  };
1362
1332
  }
1363
1333
  async getChildren(req) {
1364
1334
  const repo = this.repository;
1365
- if (!repo.getChildren) return {
1366
- success: false,
1367
- error: "Tree structure not implemented",
1368
- status: 501
1369
- };
1335
+ if (!repo.getChildren) throw createError(501, "Tree structure not implemented");
1370
1336
  const parentField = this._presetFields.parentField ?? "parent";
1371
1337
  const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
1372
1338
  const options = this.queryResolver.resolve(req, this.meta(req));
1373
1339
  return {
1374
- success: true,
1375
1340
  data: await repo.getChildren(parentId, options),
1376
1341
  status: 200
1377
1342
  };