@classytic/arc 2.11.3 → 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 (185) hide show
  1. package/README.md +27 -18
  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/{QueryCache-DOBNHBE0.d.mts → QueryCache-D41bfdBB.d.mts} +1 -1
  6. package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
  7. package/dist/audit/index.d.mts +2 -2
  8. package/dist/audit/index.mjs +1 -1
  9. package/dist/auth/audit.d.mts +199 -0
  10. package/dist/auth/audit.mjs +288 -0
  11. package/dist/auth/index.d.mts +5 -5
  12. package/dist/auth/index.mjs +117 -191
  13. package/dist/auth/redis-session.d.mts +1 -1
  14. package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
  15. package/dist/buildHandler-olo-gt94.mjs +610 -0
  16. package/dist/cache/index.d.mts +3 -3
  17. package/dist/cache/index.mjs +3 -3
  18. package/dist/cli/commands/describe.d.mts +89 -13
  19. package/dist/cli/commands/describe.mjs +56 -2
  20. package/dist/cli/commands/docs.mjs +2 -2
  21. package/dist/cli/commands/generate.mjs +147 -48
  22. package/dist/cli/commands/init.d.mts +13 -0
  23. package/dist/cli/commands/init.mjs +237 -112
  24. package/dist/cli/commands/introspect.mjs +8 -1
  25. package/dist/context/index.mjs +1 -1
  26. package/dist/core/index.d.mts +3 -3
  27. package/dist/core/index.mjs +5 -5
  28. package/dist/core-D72ia0EH.mjs +1399 -0
  29. package/dist/{createActionRouter-u3ql2EDo.mjs → createActionRouter-CEvzKcy8.mjs} +7 -20
  30. package/dist/createAggregationRouter-CyecOxnO.mjs +114 -0
  31. package/dist/{createApp-BFxtdKy6.mjs → createApp-XX2-N0Yd.mjs} +31 -27
  32. package/dist/defineEvent-D5h7EvAx.mjs +188 -0
  33. package/dist/docs/index.d.mts +2 -2
  34. package/dist/docs/index.mjs +2 -2
  35. package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
  36. package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
  37. package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
  38. package/dist/errors-j4aJm1Wg.mjs +184 -0
  39. package/dist/{eventPlugin-KrFIQ097.mjs → eventPlugin-CaKTYkYM.mjs} +35 -137
  40. package/dist/{eventPlugin-CUNjYYRY.d.mts → eventPlugin-qXpqTebY.d.mts} +57 -7
  41. package/dist/events/index.d.mts +164 -5
  42. package/dist/events/index.mjs +133 -209
  43. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  44. package/dist/events/transports/redis-stream-entry.mjs +204 -31
  45. package/dist/events/transports/redis.d.mts +1 -1
  46. package/dist/factory/index.d.mts +2 -2
  47. package/dist/factory/index.mjs +2 -2
  48. package/dist/{fields-C8Y0XLAu.d.mts → fields-COhcH3fk.d.mts} +23 -2
  49. package/dist/hooks/index.d.mts +1 -1
  50. package/dist/hooks/index.mjs +1 -1
  51. package/dist/idempotency/index.d.mts +3 -3
  52. package/dist/idempotency/index.mjs +1 -20
  53. package/dist/idempotency/redis.d.mts +1 -1
  54. package/dist/idempotency/redis.mjs +1 -1
  55. package/dist/{index-BYCqHCVu.d.mts → index-BTqLEvhu.d.mts} +164 -4
  56. package/dist/{index-6u4_Gg6G.d.mts → index-BtW7qYwa.d.mts} +661 -281
  57. package/dist/{index-BdXnTPRj.d.mts → index-Ds61mrJE.d.mts} +50 -4
  58. package/dist/{index-DdQ3O9Pg.d.mts → index-Dz5IKsrE.d.mts} +360 -219
  59. package/dist/index.d.mts +6 -7
  60. package/dist/index.mjs +9 -10
  61. package/dist/integrations/event-gateway.d.mts +2 -2
  62. package/dist/integrations/event-gateway.mjs +1 -1
  63. package/dist/integrations/index.d.mts +2 -2
  64. package/dist/integrations/mcp/index.d.mts +2 -2
  65. package/dist/integrations/mcp/index.mjs +1 -1
  66. package/dist/integrations/mcp/testing.d.mts +1 -1
  67. package/dist/integrations/mcp/testing.mjs +1 -1
  68. package/dist/integrations/streamline.d.mts +60 -11
  69. package/dist/integrations/streamline.mjs +75 -85
  70. package/dist/integrations/websocket-redis.d.mts +1 -1
  71. package/dist/integrations/websocket.d.mts +1 -1
  72. package/dist/integrations/websocket.mjs +2 -8
  73. package/dist/middleware/index.d.mts +1 -1
  74. package/dist/middleware/index.mjs +2 -2
  75. package/dist/migrations/index.d.mts +23 -3
  76. package/dist/migrations/index.mjs +0 -7
  77. package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
  78. package/dist/{openapi-BGUn7Ki1.mjs → openapi-CiOMVW1p.mjs} +143 -13
  79. package/dist/org/index.d.mts +2 -2
  80. package/dist/org/index.mjs +1 -1
  81. package/dist/permissions/index.d.mts +3 -3
  82. package/dist/permissions/index.mjs +3 -3
  83. package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
  84. package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
  85. package/dist/pipeline/index.d.mts +1 -1
  86. package/dist/pipeline/index.mjs +1 -1
  87. package/dist/plugins/index.d.mts +18 -33
  88. package/dist/plugins/index.mjs +33 -13
  89. package/dist/plugins/response-cache.mjs +1 -1
  90. package/dist/plugins/tracing-entry.d.mts +1 -1
  91. package/dist/plugins/tracing-entry.mjs +1 -1
  92. package/dist/presets/filesUpload.d.mts +5 -5
  93. package/dist/presets/filesUpload.mjs +6 -9
  94. package/dist/presets/index.d.mts +1 -1
  95. package/dist/presets/index.mjs +1 -1
  96. package/dist/presets/multiTenant.d.mts +1 -1
  97. package/dist/presets/multiTenant.mjs +2 -2
  98. package/dist/presets/search.d.mts +2 -2
  99. package/dist/presets/search.mjs +6 -8
  100. package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
  101. package/dist/{queryCachePlugin-BUXBSm4F.d.mts → queryCachePlugin-CqMdLI2-.d.mts} +2 -2
  102. package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
  103. package/dist/{redis-Cm1gnRDf.d.mts → redis-DiMkdHEl.d.mts} +1 -1
  104. package/dist/redis-stream-D6HzR1Z_.d.mts +232 -0
  105. package/dist/registry/index.d.mts +1 -1
  106. package/dist/registry/index.mjs +2 -2
  107. package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
  108. package/dist/{resourceToTools-ByZpgjeH.mjs → resourceToTools-C5coh64w.mjs} +224 -71
  109. package/dist/{routerShared-BqLRb5l7.mjs → routerShared-D6_fEGHh.mjs} +40 -36
  110. package/dist/{schemaIR-BlG9bY7v.mjs → schemaIR-7Vl611Qs.mjs} +1 -1
  111. package/dist/schemas/index.d.mts +100 -30
  112. package/dist/schemas/index.mjs +86 -29
  113. package/dist/scim/index.d.mts +264 -0
  114. package/dist/scim/index.mjs +963 -0
  115. package/dist/scope/index.d.mts +3 -3
  116. package/dist/scope/index.mjs +4 -4
  117. package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
  118. package/dist/{store-helpers-BhrzxvyQ.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
  119. package/dist/testing/index.d.mts +2 -8
  120. package/dist/testing/index.mjs +16 -24
  121. package/dist/testing/storageContract.d.mts +1 -1
  122. package/dist/types/index.d.mts +4 -4
  123. package/dist/types/storage.d.mts +1 -1
  124. package/dist/{types-BH7dEGvU.d.mts → types-BvqwCCSx.d.mts} +77 -29
  125. package/dist/{types-tgR4Pt8F.d.mts → types-CTYvcwHe.d.mts} +195 -1
  126. package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
  127. package/dist/{types-9beEMe25.d.mts → types-DQHFc8PM.d.mts} +1 -1
  128. package/dist/utils/index.d.mts +2 -2
  129. package/dist/utils/index.mjs +5 -5
  130. package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
  131. package/dist/{versioning-M9lNLhO8.d.mts → versioning-DTTvc80y.d.mts} +1 -1
  132. package/package.json +24 -34
  133. package/skills/arc/SKILL.md +521 -785
  134. package/skills/arc/references/agent-auth.md +238 -0
  135. package/skills/arc/references/api-reference.md +187 -0
  136. package/skills/arc/references/auth.md +354 -7
  137. package/skills/arc/references/enterprise-auth.md +94 -0
  138. package/skills/arc/references/events.md +8 -6
  139. package/skills/arc/references/mcp.md +2 -2
  140. package/skills/arc/references/multi-tenancy.md +11 -2
  141. package/skills/arc/references/production.md +10 -9
  142. package/skills/arc/references/scim.md +247 -0
  143. package/skills/arc/references/testing.md +1 -1
  144. package/skills/arc-code-review/SKILL.md +141 -0
  145. package/skills/arc-code-review/references/anti-patterns.md +911 -0
  146. package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
  147. package/skills/arc-code-review/references/migration-recipes.md +700 -0
  148. package/skills/arc-code-review/references/mongokit-migration.md +386 -0
  149. package/skills/arc-code-review/references/scaffolding.md +230 -0
  150. package/skills/arc-code-review/references/severity.md +127 -0
  151. package/dist/EventTransport-CfVEGaEl.d.mts +0 -293
  152. package/dist/adapters/index.d.mts +0 -3
  153. package/dist/adapters/index.mjs +0 -2
  154. package/dist/adapters-D0tT2Tyo.mjs +0 -949
  155. package/dist/auth/mongoose.d.mts +0 -191
  156. package/dist/auth/mongoose.mjs +0 -73
  157. package/dist/core-DnUsRpuX.mjs +0 -1049
  158. package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
  159. package/dist/errorHandler-Co3lnVmJ.d.mts +0 -114
  160. package/dist/errors-D5c-5BJL.mjs +0 -232
  161. package/dist/index-BbMrcvGp.d.mts +0 -362
  162. package/dist/redis-stream-CM8TXTix.d.mts +0 -110
  163. /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
  164. /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
  165. /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
  166. /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
  167. /package/dist/{elevation-s5ykdNHr.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
  168. /package/dist/{externalPaths-Bapitwvd.d.mts → externalPaths-BD5nw6St.d.mts} +0 -0
  169. /package/dist/{interface-CkkWm5uR.d.mts → interface-DfLGcus7.d.mts} +0 -0
  170. /package/dist/{interface-Da0r7Lna.d.mts → interface-beEtJyWM.d.mts} +0 -0
  171. /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
  172. /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
  173. /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
  174. /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
  175. /package/dist/{pluralize-BneOJkpi.mjs → pluralize-DQgqgifU.mjs} +0 -0
  176. /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
  177. /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
  178. /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
  179. /package/dist/{sessionManager-D-oNWHz3.d.mts → sessionManager-C4Le_UB3.d.mts} +0 -0
  180. /package/dist/{storage-BwGQXUpd.d.mts → storage-Dfzt4VTl.d.mts} +0 -0
  181. /package/dist/{tracing-DokiEsuz.d.mts → tracing-QJVprktp.d.mts} +0 -0
  182. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
  183. /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
  184. /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
  185. /package/dist/{websocket-CyJ1VIFI.d.mts → websocket-ChC2rqe1.d.mts} +0 -0
@@ -0,0 +1,1399 @@
1
+ import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-Cxde4rpC.mjs";
2
+ import { arcLog } from "./logger/index.mjs";
3
+ import { A as assertValidConfig, l as getDefaultCrudSchemas } from "./utils-_h9B3c57.mjs";
4
+ import { t as BaseController } from "./BaseController-DX_T-bDB.mjs";
5
+ import { t as applyPresets } from "./presets-BbkjdPeH.mjs";
6
+ import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-De34B1ZG.mjs";
7
+ import { t as hasEvents } from "./typeGuards-BzkXkvVv.mjs";
8
+ import { b as buildRequestScopeProjection, c as buildPreHandlerChain, d as resolveRoutePreHandlers, f as resolveRouterPluginMw, h as createFastifyHandler, i as buildAuthMiddleware, l as buildRateLimitConfig, m as createCrudHandlers, o as buildCrudPermissionMw, p as selectPluginMw, r as buildArcDecorator, s as buildPipelineHandler, u as resolvePipelineSteps } from "./routerShared-D6_fEGHh.mjs";
9
+ import { t as resolveActionPermission } from "./actionPermissions-CyUkQu6O.mjs";
10
+ //#region src/core/aggregation/defineAggregation.ts
11
+ /**
12
+ * Declare a single resource aggregation. Exported configs flow into
13
+ * `defineResource({ aggregations: { ... } })` either as named keys or
14
+ * via auto-discovery patterns (loadAggregations — future).
15
+ *
16
+ * @example Inline
17
+ * ```ts
18
+ * defineResource({
19
+ * name: 'order',
20
+ * aggregations: {
21
+ * revenueByStatus: defineAggregation({
22
+ * groupBy: 'status',
23
+ * measures: { count: 'count', revenue: 'sum:totalPrice' },
24
+ * permissions: requireRoles(['admin']),
25
+ * }),
26
+ * },
27
+ * });
28
+ * ```
29
+ *
30
+ * @example Multi-file
31
+ * ```ts
32
+ * // orders/aggregations/revenue-by-status.ts
33
+ * import { defineAggregation } from '@classytic/arc';
34
+ *
35
+ * export const revenueByStatus = defineAggregation({
36
+ * groupBy: 'status',
37
+ * measures: { count: 'count', revenue: 'sum:totalPrice' },
38
+ * permissions: requireRoles(['admin']),
39
+ * timeout: 5000,
40
+ * cache: { staleTime: 60 },
41
+ * });
42
+ *
43
+ * // orders/order.resource.ts
44
+ * import * as aggregations from './aggregations/index.js';
45
+ *
46
+ * defineResource({
47
+ * name: 'order',
48
+ * aggregations, // 30+ entries flow in
49
+ * });
50
+ * ```
51
+ */
52
+ function defineAggregation(config) {
53
+ return config;
54
+ }
55
+ //#endregion
56
+ //#region src/core/createCrudRouter.ts
57
+ /**
58
+ * Mount custom routes (from presets or user-defined `routes`) on Fastify.
59
+ * `wrapHandler` is derived inline from `!route.raw`.
60
+ */
61
+ function createCustomRoutes(fastify, routes, controller, options) {
62
+ const { tag, resourceName, arcDecorator, rateLimitConfig, pluginMw, pipeline, routeGuards } = options;
63
+ for (const route of routes) {
64
+ const opName = route.operation ?? (typeof route.handler === "string" ? route.handler : `${route.method.toLowerCase()}${route.path.replace(/[/:]/g, "_")}`);
65
+ const wrapHandler = !route.raw;
66
+ let handler;
67
+ if (typeof route.handler === "string") {
68
+ if (!controller) throw new Error(`Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller. Either provide a controller or use a function handler instead.`);
69
+ const method = controller[route.handler];
70
+ if (typeof method !== "function") throw new Error(`Handler '${route.handler}' not found on controller`);
71
+ const boundMethod = method.bind(controller);
72
+ if (wrapHandler) {
73
+ const steps = resolvePipelineSteps(pipeline, opName);
74
+ handler = steps.length > 0 ? buildPipelineHandler(boundMethod, steps, opName, resourceName) : createFastifyHandler(boundMethod);
75
+ } else handler = boundMethod;
76
+ } else if (wrapHandler) {
77
+ const steps = resolvePipelineSteps(pipeline, opName);
78
+ handler = steps.length > 0 ? buildPipelineHandler(route.handler, steps, opName, resourceName) : createFastifyHandler(route.handler);
79
+ } else handler = route.handler;
80
+ const routeTags = route.tags ?? (tag ? [tag] : void 0);
81
+ const convertedSchema = route.schema ? convertRouteSchema(route.schema) : void 0;
82
+ const schema = {
83
+ ...routeTags ? { tags: routeTags } : {},
84
+ ...route.summary ? { summary: route.summary } : {},
85
+ ...route.description ? { description: route.description } : {},
86
+ ...convertedSchema ?? {}
87
+ };
88
+ const customPreHandlers = resolveRoutePreHandlers(route.preHandler, fastify, `${route.method} ${route.path}`);
89
+ const preHandler = buildPreHandlerChain({
90
+ preAuth: route.preAuth ?? [],
91
+ arcDecorator,
92
+ authMw: buildAuthMiddleware(fastify, route.permissions),
93
+ permissionMw: buildCrudPermissionMw(route.permissions, resourceName, opName),
94
+ pluginMw: selectPluginMw(route.method, pluginMw),
95
+ routeGuards,
96
+ customMws: customPreHandlers
97
+ });
98
+ const isStream = route.streamResponse === true;
99
+ fastify.route({
100
+ method: route.method,
101
+ url: route.path,
102
+ schema,
103
+ preHandler: preHandler.length > 0 ? preHandler : void 0,
104
+ handler: isStream ? async (request, reply) => {
105
+ reply.raw.setHeader("Content-Type", "text/event-stream");
106
+ reply.raw.setHeader("Cache-Control", "no-cache");
107
+ reply.raw.setHeader("Connection", "keep-alive");
108
+ return handler(request, reply);
109
+ } : handler,
110
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
111
+ });
112
+ }
113
+ }
114
+ /**
115
+ * Create CRUD routes for a controller.
116
+ *
117
+ * @param fastify - Fastify instance with Arc decorators
118
+ * @param controller - CRUD controller with handler methods (optional when
119
+ * `disableDefaultRoutes: true` and only custom `routes`
120
+ * are being registered)
121
+ * @param options - Router configuration
122
+ */
123
+ function createCrudRouter(fastify, controller, options = {}) {
124
+ const { tag = "Resource", schemas = {}, permissions = {}, middlewares = {}, routeGuards = [], routes: customRoutes = [], disableDefaultRoutes = false, disabledRoutes = [], resourceName = "unknown", schemaOptions, rateLimit, pipe: pipeline, fields: fieldPermissions, updateMethod = DEFAULT_UPDATE_METHOD } = options;
125
+ const rateLimitConfig = buildRateLimitConfig(rateLimit);
126
+ const resourceHasQueryCache = fastify.hasDecorator("queryCache") && controller && typeof controller._cacheConfig !== "undefined" && controller._cacheConfig !== void 0;
127
+ const pluginMw = resolveRouterPluginMw(fastify, Boolean(resourceHasQueryCache));
128
+ const arcDecorator = buildArcDecorator({
129
+ resourceName,
130
+ schemaOptions,
131
+ permissions,
132
+ hooks: fastify.arc?.hooks,
133
+ events: fastify.events,
134
+ fields: fieldPermissions
135
+ });
136
+ const mw = {
137
+ list: middlewares.list ?? [],
138
+ get: middlewares.get ?? [],
139
+ create: middlewares.create ?? [],
140
+ update: middlewares.update ?? [],
141
+ delete: middlewares.delete ?? []
142
+ };
143
+ const idParamsSchema = {
144
+ type: "object",
145
+ properties: { id: { type: "string" } },
146
+ required: ["id"]
147
+ };
148
+ const defaultSchemas = getDefaultCrudSchemas();
149
+ /**
150
+ * Merge: base (tags/summary) → defaults (response/querystring) → user overrides.
151
+ * User-provided schemas always win; defaults enable fast-json-stringify when
152
+ * no user schema is set.
153
+ */
154
+ const buildSchema = (base, defaults, userSchema) => ({
155
+ ...defaults,
156
+ ...base,
157
+ ...userSchema ?? {}
158
+ });
159
+ if (!disableDefaultRoutes) {
160
+ if (!controller) throw new Error("Controller is required when disableDefaultRoutes is not true. Provide a controller or use defineResource which auto-creates BaseController.");
161
+ const handlers = buildCrudHandlers(controller, pipeline, resourceName);
162
+ const crudTable = [
163
+ {
164
+ op: "list",
165
+ method: "GET",
166
+ url: "/",
167
+ summary: `List ${tag}`,
168
+ hasIdParams: false
169
+ },
170
+ {
171
+ op: "get",
172
+ method: "GET",
173
+ url: "/:id",
174
+ summary: `Get ${tag} by ID`,
175
+ hasIdParams: true
176
+ },
177
+ {
178
+ op: "create",
179
+ method: "POST",
180
+ url: "/",
181
+ summary: `Create ${tag}`,
182
+ hasIdParams: false
183
+ },
184
+ {
185
+ op: "update",
186
+ method: "PATCH",
187
+ url: "/:id",
188
+ summary: `Update ${tag}`,
189
+ hasIdParams: true
190
+ },
191
+ {
192
+ op: "delete",
193
+ method: "DELETE",
194
+ url: "/:id",
195
+ summary: `Delete ${tag}`,
196
+ hasIdParams: true
197
+ }
198
+ ];
199
+ for (const spec of crudTable) {
200
+ if (disabledRoutes.includes(spec.op)) continue;
201
+ const permission = permissions[spec.op];
202
+ const preHandler = buildPreHandlerChain({
203
+ arcDecorator,
204
+ authMw: buildAuthMiddleware(fastify, permission),
205
+ permissionMw: buildCrudPermissionMw(permission, resourceName, spec.op),
206
+ pluginMw: selectPluginMw(spec.method, pluginMw),
207
+ routeGuards,
208
+ customMws: mw[spec.op]
209
+ });
210
+ const methodsToRegister = spec.op === "update" ? updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod] : [spec.method];
211
+ for (const method of methodsToRegister) {
212
+ const summary = spec.op === "update" ? `${method === "PUT" ? "Replace" : "Update"} ${tag}` : spec.summary;
213
+ fastify.route({
214
+ method,
215
+ url: spec.url,
216
+ schema: buildSchema({
217
+ tags: [tag],
218
+ summary,
219
+ ...spec.hasIdParams ? { params: idParamsSchema } : {}
220
+ }, defaultSchemas[spec.op], schemas[spec.op]),
221
+ preHandler: preHandler.length > 0 ? preHandler : void 0,
222
+ handler: handlers[spec.op],
223
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
224
+ });
225
+ }
226
+ }
227
+ }
228
+ if (customRoutes.length > 0) createCustomRoutes(fastify, customRoutes, controller, {
229
+ tag,
230
+ resourceName,
231
+ arcDecorator,
232
+ rateLimitConfig,
233
+ pluginMw,
234
+ pipeline,
235
+ routeGuards
236
+ });
237
+ }
238
+ function buildCrudHandlers(ctrl, pipeline, resourceName) {
239
+ const standardHandlers = createCrudHandlers(ctrl);
240
+ if (!pipeline) return standardHandlers;
241
+ const wrapped = { ...standardHandlers };
242
+ for (const op of CRUD_OPERATIONS) {
243
+ const steps = resolvePipelineSteps(pipeline, op);
244
+ if (steps.length === 0) continue;
245
+ wrapped[op] = buildPipelineHandler(ctrl[op].bind(ctrl), steps, op, resourceName);
246
+ }
247
+ return wrapped;
248
+ }
249
+ /**
250
+ * Build a permission middleware from a PermissionCheck — useful when hosts
251
+ * register their own routes outside the resource system but still want to
252
+ * evaluate permissions through the shared applicator.
253
+ */
254
+ function createPermissionMiddleware(permission, resourceName, action) {
255
+ return buildCrudPermissionMw(permission, resourceName, action);
256
+ }
257
+ //#endregion
258
+ //#region src/core/defineResource/controller.ts
259
+ /**
260
+ * Phase 4 — pick (or auto-create) the resource's controller.
261
+ *
262
+ * Three branches:
263
+ * 1. User-supplied controller → forward `queryParser` (duck-typed)
264
+ * and warn on dropped resource-level options.
265
+ * 2. No controller, has CRUD routes, has a repository → auto-build
266
+ * a `BaseController` with every resource-level knob threaded
267
+ * through (tenantField, schemaOptions, idField, defaultSort,
268
+ * cache, onFieldWriteDenied, presetFields).
269
+ * 3. Otherwise → `undefined` (custom-routes-only resource).
270
+ *
271
+ * The warns are load-bearing DX: silently dropping `queryParser`,
272
+ * `schemaOptions`, etc. on a custom controller produces 90-minute
273
+ * "why don't my filters work" debugs. Each warn names the resource,
274
+ * lists the dropped options, and points at the canonical fix. All
275
+ * warns honour `ARC_SUPPRESS_WARNINGS=1` via `arcLog()`.
276
+ */
277
+ /**
278
+ * Resolve the controller for the resource. See module docstring for
279
+ * branch semantics.
280
+ */
281
+ function resolveOrAutoCreateController(resolvedConfig, adapter, repository, hasCrudRoutes) {
282
+ const userController = resolvedConfig.controller;
283
+ if (userController) {
284
+ threadQueryParser(userController, resolvedConfig);
285
+ warnOnDroppedAuthorOptions(resolvedConfig);
286
+ warnOnDroppedPresetOptions(resolvedConfig);
287
+ return userController;
288
+ }
289
+ if (!hasCrudRoutes || !repository) return void 0;
290
+ return buildBaseController(resolvedConfig, adapter, repository);
291
+ }
292
+ /**
293
+ * Forward a resource-level `queryParser` into a user-supplied
294
+ * controller via duck-typed `setQueryParser`. Without this the
295
+ * controller's internal default would silently override the
296
+ * resource's parser, drifting `[contains]` / `[like]` semantics
297
+ * away from what the OpenAPI schema advertises.
298
+ */
299
+ function threadQueryParser(controller, resolvedConfig) {
300
+ if (!resolvedConfig.queryParser) return;
301
+ const ctrl = controller;
302
+ if (typeof ctrl.setQueryParser === "function") {
303
+ ctrl.setQueryParser(resolvedConfig.queryParser);
304
+ return;
305
+ }
306
+ arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" declares a custom \`queryParser\` but its controller does not expose \`setQueryParser(qp)\`. The parser will NOT be threaded into the controller's query resolution — operator filters (\`[contains]\`, \`[like]\`, etc.) may fall back to the controller's internal default. Extend \`BaseController\` / \`BaseCrudController\` (both implement \`setQueryParser\`) OR add the method to your custom controller to honor the resource-level parser.`);
307
+ }
308
+ /**
309
+ * Warn when the user supplies their own controller AND declares
310
+ * resource-level options arc only auto-threads on the auto-build
311
+ * path. The user *can* fix this by forwarding through `super(repo,
312
+ * { ... })`, so the warn names the dropped options + the canonical
313
+ * fix.
314
+ */
315
+ function warnOnDroppedAuthorOptions(resolvedConfig) {
316
+ const dropped = [];
317
+ if (resolvedConfig.tenantField !== void 0) dropped.push("tenantField");
318
+ if (resolvedConfig.schemaOptions !== void 0 && Object.keys(resolvedConfig.schemaOptions).length > 0) dropped.push("schemaOptions");
319
+ if (resolvedConfig.idField !== void 0) dropped.push("idField");
320
+ if (resolvedConfig.defaultSort !== void 0) dropped.push("defaultSort");
321
+ if (resolvedConfig.cache !== void 0) dropped.push("cache");
322
+ if (resolvedConfig.onFieldWriteDenied !== void 0) dropped.push("onFieldWriteDenied");
323
+ if (dropped.length === 0) return;
324
+ arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" declares a custom controller AND resource-level option(s) [${dropped.join(", ")}]. Arc only threads these when it auto-builds the controller — when you pass your own, they are dropped silently and the controller falls back to its own defaults (e.g. tenantField → 'organizationId'). Forward them to your controller's \`super(repo, { ... })\` call. Same root cause as the \`queryParser\` warn above.`);
325
+ }
326
+ /**
327
+ * Warn when a preset injected `_controllerOptions` (slugLookup,
328
+ * softDelete, parent presets) but the user supplied their own
329
+ * controller. The user did NOT declare these — "forward them" is
330
+ * bad advice. The fix is either drop the preset or extend
331
+ * `BaseController` so the auto-build path runs.
332
+ */
333
+ function warnOnDroppedPresetOptions(resolvedConfig) {
334
+ if (resolvedConfig._controllerOptions === void 0) return;
335
+ const presetFields = [];
336
+ if (resolvedConfig._controllerOptions.slugField) presetFields.push("slugField");
337
+ if (resolvedConfig._controllerOptions.parentField) presetFields.push("parentField");
338
+ arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" applies a preset that injects controller field(s) [${presetFields.join(", ") || "preset metadata"}] (e.g. slugLookup / softDelete / parent), but the resource also declares a custom controller. Preset metadata only reaches arc's auto-built BaseController — your custom controller will not see \`slugField\`/\`parentField\`/etc. Either (a) drop the preset on this resource (\`presets: [...]\` without it), or (b) extend \`BaseController\` / \`BaseCrudController\` so arc auto-builds the controller and threads the preset fields automatically.`);
339
+ }
340
+ /**
341
+ * Auto-build a `BaseController` with every resource-level knob
342
+ * threaded in. `maxLimit` is extracted from the parser's schema so
343
+ * `BaseController.QueryResolver` and Fastify validation stay in sync
344
+ * with the parser's configured limit.
345
+ */
346
+ function buildBaseController(resolvedConfig, adapter, repository) {
347
+ const qp = resolvedConfig.queryParser;
348
+ let maxLimitFromParser;
349
+ if (qp?.getQuerySchema) {
350
+ const limitProp = qp.getQuerySchema()?.properties?.limit;
351
+ if (limitProp?.maximum) maxLimitFromParser = limitProp.maximum;
352
+ }
353
+ return new BaseController(repository, {
354
+ resourceName: resolvedConfig.name,
355
+ schemaOptions: resolvedConfig.schemaOptions,
356
+ queryParser: qp,
357
+ maxLimit: maxLimitFromParser,
358
+ tenantField: resolvedConfig.tenantField,
359
+ idField: resolvedConfig.idField,
360
+ ...resolvedConfig.defaultSort !== void 0 ? { defaultSort: resolvedConfig.defaultSort } : {},
361
+ matchesFilter: adapter?.matchesFilter,
362
+ cache: resolvedConfig.cache,
363
+ onFieldWriteDenied: resolvedConfig.onFieldWriteDenied,
364
+ presetFields: resolvedConfig._controllerOptions ? {
365
+ slugField: resolvedConfig._controllerOptions.slugField,
366
+ parentField: resolvedConfig._controllerOptions.parentField
367
+ } : void 0
368
+ });
369
+ }
370
+ //#endregion
371
+ //#region src/core/defineResource/hooks.ts
372
+ /**
373
+ * Phase 6 — wire preset hooks + inline `config.hooks` onto the
374
+ * resource's `_pendingHooks`.
375
+ *
376
+ * Two sources feed the same array:
377
+ * 1. Preset hooks collected during `applyPresets()` (raw `_hooks`
378
+ * on the internal config). Already in the canonical
379
+ * `{ operation, phase, handler, priority }` shape — copied
380
+ * verbatim (priority defaults to 10).
381
+ * 2. Inline `config.hooks.beforeCreate` / `afterCreate` / etc.
382
+ * Authored by the user on the original `ResourceConfig`.
383
+ * Wrapped in a `ResourceHookContext` projection (v2.10.8) so
384
+ * authors can read `scope` / `context` without reaching into
385
+ * internal request fields.
386
+ *
387
+ * The 6 inline hook keys (before/after × create/update/delete) used
388
+ * to be 6 nearly-identical blocks; collapsed into a table + loop so
389
+ * a future hook (e.g. `beforeRead`) is one row, not seven scattered
390
+ * edits.
391
+ */
392
+ /**
393
+ * Combined entry-point for Phase 6. Pushes preset-collected hooks
394
+ * first, then inline `config.hooks` (so hosts can rely on
395
+ * registration order if priorities tie).
396
+ */
397
+ function wireHooks(resource, resolvedConfig, inlineHooksConfig) {
398
+ if (resolvedConfig._hooks?.length) resource._pendingHooks.push(...resolvedConfig._hooks.map((hook) => ({
399
+ operation: hook.operation,
400
+ phase: hook.phase,
401
+ handler: hook.handler,
402
+ priority: hook.priority ?? 10
403
+ })));
404
+ if (!inlineHooksConfig) return;
405
+ const h = inlineHooksConfig;
406
+ for (const spec of INLINE_HOOK_SPECS) {
407
+ const fn = h[spec.key];
408
+ if (typeof fn !== "function") continue;
409
+ resource._pendingHooks.push({
410
+ operation: spec.operation,
411
+ phase: spec.phase,
412
+ priority: 10,
413
+ handler: (ctx) => fn(buildHookContext(ctx))
414
+ });
415
+ }
416
+ }
417
+ /**
418
+ * Inline hook spec table — one row per `config.hooks.{key}`. Adding
419
+ * a new lifecycle hook (e.g. `beforeRead`) means appending one row
420
+ * here; the loop handles the rest.
421
+ */
422
+ const INLINE_HOOK_SPECS = [
423
+ {
424
+ key: "beforeCreate",
425
+ operation: "create",
426
+ phase: "before"
427
+ },
428
+ {
429
+ key: "afterCreate",
430
+ operation: "create",
431
+ phase: "after"
432
+ },
433
+ {
434
+ key: "beforeUpdate",
435
+ operation: "update",
436
+ phase: "before"
437
+ },
438
+ {
439
+ key: "afterUpdate",
440
+ operation: "update",
441
+ phase: "after"
442
+ },
443
+ {
444
+ key: "beforeDelete",
445
+ operation: "delete",
446
+ phase: "before"
447
+ },
448
+ {
449
+ key: "afterDelete",
450
+ operation: "delete",
451
+ phase: "after"
452
+ }
453
+ ];
454
+ /**
455
+ * Project a raw HookSystem context into a `ResourceHookContext` for
456
+ * inline `config.hooks.*` handlers. The projection lifts `scope`
457
+ * out of `context._scope` so authors don't reach into internal
458
+ * fields.
459
+ */
460
+ function buildHookContext(ctx) {
461
+ const context = ctx.context;
462
+ const rawScope = context?._scope;
463
+ return {
464
+ data: ctx.data ?? ctx.result ?? {},
465
+ user: ctx.user,
466
+ context,
467
+ scope: buildRequestScopeProjection(rawScope),
468
+ meta: ctx.meta
469
+ };
470
+ }
471
+ //#endregion
472
+ //#region src/core/defineResource/idField.ts
473
+ /**
474
+ * Returns a fresh config with `idField` filled in (when applicable),
475
+ * or the original reference when nothing changes. Never mutates the
476
+ * caller's input.
477
+ */
478
+ function resolveIdField(config, repository) {
479
+ if (config.idField !== void 0 || !repository) return config;
480
+ const repoIdField = repository.idField;
481
+ if (typeof repoIdField === "string" && repoIdField !== "_id") return {
482
+ ...config,
483
+ idField: repoIdField
484
+ };
485
+ return config;
486
+ }
487
+ //#endregion
488
+ //#region src/core/schemaOptions.ts
489
+ /**
490
+ * Inject the tenant-scoping field rule into `schemaOptions.fieldRules`:
491
+ *
492
+ * { [tenantField]: { systemManaged: true, preserveForElevated: true } }
493
+ *
494
+ * Why both flags: `systemManaged` tells `BodySanitizer` to strip the
495
+ * field from inbound bodies (so member clients can't forge a target
496
+ * tenant). `preserveForElevated` exempts elevated-admin scopes from the
497
+ * strip, so platform admins without a pinned org can still pick a target
498
+ * org via the request body (the only channel they have —
499
+ * `BaseController.create` can't re-stamp from scope when scope has no
500
+ * orgId).
501
+ *
502
+ * **Returns a new `RouteSchemaOptions`** — the input is never mutated.
503
+ * Callers should assign the return value to whatever config slot they
504
+ * read from downstream (always the `resolvedConfig`, never raw `config`).
505
+ *
506
+ * **No-op when:**
507
+ * - `tenantField` is `false` (platform-universal resource)
508
+ * - `tenantField` is undefined
509
+ * - The caller already declared `fieldRules[tenantField].systemManaged`
510
+ * (even as `false`) — explicit opt-outs are respected
511
+ *
512
+ * `preserveForElevated` defaults to `true` but is preserved verbatim
513
+ * when the caller set it explicitly.
514
+ */
515
+ function autoInjectTenantFieldRules(schemaOptions, tenantField) {
516
+ if (tenantField === false || tenantField === void 0) return schemaOptions;
517
+ const fieldName = tenantField || "organizationId";
518
+ const existing = schemaOptions?.fieldRules ?? {};
519
+ const existingRule = existing[fieldName];
520
+ if (existingRule && existingRule.systemManaged !== void 0) return schemaOptions;
521
+ return {
522
+ ...schemaOptions ?? {},
523
+ fieldRules: {
524
+ ...existing,
525
+ [fieldName]: {
526
+ ...existingRule ?? {},
527
+ systemManaged: true,
528
+ preserveForElevated: existingRule?.preserveForElevated ?? true
529
+ }
530
+ }
531
+ };
532
+ }
533
+ /**
534
+ * Remove a field from a JSON Schema's `required[]` array. Leaves `properties`
535
+ * intact so advanced callers can still send the value — the field just isn't
536
+ * mandatory at validation time.
537
+ *
538
+ * Returns a fresh schema (no mutation). No-op when the schema is undefined,
539
+ * lacks a `required[]`, or the field is already absent from it.
540
+ */
541
+ function stripFromRequired(schema, fieldName) {
542
+ if (!schema || typeof schema !== "object") return schema;
543
+ const required = schema.required;
544
+ if (!Array.isArray(required) || !required.includes(fieldName)) return schema;
545
+ const filtered = required.filter((f) => f !== fieldName);
546
+ const next = { ...schema };
547
+ if (filtered.length > 0) next.required = filtered;
548
+ else delete next.required;
549
+ return next;
550
+ }
551
+ /**
552
+ * Strip framework-injected fields from the `required[]` list of every
553
+ * body-shaped slot in an adapter's generated schemas (v2.11.0).
554
+ *
555
+ * A "framework-injected field" is any field marked `systemManaged: true`
556
+ * in `schemaOptions.fieldRules`. Arc populates those fields from the
557
+ * request scope / preset middleware / controller — the client is never
558
+ * expected to supply them, so they must not be in the wire contract's
559
+ * `required[]` even if the underlying engine's Mongoose/Zod schema
560
+ * declares them as required at the DB layer.
561
+ *
562
+ * **The primary gotcha this closes:** engines built on
563
+ * `@classytic/primitives` (mongokit, pricelist, and every downstream
564
+ * `@classytic/*` engine) default to `tenant: { required: true }` in
565
+ * `resolveTenantConfig()`. That stamps `organizationId: { required: true }`
566
+ * on the Mongoose schema, which the adapter faithfully reflects into the
567
+ * generated `createBody` / `updateBody` schema's `required[]`. Fastify's
568
+ * preValidation runs BEFORE arc's preHandler chain, so
569
+ * `multiTenantPreset`'s tenant-injection hook never gets a chance to run —
570
+ * the request is rejected with `must have required property 'organizationId'`
571
+ * even though the client correctly supplied `x-organization-id` and the
572
+ * framework had already promised to inject the value.
573
+ *
574
+ * The only workaround before 2.11 was
575
+ * `createEngine({ tenant: { required: false } })` at every consumer site —
576
+ * a leaky abstraction every new engine-backed resource had to remember.
577
+ *
578
+ * **Secondary coverage (defense-in-depth):** the same transform also fires
579
+ * for `auditedPreset`'s `createdBy` / `updatedBy`, any future preset that
580
+ * marks fields `systemManaged`, and any host-declared `fieldRules` with
581
+ * `systemManaged: true`. Every framework-injected field gets the wire
582
+ * contract / runtime pairing for free.
583
+ *
584
+ * **Leaves `properties` intact** — elevated admins or advanced callers can
585
+ * still send systemManaged fields in the body. `BodySanitizer` enforces
586
+ * the runtime policy (`preserveForElevated`, `strip` vs `reject`, etc.).
587
+ *
588
+ * **No-op when:**
589
+ * - `schemaOptions.fieldRules` is undefined / empty
590
+ * - No rule has `systemManaged: true`
591
+ * - The generated schemas object is undefined (adapter didn't generate any)
592
+ *
593
+ * Applies to both `createBody` and `updateBody` — update middleware also
594
+ * injects tenant/audit fields, so the update wire contract has the same
595
+ * problem as create.
596
+ */
597
+ function stripSystemManagedFromBodyRequired(schemas, schemaOptions) {
598
+ if (!schemas) return schemas;
599
+ const rules = schemaOptions?.fieldRules;
600
+ if (!rules) return schemas;
601
+ const systemManagedFields = Object.entries(rules).filter(([, rule]) => rule?.systemManaged === true).map(([field]) => field);
602
+ if (systemManagedFields.length === 0) return schemas;
603
+ const next = { ...schemas };
604
+ let createBody = schemas.createBody;
605
+ for (const field of systemManagedFields) createBody = stripFromRequired(createBody, field);
606
+ if (createBody !== schemas.createBody) next.createBody = createBody;
607
+ let updateBody = schemas.updateBody;
608
+ for (const field of systemManagedFields) updateBody = stripFromRequired(updateBody, field);
609
+ if (updateBody !== schemas.updateBody) next.updateBody = updateBody;
610
+ return next;
611
+ }
612
+ //#endregion
613
+ //#region src/core/defineResource/presets.ts
614
+ /**
615
+ * Phase 3 — apply presets + auto-inject tenant-field schema rules.
616
+ *
617
+ * Produces the canonical `resolvedConfig` — a fresh clone of the
618
+ * caller's config with presets applied and tenant-field schema rules
619
+ * inferred. Always returns a fresh object so downstream mutations
620
+ * (`_appliedPresets`, `schemaOptions` auto-inject, `_controllerOptions`,
621
+ * `_hooks`) never leak onto the caller's config. Pre-2.11 the
622
+ * no-preset branch returned the raw caller reference, which mutated
623
+ * resource-config fragments hosts were reusing.
624
+ *
625
+ * Centralising the auto-inject + tenant inference here means every
626
+ * downstream reader (`BodySanitizer`, adapter `generateSchemas()`,
627
+ * MCP tool generator, OpenAPI builder) sees the same post-inject
628
+ * shape — `defineResource()` only ever consults `resolvedConfig`,
629
+ * never the raw user input, after this phase runs.
630
+ */
631
+ /**
632
+ * Run the Phase 3 pipeline: clone → apply presets → infer tenant
633
+ * field → auto-inject system-managed rules. Returns the resolved
634
+ * `InternalResourceConfig` that every later phase consumes.
635
+ */
636
+ function applyPresetsAndAutoInject(config) {
637
+ const originalPresets = (config.presets ?? []).map((p) => typeof p === "string" ? p : p.name);
638
+ const resolvedConfig = config.presets?.length ? applyPresets(config, config.presets) : { ...config };
639
+ resolvedConfig._appliedPresets = originalPresets;
640
+ inferTenantFieldFromAdapter(resolvedConfig);
641
+ resolvedConfig.schemaOptions = autoInjectTenantFieldRules(resolvedConfig.schemaOptions, resolvedConfig.tenantField);
642
+ return resolvedConfig;
643
+ }
644
+ /**
645
+ * Infer `tenantField: false` for resources whose model schema doesn't
646
+ * declare the configured tenant path. Closes the silent-zero-results
647
+ * footgun where hosts forget `tenantField: false` on company-wide
648
+ * tables (lookup tables, platform settings, single-tenant apps) — the
649
+ * default `'organizationId'` filter would scope every read to the
650
+ * caller's org and return nothing for documents that don't carry the
651
+ * field. Adapters opt into inference by implementing `hasFieldPath`;
652
+ * when the hook is absent, behaviour is unchanged (legacy default).
653
+ *
654
+ * Mutates the resolved config in place because (a) the next call
655
+ * (`autoInjectTenantFieldRules`) reads the inferred value, and (b)
656
+ * `_appliedPresets` is already stamped — keeping the mutation here
657
+ * avoids a second clone per resource.
658
+ *
659
+ * Three branches:
660
+ * - `tenantField === false` → host explicitly opted out, no inference.
661
+ * - `tenantField === undefined` AND adapter says the default doesn't
662
+ * exist → set to `false`, log info (the inferred decision).
663
+ * - `tenantField === '<custom>'` AND adapter says it doesn't exist →
664
+ * warn (likely typo or stale field); leave the value as-is so
665
+ * failures surface at runtime with the configured name in error
666
+ * messages.
667
+ */
668
+ function inferTenantFieldFromAdapter(config) {
669
+ if (config.tenantField === false) return;
670
+ const adapter = config.adapter;
671
+ if (!adapter?.hasFieldPath) return;
672
+ const configured = config.tenantField ?? "organizationId";
673
+ const exists = adapter.hasFieldPath(configured);
674
+ if (exists === void 0) return;
675
+ if (exists) return;
676
+ if (config.tenantField === void 0) {
677
+ config.tenantField = false;
678
+ arcLog("defineResource").info(`Resource "${config.name}": auto-inferred \`tenantField: false\` — model has no \`${configured}\` path. Set \`tenantField\` explicitly to silence this log, or to a real field name on this resource's model.`);
679
+ return;
680
+ }
681
+ arcLog("defineResource").warn(`Resource "${config.name}": configured \`tenantField: '${configured}'\` but the model has no such path. Queries scoped by this field will silently return nothing. Either set \`tenantField: false\` (company-wide resource), or fix the field name.`);
682
+ }
683
+ /** Does this resource register any default CRUD routes? */
684
+ function computeHasCrudRoutes(config) {
685
+ const disabled = new Set(config.disabledRoutes ?? []);
686
+ return !config.disableDefaultRoutes && CRUD_OPERATIONS.some((op) => !disabled.has(op));
687
+ }
688
+ //#endregion
689
+ //#region src/core/defineResource/plugin.ts
690
+ /**
691
+ * Build the CRUD schema map from the adapter's `OpenApiSchemas` plus
692
+ * any `customSchemas` overrides on the resource.
693
+ *
694
+ * Returns `null` when neither input has anything to contribute, so
695
+ * the caller can pass `undefined` straight to `createCrudRouter`.
696
+ *
697
+ * **Per-slot layering (post-2.12 DX fix).** Adapter auto-gen runs
698
+ * unconditionally — declaring one custom slot (e.g. a richer
699
+ * `create.body`) no longer wholesale-disables generated `get`,
700
+ * `update`, `delete`, and `params` schemas. The pre-fix branch
701
+ * skipped auto-gen entirely whenever `customSchemas` had any entry,
702
+ * which silently flipped four slots from "auto-derived from the
703
+ * adapter's schema generator" to "Fastify default" the moment a
704
+ * host customised one. Now: auto-gen first, then deep-merge
705
+ * customSchemas on top per slot.
706
+ *
707
+ * **`params` cloning is load-bearing.** Three CRUD slots (`get`,
708
+ * `delete`, `update`) need a `params` schema. The previous inline
709
+ * code shared the same reference across all three, so a downstream
710
+ * mutation (e.g. attaching a vendor `description` for OpenAPI
711
+ * tooling) leaked across operations. Each slot now owns its own
712
+ * shallow clone.
713
+ */
714
+ function buildGeneratedCrudSchemas(openApi, customSchemas) {
715
+ const generated = {};
716
+ if (openApi) {
717
+ const { createBody, updateBody, params } = openApi;
718
+ if (createBody) generated.create = { body: safeBody(createBody) };
719
+ if (updateBody) {
720
+ const patchBody = { ...updateBody };
721
+ delete patchBody.required;
722
+ generated.update = { body: safeBody(patchBody) };
723
+ if (params) generated.update.params = cloneShallow(params);
724
+ }
725
+ if (params) {
726
+ generated.get = { params: cloneShallow(params) };
727
+ generated.delete = { params: cloneShallow(params) };
728
+ if (!generated.update) generated.update = { params: cloneShallow(params) };
729
+ else if (!generated.update.params) generated.update.params = cloneShallow(params);
730
+ }
731
+ }
732
+ let schemas = Object.keys(generated).length > 0 ? generated : null;
733
+ if (customSchemas && Object.keys(customSchemas).length > 0) {
734
+ schemas = schemas ?? {};
735
+ for (const [op, customSchema] of Object.entries(customSchemas)) {
736
+ const key = op;
737
+ const converted = convertRouteSchema(customSchema);
738
+ schemas[key] = schemas[key] ? deepMergeSchemas(schemas[key], converted) : converted;
739
+ }
740
+ }
741
+ return schemas;
742
+ }
743
+ /**
744
+ * Normalize the listQuery JSON Schema for Fastify/AJV strict-mode use.
745
+ *
746
+ * The `qs` parser turns bracket notation into nested objects/arrays:
747
+ * `?name[contains]=foo` → `{ name: { contains: "foo" } }`
748
+ * `?tags[]=a&tags[]=b` → `{ tags: ["a", "b"] }`
749
+ * `?populate[author][select]=name` → deeply nested object
750
+ *
751
+ * AJV rejects these against the OpenAPI-friendly `type: "string"`
752
+ * declarations adapters generate. The QueryParser is the source of truth
753
+ * for filter validation/coercion, so we replace each property with a
754
+ * minimal AJV-strict-mode-clean shape: numeric pagination keys keep
755
+ * `type: "integer"` (so `minimum`/`maximum` don't trigger AJV warnings),
756
+ * everything else collapses to `{}`.
757
+ *
758
+ * The original `limit.maximum` is preserved so the parser's configured
759
+ * max stays effective at the AJV layer.
760
+ */
761
+ function normalizeListQuerySchema(listQuerySchema) {
762
+ const NORMALIZED_PROPS = {
763
+ page: {
764
+ type: "integer",
765
+ minimum: 1
766
+ },
767
+ limit: {
768
+ type: "integer",
769
+ minimum: 1
770
+ },
771
+ sort: {},
772
+ search: {},
773
+ select: {},
774
+ after: {},
775
+ populate: {},
776
+ lookup: {},
777
+ aggregate: {}
778
+ };
779
+ const props = listQuerySchema.properties;
780
+ const normalizedProps = props ? { ...props } : void 0;
781
+ if (normalizedProps) {
782
+ const originalLimit = normalizedProps.limit;
783
+ if (originalLimit?.maximum) NORMALIZED_PROPS.limit = {
784
+ ...NORMALIZED_PROPS.limit,
785
+ maximum: originalLimit.maximum
786
+ };
787
+ for (const key of Object.keys(normalizedProps)) normalizedProps[key] = NORMALIZED_PROPS[key] ?? {};
788
+ }
789
+ return {
790
+ ...listQuerySchema,
791
+ ...normalizedProps ? { properties: normalizedProps } : {},
792
+ additionalProperties: listQuerySchema.additionalProperties ?? true
793
+ };
794
+ }
795
+ /**
796
+ * Merge two JSON schema branches deeply. Arrays are unioned with
797
+ * deduplication (so combined `required` lists don't duplicate field
798
+ * names); plain-object keys recurse; primitives are overwritten.
799
+ *
800
+ * Exported for the action/CRUD router-config code paths that need to
801
+ * compose user overrides on top of generated schemas.
802
+ */
803
+ function deepMergeSchemas(base, override) {
804
+ if (!override) return base;
805
+ if (!base) return override;
806
+ const result = { ...base };
807
+ for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [...new Set([...result[key], ...value])];
808
+ else if (value && typeof value === "object" && !Array.isArray(value)) result[key] = deepMergeSchemas(result[key], value);
809
+ else result[key] = value;
810
+ return result;
811
+ }
812
+ function cloneShallow(value) {
813
+ return { ...value };
814
+ }
815
+ function safeBody(schema) {
816
+ if (schema && typeof schema === "object" && schema.type === "object") return {
817
+ additionalProperties: true,
818
+ ...schema
819
+ };
820
+ return schema;
821
+ }
822
+ /**
823
+ * Normalize `ActionsMap` into the `ActionRouterConfig` shape that
824
+ * `createActionRouter` expects.
825
+ *
826
+ * **Permission fallback chain (fail-closed, v2.10.5):** delegated to the
827
+ * shared resolver in `actionPermissions.ts`. The resource-level gate
828
+ * goes into the resolver's slot 2 (`resourceActionPermissions`) — its
829
+ * semantic home — leaving slot 3 (`globalAuth`) reserved for direct
830
+ * `createActionRouter` callers that genuinely have a router-wide gate.
831
+ *
832
+ * The returned `actionPermissions` map is FULLY RESOLVED per action.
833
+ * Earlier versions returned a sparse map plus a `globalAuth` field that
834
+ * `createActionRouter` flattened at request time via `?? globalAuth`.
835
+ * That conflated two different layers in the resolver chain (slot 2 vs.
836
+ * slot 3). Boot-time resolution closes the drift: every action that
837
+ * survives this pass has its effective gate baked into the map, and
838
+ * `createActionRouter`'s request-time `?? globalAuth` becomes a no-op
839
+ * for the defineResource path.
840
+ */
841
+ function normalizeActionsToRouterConfig(actions, resourceActionPermissions, tag, resourcePermissions, resourceName, log) {
842
+ const handlers = {};
843
+ const permissions = {};
844
+ const schemas = {};
845
+ for (const [name, entry] of Object.entries(actions)) {
846
+ const explicit = typeof entry !== "function" && entry.permissions ? entry.permissions : void 0;
847
+ if (typeof entry === "function") handlers[name] = entry;
848
+ else {
849
+ const def = entry;
850
+ handlers[name] = def.handler;
851
+ if (def.schema) schemas[name] = def.schema;
852
+ }
853
+ const effective = resolveActionPermission({
854
+ action: entry,
855
+ resourcePermissions,
856
+ resourceActionPermissions,
857
+ globalAuth: void 0
858
+ });
859
+ if (!explicit && !resourceActionPermissions && effective && effective === resourcePermissions?.update) log?.warn?.({
860
+ resource: resourceName,
861
+ action: name,
862
+ fallback: "permissions.update"
863
+ }, `[Arc] Action '${resourceName}.${name}' has no explicit permission — falling back to the resource's \`permissions.update\` gate. Declare \`actions.${name}.permissions\` (or resource \`actionPermissions\`) to silence this.`);
864
+ if (!effective) throw new Error(`[Arc] Resource '${resourceName}': action '${name}' has no permission gate and the resource defines no \`permissions.update\` fallback. Declare one of:\n - \`actions.${name}.permissions: <PermissionCheck>\` (per-action)\n - \`actionPermissions: <PermissionCheck>\` (resource-wide)\n - \`permissions.update: <PermissionCheck>\` (inherited by actions)\nUse \`allowPublic()\` if you genuinely want the action unauthenticated.`);
865
+ permissions[name] = effective;
866
+ }
867
+ return {
868
+ tag,
869
+ actions: handlers,
870
+ actionPermissions: permissions,
871
+ actionSchemas: schemas
872
+ };
873
+ }
874
+ /**
875
+ * Build the FastifyPluginAsync that materialises a `ResourceDefinition`
876
+ * into routes, hooks, registry entries, and cache invalidation rules.
877
+ *
878
+ * Called once per `ResourceDefinition.toPlugin()`. The returned plugin
879
+ * function captures `resource` in its closure and can be `app.register`-ed
880
+ * any number of times — shared-state writes are idempotent per host
881
+ * Fastify instance via `resource._sharedStateRegisteredOn`.
882
+ */
883
+ function buildResourcePlugin(resource) {
884
+ return async function resourcePlugin(fastify, _opts) {
885
+ const sharedRoot = fastify.server ?? fastify;
886
+ const isFirstMount = !resource._sharedStateRegisteredOn.has(sharedRoot);
887
+ if (isFirstMount) resource._sharedStateRegisteredOn.add(sharedRoot);
888
+ const arc = fastify.arc;
889
+ if (isFirstMount && arc?.registry && resource._registryMeta) try {
890
+ arc.registry.register(resource, resource._registryMeta);
891
+ } catch (err) {
892
+ fastify.log?.warn?.(`Failed to register resource '${resource.name}' in registry: ${err instanceof Error ? err.message : err}`);
893
+ }
894
+ if (isFirstMount && resource._pendingHooks.length > 0 && arc?.hooks) for (const hook of resource._pendingHooks) arc.hooks.register({
895
+ resource: resource.name,
896
+ operation: hook.operation,
897
+ phase: hook.phase,
898
+ handler: hook.handler,
899
+ priority: hook.priority
900
+ });
901
+ const registerRule = fastify.registerCacheInvalidationRule;
902
+ if (isFirstMount && resource.cache?.invalidateOn && typeof registerRule === "function") for (const [pattern, tags] of Object.entries(resource.cache.invalidateOn)) registerRule({
903
+ pattern,
904
+ tags
905
+ });
906
+ await fastify.register(async (instance) => {
907
+ const typedInstance = instance;
908
+ let schemas = buildGeneratedCrudSchemas(resource._registryMeta?.openApiSchemas, resource.customSchemas);
909
+ const listQuerySchema = resource._registryMeta?.openApiSchemas?.listQuery;
910
+ if (listQuerySchema) {
911
+ const normalizedSchema = normalizeListQuerySchema(listQuerySchema);
912
+ schemas = schemas ?? {};
913
+ schemas.list = schemas.list ? deepMergeSchemas({ querystring: normalizedSchema }, schemas.list) : { querystring: normalizedSchema };
914
+ }
915
+ createCrudRouter(typedInstance, resource.controller, {
916
+ tag: resource.tag,
917
+ schemas: schemas ?? void 0,
918
+ permissions: resource.permissions,
919
+ middlewares: resource.middlewares,
920
+ routeGuards: resource.routeGuards,
921
+ routes: resource.routes,
922
+ disableDefaultRoutes: resource.disableDefaultRoutes,
923
+ disabledRoutes: [...resource.disabledRoutes],
924
+ resourceName: resource.name,
925
+ schemaOptions: resource.schemaOptions,
926
+ rateLimit: resource.rateLimit,
927
+ updateMethod: resource.updateMethod,
928
+ pipe: resource.pipe,
929
+ fields: resource.fields
930
+ });
931
+ if (resource.actions && Object.keys(resource.actions).length > 0) {
932
+ const { createActionRouter } = await import("./createActionRouter-CEvzKcy8.mjs").then((n) => n.n);
933
+ createActionRouter(typedInstance, {
934
+ ...normalizeActionsToRouterConfig(resource.actions, resource.actionPermissions, resource.tag, resource.permissions, resource.name, typedInstance.log),
935
+ resourceName: resource.name,
936
+ fields: resource.fields,
937
+ schemaOptions: resource.schemaOptions,
938
+ permissions: resource.permissions,
939
+ routeGuards: resource.routeGuards,
940
+ pipeline: resource.pipe,
941
+ rateLimit: resource.rateLimit
942
+ });
943
+ }
944
+ if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
945
+ const { createAggregationRouter } = await import("./createAggregationRouter-CyecOxnO.mjs");
946
+ const repoForAgg = resource.controller?.repository;
947
+ const buildOptions = (req) => {
948
+ return resource.controller?.tenantRepoOptions?.(req) ?? {};
949
+ };
950
+ createAggregationRouter(typedInstance, {
951
+ tag: resource.tag,
952
+ resourceName: resource.name,
953
+ aggregations: resource.aggregations,
954
+ fields: resource.fields,
955
+ schemaOptions: resource.schemaOptions,
956
+ permissions: resource.permissions,
957
+ routeGuards: resource.routeGuards,
958
+ repository: repoForAgg,
959
+ buildOptions
960
+ });
961
+ }
962
+ if (resource.events && Object.keys(resource.events).length > 0) typedInstance.log?.debug?.(`Resource '${resource.name}' defined ${Object.keys(resource.events).length} events`);
963
+ }, { prefix: resource.prefix });
964
+ if (hasEvents(fastify)) try {
965
+ await fastify.events.publish("arc.resource.registered", {
966
+ resource: resource.name,
967
+ prefix: resource.prefix,
968
+ presets: resource._appliedPresets,
969
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
970
+ });
971
+ } catch {}
972
+ };
973
+ }
974
+ //#endregion
975
+ //#region src/core/defineResource/ResourceDefinition.ts
976
+ var ResourceDefinition = class {
977
+ name;
978
+ displayName;
979
+ tag;
980
+ prefix;
981
+ skipGlobalPrefix;
982
+ adapter;
983
+ controller;
984
+ schemaOptions;
985
+ customSchemas;
986
+ permissions;
987
+ routes;
988
+ middlewares;
989
+ routeGuards;
990
+ disableDefaultRoutes;
991
+ disabledRoutes;
992
+ actions;
993
+ actionPermissions;
994
+ aggregations;
995
+ events;
996
+ rateLimit;
997
+ audit;
998
+ updateMethod;
999
+ pipe;
1000
+ fields;
1001
+ cache;
1002
+ tenantField;
1003
+ idField;
1004
+ queryParser;
1005
+ _appliedPresets;
1006
+ _pendingHooks;
1007
+ _registryMeta;
1008
+ /**
1009
+ * Per-host idempotency guard used by `buildResourcePlugin` to
1010
+ * skip duplicate shared-state writes when the same resource is
1011
+ * mounted at multiple prefixes (`/v1`, `/v2`). See the plugin
1012
+ * file for the full rationale; surfaced here as `readonly` so
1013
+ * the helper can consult it without a class-method indirection.
1014
+ */
1015
+ _sharedStateRegisteredOn = /* @__PURE__ */ new WeakSet();
1016
+ constructor(config) {
1017
+ this.name = config.name;
1018
+ this.displayName = config.displayName ?? `${capitalize(config.name)}s`;
1019
+ this.tag = config.tag ?? this.displayName;
1020
+ this.prefix = config.prefix ?? `/${config.name}s`;
1021
+ this.skipGlobalPrefix = config.skipGlobalPrefix ?? false;
1022
+ this.adapter = config.adapter;
1023
+ this.controller = config.controller;
1024
+ this.schemaOptions = Object.freeze({ ...config.schemaOptions ?? {} });
1025
+ this.customSchemas = Object.freeze({ ...config.customSchemas ?? {} });
1026
+ this.permissions = Object.freeze({ ...config.permissions ?? {} });
1027
+ this.routes = freezeRoutes(config.routes);
1028
+ this.disabledRoutes = Object.freeze([...config.disabledRoutes ?? []]);
1029
+ this.events = Object.freeze({ ...config.events ?? {} });
1030
+ this.middlewares = config.middlewares ?? {};
1031
+ this.routeGuards = config.routeGuards;
1032
+ this.disableDefaultRoutes = config.disableDefaultRoutes ?? false;
1033
+ this.actions = freezeActions(config.actions);
1034
+ this.actionPermissions = config.actionPermissions;
1035
+ this.aggregations = config.aggregations;
1036
+ this.rateLimit = config.rateLimit;
1037
+ this.audit = config.audit;
1038
+ this.updateMethod = config.updateMethod;
1039
+ this.pipe = config.pipe;
1040
+ this.fields = config.fields;
1041
+ this.cache = config.cache;
1042
+ this.tenantField = config.tenantField;
1043
+ this.idField = config.idField;
1044
+ this.queryParser = config.queryParser;
1045
+ this._appliedPresets = config._appliedPresets ?? [];
1046
+ this._pendingHooks = config._pendingHooks ?? [];
1047
+ }
1048
+ /** Repository accessor — pulled off the adapter when one is wired. */
1049
+ get repository() {
1050
+ return this.adapter?.repository;
1051
+ }
1052
+ /**
1053
+ * Validate that the wired controller implements every method
1054
+ * needed by enabled CRUD routes + every string-handler custom
1055
+ * route. Runs at the end of `defineResource()` (skippable via
1056
+ * `skipValidation: true`) so misconfigured resources fail at
1057
+ * boot, not on first request.
1058
+ */
1059
+ _validateControllerMethods() {
1060
+ const errors = [];
1061
+ const enabledCrudRoutes = CRUD_OPERATIONS.filter((route) => !this.disabledRoutes.includes(route));
1062
+ if (!this.disableDefaultRoutes && enabledCrudRoutes.length > 0) if (!this.controller) errors.push("Controller is required when CRUD routes are enabled");
1063
+ else {
1064
+ const ctrl = this.controller;
1065
+ for (const method of enabledCrudRoutes) if (typeof ctrl[method] !== "function") errors.push(`CRUD method '${method}' not found on controller`);
1066
+ }
1067
+ for (const route of this.routes) {
1068
+ if (typeof route.handler !== "string") continue;
1069
+ if (!this.controller) {
1070
+ errors.push(`Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller`);
1071
+ continue;
1072
+ }
1073
+ if (typeof this.controller[route.handler] !== "function") errors.push(`Route ${route.method} ${route.path}: handler '${route.handler}' not found`);
1074
+ }
1075
+ if (errors.length === 0) return;
1076
+ throw new Error([
1077
+ `Resource '${this.name}' validation failed:`,
1078
+ ...errors.map((e) => ` - ${e}`),
1079
+ "",
1080
+ "Ensure controller implements IController<TDoc> interface.",
1081
+ "For preset routes (softDelete, tree), add corresponding methods to controller."
1082
+ ].join("\n"));
1083
+ }
1084
+ /**
1085
+ * Build the Fastify plugin that materialises this resource into
1086
+ * routes, hooks, registry entries, and cache invalidation rules.
1087
+ * One-line delegate — the implementation lives in `./plugin.ts`.
1088
+ */
1089
+ toPlugin() {
1090
+ return buildResourcePlugin(this);
1091
+ }
1092
+ /** Event definitions for registry consumption. */
1093
+ getEvents() {
1094
+ return Object.entries(this.events).map(([action, meta]) => ({
1095
+ name: `${this.name}:${action}`,
1096
+ module: this.name,
1097
+ schema: meta.schema,
1098
+ description: meta.description
1099
+ }));
1100
+ }
1101
+ /** Resource metadata — shape consumed by registry / introspection. */
1102
+ getMetadata() {
1103
+ return {
1104
+ name: this.name,
1105
+ displayName: this.displayName,
1106
+ tag: this.tag,
1107
+ prefix: this.prefix,
1108
+ presets: this._appliedPresets,
1109
+ permissions: this.permissions,
1110
+ customRoutes: this.routes.map((r) => ({
1111
+ method: r.method,
1112
+ path: r.path,
1113
+ handler: typeof r.handler === "string" ? r.handler : r.handler.name || "anonymous",
1114
+ operation: r.operation,
1115
+ summary: r.summary,
1116
+ description: r.description,
1117
+ permissions: r.permissions,
1118
+ raw: r.raw,
1119
+ schema: r.schema
1120
+ })),
1121
+ routes: [],
1122
+ events: Object.keys(this.events)
1123
+ };
1124
+ }
1125
+ };
1126
+ function capitalize(str) {
1127
+ if (!str) return "";
1128
+ return str.charAt(0).toUpperCase() + str.slice(1);
1129
+ }
1130
+ /**
1131
+ * Freeze the routes array AND each route object inside it. Catches
1132
+ * `resource.routes[0].permissions = bypass` and equivalent post-
1133
+ * define mutations that would silently rewire the registered surface.
1134
+ *
1135
+ * Each route is shallow-copied before freezing so the host's
1136
+ * original route object stays mutable (consistent with how the
1137
+ * top-level config slots are treated).
1138
+ */
1139
+ function freezeRoutes(routes) {
1140
+ const list = (routes ?? []).map((route) => Object.freeze({ ...route }));
1141
+ return Object.freeze(list);
1142
+ }
1143
+ /**
1144
+ * Freeze the actions map AND each action entry. Function-shorthand
1145
+ * actions (`async (id, data, req) => ...`) need no per-entry freeze
1146
+ * — function references are immutable in practice; you can't mutate
1147
+ * a closure post-hoc. Object-form `ActionDefinition` entries DO need
1148
+ * a freeze so `actions.send.permissions = bypass` throws.
1149
+ */
1150
+ function freezeActions(actions) {
1151
+ if (!actions) return void 0;
1152
+ const frozen = {};
1153
+ for (const [name, entry] of Object.entries(actions)) frozen[name] = typeof entry === "function" ? entry : Object.freeze({ ...entry });
1154
+ return Object.freeze(frozen);
1155
+ }
1156
+ //#endregion
1157
+ //#region src/core/defineResource/schemas.ts
1158
+ /**
1159
+ * OpenAPI schema resolution — Phase 7 of `defineResource()`.
1160
+ *
1161
+ * Pipeline (each step is a pure function over `OpenApiSchemas | undefined`):
1162
+ *
1163
+ * adapter.generateSchemas()
1164
+ * → stripSystemManagedFromBodyRequired (from `../schemaOptions.js`)
1165
+ * → cleanLegacyObjectIdParams (idField safety net)
1166
+ * → layerQueryParserListQuery (kit's listQuery JSON Schema)
1167
+ * → mergeUserOpenApiOverrides (per-resource overrides)
1168
+ * → convertOpenApiSchemas (Zod → JSON Schema if needed)
1169
+ *
1170
+ * Non-fatal: if any step throws, the orchestrator returns `undefined` so
1171
+ * the resource still boots — docs / introspection / MCP tool schemas
1172
+ * degrade visibly instead of silently drifting.
1173
+ *
1174
+ * Pulled out of `defineResource.ts` so the central function reads as
1175
+ * orchestration only; the schema mechanics live next to each other and
1176
+ * are easier to evolve in isolation.
1177
+ */
1178
+ /**
1179
+ * Phase 7 orchestrator — runs the schema pipeline and returns the
1180
+ * registry metadata for the resource. Returns `undefined` (with a
1181
+ * structured warn log) if any step throws.
1182
+ */
1183
+ function resolveOpenApiSchemas(resolvedConfig) {
1184
+ try {
1185
+ let openApiSchemas = generateAdapterSchemas(resolvedConfig);
1186
+ openApiSchemas = stripSystemManagedFromBodyRequired(openApiSchemas, resolvedConfig.schemaOptions);
1187
+ openApiSchemas = cleanLegacyObjectIdParams(openApiSchemas, resolvedConfig.idField);
1188
+ openApiSchemas = layerQueryParserListQuery(openApiSchemas, resolvedConfig.queryParser);
1189
+ openApiSchemas = mergeUserOpenApiOverrides(openApiSchemas, resolvedConfig.openApiSchemas);
1190
+ if (openApiSchemas) openApiSchemas = convertOpenApiSchemas(openApiSchemas);
1191
+ return {
1192
+ module: resolvedConfig.module,
1193
+ openApiSchemas
1194
+ };
1195
+ } catch (err) {
1196
+ arcLog("defineResource").warn(`OpenAPI/MCP schema generation failed for resource "${resolvedConfig.name}": ${err instanceof Error ? err.message : String(err)}. Resource will boot without registry metadata — OpenAPI docs and MCP tool schemas will be missing.`);
1197
+ return;
1198
+ }
1199
+ }
1200
+ /**
1201
+ * Step 1 — delegate to the adapter's `generateSchemas`. Returns
1202
+ * `undefined` when the adapter doesn't implement the optional method.
1203
+ */
1204
+ function generateAdapterSchemas(resolvedConfig) {
1205
+ if (!resolvedConfig.adapter?.generateSchemas) return void 0;
1206
+ const adapterContext = {
1207
+ idField: resolvedConfig.idField,
1208
+ resourceName: resolvedConfig.name
1209
+ };
1210
+ return resolvedConfig.adapter.generateSchemas(resolvedConfig.schemaOptions, adapterContext);
1211
+ }
1212
+ /**
1213
+ * Safety net: when `idField` is overridden to a non-default value (UUIDs,
1214
+ * slugs, ORD-2026-0001), strip any ObjectId pattern left on `params.id` by
1215
+ * legacy adapters or plugins that didn't honor `AdapterSchemaContext.idField`.
1216
+ * Custom IDs must not be rejected by AJV before BaseController runs the
1217
+ * actual lookup.
1218
+ */
1219
+ function cleanLegacyObjectIdParams(openApiSchemas, idField) {
1220
+ if (!openApiSchemas || !idField || idField === "_id") return openApiSchemas;
1221
+ const params = openApiSchemas.params;
1222
+ if (!params || typeof params !== "object") return openApiSchemas;
1223
+ const properties = params.properties;
1224
+ const idProp = properties?.id;
1225
+ if (!idProp || typeof idProp !== "object") return openApiSchemas;
1226
+ const pattern = idProp.pattern;
1227
+ if (!(typeof pattern === "string" && (pattern === "^[0-9a-fA-F]{24}$" || pattern === "^[a-f\\d]{24}$" || pattern === "^[a-fA-F0-9]{24}$" || /^\^\[[a-fA-F0-9\\d]+\]\{24\}\$$/.test(pattern)))) return openApiSchemas;
1228
+ const cleanedId = { ...idProp };
1229
+ delete cleanedId.pattern;
1230
+ delete cleanedId.minLength;
1231
+ delete cleanedId.maxLength;
1232
+ if (!cleanedId.description) cleanedId.description = `${idField} (custom ID field)`;
1233
+ return {
1234
+ ...openApiSchemas,
1235
+ params: {
1236
+ ...params,
1237
+ properties: {
1238
+ ...properties,
1239
+ id: cleanedId
1240
+ }
1241
+ }
1242
+ };
1243
+ }
1244
+ /**
1245
+ * Layer the query parser's `getQuerySchema()` output as `listQuery` so
1246
+ * the kit's filterable-fields surface flows into OpenAPI / MCP without
1247
+ * the user re-declaring it.
1248
+ */
1249
+ function layerQueryParserListQuery(openApiSchemas, queryParser) {
1250
+ const qp = queryParser;
1251
+ if (!qp?.getQuerySchema) return openApiSchemas;
1252
+ const querySchema = qp.getQuerySchema();
1253
+ if (!querySchema) return openApiSchemas;
1254
+ return {
1255
+ ...openApiSchemas,
1256
+ listQuery: querySchema
1257
+ };
1258
+ }
1259
+ /**
1260
+ * Apply per-resource `openApiSchemas` overrides on top of the kit's
1261
+ * generated schemas. Shallow merge by slot — users who want field-level
1262
+ * surgery should compose at the schema-options layer before this point.
1263
+ */
1264
+ function mergeUserOpenApiOverrides(openApiSchemas, userOverrides) {
1265
+ if (!userOverrides) return openApiSchemas;
1266
+ return {
1267
+ ...openApiSchemas,
1268
+ ...userOverrides
1269
+ };
1270
+ }
1271
+ //#endregion
1272
+ //#region src/core/defineResource/validate.ts
1273
+ /**
1274
+ * CRUD op names — kept module-scope (vs allocated per `defineResource()`
1275
+ * call) since the set is fixed and the cost of re-allocating is a
1276
+ * pointless boot tax for hosts with hundreds of resources.
1277
+ */
1278
+ const CRUD_OP_NAMES = new Set([
1279
+ "create",
1280
+ "update",
1281
+ "delete",
1282
+ "list",
1283
+ "get"
1284
+ ]);
1285
+ /**
1286
+ * Run the structural validation pipeline. Throws an `Error` with a
1287
+ * resource-named message on the first failure — `defineResource()`
1288
+ * surfaces it verbatim so hosts get a clear "fix this resource"
1289
+ * pointer.
1290
+ */
1291
+ function validateDefineResourceConfig(config) {
1292
+ assertValidConfig(config, { skipControllerCheck: true });
1293
+ validatePermissionsShape(config);
1294
+ validateCustomRoutePermissions(config);
1295
+ validateActionsShape(config);
1296
+ }
1297
+ /** Permissions must be `PermissionCheck` functions, not arbitrary values. */
1298
+ function validatePermissionsShape(config) {
1299
+ if (!config.permissions) return;
1300
+ for (const [key, value] of Object.entries(config.permissions)) if (value !== void 0 && typeof value !== "function") throw new Error(`[Arc] Resource '${config.name}': permissions.${key} must be a PermissionCheck function.\nUse allowPublic(), requireAuth(), or requireRoles(['role']) from @classytic/arc/permissions.`);
1301
+ }
1302
+ /**
1303
+ * Custom routes must declare `permissions` as a function — fail-closed
1304
+ * default. A missing `permissions` could otherwise quietly mount an
1305
+ * unauthenticated route.
1306
+ */
1307
+ function validateCustomRoutePermissions(config) {
1308
+ for (const route of config.routes ?? []) if (typeof route.permissions !== "function") throw new Error(`[Arc] Resource '${config.name}' route ${route.method} ${route.path}: permissions is required and must be a PermissionCheck function.`);
1309
+ }
1310
+ /**
1311
+ * Actions (v2.8) — name must not collide with CRUD ops; handler +
1312
+ * permissions must have the right shapes. Fail at boot so production
1313
+ * never ships a misconfigured action endpoint.
1314
+ */
1315
+ function validateActionsShape(config) {
1316
+ if (!config.actions) return;
1317
+ for (const [name, entry] of Object.entries(config.actions)) {
1318
+ if (CRUD_OP_NAMES.has(name)) throw new Error(`[Arc] Resource '${config.name}': action '${name}' conflicts with CRUD operation.\nUse a different name (e.g., '${name}_item', 'do_${name}').`);
1319
+ if (typeof entry !== "function") {
1320
+ const def = entry;
1321
+ if (typeof def.handler !== "function") throw new Error(`[Arc] Resource '${config.name}': actions.${name}.handler must be a function.`);
1322
+ if (def.permissions !== void 0 && typeof def.permissions !== "function") throw new Error(`[Arc] Resource '${config.name}': actions.${name}.permissions must be a PermissionCheck function.`);
1323
+ }
1324
+ }
1325
+ }
1326
+ //#endregion
1327
+ //#region src/core/defineResource.ts
1328
+ /**
1329
+ * `TDoc` is **unconstrained** at this layer. The previous `TDoc
1330
+ * extends AnyRecord` bound leaked out of `BaseController`'s
1331
+ * mixin-composition requirement into every host's adapter boundary:
1332
+ * Mongoose's `HydratedDocument<T>`, Prisma's generated row types,
1333
+ * and any domain interface without an explicit index signature all
1334
+ * failed to satisfy `Record<string, unknown>` even though at runtime
1335
+ * they ARE string-keyed objects. Hosts were forced to cast at every
1336
+ * adapter (`as RepositoryLike<Record<string, unknown>>`) — a type
1337
+ * escape with no runtime purpose, since arc's pipeline only reads
1338
+ * known envelope fields.
1339
+ *
1340
+ * The cast moved inside `resolveOrAutoCreateController` where
1341
+ * `BaseController<TDoc extends AnyRecord>` actually requires it.
1342
+ * One internal boundary cast replaces N host-side casts.
1343
+ */
1344
+ function defineResource(config) {
1345
+ if (!config.skipValidation) validateDefineResourceConfig(config);
1346
+ const repository = config.adapter?.repository;
1347
+ const configWithId = resolveIdField(config, repository);
1348
+ const resolvedConfig = applyPresetsAndAutoInject(configWithId);
1349
+ const hasCrudRoutes = computeHasCrudRoutes(resolvedConfig);
1350
+ const narrowedConfig = resolvedConfig;
1351
+ const narrowedAdapter = configWithId.adapter;
1352
+ const controller = resolveOrAutoCreateController(narrowedConfig, narrowedAdapter, repository, hasCrudRoutes);
1353
+ const resource = new ResourceDefinition({
1354
+ ...resolvedConfig,
1355
+ adapter: configWithId.adapter,
1356
+ controller
1357
+ });
1358
+ if (!config.skipValidation && controller) resource._validateControllerMethods();
1359
+ wireHooks(resource, narrowedConfig, configWithId.hooks);
1360
+ if (!config.skipRegistry) {
1361
+ const registryMeta = resolveOpenApiSchemas(narrowedConfig);
1362
+ if (registryMeta) resource._registryMeta = registryMeta;
1363
+ }
1364
+ return resource;
1365
+ }
1366
+ //#endregion
1367
+ //#region src/core/defineResourceVariants.ts
1368
+ /**
1369
+ * Define multiple resources from a shared base config and per-variant overrides.
1370
+ *
1371
+ * Each variant is independently passed through `defineResource()` — the
1372
+ * returned `ResourceDefinition`s are real, fully-registered resources.
1373
+ * Register each one's plugin in your app:
1374
+ *
1375
+ * ```typescript
1376
+ * await app.register(articlePublic.toPlugin());
1377
+ * await app.register(articleAdmin.toPlugin());
1378
+ * ```
1379
+ *
1380
+ * @param base Shared config — adapter, queryParser, schemaOptions, hooks, etc.
1381
+ * Must NOT include `name` or `prefix` (those are per-variant).
1382
+ * @param variants Map of variant key → override. Each variant must declare
1383
+ * its own `name` and `prefix`. Other fields override the base.
1384
+ * @returns A record where each key from `variants` maps to a real
1385
+ * `ResourceDefinition` ready for `.toPlugin()` registration.
1386
+ */
1387
+ function defineResourceVariants(base, variants) {
1388
+ const out = {};
1389
+ for (const key of Object.keys(variants)) {
1390
+ const override = variants[key];
1391
+ out[key] = defineResource({
1392
+ ...base,
1393
+ ...override
1394
+ });
1395
+ }
1396
+ return out;
1397
+ }
1398
+ //#endregion
1399
+ export { createPermissionMiddleware as a, createCrudRouter as i, defineResource as n, defineAggregation as o, ResourceDefinition as r, defineResourceVariants as t };