@classytic/arc 2.18.3 → 2.18.5

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 (158) hide show
  1. package/dist/{BaseController-B-uXzf6I.mjs → BaseController-Dy6EQHjM.mjs} +7 -7
  2. package/dist/{QueryCache-Dsyv2mLS.d.mts → QueryCache-TRfXau3W.d.mts} +1 -1
  3. package/dist/{ResourceRegistry-CWmOggxS.mjs → ResourceRegistry-5fKZwhK_.mjs} +1 -1
  4. package/dist/{pipeline-D4sqiokR.mjs → actionPermissions-h7DYDNMg.mjs} +28 -5
  5. package/dist/audit/index.d.mts +1 -1
  6. package/dist/auth/index.d.mts +4 -4
  7. package/dist/auth/index.mjs +252 -5
  8. package/dist/auth/redis-session.d.mts +1 -1
  9. package/dist/betterAuthOpenApi-CV1lxpRX.mjs +2 -0
  10. package/dist/{buildHandler-Cvrc2p7r.mjs → buildHandler-B0qvAguf.mjs} +1 -1
  11. package/dist/cache/index.d.mts +3 -3
  12. package/dist/cache/index.mjs +3 -3
  13. package/dist/{caching-C0DdKiYB.mjs → caching-_NJeDd1_.mjs} +1 -1
  14. package/dist/cli/commands/describe.d.mts +1 -1
  15. package/dist/cli/commands/describe.mjs +2 -2
  16. package/dist/cli/commands/docs.mjs +2 -2
  17. package/dist/cli/commands/generate.d.mts +2 -2
  18. package/dist/cli/commands/generate.mjs +1 -1
  19. package/dist/cli/commands/init.d.mts +2 -2
  20. package/dist/cli/commands/init.mjs +1 -1
  21. package/dist/cli/commands/introspect.mjs +1 -1
  22. package/dist/cli/index.d.mts +2 -2
  23. package/dist/cli/index.mjs +2 -2
  24. package/dist/context/index.mjs +1 -1
  25. package/dist/core/index.d.mts +3 -3
  26. package/dist/core/index.mjs +4 -4
  27. package/dist/{core-C88uIwd3.mjs → core-ZnQBa5rh.mjs} +55 -56
  28. package/dist/{createActionRouter-DGCCp-kP.mjs → createActionRouter-CagItpxI.mjs} +6 -7
  29. package/dist/{createAggregationRouter-Ca77b8iK.mjs → createAggregationRouter-DVKroXhC.mjs} +4 -4
  30. package/dist/{createApp-DLuUGVZQ.mjs → createApp--703hQE1.mjs} +14 -14
  31. package/dist/docs/index.d.mts +2 -2
  32. package/dist/docs/index.mjs +2 -2
  33. package/dist/{elevation-BzhyVWfn.mjs → elevation-COpYKt2h.mjs} +2 -2
  34. package/dist/{errorHandler-Bua97SEt.mjs → errorHandler-Dxf6P_kV.mjs} +2 -2
  35. package/dist/{eventPlugin-Bw4bUDEO.mjs → eventPlugin-DJG2D7sy.mjs} +106 -5
  36. package/dist/{eventPlugin-C1W1CRbf.d.mts → eventPlugin-DbBFd3x_.d.mts} +1 -1
  37. package/dist/events/index.d.mts +3 -3
  38. package/dist/events/index.mjs +189 -5
  39. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  40. package/dist/events/transports/redis.d.mts +1 -1
  41. package/dist/factory/index.d.mts +1 -1
  42. package/dist/factory/index.mjs +3 -3
  43. package/dist/{fastify-augmentation-BdCl_eNo.d.mts → fastify-augmentation-Cxb0ROIw.d.mts} +2 -2
  44. package/dist/{fields-CKLrhyOb.d.mts → fields-C7ahdGYf.d.mts} +1 -1
  45. package/dist/{generate-BaxDdmJ8.mjs → generate-Brans3fW.mjs} +1 -1
  46. package/dist/hooks/index.d.mts +1 -1
  47. package/dist/hooks/index.mjs +1 -1
  48. package/dist/idempotency/index.d.mts +2 -2
  49. package/dist/idempotency/index.mjs +1 -1
  50. package/dist/idempotency/redis.d.mts +1 -1
  51. package/dist/{index-QYma8mdC.d.mts → index-CHNQIhcb.d.mts} +5 -6
  52. package/dist/{index-vcWUGkwf.d.mts → index-CRKiiP5o.d.mts} +3 -3
  53. package/dist/{index-Cvo1u9n6.d.mts → index-Dh0_RSTd.d.mts} +3 -3
  54. package/dist/index.d.mts +5 -5
  55. package/dist/index.mjs +7 -7
  56. package/dist/{init-DSsdRfk9.mjs → init-Bqpt1Hxf.mjs} +274 -130
  57. package/dist/integrations/event-gateway.d.mts +2 -2
  58. package/dist/integrations/event-gateway.mjs +1 -1
  59. package/dist/integrations/index.d.mts +3 -3
  60. package/dist/integrations/mcp/index.d.mts +4 -4
  61. package/dist/integrations/mcp/index.mjs +1 -1
  62. package/dist/integrations/mcp/testing.d.mts +1 -1
  63. package/dist/integrations/mcp/testing.mjs +1 -1
  64. package/dist/integrations/streamline.mjs +7 -3
  65. package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
  66. package/dist/integrations/websocket-redis.d.mts +1 -1
  67. package/dist/integrations/websocket.d.mts +3 -3
  68. package/dist/{loadResources-CwXt9MCh.mjs → loadResources-B8UmpITt.mjs} +1 -1
  69. package/dist/{loadResourcesFromEntry-XHNeUWpu.mjs → loadResourcesFromEntry-BTlFEx05.mjs} +1 -1
  70. package/dist/{mcpPlugin-CzKdt3B8.mjs → mcpPlugin-D1wuRD2c.mjs} +10 -11
  71. package/dist/{memory-TdTHCS9-.mjs → memory-CLXA2VeJ.mjs} +1 -1
  72. package/dist/{metrics-Da2EUPi9.mjs → metrics-fdxI5mn_.mjs} +1 -1
  73. package/dist/middleware/index.d.mts +1 -1
  74. package/dist/middleware/index.mjs +2 -2
  75. package/dist/{openapi-ButnRyOM.mjs → openapi-xVJibapr.mjs} +5 -5
  76. package/dist/permissions/index.d.mts +3 -3
  77. package/dist/permissions/index.mjs +2 -2
  78. package/dist/{permissions-3uzwkbQt.mjs → permissions-BPB1xqGf.mjs} +4 -4
  79. package/dist/{pipe-9Tl-OAus.mjs → pipe-BOLmGIxa.mjs} +1 -1
  80. package/dist/pipeline/index.d.mts +1 -1
  81. package/dist/pipeline/index.mjs +1 -1
  82. package/dist/plugins/index.d.mts +6 -7
  83. package/dist/plugins/index.mjs +12 -12
  84. package/dist/plugins/response-cache.mjs +1 -1
  85. package/dist/plugins/tracing-entry.d.mts +1 -1
  86. package/dist/plugins/tracing-entry.mjs +1 -1
  87. package/dist/presets/filesUpload.d.mts +4 -4
  88. package/dist/presets/filesUpload.mjs +4 -4
  89. package/dist/presets/index.d.mts +1 -1
  90. package/dist/presets/index.mjs +1 -1
  91. package/dist/presets/multiTenant.d.mts +1 -1
  92. package/dist/presets/multiTenant.mjs +2 -2
  93. package/dist/presets/search.d.mts +2 -2
  94. package/dist/presets/search.mjs +1 -1
  95. package/dist/{presets-CF79RHEo.mjs → presets-BtyFwTO4.mjs} +2 -2
  96. package/dist/{queryCachePlugin-CnAeFqju.mjs → queryCachePlugin-emHb0CuX.mjs} +4 -4
  97. package/dist/{queryCachePlugin-Bd2tYgh5.d.mts → queryCachePlugin-xzFyKAjT.d.mts} +2 -2
  98. package/dist/{redis-3hqH_Qk_.d.mts → redis-C3W0qbz8.d.mts} +1 -1
  99. package/dist/{redis-stream-BtEUtjxZ.d.mts → redis-stream-DsP1vqZB.d.mts} +1 -1
  100. package/dist/{rateLimit-yKvxWAky.mjs → registerRoute-CPmdkZGu.mjs} +109 -2
  101. package/dist/registry/index.d.mts +1 -1
  102. package/dist/registry/index.mjs +2 -2
  103. package/dist/{registry-Br15xdGM.mjs → registry-Bm9LjWKs.mjs} +1 -1
  104. package/dist/{replyHelpers-DXHVxrrj.mjs → replyHelpers-67tRawQZ.mjs} +1 -1
  105. package/dist/{schemaIR-CE6USIRi.mjs → schemaIR-D1MJwqmp.mjs} +1 -1
  106. package/dist/schemas/index.d.mts +64 -65
  107. package/dist/scope/index.d.mts +2 -2
  108. package/dist/scope/index.mjs +3 -3
  109. package/dist/{sse-Xq8UtuQx.mjs → sse-CfUjwjDk.mjs} +18 -6
  110. package/dist/streaming-BQcW6_Kq.mjs +162 -0
  111. package/dist/testing/index.d.mts +7 -7
  112. package/dist/testing/index.mjs +3 -3
  113. package/dist/testing/storageContract.d.mts +1 -1
  114. package/dist/types/index.d.mts +5 -5
  115. package/dist/types/storage.d.mts +1 -1
  116. package/dist/{types-WVlAVp8T.d.mts → types-CmkknLzI.d.mts} +10 -10
  117. package/dist/{types-BWBcb88c.d.mts → types-D60PFwfG.d.mts} +1 -1
  118. package/dist/{types-Bi0r0vjG.mjs → types-bHblZ9YN.mjs} +1 -1
  119. package/dist/utils/index.d.mts +42 -4
  120. package/dist/utils/index.mjs +6 -5
  121. package/dist/{utils-xAQeDyU_.mjs → utils-C6QQ9-hd.mjs} +1415 -1524
  122. package/dist/{validate-0__MuUsK.mjs → validate-CRcaWzp2.mjs} +1 -1
  123. package/dist/{versioning-BSEDGXKC.d.mts → versioning-D_eBCfNr.d.mts} +8 -1
  124. package/dist/{versioning-C0K8KQLx.mjs → versioning-Dk-71iIz.mjs} +1 -1
  125. package/dist/{websocket-BFBT5rH-.d.mts → websocket-MlYlrYq0.d.mts} +1 -1
  126. package/package.json +36 -30
  127. package/dist/EventTransport-7LPXLEUX.mjs +0 -103
  128. package/dist/actionPermissions-CX9QIMTd.mjs +0 -24
  129. package/dist/betterAuthOpenApi-DAAhMZff.mjs +0 -251
  130. package/dist/defineEvent-DC981g-o.mjs +0 -188
  131. package/dist/registerRoute-BEYn0ylk.mjs +0 -108
  132. /package/dist/{EventTransport-CaaXM5Y1.d.mts → EventTransport-IEn7Tn-Q.d.mts} +0 -0
  133. /package/dist/{HookSystem-BfCpWbwM.mjs → HookSystem-C1vB_Gc9.mjs} +0 -0
  134. /package/dist/{constants-TrJVIJl0.mjs → constants-gaEeMlz-.mjs} +0 -0
  135. /package/dist/{elevation-DW90z5bT.d.mts → elevation-xGUBq6p_.d.mts} +0 -0
  136. /package/dist/{errorHandler-_Lp2k9M3.d.mts → errorHandler-BtUUDPEa.d.mts} +0 -0
  137. /package/dist/{errors-DZOemeGa.d.mts → errors-DOHkjQlD.d.mts} +0 -0
  138. /package/dist/{errors-j4aJm1Wg.mjs → errors-DkO8Nk6-.mjs} +0 -0
  139. /package/dist/{externalPaths-DLle0R07.d.mts → externalPaths-BE6wFpYv.d.mts} +0 -0
  140. /package/dist/{generate-Dmk8cfvX.d.mts → generate-BnsL9GBm.d.mts} +0 -0
  141. /package/dist/{init-BS4Daqnv.d.mts → init-BPEu8cs-.d.mts} +0 -0
  142. /package/dist/{interface-DL7LlWS5.d.mts → interface-C1_WLTMK.d.mts} +0 -0
  143. /package/dist/{interface-CsrGR8LF.d.mts → interface-Uk8ONJv7.d.mts} +0 -0
  144. /package/dist/{keys-B3yyBEyH.mjs → keys-Cd316u_y.mjs} +0 -0
  145. /package/dist/{multipartBody-709YcXi1.mjs → multipartBody-CHEQSZ6G.mjs} +0 -0
  146. /package/dist/{pluralize-BxdUl2P4.mjs → pluralize-DccFV9R7.mjs} +0 -0
  147. /package/dist/{preloadResources-B-j-iqo-.mjs → preloadResources-CHw3_oA_.mjs} +0 -0
  148. /package/dist/{pushref-registry-Dqclzjy7.d.mts → pushref-registry-DnCqL3aa.d.mts} +0 -0
  149. /package/dist/{requestContext-DDLS3NVU.mjs → requestContext-BZKOr13H.mjs} +0 -0
  150. /package/dist/{chunk-BpYLSNr0.mjs → rolldown-runtime-8H4AJuhK.mjs} +0 -0
  151. /package/dist/{schemaConverter-DatAaVNj.mjs → schemaConverter-U0MnO9vd.mjs} +0 -0
  152. /package/dist/{sessionManager-CyHpYXoB.d.mts → sessionManager-BXG-HGwG.d.mts} +0 -0
  153. /package/dist/{storage-C5q8fuEt.d.mts → storage-Co_PQpeQ.d.mts} +0 -0
  154. /package/dist/{store-helpers-DjSYI2uP.mjs → store-helpers-DY6xq4sM.mjs} +0 -0
  155. /package/dist/{tracing-Bnb1-YVd.d.mts → tracing-D8jlGXP7.d.mts} +0 -0
  156. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-C9KXUly6.mjs} +0 -0
  157. /package/dist/{types-BJwJN2Pg.d.mts → types-LNebCoJx.d.mts} +0 -0
  158. /package/dist/{types-D57iXYb8.mjs → types-XTApa8b6.mjs} +0 -0
@@ -1,9 +1,8 @@
1
- import { m as RESERVED_QUERY_PARAMS, t as CRUD_OPERATIONS } from "./constants-TrJVIJl0.mjs";
1
+ import { m as RESERVED_QUERY_PARAMS, t as CRUD_OPERATIONS } from "./constants-gaEeMlz-.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
- import { t as ArcError } from "./errors-j4aJm1Wg.mjs";
4
- import { r as getAvailablePresets } from "./presets-CF79RHEo.mjs";
3
+ import { t as ArcError } from "./errors-DkO8Nk6-.mjs";
4
+ import { r as getAvailablePresets } from "./presets-BtyFwTO4.mjs";
5
5
  import { errorContractSchema as errorContractSchema$1, errorDetailSchema as errorDetailSchema$1 } from "@classytic/repo-core/errors";
6
- import { Readable } from "node:stream";
7
6
  //#region src/utils/simpleEqualityMatcher.ts
8
7
  /**
9
8
  * `simpleEqualityMatcher` — a minimal, dialect-agnostic flat-key equality
@@ -71,1504 +70,1522 @@ function simpleEqualityMatcher(item, filters) {
71
70
  return true;
72
71
  }
73
72
  //#endregion
74
- //#region src/core/validateResourceConfig.ts
73
+ //#region src/utils/runtime.ts
75
74
  /**
76
- * Resource Configuration Validator
75
+ * Portable "run on next tick" scheduler. `setImmediate` is Node-only — not
76
+ * available in Bun workers, Deno, Cloudflare Workers, or edge runtimes.
77
+ */
78
+ const scheduleBackground = typeof setImmediate === "function" ? (cb) => void setImmediate(cb) : (cb) => queueMicrotask(cb);
79
+ //#endregion
80
+ //#region src/utils/userHelpers.ts
81
+ /**
82
+ * Extract a user ID from a user object. Accepts `id` or `_id` — returns
83
+ * `undefined` when neither is present. Used by arc's controllers to
84
+ * populate `createdBy` / `updatedBy` fields and for cache scoping.
77
85
  *
78
- * Fail-fast validation at definition time.
79
- * Invalid configs throw immediately with clear, actionable errors.
86
+ * @example
87
+ * ```ts
88
+ * import { getUserId } from '@classytic/arc/utils';
89
+ * const uid = getUserId(request.user);
90
+ * ```
91
+ */
92
+ function getUserId(user) {
93
+ if (!user) return void 0;
94
+ const id = user.id ?? user._id;
95
+ return id ? String(id) : void 0;
96
+ }
97
+ //#endregion
98
+ //#region src/utils/queryParser.ts
99
+ /**
100
+ * Arc Query Parser - Default URL-to-Query Parser
101
+ *
102
+ * Framework-agnostic query parser that converts URL parameters to query options.
103
+ * This is Arc's built-in parser; users can swap in MongoKit's QueryParser,
104
+ * pgkit's parser, or any custom parser implementing QueryParserInterface.
80
105
  *
81
106
  * @example
82
- * const result = validateResourceConfig(config);
83
- * if (!result.valid) {
84
- * console.error(formatValidationErrors(result.errors));
85
- * }
107
+ * // Use Arc default parser (auto-applied if no queryParser option)
108
+ * defineResource({ name: 'product', adapter: ... });
109
+ *
110
+ * // Use MongoKit's QueryParser (recommended for MongoDB - has $lookup, aggregations, etc.)
111
+ * import { QueryParser } from '@classytic/mongokit';
112
+ * defineResource({
113
+ * name: 'product',
114
+ * adapter: ...,
115
+ * queryParser: new QueryParser(),
116
+ * });
117
+ *
118
+ * // Use custom parser for SQL databases
119
+ * defineResource({
120
+ * name: 'user',
121
+ * adapter: ...,
122
+ * queryParser: new PgQueryParser(),
123
+ * });
86
124
  */
125
+ const log = arcLog("queryParser");
87
126
  /**
88
- * Validate a resource configuration
127
+ * Regex patterns that can cause catastrophic backtracking (ReDoS attacks)
128
+ * Detects:
129
+ * - Quantifiers: {n,m}
130
+ * - Possessive quantifiers: *+, ++, ?+
131
+ * - Nested quantifiers: (a+)+, (a*)*
132
+ * - Backreferences: \1, \2, etc.
89
133
  */
90
- function validateResourceConfig(config, options = {}) {
91
- const errors = [];
92
- const warnings = [];
93
- if (!config.name) errors.push({
94
- field: "name",
95
- message: "Resource name is required",
96
- suggestion: "Add a unique resource name (e.g., \"product\", \"user\")"
97
- });
98
- else if (!/^[a-z][a-z0-9-]*$/i.test(config.name)) errors.push({
99
- field: "name",
100
- message: `Invalid resource name "${config.name}"`,
101
- suggestion: "Use alphanumeric characters and hyphens, starting with a letter"
102
- });
103
- const crudRoutes = CRUD_OPERATIONS;
104
- const disabledRoutes = new Set(config.disabledRoutes ?? []);
105
- const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
106
- if (!config.disableDefaultRoutes && enabledCrudRoutes.length > 0) {
107
- if (!config.adapter) errors.push({
108
- field: "adapter",
109
- message: "Data adapter is required when CRUD routes are enabled",
110
- suggestion: "Provide an adapter: createMongooseAdapter({ model, repository })"
111
- });
112
- else if (!config.adapter.repository) errors.push({
113
- field: "adapter.repository",
114
- message: "Adapter must provide a repository",
115
- suggestion: "Ensure your adapter returns a valid StandardRepo (see @classytic/repo-core)"
116
- });
117
- } else if (!config.adapter && !config.routes?.length) warnings.push({
118
- field: "config",
119
- message: "Resource has no adapter and no routes",
120
- suggestion: "Provide either adapter for CRUD or routes for custom logic"
121
- });
122
- if (config.controller && !options.skipControllerCheck && !config.disableDefaultRoutes) {
123
- const ctrl = config.controller;
124
- const requiredMethods = CRUD_OPERATIONS;
125
- for (const method of requiredMethods) if (typeof ctrl[method] !== "function") errors.push({
126
- field: `controller.${method}`,
127
- message: `Missing required CRUD method "${method}"`,
128
- suggestion: "Extend BaseController which implements IController interface"
129
- });
130
- }
131
- if (config.controller && config.routes) validateRouteHandlers(config.controller, config.routes, errors);
132
- if (config.permissions) validatePermissionKeys(config, options, errors, warnings);
133
- if (config.presets && !options.allowUnknownPresets) validatePresets(config.presets, errors, warnings);
134
- if (config.prefix) {
135
- if (!config.prefix.startsWith("/")) errors.push({
136
- field: "prefix",
137
- message: `Prefix must start with "/" (got "${config.prefix}")`,
138
- suggestion: `Change to "/${config.prefix}"`
139
- });
140
- if (config.prefix.endsWith("/") && config.prefix !== "/") warnings.push({
141
- field: "prefix",
142
- message: `Prefix should not end with "/" (got "${config.prefix}")`,
143
- suggestion: `Change to "${config.prefix.slice(0, -1)}"`
144
- });
145
- }
146
- if (config.routes) {
147
- validateRoutes(config.routes, errors);
148
- validateRouteCrudCollisions(config, errors);
149
- }
150
- return {
151
- valid: errors.length === 0,
152
- errors,
153
- warnings
154
- };
155
- }
156
- function validateRouteHandlers(controller, routes, errors) {
157
- const ctrl = controller;
158
- for (const route of routes) if (typeof route.handler === "string") {
159
- if (typeof ctrl[route.handler] !== "function") errors.push({
160
- field: `routes[${route.method} ${route.path}]`,
161
- message: `Handler "${route.handler}" not found on controller`,
162
- suggestion: `Add method "${route.handler}" to controller or use a function handler`
163
- });
164
- }
165
- }
166
- function validatePermissionKeys(config, options, _errors, warnings) {
167
- const validKeys = new Set([...CRUD_OPERATIONS, ...options.additionalPermissionKeys ?? []]);
168
- for (const route of config.routes ?? []) if (typeof route.handler === "string") validKeys.add(route.handler);
169
- for (const preset of config.presets ?? []) {
170
- const presetName = typeof preset === "string" ? preset : preset.name;
171
- if (presetName === "softDelete") {
172
- validKeys.add("deleted");
173
- validKeys.add("restore");
174
- }
175
- if (presetName === "slugLookup") validKeys.add("getBySlug");
176
- if (presetName === "tree") {
177
- validKeys.add("tree");
178
- validKeys.add("children");
179
- validKeys.add("getTree");
180
- validKeys.add("getChildren");
181
- }
182
- }
183
- for (const key of Object.keys(config.permissions ?? {})) if (!validKeys.has(key)) warnings.push({
184
- field: `permissions.${key}`,
185
- message: `Unknown permission key "${key}"`,
186
- suggestion: `Valid keys: ${Array.from(validKeys).join(", ")}`
187
- });
188
- }
189
- function validatePresets(presets, errors, warnings) {
190
- const availablePresets = getAvailablePresets();
191
- for (const preset of presets) {
192
- if (typeof preset === "object" && ("middlewares" in preset || "routes" in preset)) continue;
193
- const presetName = typeof preset === "string" ? preset : preset.name;
194
- if (!availablePresets.includes(presetName)) errors.push({
195
- field: "presets",
196
- message: `Unknown preset "${presetName}"`,
197
- suggestion: `Available presets: ${availablePresets.join(", ")}`
198
- });
199
- if (typeof preset === "object") validatePresetOptions(preset, warnings);
200
- }
201
- }
202
- function validatePresetOptions(preset, warnings) {
203
- const validOptions = {
204
- slugLookup: ["slugField"],
205
- tree: ["parentField"],
206
- softDelete: ["deletedField"],
207
- ownedByUser: ["ownerField"],
208
- multiTenant: ["tenantField", "allowPublic"]
209
- }[preset.name] ?? [];
210
- const providedOptions = Object.keys(preset).filter((k) => k !== "name");
211
- for (const opt of providedOptions) if (!validOptions.includes(opt)) warnings.push({
212
- field: `presets[${preset.name}].${opt}`,
213
- message: `Unknown option "${opt}" for preset "${preset.name}"`,
214
- suggestion: validOptions.length > 0 ? `Valid options: ${validOptions.join(", ")}` : `Preset "${preset.name}" has no configurable options`
215
- });
216
- }
134
+ const DANGEROUS_REGEX_PATTERNS = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
217
135
  /**
218
- * Detect collisions between user-declared `routes:` entries and the
219
- * auto-CRUD route table that `createCrudRouter` will register.
136
+ * Arc's default query parser
220
137
  *
221
- * Without this check, a custom `POST /` on the same prefix as auto-CRUD's
222
- * `create` boots fine through `defineResource()` and only blows up at
223
- * `app.register()` time with Fastify's opaque `FST_ERR_DUPLICATED_ROUTE`.
224
- * That error doesn't mention `disabledRoutes` and doesn't distinguish
225
- * "you collided with auto-CRUD" from "you collided with yourself" — so
226
- * the consumer has to grep arc's source to discover the fix.
138
+ * Converts URL query parameters to a structured query format:
139
+ * - Pagination: ?page=1&limit=20
140
+ * - Sorting: ?sort=-createdAt,name (- prefix = descending)
141
+ * - Filtering — TWO supported forms (both produce identical output):
142
+ * - Bare (default, terse): `?status=active&price[gte]=100&price[lte]=500`
143
+ * - Bracket envelope: `?filter[status]=active&filter[price][gte]=100`
144
+ * The envelope form matches the convention used by JSON:API / Stripe /
145
+ * most modern REST style guides, and is the safe choice on busy URLs
146
+ * that might otherwise collide with reserved meta keys (`page`, `sort`,
147
+ * `select`, `populate`, etc.). Operator notation (`[gte]`, `[lte]`,
148
+ * `[ne]`, `[in]`, ...) works inside either form.
149
+ * - Search: ?search=keyword
150
+ * - Populate: ?populate=author,category
151
+ * - Field selection: ?select=name,price,status
152
+ * - Keyset pagination: ?after=cursor_value
227
153
  *
228
- * We surface the fix in the error message, naming the conflicting op
229
- * and the literal line to add.
154
+ * **NOTE on parser portability.** `ArcQueryParser` accepts both filter
155
+ * forms. Other parser implementations (`@classytic/mongokit`'s
156
+ * `QueryParser`, custom hosts-side parsers) may accept only one form —
157
+ * MongoKit historically only accepts the bare form. If your host swaps
158
+ * `queryParser` to a different implementation, double-check which forms
159
+ * it understands before publishing URLs that mix the two.
230
160
  *
231
- * Honored config:
232
- * - `disableDefaultRoutes: true` → no auto-CRUD, no collisions possible
233
- * - `disabledRoutes: ['create']` → that op's path is free for custom use
234
- * - `updateMethod: 'PUT' | 'both'` → update slot covers PUT/PATCH accordingly
161
+ * For advanced MongoDB features ($lookup, aggregations), use MongoKit's QueryParser.
235
162
  */
236
- function validateRouteCrudCollisions(config, errors) {
237
- if (config.disableDefaultRoutes) return;
238
- if (!config.routes || config.routes.length === 0) return;
239
- const updateMethod = config.updateMethod ?? "PATCH";
240
- const updateMethods = updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod];
241
- const disabled = new Set(config.disabledRoutes ?? []);
242
- const crudTable = [
243
- {
244
- op: "list",
245
- method: "GET",
246
- path: "/"
247
- },
248
- {
249
- op: "get",
250
- method: "GET",
251
- path: "/:id"
252
- },
253
- {
254
- op: "create",
255
- method: "POST",
256
- path: "/"
257
- },
258
- ...updateMethods.map((m) => ({
259
- op: "update",
260
- method: m,
261
- path: "/:id"
262
- })),
263
- {
264
- op: "delete",
265
- method: "DELETE",
266
- path: "/:id"
267
- }
268
- ];
269
- for (const [i, route] of config.routes.entries()) {
270
- if (!route.method || !route.path) continue;
271
- for (const crud of crudTable) {
272
- if (disabled.has(crud.op)) continue;
273
- if (route.method !== crud.method || route.path !== crud.path) continue;
274
- errors.push({
275
- field: `routes[${i}]`,
276
- message: `Route ${route.method} ${route.path} collides with auto-CRUD "${crud.op}"`,
277
- suggestion: `Add \`disabledRoutes: ['${crud.op}']\` to suppress the auto-CRUD route, or move your custom handler to a different path. Set \`disableDefaultRoutes: true\` to disable all auto-CRUD on this resource.`
278
- });
163
+ var ArcQueryParser = class {
164
+ maxLimit;
165
+ defaultLimit;
166
+ maxRegexLength;
167
+ maxSearchLength;
168
+ maxFilterDepth;
169
+ _allowedFilterFields;
170
+ _allowedSortFields;
171
+ _allowedOperators;
172
+ /** Allowed filter fields (used by MCP for auto-derive) */
173
+ allowedFilterFields;
174
+ /** Allowed sort fields (used by MCP for sort descriptions) */
175
+ allowedSortFields;
176
+ /** Allowed operators (used by MCP for operator descriptions) */
177
+ allowedOperators;
178
+ /** Supported filter operators */
179
+ operators = {
180
+ eq: "$eq",
181
+ ne: "$ne",
182
+ gt: "$gt",
183
+ gte: "$gte",
184
+ lt: "$lt",
185
+ lte: "$lte",
186
+ in: "$in",
187
+ nin: "$nin",
188
+ like: "$regex",
189
+ contains: "$regex",
190
+ regex: "$regex",
191
+ exists: "$exists"
192
+ };
193
+ constructor(options = {}) {
194
+ this.maxLimit = options.maxLimit ?? 1e3;
195
+ this.defaultLimit = options.defaultLimit ?? 20;
196
+ this.maxRegexLength = options.maxRegexLength ?? 200;
197
+ this.maxSearchLength = options.maxSearchLength ?? 200;
198
+ this.maxFilterDepth = options.maxFilterDepth ?? 10;
199
+ if (options.allowedFilterFields) {
200
+ this._allowedFilterFields = new Set(options.allowedFilterFields);
201
+ this.allowedFilterFields = options.allowedFilterFields;
279
202
  }
280
- }
281
- }
282
- function validateRoutes(routes, errors) {
283
- const validMethods = [
284
- "GET",
285
- "POST",
286
- "PUT",
287
- "PATCH",
288
- "DELETE",
289
- "OPTIONS",
290
- "HEAD"
291
- ];
292
- const seenRoutes = /* @__PURE__ */ new Set();
293
- for (const [i, route] of routes.entries()) {
294
- if (!validMethods.includes(route.method)) errors.push({
295
- field: `routes[${i}].method`,
296
- message: `Invalid HTTP method "${route.method}"`,
297
- suggestion: `Valid methods: ${validMethods.join(", ")}`
298
- });
299
- if (!route.path) errors.push({
300
- field: `routes[${i}].path`,
301
- message: "Route path is required"
302
- });
303
- else if (!route.path.startsWith("/")) errors.push({
304
- field: `routes[${i}].path`,
305
- message: `Route path must start with "/" (got "${route.path}")`,
306
- suggestion: `Change to "/${route.path}"`
307
- });
308
- const routeWithRefs = route;
309
- if (!route.handler && typeof routeWithRefs.controllerMethod !== "function") errors.push({
310
- field: `routes[${i}].handler`,
311
- message: "Route must declare either `handler` (string / function) or `controllerMethod`",
312
- suggestion: "Prefer `controllerMethod: (c: MyController) => c.method` for typed handler refs."
313
- });
314
- const routeKey = `${route.method} ${route.path}`;
315
- if (seenRoutes.has(routeKey)) errors.push({
316
- field: `routes[${i}]`,
317
- message: `Duplicate route "${routeKey}"`
318
- });
319
- seenRoutes.add(routeKey);
320
- }
321
- }
322
- /**
323
- * Format validation errors for display
324
- */
325
- function formatValidationErrors(resourceName, result) {
326
- const lines = [];
327
- if (result.errors.length > 0) {
328
- lines.push(`Resource "${resourceName}" validation failed:`);
329
- lines.push("");
330
- lines.push("ERRORS:");
331
- for (const err of result.errors) {
332
- lines.push(` ✗ ${err.field}: ${err.message}`);
333
- if (err.suggestion) lines.push(` → ${err.suggestion}`);
203
+ if (options.allowedSortFields) {
204
+ this._allowedSortFields = new Set(options.allowedSortFields);
205
+ this.allowedSortFields = options.allowedSortFields;
334
206
  }
335
- }
336
- if (result.warnings.length > 0) {
337
- if (lines.length > 0) lines.push("");
338
- lines.push("WARNINGS:");
339
- for (const warn of result.warnings) {
340
- lines.push(` ⚠ ${warn.field}: ${warn.message}`);
341
- if (warn.suggestion) lines.push(` → ${warn.suggestion}`);
207
+ if (options.allowedOperators) {
208
+ this._allowedOperators = new Set(options.allowedOperators);
209
+ this.allowedOperators = options.allowedOperators;
342
210
  }
343
211
  }
344
- return lines.join("\n");
345
- }
346
- /**
347
- * Validate and throw if invalid
348
- */
349
- function assertValidConfig(config, options) {
350
- const result = validateResourceConfig(config, options);
351
- if (!result.valid) {
352
- const errorMsg = formatValidationErrors(config.name ?? "unknown", result);
353
- throw new Error(errorMsg);
212
+ /**
213
+ * Parse URL query parameters into structured query options
214
+ */
215
+ parse(query) {
216
+ const q = query ?? {};
217
+ const page = this.parseNumber(q.page, 1);
218
+ const limit = Math.min(this.parseNumber(q.limit, this.defaultLimit), this.maxLimit);
219
+ const after = this.parseString(q.after ?? q.cursor);
220
+ const sort = this.parseSort(q.sort);
221
+ const { populate, populateOptions } = this.parsePopulate(q.populate);
222
+ const search = this.parseSearch(q.search);
223
+ const select = this.parseSelect(q.select);
224
+ return {
225
+ filters: this.parseFilters(q),
226
+ limit,
227
+ sort,
228
+ populate,
229
+ populateOptions,
230
+ search,
231
+ page: after ? void 0 : page,
232
+ after,
233
+ select
234
+ };
354
235
  }
355
- }
356
- //#endregion
357
- //#region src/utils/circuitBreaker.ts
358
- /**
359
- * Circuit Breaker Pattern
360
- *
361
- * Wraps external service calls with failure protection.
362
- * Prevents cascading failures by "opening" the circuit when
363
- * a service is failing, allowing it time to recover.
364
- *
365
- * States:
366
- * - CLOSED: Normal operation, requests pass through
367
- * - OPEN: Too many failures, all requests fail fast
368
- * - HALF_OPEN: Testing if service recovered, limited requests
369
- *
370
- * @example
371
- * import { CircuitBreaker } from '@classytic/arc/utils';
372
- *
373
- * const paymentBreaker = new CircuitBreaker(async (amount) => {
374
- * return await stripe.charges.create({ amount });
375
- * }, {
376
- * failureThreshold: 5,
377
- * resetTimeout: 30000,
378
- * timeout: 5000,
379
- * });
380
- *
381
- * try {
382
- * const result = await paymentBreaker.call(100);
383
- * } catch (error) {
384
- * // Handle failure or circuit open
385
- * }
386
- */
387
- const CircuitState = {
388
- CLOSED: "CLOSED",
389
- OPEN: "OPEN",
390
- HALF_OPEN: "HALF_OPEN"
391
- };
392
- var CircuitBreakerError = class extends Error {
393
- state;
394
- constructor(message, state) {
395
- super(message);
396
- this.name = "CircuitBreakerError";
397
- this.state = state;
236
+ parseNumber(value, defaultValue) {
237
+ if (value === void 0 || value === null) return defaultValue;
238
+ const num = parseInt(String(value), 10);
239
+ return Number.isNaN(num) ? defaultValue : Math.max(1, num);
398
240
  }
399
- };
400
- var CircuitBreaker = class {
401
- state = CircuitState.CLOSED;
402
- failures = 0;
403
- successes = 0;
404
- totalCalls = 0;
405
- nextAttempt = 0;
406
- lastCallAt = null;
407
- openedAt = null;
408
- failureThreshold;
409
- resetTimeout;
410
- timeout;
411
- successThreshold;
412
- fallback;
413
- onStateChange;
414
- onError;
415
- name;
416
- fn;
417
- constructor(fn, options = {}) {
418
- this.fn = fn;
419
- this.failureThreshold = options.failureThreshold ?? 5;
420
- this.resetTimeout = options.resetTimeout ?? 6e4;
421
- this.timeout = options.timeout ?? 1e4;
422
- this.successThreshold = options.successThreshold ?? 1;
423
- this.fallback = options.fallback;
424
- this.onStateChange = options.onStateChange;
425
- this.onError = options.onError;
426
- this.name = options.name ?? "CircuitBreaker";
241
+ parseString(value) {
242
+ if (value === void 0 || value === null) return void 0;
243
+ const str = String(value).trim();
244
+ return str.length > 0 ? str : void 0;
427
245
  }
428
246
  /**
429
- * Call the wrapped function with circuit breaker protection
247
+ * Parse populate parameter — handles both simple string and bracket notation.
248
+ *
249
+ * Simple: ?populate=author,category → { populate: 'author,category' }
250
+ * Bracket: ?populate[author][select]=name,email → { populateOptions: [{ path: 'author', select: 'name email' }] }
430
251
  */
431
- async call(...args) {
432
- this.totalCalls++;
433
- this.lastCallAt = Date.now();
434
- if (this.state === CircuitState.OPEN) {
435
- if (Date.now() < this.nextAttempt) {
436
- const error = new CircuitBreakerError(`Circuit breaker is OPEN for ${this.name}`, CircuitState.OPEN);
437
- if (this.fallback) return this.fallback(...args);
438
- throw error;
439
- }
440
- this.setState(CircuitState.HALF_OPEN);
252
+ parsePopulate(value) {
253
+ if (value === void 0 || value === null) return {};
254
+ if (typeof value === "string") {
255
+ const trimmed = value.trim();
256
+ return trimmed.length > 0 ? { populate: trimmed } : {};
441
257
  }
442
- try {
443
- const result = await this.executeWithTimeout(args);
444
- this.onSuccess();
445
- return result;
446
- } catch (err) {
447
- this.onFailure(err instanceof Error ? err : new Error(String(err)));
448
- throw err;
449
- }
450
- }
451
- /**
452
- * Execute function with timeout
453
- */
454
- async executeWithTimeout(args) {
455
- return new Promise((resolve, reject) => {
456
- const timeoutId = setTimeout(() => {
457
- reject(/* @__PURE__ */ new Error(`Request timeout after ${this.timeout}ms`));
458
- }, this.timeout);
459
- this.fn(...args).then((result) => {
460
- clearTimeout(timeoutId);
461
- resolve(result);
462
- }).catch((error) => {
463
- clearTimeout(timeoutId);
464
- reject(error);
465
- });
466
- });
467
- }
468
- /**
469
- * Handle successful call
470
- */
471
- onSuccess() {
472
- this.failures = 0;
473
- this.successes++;
474
- if (this.state === CircuitState.HALF_OPEN) {
475
- if (this.successes >= this.successThreshold) {
476
- this.setState(CircuitState.CLOSED);
477
- this.successes = 0;
258
+ if (typeof value === "object" && !Array.isArray(value)) {
259
+ const obj = value;
260
+ const keys = Object.keys(obj);
261
+ if (keys.length === 0) return {};
262
+ const options = [];
263
+ for (const path of keys) {
264
+ if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(path)) continue;
265
+ const config = obj[path];
266
+ if (typeof config === "object" && config !== null && !Array.isArray(config)) {
267
+ const cfg = config;
268
+ const option = { path };
269
+ if (typeof cfg.select === "string") option.select = cfg.select.split(",").map((s) => s.trim()).filter(Boolean).join(" ");
270
+ if (typeof cfg.match === "object" && cfg.match !== null) option.match = cfg.match;
271
+ options.push(option);
272
+ } else options.push({ path });
478
273
  }
274
+ return options.length > 0 ? { populateOptions: options } : {};
479
275
  }
276
+ return {};
480
277
  }
481
- /**
482
- * Handle failed call
483
- */
484
- onFailure(error) {
485
- this.failures++;
486
- this.successes = 0;
487
- if (this.onError) this.onError(error);
488
- if (this.state === CircuitState.HALF_OPEN || this.failures >= this.failureThreshold) {
489
- this.setState(CircuitState.OPEN);
490
- this.nextAttempt = Date.now() + this.resetTimeout;
491
- this.openedAt = Date.now();
278
+ parseSort(value) {
279
+ if (!value) return void 0;
280
+ const sortStr = String(value);
281
+ const result = {};
282
+ for (const field of sortStr.split(",")) {
283
+ const trimmed = field.trim();
284
+ if (!trimmed) continue;
285
+ if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
286
+ const fieldName = trimmed.startsWith("-") ? trimmed.slice(1) : trimmed;
287
+ if (this._allowedSortFields && !this._allowedSortFields.has(fieldName)) continue;
288
+ if (trimmed.startsWith("-")) result[fieldName] = -1;
289
+ else result[fieldName] = 1;
492
290
  }
291
+ return Object.keys(result).length > 0 ? result : void 0;
493
292
  }
494
- /**
495
- * Change circuit state
496
- */
497
- setState(newState) {
498
- const oldState = this.state;
499
- if (oldState !== newState) {
500
- this.state = newState;
501
- if (this.onStateChange) this.onStateChange(oldState, newState);
502
- }
293
+ parseSearch(value) {
294
+ if (!value) return void 0;
295
+ const search = String(value).trim();
296
+ if (search.length === 0) return void 0;
297
+ if (search.length > this.maxSearchLength) return search.slice(0, this.maxSearchLength);
298
+ return search;
503
299
  }
504
- /**
505
- * Manually open the circuit
506
- */
507
- open() {
508
- this.setState(CircuitState.OPEN);
509
- this.nextAttempt = Date.now() + this.resetTimeout;
510
- this.openedAt = Date.now();
300
+ parseSelect(value) {
301
+ if (!value) return void 0;
302
+ const selectStr = String(value);
303
+ const result = {};
304
+ for (const field of selectStr.split(",")) {
305
+ const trimmed = field.trim();
306
+ if (!trimmed) continue;
307
+ if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
308
+ if (trimmed.startsWith("-")) result[trimmed.slice(1)] = 0;
309
+ else result[trimmed] = 1;
310
+ }
311
+ return Object.keys(result).length > 0 ? result : void 0;
511
312
  }
512
313
  /**
513
- * Manually close the circuit
314
+ * Check if a value exceeds the maximum nesting depth.
315
+ * Prevents filter bombs where deeply nested objects consume excessive memory/CPU.
514
316
  */
515
- close() {
516
- this.failures = 0;
517
- this.successes = 0;
518
- this.setState(CircuitState.CLOSED);
519
- this.openedAt = null;
317
+ exceedsDepth(obj, currentDepth = 0) {
318
+ if (currentDepth > this.maxFilterDepth) return true;
319
+ if (obj === null || obj === void 0) return false;
320
+ if (Array.isArray(obj)) return obj.some((v) => this.exceedsDepth(v, currentDepth));
321
+ if (typeof obj !== "object") return false;
322
+ return Object.values(obj).some((v) => this.exceedsDepth(v, currentDepth + 1));
520
323
  }
521
324
  /**
522
- * Get current statistics
325
+ * Parse all filter entries from the query, supporting BOTH the bare
326
+ * top-level form and the bracket-wrapped `filter[...]` envelope.
327
+ *
328
+ * Two equivalent ways to express the same query:
329
+ * - Bare (legacy, arc default): `?status=active&price[gte]=40`
330
+ * - Bracket envelope (REST convention): `?filter[status]=active&filter[price][gte]=40`
331
+ *
332
+ * Both forms parse to the same `filters: { status: 'active', price: { $gte: 40 } }`.
333
+ * The envelope form mirrors the convention used by most REST API style
334
+ * guides (JSON:API, Stripe API, etc.) and disambiguates filter fields
335
+ * from reserved meta params on busy URLs — a query string with a field
336
+ * literally named `page` or `sort` can't collide with the framework's
337
+ * meta keys when wrapped under `filter[...]`. The bare form stays as
338
+ * the default since it's the shortest path for simple queries; expect
339
+ * it to remain supported indefinitely.
340
+ *
341
+ * Precedence when the same key appears in both forms:
342
+ * `?status=closed&filter[status]=active` → bare wins (`status: closed`).
343
+ * Mixing should be rare; the deterministic rule avoids silent surprises.
523
344
  */
524
- getStats() {
525
- return {
526
- name: this.name,
527
- state: this.state,
528
- failures: this.failures,
529
- successes: this.successes,
530
- totalCalls: this.totalCalls,
531
- openedAt: this.openedAt,
532
- lastCallAt: this.lastCallAt
533
- };
345
+ parseFilters(query) {
346
+ const filters = {};
347
+ const envelope = query.filter;
348
+ if (typeof envelope === "object" && envelope !== null && !Array.isArray(envelope)) for (const [key, value] of Object.entries(envelope)) this.applyFilterEntry(filters, key, value);
349
+ for (const [key, value] of Object.entries(query)) {
350
+ if (RESERVED_QUERY_PARAMS.has(key)) continue;
351
+ this.applyFilterEntry(filters, key, value);
352
+ }
353
+ return filters;
534
354
  }
535
355
  /**
536
- * Get current state
356
+ * Apply one filter entry — used by both the bracket-envelope branch and
357
+ * the bare top-level branch of `parseFilters`. Centralising the field-
358
+ * validation + operator-conversion logic keeps the two forms in
359
+ * lockstep; a future regex/security tweak only changes one place.
537
360
  */
538
- getState() {
539
- return this.state;
361
+ applyFilterEntry(filters, key, value) {
362
+ if (value === void 0 || value === null) return;
363
+ if (!/^[a-zA-Z_][a-zA-Z0-9_.]*(?:\[[a-z]+\])?$/.test(key)) return;
364
+ const bareKey = key.replace(/\[[a-z]+\]$/, "");
365
+ if (this._allowedFilterFields && !this._allowedFilterFields.has(bareKey)) return;
366
+ if (this.exceedsDepth(value)) return;
367
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
368
+ const operatorObj = value;
369
+ const operatorKeys = Object.keys(operatorObj);
370
+ const allOperators = operatorKeys.every((op) => this.operators[op] && (!this._allowedOperators || this._allowedOperators.has(op)));
371
+ const allKnownOperators = operatorKeys.every((op) => this.operators[op]);
372
+ if (allOperators && operatorKeys.length > 0) {
373
+ const mongoFilters = {};
374
+ let needsCaseInsensitive = false;
375
+ for (const [op, opValue] of Object.entries(operatorObj)) {
376
+ const mongoOp = this.operators[op];
377
+ if (mongoOp) {
378
+ mongoFilters[mongoOp] = this.parseFilterValue(opValue, op);
379
+ if (op === "contains" || op === "like") needsCaseInsensitive = true;
380
+ }
381
+ }
382
+ if (needsCaseInsensitive) mongoFilters.$options = "i";
383
+ filters[key] = mongoFilters;
384
+ return;
385
+ }
386
+ if (allKnownOperators && this._allowedOperators) return;
387
+ }
388
+ const match = key.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)(?:\[([a-z]+)\])?$/);
389
+ if (!match) return;
390
+ const [, fieldName, operator] = match;
391
+ if (!fieldName) return;
392
+ if (operator && this.operators[operator] && (!this._allowedOperators || this._allowedOperators.has(operator))) {
393
+ const mongoOp = this.operators[operator];
394
+ const parsedValue = this.parseFilterValue(value, operator);
395
+ if (!filters[fieldName]) filters[fieldName] = {};
396
+ const fieldFilter = filters[fieldName];
397
+ fieldFilter[mongoOp] = parsedValue;
398
+ if (operator === "contains" || operator === "like") fieldFilter.$options = "i";
399
+ } else if (!operator) filters[fieldName] = this.parseFilterValue(value);
540
400
  }
541
- /**
542
- * Check if circuit is open
543
- */
544
- isOpen() {
545
- return this.state === CircuitState.OPEN;
401
+ parseFilterValue(value, operator) {
402
+ if (operator === "in" || operator === "nin") {
403
+ if (Array.isArray(value)) return value.map((v) => this.coerceValue(v));
404
+ if (typeof value === "string" && value.includes(",")) return value.split(",").map((v) => this.coerceValue(v.trim()));
405
+ return [this.coerceValue(value)];
406
+ }
407
+ if (operator === "like" || operator === "contains" || operator === "regex") return this.sanitizeRegex(String(value));
408
+ if (operator === "exists") {
409
+ const str = String(value).toLowerCase();
410
+ return str === "true" || str === "1";
411
+ }
412
+ return this.coerceValue(value);
546
413
  }
547
- /**
548
- * Check if circuit is closed
549
- */
550
- isClosed() {
551
- return this.state === CircuitState.CLOSED;
414
+ coerceValue(value) {
415
+ if (value === "true") return true;
416
+ if (value === "false") return false;
417
+ if (value === "null") return null;
418
+ if (typeof value === "string") {
419
+ const num = Number(value);
420
+ if (!Number.isNaN(num) && value.trim() !== "") return num;
421
+ }
422
+ return value;
552
423
  }
553
424
  /**
554
- * Reset statistics
425
+ * Generate OpenAPI-compatible JSON Schema for query parameters.
426
+ * Arc's defineResource() auto-detects this method and uses it
427
+ * to document list endpoint query parameters in OpenAPI/Swagger.
555
428
  */
556
- reset() {
557
- this.failures = 0;
558
- this.successes = 0;
559
- this.totalCalls = 0;
560
- this.lastCallAt = null;
561
- this.openedAt = null;
562
- this.setState(CircuitState.CLOSED);
563
- }
564
- };
565
- /**
566
- * Create a circuit breaker with sensible defaults
567
- *
568
- * @example
569
- * const emailBreaker = createCircuitBreaker(
570
- * async (to, subject, body) => sendEmail(to, subject, body),
571
- * { name: 'email-service' }
572
- * );
573
- */
574
- function createCircuitBreaker(fn, options) {
575
- return new CircuitBreaker(fn, options);
576
- }
577
- /**
578
- * Circuit breaker registry for managing multiple breakers
579
- */
580
- var CircuitBreakerRegistry = class {
581
- breakers = /* @__PURE__ */ new Map();
582
- /**
583
- * Register a circuit breaker
584
- */
585
- register(name, fn, options) {
586
- const breaker = new CircuitBreaker(fn, {
587
- ...options,
588
- name
429
+ getQuerySchema() {
430
+ const operatorLines = Object.entries(this.operators).map(([op, mongoOp]) => {
431
+ return ` ${op} → ${mongoOp}: ${{
432
+ eq: "Equal (default when no operator specified)",
433
+ ne: "Not equal",
434
+ gt: "Greater than",
435
+ gte: "Greater than or equal",
436
+ lt: "Less than",
437
+ lte: "Less than or equal",
438
+ in: "In list (comma-separated values)",
439
+ nin: "Not in list",
440
+ like: "Pattern match (case-insensitive)",
441
+ contains: "Contains substring (case-insensitive)",
442
+ regex: "Regex pattern",
443
+ exists: "Field exists (true/false)"
444
+ }[op] || op}`;
589
445
  });
590
- this.breakers.set(name, breaker);
591
- return breaker;
592
- }
593
- /**
594
- * Get a circuit breaker by name
595
- */
596
- get(name) {
597
- return this.breakers.get(name);
598
- }
599
- /**
600
- * Get all breakers
601
- */
602
- getAll() {
603
- return this.breakers;
604
- }
605
- /**
606
- * Get statistics for all breakers
607
- */
608
- getAllStats() {
609
- const stats = {};
610
- for (const [name, breaker] of this.breakers.entries()) stats[name] = breaker.getStats();
611
- return stats;
612
- }
613
- /**
614
- * Reset all breakers
615
- */
616
- resetAll() {
617
- for (const breaker of this.breakers.values()) breaker.reset();
618
- }
619
- /**
620
- * Open all breakers
621
- */
622
- openAll() {
623
- for (const breaker of this.breakers.values()) breaker.open();
446
+ return {
447
+ type: "object",
448
+ properties: {
449
+ page: {
450
+ type: "integer",
451
+ description: "Page number for offset pagination",
452
+ default: 1,
453
+ minimum: 1
454
+ },
455
+ limit: {
456
+ type: "integer",
457
+ description: "Number of items per page",
458
+ default: this.defaultLimit,
459
+ minimum: 1,
460
+ maximum: this.maxLimit
461
+ },
462
+ sort: {
463
+ type: "string",
464
+ description: "Sort fields (comma-separated). Prefix with - for descending. Example: -createdAt,name"
465
+ },
466
+ search: {
467
+ type: "string",
468
+ description: "Full-text search query",
469
+ maxLength: this.maxSearchLength
470
+ },
471
+ select: {
472
+ type: "string",
473
+ description: "Fields to include/exclude (comma-separated). Prefix with - to exclude. Example: name,email,-password"
474
+ },
475
+ populate: {
476
+ type: "string",
477
+ description: "Fields to populate/join (comma-separated). Example: author,category"
478
+ },
479
+ after: {
480
+ type: "string",
481
+ description: "Cursor value for keyset pagination"
482
+ },
483
+ _filterOperators: {
484
+ type: "string",
485
+ description: ["Available filter operators (use as field[operator]=value):", ...operatorLines].join("\n")
486
+ }
487
+ }
488
+ };
624
489
  }
625
- /**
626
- * Close all breakers
627
- */
628
- closeAll() {
629
- for (const breaker of this.breakers.values()) breaker.close();
490
+ sanitizeRegex(pattern) {
491
+ const truncated = pattern.length > this.maxRegexLength;
492
+ let sanitized = pattern.slice(0, this.maxRegexLength);
493
+ if (DANGEROUS_REGEX_PATTERNS.test(sanitized)) {
494
+ sanitized = sanitized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
495
+ log.warn(`regex pattern matched a ReDoS-dangerous shape and was escaped to a literal string. original="${pattern}" sanitized="${sanitized}"`);
496
+ } else if (truncated) log.warn(`regex pattern exceeded maxRegexLength (${this.maxRegexLength}) and was truncated. original-length=${pattern.length}`);
497
+ return sanitized;
630
498
  }
631
499
  };
632
500
  /**
633
- * Create a new CircuitBreakerRegistry instance.
634
- * Use this instead of a global singleton — attach to fastify.arc or pass explicitly.
501
+ * Create a new ArcQueryParser instance
635
502
  */
636
- function createCircuitBreakerRegistry() {
637
- return new CircuitBreakerRegistry();
503
+ function createQueryParser(options) {
504
+ return new ArcQueryParser(options);
638
505
  }
639
506
  //#endregion
640
- //#region src/utils/compensation.ts
507
+ //#region src/utils/responseSchemas.ts
641
508
  /**
642
- * Run steps in order with automatic compensation on failure.
509
+ * JSON Schema definitions for arc API responses.
643
510
  *
644
- * @typeParam TCtx - Context type shared across steps (defaults to Record<string, unknown>)
511
+ * Wire shape (post-2.12): no envelope. HTTP status discriminates success
512
+ * vs error. Success-path schemas describe the data shape directly; the
513
+ * error path uses the canonical `ErrorContract` JSON Schema imported
514
+ * from `@classytic/repo-core/errors` — single source of truth shared
515
+ * with every other consumer in the org.
645
516
  */
646
- async function withCompensation(_name, steps, initialContext, hooks) {
647
- const ctx = { ...initialContext };
648
- const completedSteps = [];
649
- const results = {};
650
- const completed = [];
651
- for (const step of steps) {
652
- if (step.fireAndForget) {
653
- completedSteps.push(step.name);
654
- step.execute(ctx).then((result) => hooks?.onStepComplete?.(step.name, result), () => {});
655
- continue;
656
- }
657
- try {
658
- const result = await step.execute(ctx);
659
- completedSteps.push(step.name);
660
- results[step.name] = result;
661
- completed.push({
662
- step,
663
- result
664
- });
665
- hooks?.onStepComplete?.(step.name, result);
666
- } catch (err) {
667
- const error = err instanceof Error ? err : new Error(String(err));
668
- hooks?.onStepFailed?.(step.name, error);
669
- const compensationErrors = await rollback(ctx, completed, hooks);
670
- return {
671
- success: false,
672
- completedSteps,
673
- results,
674
- failedStep: step.name,
675
- error: error.message,
676
- ...compensationErrors.length > 0 ? { compensationErrors } : {}
677
- };
678
- }
679
- }
680
- return {
681
- success: true,
682
- completedSteps,
683
- results
684
- };
685
- }
686
- async function rollback(ctx, completed, hooks) {
687
- const errors = [];
688
- for (let i = completed.length - 1; i >= 0; i--) {
689
- const entry = completed[i];
690
- if (!entry?.step.compensate) continue;
691
- const compensateFn = entry.step.compensate;
692
- try {
693
- await compensateFn(ctx, entry.result);
694
- hooks?.onCompensate?.(entry.step.name);
695
- } catch (err) {
696
- errors.push({
697
- step: entry.step.name,
698
- error: err instanceof Error ? err.message : String(err)
699
- });
700
- }
701
- }
702
- return errors;
703
- }
704
- function defineCompensation(name, steps) {
705
- return {
706
- name,
707
- execute: (initialContext, hooks) => withCompensation(name, steps, initialContext, hooks)
708
- };
709
- }
710
- //#endregion
711
- //#region src/utils/defineErrorMapper.ts
712
517
  /**
713
- * Register an `ErrorMapper` with its domain-specific generic argument and
714
- * have it assign cleanly into `ErrorMapper[]` (no `as unknown as ErrorMapper`).
715
- *
716
- * The returned mapper is identical at runtime — `type` and `toResponse` are
717
- * passed through untouched. Only the declared type widens from
718
- * `ErrorMapper<T>` to `ErrorMapper` so the array inference works.
518
+ * Pagination schema - matches MongoKit/Arc runtime format
719
519
  *
720
- * Safety: the `errorHandlerPlugin` dispatches via `error instanceof mapper.type`
721
- * before invoking `toResponse`, so the widened callback signature is never
722
- * called with a non-`T` error at runtime. This helper codifies that invariant
723
- * in one place.
724
- */
725
- function defineErrorMapper(mapper) {
726
- return mapper;
727
- }
728
- //#endregion
729
- //#region src/utils/defineGuard.ts
730
- /** Hidden property key for guard context storage on the request object. */
731
- const GUARD_STORE_KEY = "__arcGuardContext";
732
- /**
733
- * Create a typed guard. See module JSDoc for usage.
520
+ * Runtime format (flat fields):
521
+ * { page, limit, total, pages, hasNext, hasPrev }
734
522
  */
735
- function defineGuard(config) {
736
- const { name, resolve } = config;
737
- const preHandler = async (req, reply) => {
738
- const ctx = await resolve(req, reply);
739
- if (!reply.sent) {
740
- const store = req[GUARD_STORE_KEY] ?? {};
741
- store[name] = ctx;
742
- req[GUARD_STORE_KEY] = store;
743
- }
744
- };
745
- return {
746
- preHandler,
747
- name,
748
- from(req) {
749
- const store = req[GUARD_STORE_KEY];
750
- if (!store || !(name in store)) throw new Error(`Guard '${name}' not resolved on this request. Add it to routeGuards or the route's preHandler array.`);
751
- return store[name];
752
- }
753
- };
754
- }
755
- //#endregion
756
- //#region src/utils/handleRaw.ts
757
- /**
758
- * Wrap a raw Fastify handler with Arc's response shape and error handling.
759
- *
760
- * @param handler - Async function that receives `(request, reply)` and returns data.
761
- * The return value is sent raw (no envelope). If it returns `undefined`,
762
- * the response body is empty (HTTP status only).
763
- * @param statusCode - HTTP status code for successful responses (default: 200)
764
- */
765
- function handleRaw(handler, statusCode = 200) {
766
- return async (request, reply) => {
767
- try {
768
- const result = await handler(request, reply);
769
- if (reply.sent) return;
770
- if (result === void 0 || result === null) reply.code(statusCode).send();
771
- else reply.code(statusCode).send(result);
772
- } catch (err) {
773
- if (reply.sent) return;
774
- if (err instanceof ArcError) {
775
- reply.code(err.statusCode).send({
776
- error: err.message,
777
- code: err.code,
778
- ...err.details ? { details: err.details } : {}
779
- });
780
- return;
781
- }
782
- const error = err;
783
- const code = error.statusCode ?? error.status ?? 500;
784
- reply.code(code).send({
785
- error: error.message ?? "Internal server error",
786
- ...error.code && { code: error.code }
787
- });
523
+ const paginationSchema = {
524
+ type: "object",
525
+ properties: {
526
+ page: {
527
+ type: "integer",
528
+ example: 1
529
+ },
530
+ limit: {
531
+ type: "integer",
532
+ example: 20
533
+ },
534
+ total: {
535
+ type: "integer",
536
+ example: 100
537
+ },
538
+ pages: {
539
+ type: "integer",
540
+ example: 5
541
+ },
542
+ hasNext: {
543
+ type: "boolean",
544
+ example: true
545
+ },
546
+ hasPrev: {
547
+ type: "boolean",
548
+ example: false
788
549
  }
789
- };
790
- }
791
- //#endregion
792
- //#region src/utils/queryParser.ts
793
- /**
794
- * Arc Query Parser - Default URL-to-Query Parser
795
- *
796
- * Framework-agnostic query parser that converts URL parameters to query options.
797
- * This is Arc's built-in parser; users can swap in MongoKit's QueryParser,
798
- * pgkit's parser, or any custom parser implementing QueryParserInterface.
799
- *
800
- * @example
801
- * // Use Arc default parser (auto-applied if no queryParser option)
802
- * defineResource({ name: 'product', adapter: ... });
803
- *
804
- * // Use MongoKit's QueryParser (recommended for MongoDB - has $lookup, aggregations, etc.)
805
- * import { QueryParser } from '@classytic/mongokit';
806
- * defineResource({
807
- * name: 'product',
808
- * adapter: ...,
809
- * queryParser: new QueryParser(),
810
- * });
811
- *
812
- * // Use custom parser for SQL databases
813
- * defineResource({
814
- * name: 'user',
815
- * adapter: ...,
816
- * queryParser: new PgQueryParser(),
817
- * });
818
- */
819
- const log = arcLog("queryParser");
820
- /**
821
- * Regex patterns that can cause catastrophic backtracking (ReDoS attacks)
822
- * Detects:
823
- * - Quantifiers: {n,m}
824
- * - Possessive quantifiers: *+, ++, ?+
825
- * - Nested quantifiers: (a+)+, (a*)*
826
- * - Backreferences: \1, \2, etc.
827
- */
828
- const DANGEROUS_REGEX_PATTERNS = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
550
+ },
551
+ required: [
552
+ "page",
553
+ "limit",
554
+ "total",
555
+ "pages",
556
+ "hasNext",
557
+ "hasPrev"
558
+ ]
559
+ };
829
560
  /**
830
- * Arc's default query parser
831
- *
832
- * Converts URL query parameters to a structured query format:
833
- * - Pagination: ?page=1&limit=20
834
- * - Sorting: ?sort=-createdAt,name (- prefix = descending)
835
- * - Filtering — TWO supported forms (both produce identical output):
836
- * - Bare (default, terse): `?status=active&price[gte]=100&price[lte]=500`
837
- * - Bracket envelope: `?filter[status]=active&filter[price][gte]=100`
838
- * The envelope form matches the convention used by JSON:API / Stripe /
839
- * most modern REST style guides, and is the safe choice on busy URLs
840
- * that might otherwise collide with reserved meta keys (`page`, `sort`,
841
- * `select`, `populate`, etc.). Operator notation (`[gte]`, `[lte]`,
842
- * `[ne]`, `[in]`, ...) works inside either form.
843
- * - Search: ?search=keyword
844
- * - Populate: ?populate=author,category
845
- * - Field selection: ?select=name,price,status
846
- * - Keyset pagination: ?after=cursor_value
847
- *
848
- * **NOTE on parser portability.** `ArcQueryParser` accepts both filter
849
- * forms. Other parser implementations (`@classytic/mongokit`'s
850
- * `QueryParser`, custom hosts-side parsers) may accept only one form —
851
- * MongoKit historically only accepts the bare form. If your host swaps
852
- * `queryParser` to a different implementation, double-check which forms
853
- * it understands before publishing URLs that mix the two.
561
+ * List response schema — full union of every wire shape `toCanonicalList`
562
+ * can emit. Hosts who know their endpoint only ever produces one variant
563
+ * can pin to a narrower helper:
564
+ * - `offsetListResponse(item)` — `{ method: 'offset', data, page, limit, total, pages, hasNext, hasPrev }`
565
+ * - `keysetListResponse(item)` — `{ method: 'keyset', data, limit, hasMore, next: string|null }`
566
+ * - `aggregateListResponse(item)` — `{ method: 'aggregate', ...offset fields }`
567
+ * - `bareListResponse(item)` — `{ data }`
854
568
  *
855
- * For advanced MongoDB features ($lookup, aggregations), use MongoKit's QueryParser.
569
+ * The default `listResponse(item)` is the union (`oneOf`) of all four so
570
+ * Fastify validation accepts any canonical kit shape — pre-2.13 this only
571
+ * modelled offset and rejected keyset/aggregate/bare lists at the
572
+ * response-schema gate.
856
573
  */
857
- var ArcQueryParser = class {
858
- maxLimit;
859
- defaultLimit;
860
- maxRegexLength;
861
- maxSearchLength;
862
- maxFilterDepth;
863
- _allowedFilterFields;
864
- _allowedSortFields;
865
- _allowedOperators;
866
- /** Allowed filter fields (used by MCP for auto-derive) */
867
- allowedFilterFields;
868
- /** Allowed sort fields (used by MCP for sort descriptions) */
869
- allowedSortFields;
870
- /** Allowed operators (used by MCP for operator descriptions) */
871
- allowedOperators;
872
- /** Supported filter operators */
873
- operators = {
874
- eq: "$eq",
875
- ne: "$ne",
876
- gt: "$gt",
877
- gte: "$gte",
878
- lt: "$lt",
879
- lte: "$lte",
880
- in: "$in",
881
- nin: "$nin",
882
- like: "$regex",
883
- contains: "$regex",
884
- regex: "$regex",
885
- exists: "$exists"
574
+ function listResponse(itemSchema) {
575
+ return { oneOf: [
576
+ offsetListResponse(itemSchema),
577
+ keysetListResponse(itemSchema),
578
+ aggregateListResponse(itemSchema),
579
+ bareListResponse(itemSchema)
580
+ ] };
581
+ }
582
+ /** Offset variant — `{ method: 'offset', data, page, limit, total, pages, hasNext, hasPrev }`. */
583
+ function offsetListResponse(itemSchema) {
584
+ return {
585
+ type: "object",
586
+ properties: {
587
+ method: {
588
+ type: "string",
589
+ const: "offset",
590
+ example: "offset"
591
+ },
592
+ data: {
593
+ type: "array",
594
+ items: itemSchema
595
+ },
596
+ page: {
597
+ type: "integer",
598
+ example: 1
599
+ },
600
+ limit: {
601
+ type: "integer",
602
+ example: 20
603
+ },
604
+ total: {
605
+ type: "integer",
606
+ example: 100
607
+ },
608
+ pages: {
609
+ type: "integer",
610
+ example: 5
611
+ },
612
+ hasNext: {
613
+ type: "boolean",
614
+ example: false
615
+ },
616
+ hasPrev: {
617
+ type: "boolean",
618
+ example: false
619
+ }
620
+ },
621
+ required: [
622
+ "method",
623
+ "data",
624
+ "page",
625
+ "limit",
626
+ "total",
627
+ "pages",
628
+ "hasNext",
629
+ "hasPrev"
630
+ ],
631
+ additionalProperties: true
886
632
  };
887
- constructor(options = {}) {
888
- this.maxLimit = options.maxLimit ?? 1e3;
889
- this.defaultLimit = options.defaultLimit ?? 20;
890
- this.maxRegexLength = options.maxRegexLength ?? 200;
891
- this.maxSearchLength = options.maxSearchLength ?? 200;
892
- this.maxFilterDepth = options.maxFilterDepth ?? 10;
893
- if (options.allowedFilterFields) {
894
- this._allowedFilterFields = new Set(options.allowedFilterFields);
895
- this.allowedFilterFields = options.allowedFilterFields;
896
- }
897
- if (options.allowedSortFields) {
898
- this._allowedSortFields = new Set(options.allowedSortFields);
899
- this.allowedSortFields = options.allowedSortFields;
900
- }
901
- if (options.allowedOperators) {
902
- this._allowedOperators = new Set(options.allowedOperators);
903
- this.allowedOperators = options.allowedOperators;
904
- }
905
- }
906
- /**
907
- * Parse URL query parameters into structured query options
908
- */
909
- parse(query) {
910
- const q = query ?? {};
911
- const page = this.parseNumber(q.page, 1);
912
- const limit = Math.min(this.parseNumber(q.limit, this.defaultLimit), this.maxLimit);
913
- const after = this.parseString(q.after ?? q.cursor);
914
- const sort = this.parseSort(q.sort);
915
- const { populate, populateOptions } = this.parsePopulate(q.populate);
916
- const search = this.parseSearch(q.search);
917
- const select = this.parseSelect(q.select);
918
- return {
919
- filters: this.parseFilters(q),
920
- limit,
921
- sort,
922
- populate,
923
- populateOptions,
924
- search,
925
- page: after ? void 0 : page,
926
- after,
927
- select
928
- };
929
- }
930
- parseNumber(value, defaultValue) {
931
- if (value === void 0 || value === null) return defaultValue;
932
- const num = parseInt(String(value), 10);
933
- return Number.isNaN(num) ? defaultValue : Math.max(1, num);
934
- }
935
- parseString(value) {
936
- if (value === void 0 || value === null) return void 0;
937
- const str = String(value).trim();
938
- return str.length > 0 ? str : void 0;
939
- }
940
- /**
941
- * Parse populate parameter — handles both simple string and bracket notation.
942
- *
943
- * Simple: ?populate=author,category → { populate: 'author,category' }
944
- * Bracket: ?populate[author][select]=name,email → { populateOptions: [{ path: 'author', select: 'name email' }] }
945
- */
946
- parsePopulate(value) {
947
- if (value === void 0 || value === null) return {};
948
- if (typeof value === "string") {
949
- const trimmed = value.trim();
950
- return trimmed.length > 0 ? { populate: trimmed } : {};
951
- }
952
- if (typeof value === "object" && !Array.isArray(value)) {
953
- const obj = value;
954
- const keys = Object.keys(obj);
955
- if (keys.length === 0) return {};
956
- const options = [];
957
- for (const path of keys) {
958
- if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(path)) continue;
959
- const config = obj[path];
960
- if (typeof config === "object" && config !== null && !Array.isArray(config)) {
961
- const cfg = config;
962
- const option = { path };
963
- if (typeof cfg.select === "string") option.select = cfg.select.split(",").map((s) => s.trim()).filter(Boolean).join(" ");
964
- if (typeof cfg.match === "object" && cfg.match !== null) option.match = cfg.match;
965
- options.push(option);
966
- } else options.push({ path });
633
+ }
634
+ /** Keyset variant — `{ method: 'keyset', data, limit, hasMore, next: string | null }`. */
635
+ function keysetListResponse(itemSchema) {
636
+ return {
637
+ type: "object",
638
+ properties: {
639
+ method: {
640
+ type: "string",
641
+ const: "keyset",
642
+ example: "keyset"
643
+ },
644
+ data: {
645
+ type: "array",
646
+ items: itemSchema
647
+ },
648
+ limit: {
649
+ type: "integer",
650
+ example: 20
651
+ },
652
+ hasMore: {
653
+ type: "boolean",
654
+ example: true
655
+ },
656
+ next: {
657
+ type: ["string", "null"],
658
+ description: "Cursor token for the next page, or null."
967
659
  }
968
- return options.length > 0 ? { populateOptions: options } : {};
969
- }
970
- return {};
971
- }
972
- parseSort(value) {
973
- if (!value) return void 0;
974
- const sortStr = String(value);
975
- const result = {};
976
- for (const field of sortStr.split(",")) {
977
- const trimmed = field.trim();
978
- if (!trimmed) continue;
979
- if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
980
- const fieldName = trimmed.startsWith("-") ? trimmed.slice(1) : trimmed;
981
- if (this._allowedSortFields && !this._allowedSortFields.has(fieldName)) continue;
982
- if (trimmed.startsWith("-")) result[fieldName] = -1;
983
- else result[fieldName] = 1;
660
+ },
661
+ required: [
662
+ "method",
663
+ "data",
664
+ "limit",
665
+ "hasMore",
666
+ "next"
667
+ ],
668
+ additionalProperties: true
669
+ };
670
+ }
671
+ /** Aggregate variant — same shape as offset, discriminated by `method: 'aggregate'`. */
672
+ function aggregateListResponse(itemSchema) {
673
+ return {
674
+ type: "object",
675
+ properties: {
676
+ method: {
677
+ type: "string",
678
+ const: "aggregate",
679
+ example: "aggregate"
680
+ },
681
+ data: {
682
+ type: "array",
683
+ items: itemSchema
684
+ },
685
+ page: { type: "integer" },
686
+ limit: { type: "integer" },
687
+ total: { type: "integer" },
688
+ pages: { type: "integer" },
689
+ hasNext: { type: "boolean" },
690
+ hasPrev: { type: "boolean" }
691
+ },
692
+ required: [
693
+ "method",
694
+ "data",
695
+ "page",
696
+ "limit",
697
+ "total",
698
+ "pages",
699
+ "hasNext",
700
+ "hasPrev"
701
+ ],
702
+ additionalProperties: true
703
+ };
704
+ }
705
+ /** Bare variant — `{ data }`, no `method` discriminant. */
706
+ function bareListResponse(itemSchema) {
707
+ return {
708
+ type: "object",
709
+ properties: { data: {
710
+ type: "array",
711
+ items: itemSchema
712
+ } },
713
+ required: ["data"],
714
+ additionalProperties: true
715
+ };
716
+ }
717
+ /** Delete response — flat shape mirroring the canonical delete envelope. */
718
+ function deleteResponse() {
719
+ return {
720
+ type: "object",
721
+ properties: {
722
+ message: {
723
+ type: "string",
724
+ example: "Deleted successfully"
725
+ },
726
+ id: {
727
+ type: "string",
728
+ example: "507f1f77bcf86cd799439011"
729
+ },
730
+ soft: {
731
+ type: "boolean",
732
+ example: false
733
+ }
734
+ },
735
+ additionalProperties: true
736
+ };
737
+ }
738
+ const ERROR_DESCRIPTIONS = {
739
+ 400: "Bad Request",
740
+ 401: "Unauthorized",
741
+ 403: "Forbidden",
742
+ 404: "Not Found",
743
+ 409: "Conflict",
744
+ 422: "Unprocessable Entity",
745
+ 429: "Too Many Requests",
746
+ 500: "Internal Server Error",
747
+ 503: "Service Unavailable"
748
+ };
749
+ /** Build an OpenAPI response entry for an `ErrorContract` at the given status. */
750
+ function errorResponse(status) {
751
+ return {
752
+ description: ERROR_DESCRIPTIONS[status] ?? "Error",
753
+ content: { "application/json": { schema: errorContractSchema$1 } }
754
+ };
755
+ }
756
+ const responses = {
757
+ 200: (schema) => ({
758
+ description: "Successful response",
759
+ content: { "application/json": { schema } }
760
+ }),
761
+ 201: (schema) => ({
762
+ description: "Created successfully",
763
+ content: { "application/json": { schema: {
764
+ ...schema,
765
+ additionalProperties: true
766
+ } } }
767
+ }),
768
+ 400: errorResponse(400),
769
+ 401: errorResponse(401),
770
+ 403: errorResponse(403),
771
+ 404: errorResponse(404),
772
+ 409: errorResponse(409),
773
+ 500: errorResponse(500)
774
+ };
775
+ const queryParams = {
776
+ pagination: {
777
+ page: {
778
+ type: "integer",
779
+ minimum: 1,
780
+ default: 1,
781
+ description: "Page number"
782
+ },
783
+ limit: {
784
+ type: "integer",
785
+ minimum: 1,
786
+ maximum: 100,
787
+ default: 20,
788
+ description: "Items per page"
984
789
  }
985
- return Object.keys(result).length > 0 ? result : void 0;
986
- }
987
- parseSearch(value) {
988
- if (!value) return void 0;
989
- const search = String(value).trim();
990
- if (search.length === 0) return void 0;
991
- if (search.length > this.maxSearchLength) return search.slice(0, this.maxSearchLength);
992
- return search;
993
- }
994
- parseSelect(value) {
995
- if (!value) return void 0;
996
- const selectStr = String(value);
997
- const result = {};
998
- for (const field of selectStr.split(",")) {
999
- const trimmed = field.trim();
1000
- if (!trimmed) continue;
1001
- if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
1002
- if (trimmed.startsWith("-")) result[trimmed.slice(1)] = 0;
1003
- else result[trimmed] = 1;
790
+ },
791
+ sorting: { sort: {
792
+ type: "string",
793
+ description: "Sort field (prefix with - for descending)",
794
+ example: "-createdAt"
795
+ } },
796
+ filtering: {
797
+ select: {
798
+ description: "Fields to include (space-separated or object)",
799
+ example: "name email createdAt"
800
+ },
801
+ populate: {
802
+ description: "Relations to populate (comma-separated string or bracket-notation object)",
803
+ example: "author,category"
1004
804
  }
1005
- return Object.keys(result).length > 0 ? result : void 0;
1006
- }
1007
- /**
1008
- * Check if a value exceeds the maximum nesting depth.
1009
- * Prevents filter bombs where deeply nested objects consume excessive memory/CPU.
1010
- */
1011
- exceedsDepth(obj, currentDepth = 0) {
1012
- if (currentDepth > this.maxFilterDepth) return true;
1013
- if (obj === null || obj === void 0) return false;
1014
- if (Array.isArray(obj)) return obj.some((v) => this.exceedsDepth(v, currentDepth));
1015
- if (typeof obj !== "object") return false;
1016
- return Object.values(obj).some((v) => this.exceedsDepth(v, currentDepth + 1));
1017
- }
1018
- /**
1019
- * Parse all filter entries from the query, supporting BOTH the bare
1020
- * top-level form and the bracket-wrapped `filter[...]` envelope.
1021
- *
1022
- * Two equivalent ways to express the same query:
1023
- * - Bare (legacy, arc default): `?status=active&price[gte]=40`
1024
- * - Bracket envelope (REST convention): `?filter[status]=active&filter[price][gte]=40`
1025
- *
1026
- * Both forms parse to the same `filters: { status: 'active', price: { $gte: 40 } }`.
1027
- * The envelope form mirrors the convention used by most REST API style
1028
- * guides (JSON:API, Stripe API, etc.) and disambiguates filter fields
1029
- * from reserved meta params on busy URLs — a query string with a field
1030
- * literally named `page` or `sort` can't collide with the framework's
1031
- * meta keys when wrapped under `filter[...]`. The bare form stays as
1032
- * the default since it's the shortest path for simple queries; expect
1033
- * it to remain supported indefinitely.
1034
- *
1035
- * Precedence when the same key appears in both forms:
1036
- * `?status=closed&filter[status]=active` → bare wins (`status: closed`).
1037
- * Mixing should be rare; the deterministic rule avoids silent surprises.
1038
- */
1039
- parseFilters(query) {
1040
- const filters = {};
1041
- const envelope = query.filter;
1042
- if (typeof envelope === "object" && envelope !== null && !Array.isArray(envelope)) for (const [key, value] of Object.entries(envelope)) this.applyFilterEntry(filters, key, value);
1043
- for (const [key, value] of Object.entries(query)) {
1044
- if (RESERVED_QUERY_PARAMS.has(key)) continue;
1045
- this.applyFilterEntry(filters, key, value);
1046
- }
1047
- return filters;
1048
- }
1049
- /**
1050
- * Apply one filter entry — used by both the bracket-envelope branch and
1051
- * the bare top-level branch of `parseFilters`. Centralising the field-
1052
- * validation + operator-conversion logic keeps the two forms in
1053
- * lockstep; a future regex/security tweak only changes one place.
1054
- */
1055
- applyFilterEntry(filters, key, value) {
1056
- if (value === void 0 || value === null) return;
1057
- if (!/^[a-zA-Z_][a-zA-Z0-9_.]*(?:\[[a-z]+\])?$/.test(key)) return;
1058
- const bareKey = key.replace(/\[[a-z]+\]$/, "");
1059
- if (this._allowedFilterFields && !this._allowedFilterFields.has(bareKey)) return;
1060
- if (this.exceedsDepth(value)) return;
1061
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1062
- const operatorObj = value;
1063
- const operatorKeys = Object.keys(operatorObj);
1064
- const allOperators = operatorKeys.every((op) => this.operators[op] && (!this._allowedOperators || this._allowedOperators.has(op)));
1065
- const allKnownOperators = operatorKeys.every((op) => this.operators[op]);
1066
- if (allOperators && operatorKeys.length > 0) {
1067
- const mongoFilters = {};
1068
- let needsCaseInsensitive = false;
1069
- for (const [op, opValue] of Object.entries(operatorObj)) {
1070
- const mongoOp = this.operators[op];
1071
- if (mongoOp) {
1072
- mongoFilters[mongoOp] = this.parseFilterValue(opValue, op);
1073
- if (op === "contains" || op === "like") needsCaseInsensitive = true;
1074
- }
1075
- }
1076
- if (needsCaseInsensitive) mongoFilters.$options = "i";
1077
- filters[key] = mongoFilters;
1078
- return;
1079
- }
1080
- if (allKnownOperators && this._allowedOperators) return;
1081
- }
1082
- const match = key.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)(?:\[([a-z]+)\])?$/);
1083
- if (!match) return;
1084
- const [, fieldName, operator] = match;
1085
- if (!fieldName) return;
1086
- if (operator && this.operators[operator] && (!this._allowedOperators || this._allowedOperators.has(operator))) {
1087
- const mongoOp = this.operators[operator];
1088
- const parsedValue = this.parseFilterValue(value, operator);
1089
- if (!filters[fieldName]) filters[fieldName] = {};
1090
- const fieldFilter = filters[fieldName];
1091
- fieldFilter[mongoOp] = parsedValue;
1092
- if (operator === "contains" || operator === "like") fieldFilter.$options = "i";
1093
- } else if (!operator) filters[fieldName] = this.parseFilterValue(value);
1094
- }
1095
- parseFilterValue(value, operator) {
1096
- if (operator === "in" || operator === "nin") {
1097
- if (Array.isArray(value)) return value.map((v) => this.coerceValue(v));
1098
- if (typeof value === "string" && value.includes(",")) return value.split(",").map((v) => this.coerceValue(v.trim()));
1099
- return [this.coerceValue(value)];
1100
- }
1101
- if (operator === "like" || operator === "contains" || operator === "regex") return this.sanitizeRegex(String(value));
1102
- if (operator === "exists") {
1103
- const str = String(value).toLowerCase();
1104
- return str === "true" || str === "1";
1105
- }
1106
- return this.coerceValue(value);
1107
- }
1108
- coerceValue(value) {
1109
- if (value === "true") return true;
1110
- if (value === "false") return false;
1111
- if (value === "null") return null;
1112
- if (typeof value === "string") {
1113
- const num = Number(value);
1114
- if (!Number.isNaN(num) && value.trim() !== "") return num;
1115
- }
1116
- return value;
1117
- }
1118
- /**
1119
- * Generate OpenAPI-compatible JSON Schema for query parameters.
1120
- * Arc's defineResource() auto-detects this method and uses it
1121
- * to document list endpoint query parameters in OpenAPI/Swagger.
1122
- */
1123
- getQuerySchema() {
1124
- const operatorLines = Object.entries(this.operators).map(([op, mongoOp]) => {
1125
- return ` ${op} → ${mongoOp}: ${{
1126
- eq: "Equal (default when no operator specified)",
1127
- ne: "Not equal",
1128
- gt: "Greater than",
1129
- gte: "Greater than or equal",
1130
- lt: "Less than",
1131
- lte: "Less than or equal",
1132
- in: "In list (comma-separated values)",
1133
- nin: "Not in list",
1134
- like: "Pattern match (case-insensitive)",
1135
- contains: "Contains substring (case-insensitive)",
1136
- regex: "Regex pattern",
1137
- exists: "Field exists (true/false)"
1138
- }[op] || op}`;
1139
- });
1140
- return {
1141
- type: "object",
1142
- properties: {
1143
- page: {
1144
- type: "integer",
1145
- description: "Page number for offset pagination",
1146
- default: 1,
1147
- minimum: 1
1148
- },
1149
- limit: {
1150
- type: "integer",
1151
- description: "Number of items per page",
1152
- default: this.defaultLimit,
1153
- minimum: 1,
1154
- maximum: this.maxLimit
1155
- },
1156
- sort: {
1157
- type: "string",
1158
- description: "Sort fields (comma-separated). Prefix with - for descending. Example: -createdAt,name"
1159
- },
1160
- search: {
1161
- type: "string",
1162
- description: "Full-text search query",
1163
- maxLength: this.maxSearchLength
1164
- },
1165
- select: {
1166
- type: "string",
1167
- description: "Fields to include/exclude (comma-separated). Prefix with - to exclude. Example: name,email,-password"
1168
- },
1169
- populate: {
1170
- type: "string",
1171
- description: "Fields to populate/join (comma-separated). Example: author,category"
1172
- },
1173
- after: {
1174
- type: "string",
1175
- description: "Cursor value for keyset pagination"
1176
- },
1177
- _filterOperators: {
1178
- type: "string",
1179
- description: ["Available filter operators (use as field[operator]=value):", ...operatorLines].join("\n")
1180
- }
1181
- }
1182
- };
1183
- }
1184
- sanitizeRegex(pattern) {
1185
- const truncated = pattern.length > this.maxRegexLength;
1186
- let sanitized = pattern.slice(0, this.maxRegexLength);
1187
- if (DANGEROUS_REGEX_PATTERNS.test(sanitized)) {
1188
- sanitized = sanitized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1189
- log.warn(`regex pattern matched a ReDoS-dangerous shape and was escaped to a literal string. original="${pattern}" sanitized="${sanitized}"`);
1190
- } else if (truncated) log.warn(`regex pattern exceeded maxRegexLength (${this.maxRegexLength}) and was truncated. original-length=${pattern.length}`);
1191
- return sanitized;
1192
805
  }
1193
806
  };
1194
807
  /**
1195
- * Create a new ArcQueryParser instance
808
+ * Get standard list query parameters schema
1196
809
  */
1197
- function createQueryParser(options) {
1198
- return new ArcQueryParser(options);
810
+ function getListQueryParams() {
811
+ return {
812
+ type: "object",
813
+ properties: {
814
+ ...queryParams.pagination,
815
+ ...queryParams.sorting,
816
+ ...queryParams.filtering
817
+ },
818
+ additionalProperties: true
819
+ };
1199
820
  }
1200
- //#endregion
1201
- //#region src/utils/responseSchemas.ts
1202
821
  /**
1203
- * JSON Schema definitions for arc API responses.
1204
- *
1205
- * Wire shape (post-2.12): no envelope. HTTP status discriminates success
1206
- * vs error. Success-path schemas describe the data shape directly; the
1207
- * error path uses the canonical `ErrorContract` JSON Schema imported
1208
- * from `@classytic/repo-core/errors` — single source of truth shared
1209
- * with every other consumer in the org.
822
+ * Generic item schema that allows any properties.
823
+ * Used as default when no user schema is provided.
824
+ * Enables fast-json-stringify while still passing through all fields.
1210
825
  */
826
+ const genericItemSchema = {
827
+ type: "object",
828
+ additionalProperties: true
829
+ };
1211
830
  /**
1212
- * Pagination schema - matches MongoKit/Arc runtime format
831
+ * Recursively strip `example` keys from a schema object.
832
+ * The `example` keyword is OpenAPI metadata — not standard JSON Schema —
833
+ * and triggers Ajv strict mode errors when used on routes without the
834
+ * `keywords: ['example']` AJV config (e.g., raw Fastify without createApp).
835
+ */
836
+ function stripExamples(schema) {
837
+ if (schema === null || typeof schema !== "object") return schema;
838
+ if (Array.isArray(schema)) return schema.map(stripExamples);
839
+ const result = {};
840
+ for (const [key, value] of Object.entries(schema)) {
841
+ if (key === "example") continue;
842
+ result[key] = stripExamples(value);
843
+ }
844
+ return result;
845
+ }
846
+ /**
847
+ * Get default response schemas for all CRUD operations.
1213
848
  *
1214
- * Runtime format (flat fields):
1215
- * { page, limit, total, pages, hasNext, hasPrev }
849
+ * When routes have response schemas, Fastify compiles them with
850
+ * fast-json-stringify for 2-3x faster serialization and prevents
851
+ * accidental field disclosure.
852
+ *
853
+ * These defaults use `additionalProperties: true` so all fields pass through.
854
+ * Override with specific schemas for full serialization performance + safety.
855
+ *
856
+ * Note: `example` properties are stripped from defaults so they work with
857
+ * any Fastify instance (not just createApp which adds `keywords: ['example']`).
1216
858
  */
1217
- const paginationSchema = {
1218
- type: "object",
1219
- properties: {
1220
- page: {
1221
- type: "integer",
1222
- example: 1
859
+ function getDefaultCrudSchemas() {
860
+ return stripExamples({
861
+ list: {
862
+ querystring: getListQueryParams(),
863
+ response: { 200: listResponse(genericItemSchema) }
1223
864
  },
1224
- limit: {
1225
- type: "integer",
1226
- example: 20
1227
- },
1228
- total: {
1229
- type: "integer",
1230
- example: 100
1231
- },
1232
- pages: {
1233
- type: "integer",
1234
- example: 5
1235
- },
1236
- hasNext: {
1237
- type: "boolean",
1238
- example: true
1239
- },
1240
- hasPrev: {
1241
- type: "boolean",
1242
- example: false
1243
- }
1244
- },
1245
- required: [
1246
- "page",
1247
- "limit",
1248
- "total",
1249
- "pages",
1250
- "hasNext",
1251
- "hasPrev"
1252
- ]
1253
- };
865
+ get: { response: { 200: genericItemSchema } },
866
+ create: { response: { 201: genericItemSchema } },
867
+ update: { response: { 200: genericItemSchema } },
868
+ delete: { response: { 200: deleteResponse() } }
869
+ });
870
+ }
871
+ //#endregion
872
+ //#region src/core/validateResourceConfig.ts
1254
873
  /**
1255
- * List response schema — full union of every wire shape `toCanonicalList`
1256
- * can emit. Hosts who know their endpoint only ever produces one variant
1257
- * can pin to a narrower helper:
1258
- * - `offsetListResponse(item)` — `{ method: 'offset', data, page, limit, total, pages, hasNext, hasPrev }`
1259
- * - `keysetListResponse(item)` — `{ method: 'keyset', data, limit, hasMore, next: string|null }`
1260
- * - `aggregateListResponse(item)` — `{ method: 'aggregate', ...offset fields }`
1261
- * - `bareListResponse(item)` — `{ data }`
874
+ * Resource Configuration Validator
1262
875
  *
1263
- * The default `listResponse(item)` is the union (`oneOf`) of all four so
1264
- * Fastify validation accepts any canonical kit shape — pre-2.13 this only
1265
- * modelled offset and rejected keyset/aggregate/bare lists at the
1266
- * response-schema gate.
876
+ * Fail-fast validation at definition time.
877
+ * Invalid configs throw immediately with clear, actionable errors.
878
+ *
879
+ * @example
880
+ * const result = validateResourceConfig(config);
881
+ * if (!result.valid) {
882
+ * console.error(formatValidationErrors(result.errors));
883
+ * }
1267
884
  */
1268
- function listResponse(itemSchema) {
1269
- return { oneOf: [
1270
- offsetListResponse(itemSchema),
1271
- keysetListResponse(itemSchema),
1272
- aggregateListResponse(itemSchema),
1273
- bareListResponse(itemSchema)
1274
- ] };
1275
- }
1276
- /** Offset variant — `{ method: 'offset', data, page, limit, total, pages, hasNext, hasPrev }`. */
1277
- function offsetListResponse(itemSchema) {
885
+ /**
886
+ * Validate a resource configuration
887
+ */
888
+ function validateResourceConfig(config, options = {}) {
889
+ const errors = [];
890
+ const warnings = [];
891
+ if (!config.name) errors.push({
892
+ field: "name",
893
+ message: "Resource name is required",
894
+ suggestion: "Add a unique resource name (e.g., \"product\", \"user\")"
895
+ });
896
+ else if (!/^[a-z][a-z0-9-]*$/i.test(config.name)) errors.push({
897
+ field: "name",
898
+ message: `Invalid resource name "${config.name}"`,
899
+ suggestion: "Use alphanumeric characters and hyphens, starting with a letter"
900
+ });
901
+ const crudRoutes = CRUD_OPERATIONS;
902
+ const disabledRoutes = new Set(config.disabledRoutes ?? []);
903
+ const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
904
+ if (!config.disableDefaultRoutes && enabledCrudRoutes.length > 0) {
905
+ if (!config.adapter) errors.push({
906
+ field: "adapter",
907
+ message: "Data adapter is required when CRUD routes are enabled",
908
+ suggestion: "Provide an adapter: createMongooseAdapter({ model, repository })"
909
+ });
910
+ else if (!config.adapter.repository) errors.push({
911
+ field: "adapter.repository",
912
+ message: "Adapter must provide a repository",
913
+ suggestion: "Ensure your adapter returns a valid StandardRepo (see @classytic/repo-core)"
914
+ });
915
+ } else if (!config.adapter && !config.routes?.length) warnings.push({
916
+ field: "config",
917
+ message: "Resource has no adapter and no routes",
918
+ suggestion: "Provide either adapter for CRUD or routes for custom logic"
919
+ });
920
+ if (config.controller && !options.skipControllerCheck && !config.disableDefaultRoutes) {
921
+ const ctrl = config.controller;
922
+ const requiredMethods = CRUD_OPERATIONS;
923
+ for (const method of requiredMethods) if (typeof ctrl[method] !== "function") errors.push({
924
+ field: `controller.${method}`,
925
+ message: `Missing required CRUD method "${method}"`,
926
+ suggestion: "Extend BaseController which implements IController interface"
927
+ });
928
+ }
929
+ if (config.controller && config.routes) validateRouteHandlers(config.controller, config.routes, errors);
930
+ if (config.permissions) validatePermissionKeys(config, options, errors, warnings);
931
+ if (config.presets && !options.allowUnknownPresets) validatePresets(config.presets, errors, warnings);
932
+ if (config.prefix) {
933
+ if (!config.prefix.startsWith("/")) errors.push({
934
+ field: "prefix",
935
+ message: `Prefix must start with "/" (got "${config.prefix}")`,
936
+ suggestion: `Change to "/${config.prefix}"`
937
+ });
938
+ if (config.prefix.endsWith("/") && config.prefix !== "/") warnings.push({
939
+ field: "prefix",
940
+ message: `Prefix should not end with "/" (got "${config.prefix}")`,
941
+ suggestion: `Change to "${config.prefix.slice(0, -1)}"`
942
+ });
943
+ }
944
+ if (config.routes) {
945
+ validateRoutes(config.routes, errors);
946
+ validateRouteCrudCollisions(config, errors);
947
+ }
1278
948
  return {
1279
- type: "object",
1280
- properties: {
1281
- method: {
1282
- type: "string",
1283
- const: "offset",
1284
- example: "offset"
1285
- },
1286
- data: {
1287
- type: "array",
1288
- items: itemSchema
1289
- },
1290
- page: {
1291
- type: "integer",
1292
- example: 1
1293
- },
1294
- limit: {
1295
- type: "integer",
1296
- example: 20
1297
- },
1298
- total: {
1299
- type: "integer",
1300
- example: 100
1301
- },
1302
- pages: {
1303
- type: "integer",
1304
- example: 5
1305
- },
1306
- hasNext: {
1307
- type: "boolean",
1308
- example: false
1309
- },
1310
- hasPrev: {
1311
- type: "boolean",
1312
- example: false
1313
- }
1314
- },
1315
- required: [
1316
- "method",
1317
- "data",
1318
- "page",
1319
- "limit",
1320
- "total",
1321
- "pages",
1322
- "hasNext",
1323
- "hasPrev"
1324
- ],
1325
- additionalProperties: true
949
+ valid: errors.length === 0,
950
+ errors,
951
+ warnings
1326
952
  };
1327
953
  }
1328
- /** Keyset variant — `{ method: 'keyset', data, limit, hasMore, next: string | null }`. */
1329
- function keysetListResponse(itemSchema) {
1330
- return {
1331
- type: "object",
1332
- properties: {
1333
- method: {
1334
- type: "string",
1335
- const: "keyset",
1336
- example: "keyset"
1337
- },
1338
- data: {
1339
- type: "array",
1340
- items: itemSchema
1341
- },
1342
- limit: {
1343
- type: "integer",
1344
- example: 20
1345
- },
1346
- hasMore: {
1347
- type: "boolean",
1348
- example: true
1349
- },
1350
- next: {
1351
- type: ["string", "null"],
1352
- description: "Cursor token for the next page, or null."
1353
- }
1354
- },
1355
- required: [
1356
- "method",
1357
- "data",
1358
- "limit",
1359
- "hasMore",
1360
- "next"
1361
- ],
1362
- additionalProperties: true
1363
- };
954
+ function validateRouteHandlers(controller, routes, errors) {
955
+ const ctrl = controller;
956
+ for (const route of routes) if (typeof route.handler === "string") {
957
+ if (typeof ctrl[route.handler] !== "function") errors.push({
958
+ field: `routes[${route.method} ${route.path}]`,
959
+ message: `Handler "${route.handler}" not found on controller`,
960
+ suggestion: `Add method "${route.handler}" to controller or use a function handler`
961
+ });
962
+ }
1364
963
  }
1365
- /** Aggregate variant — same shape as offset, discriminated by `method: 'aggregate'`. */
1366
- function aggregateListResponse(itemSchema) {
1367
- return {
1368
- type: "object",
1369
- properties: {
1370
- method: {
1371
- type: "string",
1372
- const: "aggregate",
1373
- example: "aggregate"
1374
- },
1375
- data: {
1376
- type: "array",
1377
- items: itemSchema
1378
- },
1379
- page: { type: "integer" },
1380
- limit: { type: "integer" },
1381
- total: { type: "integer" },
1382
- pages: { type: "integer" },
1383
- hasNext: { type: "boolean" },
1384
- hasPrev: { type: "boolean" }
1385
- },
1386
- required: [
1387
- "method",
1388
- "data",
1389
- "page",
1390
- "limit",
1391
- "total",
1392
- "pages",
1393
- "hasNext",
1394
- "hasPrev"
1395
- ],
1396
- additionalProperties: true
1397
- };
1398
- }
1399
- /** Bare variant — `{ data }`, no `method` discriminant. */
1400
- function bareListResponse(itemSchema) {
1401
- return {
1402
- type: "object",
1403
- properties: { data: {
1404
- type: "array",
1405
- items: itemSchema
1406
- } },
1407
- required: ["data"],
1408
- additionalProperties: true
1409
- };
964
+ function validatePermissionKeys(config, options, _errors, warnings) {
965
+ const validKeys = new Set([...CRUD_OPERATIONS, ...options.additionalPermissionKeys ?? []]);
966
+ for (const route of config.routes ?? []) if (typeof route.handler === "string") validKeys.add(route.handler);
967
+ for (const preset of config.presets ?? []) {
968
+ const presetName = typeof preset === "string" ? preset : preset.name;
969
+ if (presetName === "softDelete") {
970
+ validKeys.add("deleted");
971
+ validKeys.add("restore");
972
+ }
973
+ if (presetName === "slugLookup") validKeys.add("getBySlug");
974
+ if (presetName === "tree") {
975
+ validKeys.add("tree");
976
+ validKeys.add("children");
977
+ validKeys.add("getTree");
978
+ validKeys.add("getChildren");
979
+ }
980
+ }
981
+ for (const key of Object.keys(config.permissions ?? {})) if (!validKeys.has(key)) warnings.push({
982
+ field: `permissions.${key}`,
983
+ message: `Unknown permission key "${key}"`,
984
+ suggestion: `Valid keys: ${Array.from(validKeys).join(", ")}`
985
+ });
1410
986
  }
1411
- /** Delete response — flat shape mirroring the canonical delete envelope. */
1412
- function deleteResponse() {
1413
- return {
1414
- type: "object",
1415
- properties: {
1416
- message: {
1417
- type: "string",
1418
- example: "Deleted successfully"
1419
- },
1420
- id: {
1421
- type: "string",
1422
- example: "507f1f77bcf86cd799439011"
1423
- },
1424
- soft: {
1425
- type: "boolean",
1426
- example: false
1427
- }
1428
- },
1429
- additionalProperties: true
1430
- };
987
+ function validatePresets(presets, errors, warnings) {
988
+ const availablePresets = getAvailablePresets();
989
+ for (const preset of presets) {
990
+ if (typeof preset === "object" && ("middlewares" in preset || "routes" in preset)) continue;
991
+ const presetName = typeof preset === "string" ? preset : preset.name;
992
+ if (!availablePresets.includes(presetName)) errors.push({
993
+ field: "presets",
994
+ message: `Unknown preset "${presetName}"`,
995
+ suggestion: `Available presets: ${availablePresets.join(", ")}`
996
+ });
997
+ if (typeof preset === "object") validatePresetOptions(preset, warnings);
998
+ }
1431
999
  }
1432
- const ERROR_DESCRIPTIONS = {
1433
- 400: "Bad Request",
1434
- 401: "Unauthorized",
1435
- 403: "Forbidden",
1436
- 404: "Not Found",
1437
- 409: "Conflict",
1438
- 422: "Unprocessable Entity",
1439
- 429: "Too Many Requests",
1440
- 500: "Internal Server Error",
1441
- 503: "Service Unavailable"
1442
- };
1443
- /** Build an OpenAPI response entry for an `ErrorContract` at the given status. */
1444
- function errorResponse(status) {
1445
- return {
1446
- description: ERROR_DESCRIPTIONS[status] ?? "Error",
1447
- content: { "application/json": { schema: errorContractSchema$1 } }
1448
- };
1000
+ function validatePresetOptions(preset, warnings) {
1001
+ const validOptions = {
1002
+ slugLookup: ["slugField"],
1003
+ tree: ["parentField"],
1004
+ softDelete: ["deletedField"],
1005
+ ownedByUser: ["ownerField"],
1006
+ multiTenant: ["tenantField", "allowPublic"]
1007
+ }[preset.name] ?? [];
1008
+ const providedOptions = Object.keys(preset).filter((k) => k !== "name");
1009
+ for (const opt of providedOptions) if (!validOptions.includes(opt)) warnings.push({
1010
+ field: `presets[${preset.name}].${opt}`,
1011
+ message: `Unknown option "${opt}" for preset "${preset.name}"`,
1012
+ suggestion: validOptions.length > 0 ? `Valid options: ${validOptions.join(", ")}` : `Preset "${preset.name}" has no configurable options`
1013
+ });
1449
1014
  }
1450
- const responses = {
1451
- 200: (schema) => ({
1452
- description: "Successful response",
1453
- content: { "application/json": { schema } }
1454
- }),
1455
- 201: (schema) => ({
1456
- description: "Created successfully",
1457
- content: { "application/json": { schema: {
1458
- ...schema,
1459
- additionalProperties: true
1460
- } } }
1461
- }),
1462
- 400: errorResponse(400),
1463
- 401: errorResponse(401),
1464
- 403: errorResponse(403),
1465
- 404: errorResponse(404),
1466
- 409: errorResponse(409),
1467
- 500: errorResponse(500)
1468
- };
1469
- const queryParams = {
1470
- pagination: {
1471
- page: {
1472
- type: "integer",
1473
- minimum: 1,
1474
- default: 1,
1475
- description: "Page number"
1015
+ /**
1016
+ * Detect collisions between user-declared `routes:` entries and the
1017
+ * auto-CRUD route table that `createCrudRouter` will register.
1018
+ *
1019
+ * Without this check, a custom `POST /` on the same prefix as auto-CRUD's
1020
+ * `create` boots fine through `defineResource()` and only blows up at
1021
+ * `app.register()` time with Fastify's opaque `FST_ERR_DUPLICATED_ROUTE`.
1022
+ * That error doesn't mention `disabledRoutes` and doesn't distinguish
1023
+ * "you collided with auto-CRUD" from "you collided with yourself" — so
1024
+ * the consumer has to grep arc's source to discover the fix.
1025
+ *
1026
+ * We surface the fix in the error message, naming the conflicting op
1027
+ * and the literal line to add.
1028
+ *
1029
+ * Honored config:
1030
+ * - `disableDefaultRoutes: true` → no auto-CRUD, no collisions possible
1031
+ * - `disabledRoutes: ['create']` → that op's path is free for custom use
1032
+ * - `updateMethod: 'PUT' | 'both'` → update slot covers PUT/PATCH accordingly
1033
+ */
1034
+ function validateRouteCrudCollisions(config, errors) {
1035
+ if (config.disableDefaultRoutes) return;
1036
+ if (!config.routes || config.routes.length === 0) return;
1037
+ const updateMethod = config.updateMethod ?? "PATCH";
1038
+ const updateMethods = updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod];
1039
+ const disabled = new Set(config.disabledRoutes ?? []);
1040
+ const crudTable = [
1041
+ {
1042
+ op: "list",
1043
+ method: "GET",
1044
+ path: "/"
1476
1045
  },
1477
- limit: {
1478
- type: "integer",
1479
- minimum: 1,
1480
- maximum: 100,
1481
- default: 20,
1482
- description: "Items per page"
1483
- }
1484
- },
1485
- sorting: { sort: {
1486
- type: "string",
1487
- description: "Sort field (prefix with - for descending)",
1488
- example: "-createdAt"
1489
- } },
1490
- filtering: {
1491
- select: {
1492
- description: "Fields to include (space-separated or object)",
1493
- example: "name email createdAt"
1046
+ {
1047
+ op: "get",
1048
+ method: "GET",
1049
+ path: "/:id"
1494
1050
  },
1495
- populate: {
1496
- description: "Relations to populate (comma-separated string or bracket-notation object)",
1497
- example: "author,category"
1051
+ {
1052
+ op: "create",
1053
+ method: "POST",
1054
+ path: "/"
1055
+ },
1056
+ ...updateMethods.map((m) => ({
1057
+ op: "update",
1058
+ method: m,
1059
+ path: "/:id"
1060
+ })),
1061
+ {
1062
+ op: "delete",
1063
+ method: "DELETE",
1064
+ path: "/:id"
1065
+ }
1066
+ ];
1067
+ for (const [i, route] of config.routes.entries()) {
1068
+ if (!route.method || !route.path) continue;
1069
+ for (const crud of crudTable) {
1070
+ if (disabled.has(crud.op)) continue;
1071
+ if (route.method !== crud.method || route.path !== crud.path) continue;
1072
+ errors.push({
1073
+ field: `routes[${i}]`,
1074
+ message: `Route ${route.method} ${route.path} collides with auto-CRUD "${crud.op}"`,
1075
+ suggestion: `Add \`disabledRoutes: ['${crud.op}']\` to suppress the auto-CRUD route, or move your custom handler to a different path. Set \`disableDefaultRoutes: true\` to disable all auto-CRUD on this resource.`
1076
+ });
1498
1077
  }
1499
1078
  }
1079
+ }
1080
+ function validateRoutes(routes, errors) {
1081
+ const validMethods = [
1082
+ "GET",
1083
+ "POST",
1084
+ "PUT",
1085
+ "PATCH",
1086
+ "DELETE",
1087
+ "OPTIONS",
1088
+ "HEAD"
1089
+ ];
1090
+ const seenRoutes = /* @__PURE__ */ new Set();
1091
+ for (const [i, route] of routes.entries()) {
1092
+ if (!validMethods.includes(route.method)) errors.push({
1093
+ field: `routes[${i}].method`,
1094
+ message: `Invalid HTTP method "${route.method}"`,
1095
+ suggestion: `Valid methods: ${validMethods.join(", ")}`
1096
+ });
1097
+ if (!route.path) errors.push({
1098
+ field: `routes[${i}].path`,
1099
+ message: "Route path is required"
1100
+ });
1101
+ else if (!route.path.startsWith("/")) errors.push({
1102
+ field: `routes[${i}].path`,
1103
+ message: `Route path must start with "/" (got "${route.path}")`,
1104
+ suggestion: `Change to "/${route.path}"`
1105
+ });
1106
+ const routeWithRefs = route;
1107
+ if (!route.handler && typeof routeWithRefs.controllerMethod !== "function") errors.push({
1108
+ field: `routes[${i}].handler`,
1109
+ message: "Route must declare either `handler` (string / function) or `controllerMethod`",
1110
+ suggestion: "Prefer `controllerMethod: (c: MyController) => c.method` for typed handler refs."
1111
+ });
1112
+ const routeKey = `${route.method} ${route.path}`;
1113
+ if (seenRoutes.has(routeKey)) errors.push({
1114
+ field: `routes[${i}]`,
1115
+ message: `Duplicate route "${routeKey}"`
1116
+ });
1117
+ seenRoutes.add(routeKey);
1118
+ }
1119
+ }
1120
+ /**
1121
+ * Format validation errors for display
1122
+ */
1123
+ function formatValidationErrors(resourceName, result) {
1124
+ const lines = [];
1125
+ if (result.errors.length > 0) {
1126
+ lines.push(`Resource "${resourceName}" validation failed:`);
1127
+ lines.push("");
1128
+ lines.push("ERRORS:");
1129
+ for (const err of result.errors) {
1130
+ lines.push(` ✗ ${err.field}: ${err.message}`);
1131
+ if (err.suggestion) lines.push(` → ${err.suggestion}`);
1132
+ }
1133
+ }
1134
+ if (result.warnings.length > 0) {
1135
+ if (lines.length > 0) lines.push("");
1136
+ lines.push("WARNINGS:");
1137
+ for (const warn of result.warnings) {
1138
+ lines.push(` ⚠ ${warn.field}: ${warn.message}`);
1139
+ if (warn.suggestion) lines.push(` → ${warn.suggestion}`);
1140
+ }
1141
+ }
1142
+ return lines.join("\n");
1143
+ }
1144
+ /**
1145
+ * Validate and throw if invalid
1146
+ */
1147
+ function assertValidConfig(config, options) {
1148
+ const result = validateResourceConfig(config, options);
1149
+ if (!result.valid) {
1150
+ const errorMsg = formatValidationErrors(config.name ?? "unknown", result);
1151
+ throw new Error(errorMsg);
1152
+ }
1153
+ }
1154
+ //#endregion
1155
+ //#region src/utils/circuitBreaker.ts
1156
+ /**
1157
+ * Circuit Breaker Pattern
1158
+ *
1159
+ * Wraps external service calls with failure protection.
1160
+ * Prevents cascading failures by "opening" the circuit when
1161
+ * a service is failing, allowing it time to recover.
1162
+ *
1163
+ * States:
1164
+ * - CLOSED: Normal operation, requests pass through
1165
+ * - OPEN: Too many failures, all requests fail fast
1166
+ * - HALF_OPEN: Testing if service recovered, limited requests
1167
+ *
1168
+ * @example
1169
+ * import { CircuitBreaker } from '@classytic/arc/utils';
1170
+ *
1171
+ * const paymentBreaker = new CircuitBreaker(async (amount) => {
1172
+ * return await stripe.charges.create({ amount });
1173
+ * }, {
1174
+ * failureThreshold: 5,
1175
+ * resetTimeout: 30000,
1176
+ * timeout: 5000,
1177
+ * });
1178
+ *
1179
+ * try {
1180
+ * const result = await paymentBreaker.call(100);
1181
+ * } catch (error) {
1182
+ * // Handle failure or circuit open
1183
+ * }
1184
+ */
1185
+ const CircuitState = {
1186
+ CLOSED: "CLOSED",
1187
+ OPEN: "OPEN",
1188
+ HALF_OPEN: "HALF_OPEN"
1189
+ };
1190
+ var CircuitBreakerError = class extends Error {
1191
+ state;
1192
+ constructor(message, state) {
1193
+ super(message);
1194
+ this.name = "CircuitBreakerError";
1195
+ this.state = state;
1196
+ }
1197
+ };
1198
+ var CircuitBreaker = class {
1199
+ state = CircuitState.CLOSED;
1200
+ failures = 0;
1201
+ successes = 0;
1202
+ totalCalls = 0;
1203
+ nextAttempt = 0;
1204
+ lastCallAt = null;
1205
+ openedAt = null;
1206
+ failureThreshold;
1207
+ resetTimeout;
1208
+ timeout;
1209
+ successThreshold;
1210
+ fallback;
1211
+ onStateChange;
1212
+ onError;
1213
+ name;
1214
+ fn;
1215
+ constructor(fn, options = {}) {
1216
+ this.fn = fn;
1217
+ this.failureThreshold = options.failureThreshold ?? 5;
1218
+ this.resetTimeout = options.resetTimeout ?? 6e4;
1219
+ this.timeout = options.timeout ?? 1e4;
1220
+ this.successThreshold = options.successThreshold ?? 1;
1221
+ this.fallback = options.fallback;
1222
+ this.onStateChange = options.onStateChange;
1223
+ this.onError = options.onError;
1224
+ this.name = options.name ?? "CircuitBreaker";
1225
+ }
1226
+ /**
1227
+ * Call the wrapped function with circuit breaker protection
1228
+ */
1229
+ async call(...args) {
1230
+ this.totalCalls++;
1231
+ this.lastCallAt = Date.now();
1232
+ if (this.state === CircuitState.OPEN) {
1233
+ if (Date.now() < this.nextAttempt) {
1234
+ const error = new CircuitBreakerError(`Circuit breaker is OPEN for ${this.name}`, CircuitState.OPEN);
1235
+ if (this.fallback) return this.fallback(...args);
1236
+ throw error;
1237
+ }
1238
+ this.setState(CircuitState.HALF_OPEN);
1239
+ }
1240
+ try {
1241
+ const result = await this.executeWithTimeout(args);
1242
+ this.onSuccess();
1243
+ return result;
1244
+ } catch (err) {
1245
+ this.onFailure(err instanceof Error ? err : new Error(String(err)));
1246
+ throw err;
1247
+ }
1248
+ }
1249
+ /**
1250
+ * Execute function with timeout
1251
+ */
1252
+ async executeWithTimeout(args) {
1253
+ return new Promise((resolve, reject) => {
1254
+ const timeoutId = setTimeout(() => {
1255
+ reject(/* @__PURE__ */ new Error(`Request timeout after ${this.timeout}ms`));
1256
+ }, this.timeout);
1257
+ this.fn(...args).then((result) => {
1258
+ clearTimeout(timeoutId);
1259
+ resolve(result);
1260
+ }).catch((error) => {
1261
+ clearTimeout(timeoutId);
1262
+ reject(error);
1263
+ });
1264
+ });
1265
+ }
1266
+ /**
1267
+ * Handle successful call
1268
+ */
1269
+ onSuccess() {
1270
+ this.failures = 0;
1271
+ this.successes++;
1272
+ if (this.state === CircuitState.HALF_OPEN) {
1273
+ if (this.successes >= this.successThreshold) {
1274
+ this.setState(CircuitState.CLOSED);
1275
+ this.successes = 0;
1276
+ }
1277
+ }
1278
+ }
1279
+ /**
1280
+ * Handle failed call
1281
+ */
1282
+ onFailure(error) {
1283
+ this.failures++;
1284
+ this.successes = 0;
1285
+ if (this.onError) this.onError(error);
1286
+ if (this.state === CircuitState.HALF_OPEN || this.failures >= this.failureThreshold) {
1287
+ this.setState(CircuitState.OPEN);
1288
+ this.nextAttempt = Date.now() + this.resetTimeout;
1289
+ this.openedAt = Date.now();
1290
+ }
1291
+ }
1292
+ /**
1293
+ * Change circuit state
1294
+ */
1295
+ setState(newState) {
1296
+ const oldState = this.state;
1297
+ if (oldState !== newState) {
1298
+ this.state = newState;
1299
+ if (this.onStateChange) this.onStateChange(oldState, newState);
1300
+ }
1301
+ }
1302
+ /**
1303
+ * Manually open the circuit
1304
+ */
1305
+ open() {
1306
+ this.setState(CircuitState.OPEN);
1307
+ this.nextAttempt = Date.now() + this.resetTimeout;
1308
+ this.openedAt = Date.now();
1309
+ }
1310
+ /**
1311
+ * Manually close the circuit
1312
+ */
1313
+ close() {
1314
+ this.failures = 0;
1315
+ this.successes = 0;
1316
+ this.setState(CircuitState.CLOSED);
1317
+ this.openedAt = null;
1318
+ }
1319
+ /**
1320
+ * Get current statistics
1321
+ */
1322
+ getStats() {
1323
+ return {
1324
+ name: this.name,
1325
+ state: this.state,
1326
+ failures: this.failures,
1327
+ successes: this.successes,
1328
+ totalCalls: this.totalCalls,
1329
+ openedAt: this.openedAt,
1330
+ lastCallAt: this.lastCallAt
1331
+ };
1332
+ }
1333
+ /**
1334
+ * Get current state
1335
+ */
1336
+ getState() {
1337
+ return this.state;
1338
+ }
1339
+ /**
1340
+ * Check if circuit is open
1341
+ */
1342
+ isOpen() {
1343
+ return this.state === CircuitState.OPEN;
1344
+ }
1345
+ /**
1346
+ * Check if circuit is closed
1347
+ */
1348
+ isClosed() {
1349
+ return this.state === CircuitState.CLOSED;
1350
+ }
1351
+ /**
1352
+ * Reset statistics
1353
+ */
1354
+ reset() {
1355
+ this.failures = 0;
1356
+ this.successes = 0;
1357
+ this.totalCalls = 0;
1358
+ this.lastCallAt = null;
1359
+ this.openedAt = null;
1360
+ this.setState(CircuitState.CLOSED);
1361
+ }
1362
+ };
1363
+ /**
1364
+ * Create a circuit breaker with sensible defaults
1365
+ *
1366
+ * @example
1367
+ * const emailBreaker = createCircuitBreaker(
1368
+ * async (to, subject, body) => sendEmail(to, subject, body),
1369
+ * { name: 'email-service' }
1370
+ * );
1371
+ */
1372
+ function createCircuitBreaker(fn, options) {
1373
+ return new CircuitBreaker(fn, options);
1374
+ }
1375
+ /**
1376
+ * Circuit breaker registry for managing multiple breakers
1377
+ */
1378
+ var CircuitBreakerRegistry = class {
1379
+ breakers = /* @__PURE__ */ new Map();
1380
+ /**
1381
+ * Register a circuit breaker
1382
+ */
1383
+ register(name, fn, options) {
1384
+ const breaker = new CircuitBreaker(fn, {
1385
+ ...options,
1386
+ name
1387
+ });
1388
+ this.breakers.set(name, breaker);
1389
+ return breaker;
1390
+ }
1391
+ /**
1392
+ * Get a circuit breaker by name
1393
+ */
1394
+ get(name) {
1395
+ return this.breakers.get(name);
1396
+ }
1397
+ /**
1398
+ * Get all breakers
1399
+ */
1400
+ getAll() {
1401
+ return this.breakers;
1402
+ }
1403
+ /**
1404
+ * Get statistics for all breakers
1405
+ */
1406
+ getAllStats() {
1407
+ const stats = {};
1408
+ for (const [name, breaker] of this.breakers.entries()) stats[name] = breaker.getStats();
1409
+ return stats;
1410
+ }
1411
+ /**
1412
+ * Reset all breakers
1413
+ */
1414
+ resetAll() {
1415
+ for (const breaker of this.breakers.values()) breaker.reset();
1416
+ }
1417
+ /**
1418
+ * Open all breakers
1419
+ */
1420
+ openAll() {
1421
+ for (const breaker of this.breakers.values()) breaker.open();
1422
+ }
1423
+ /**
1424
+ * Close all breakers
1425
+ */
1426
+ closeAll() {
1427
+ for (const breaker of this.breakers.values()) breaker.close();
1428
+ }
1500
1429
  };
1501
1430
  /**
1502
- * Get standard list query parameters schema
1431
+ * Create a new CircuitBreakerRegistry instance.
1432
+ * Use this instead of a global singleton — attach to fastify.arc or pass explicitly.
1503
1433
  */
1504
- function getListQueryParams() {
1505
- return {
1506
- type: "object",
1507
- properties: {
1508
- ...queryParams.pagination,
1509
- ...queryParams.sorting,
1510
- ...queryParams.filtering
1511
- },
1512
- additionalProperties: true
1513
- };
1434
+ function createCircuitBreakerRegistry() {
1435
+ return new CircuitBreakerRegistry();
1514
1436
  }
1437
+ //#endregion
1438
+ //#region src/utils/compensation.ts
1515
1439
  /**
1516
- * Generic item schema that allows any properties.
1517
- * Used as default when no user schema is provided.
1518
- * Enables fast-json-stringify while still passing through all fields.
1519
- */
1520
- const genericItemSchema = {
1521
- type: "object",
1522
- additionalProperties: true
1523
- };
1524
- /**
1525
- * Recursively strip `example` keys from a schema object.
1526
- * The `example` keyword is OpenAPI metadata — not standard JSON Schema —
1527
- * and triggers Ajv strict mode errors when used on routes without the
1528
- * `keywords: ['example']` AJV config (e.g., raw Fastify without createApp).
1440
+ * Run steps in order with automatic compensation on failure.
1441
+ *
1442
+ * @typeParam TCtx - Context type shared across steps (defaults to Record<string, unknown>)
1529
1443
  */
1530
- function stripExamples(schema) {
1531
- if (schema === null || typeof schema !== "object") return schema;
1532
- if (Array.isArray(schema)) return schema.map(stripExamples);
1533
- const result = {};
1534
- for (const [key, value] of Object.entries(schema)) {
1535
- if (key === "example") continue;
1536
- result[key] = stripExamples(value);
1444
+ async function withCompensation(_name, steps, initialContext, hooks) {
1445
+ const ctx = { ...initialContext };
1446
+ const completedSteps = [];
1447
+ const results = {};
1448
+ const completed = [];
1449
+ for (const step of steps) {
1450
+ if (step.fireAndForget) {
1451
+ completedSteps.push(step.name);
1452
+ step.execute(ctx).then((result) => hooks?.onStepComplete?.(step.name, result), () => {});
1453
+ continue;
1454
+ }
1455
+ try {
1456
+ const result = await step.execute(ctx);
1457
+ completedSteps.push(step.name);
1458
+ results[step.name] = result;
1459
+ completed.push({
1460
+ step,
1461
+ result
1462
+ });
1463
+ hooks?.onStepComplete?.(step.name, result);
1464
+ } catch (err) {
1465
+ const error = err instanceof Error ? err : new Error(String(err));
1466
+ hooks?.onStepFailed?.(step.name, error);
1467
+ const compensationErrors = await rollback(ctx, completed, hooks);
1468
+ return {
1469
+ success: false,
1470
+ completedSteps,
1471
+ results,
1472
+ failedStep: step.name,
1473
+ error: error.message,
1474
+ ...compensationErrors.length > 0 ? { compensationErrors } : {}
1475
+ };
1476
+ }
1537
1477
  }
1538
- return result;
1478
+ return {
1479
+ success: true,
1480
+ completedSteps,
1481
+ results
1482
+ };
1483
+ }
1484
+ async function rollback(ctx, completed, hooks) {
1485
+ const errors = [];
1486
+ for (let i = completed.length - 1; i >= 0; i--) {
1487
+ const entry = completed[i];
1488
+ if (!entry?.step.compensate) continue;
1489
+ const compensateFn = entry.step.compensate;
1490
+ try {
1491
+ await compensateFn(ctx, entry.result);
1492
+ hooks?.onCompensate?.(entry.step.name);
1493
+ } catch (err) {
1494
+ errors.push({
1495
+ step: entry.step.name,
1496
+ error: err instanceof Error ? err.message : String(err)
1497
+ });
1498
+ }
1499
+ }
1500
+ return errors;
1539
1501
  }
1502
+ function defineCompensation(name, steps) {
1503
+ return {
1504
+ name,
1505
+ execute: (initialContext, hooks) => withCompensation(name, steps, initialContext, hooks)
1506
+ };
1507
+ }
1508
+ //#endregion
1509
+ //#region src/utils/defineErrorMapper.ts
1540
1510
  /**
1541
- * Get default response schemas for all CRUD operations.
1542
- *
1543
- * When routes have response schemas, Fastify compiles them with
1544
- * fast-json-stringify for 2-3x faster serialization and prevents
1545
- * accidental field disclosure.
1511
+ * Register an `ErrorMapper` with its domain-specific generic argument and
1512
+ * have it assign cleanly into `ErrorMapper[]` (no `as unknown as ErrorMapper`).
1546
1513
  *
1547
- * These defaults use `additionalProperties: true` so all fields pass through.
1548
- * Override with specific schemas for full serialization performance + safety.
1514
+ * The returned mapper is identical at runtime — `type` and `toResponse` are
1515
+ * passed through untouched. Only the declared type widens from
1516
+ * `ErrorMapper<T>` to `ErrorMapper` so the array inference works.
1549
1517
  *
1550
- * Note: `example` properties are stripped from defaults so they work with
1551
- * any Fastify instance (not just createApp which adds `keywords: ['example']`).
1518
+ * Safety: the `errorHandlerPlugin` dispatches via `error instanceof mapper.type`
1519
+ * before invoking `toResponse`, so the widened callback signature is never
1520
+ * called with a non-`T` error at runtime. This helper codifies that invariant
1521
+ * in one place.
1552
1522
  */
1553
- function getDefaultCrudSchemas() {
1554
- return stripExamples({
1555
- list: {
1556
- querystring: getListQueryParams(),
1557
- response: { 200: listResponse(genericItemSchema) }
1558
- },
1559
- get: { response: { 200: genericItemSchema } },
1560
- create: { response: { 201: genericItemSchema } },
1561
- update: { response: { 200: genericItemSchema } },
1562
- delete: { response: { 200: deleteResponse() } }
1563
- });
1523
+ function defineErrorMapper(mapper) {
1524
+ return mapper;
1564
1525
  }
1565
1526
  //#endregion
1566
- //#region src/utils/runtime.ts
1527
+ //#region src/utils/defineGuard.ts
1528
+ /** Hidden property key for guard context storage on the request object. */
1529
+ const GUARD_STORE_KEY = "__arcGuardContext";
1567
1530
  /**
1568
- * Portable "run on next tick" scheduler. `setImmediate` is Node-only — not
1569
- * available in Bun workers, Deno, Cloudflare Workers, or edge runtimes.
1531
+ * Create a typed guard. See module JSDoc for usage.
1570
1532
  */
1571
- const scheduleBackground = typeof setImmediate === "function" ? (cb) => void setImmediate(cb) : (cb) => queueMicrotask(cb);
1533
+ function defineGuard(config) {
1534
+ const { name, resolve } = config;
1535
+ const preHandler = async (req, reply) => {
1536
+ const ctx = await resolve(req, reply);
1537
+ if (!reply.sent) {
1538
+ const store = req[GUARD_STORE_KEY] ?? {};
1539
+ store[name] = ctx;
1540
+ req[GUARD_STORE_KEY] = store;
1541
+ }
1542
+ };
1543
+ return {
1544
+ preHandler,
1545
+ name,
1546
+ from(req) {
1547
+ const store = req[GUARD_STORE_KEY];
1548
+ if (!store || !(name in store)) throw new Error(`Guard '${name}' not resolved on this request. Add it to routeGuards or the route's preHandler array.`);
1549
+ return store[name];
1550
+ }
1551
+ };
1552
+ }
1553
+ //#endregion
1554
+ //#region src/utils/handleRaw.ts
1555
+ /**
1556
+ * Wrap a raw Fastify handler with Arc's response shape and error handling.
1557
+ *
1558
+ * @param handler - Async function that receives `(request, reply)` and returns data.
1559
+ * The return value is sent raw (no envelope). If it returns `undefined`,
1560
+ * the response body is empty (HTTP status only).
1561
+ * @param statusCode - HTTP status code for successful responses (default: 200)
1562
+ */
1563
+ function handleRaw(handler, statusCode = 200) {
1564
+ return async (request, reply) => {
1565
+ try {
1566
+ const result = await handler(request, reply);
1567
+ if (reply.sent) return;
1568
+ if (result === void 0 || result === null) reply.code(statusCode).send();
1569
+ else reply.code(statusCode).send(result);
1570
+ } catch (err) {
1571
+ if (reply.sent) return;
1572
+ if (err instanceof ArcError) {
1573
+ reply.code(err.statusCode).send({
1574
+ error: err.message,
1575
+ code: err.code,
1576
+ ...err.details ? { details: err.details } : {}
1577
+ });
1578
+ return;
1579
+ }
1580
+ const error = err;
1581
+ const code = error.statusCode ?? error.status ?? 500;
1582
+ reply.code(code).send({
1583
+ error: error.message ?? "Internal server error",
1584
+ ...error.code && { code: error.code }
1585
+ });
1586
+ }
1587
+ };
1588
+ }
1572
1589
  //#endregion
1573
1590
  //#region src/utils/stateMachine.ts
1574
1591
  /**
@@ -1671,130 +1688,4 @@ function createStateMachine(name, transitions = {}, options = {}) {
1671
1688
  };
1672
1689
  }
1673
1690
  //#endregion
1674
- //#region src/utils/streaming.ts
1675
- /**
1676
- * Streaming helpers — adapters between Web `ReadableStream` producers
1677
- * (Vercel AI SDK, OpenAI SDK, `fetch().body`, hand-built `TransformStream`s)
1678
- * and Fastify's raw Node-stream reply.
1679
- *
1680
- * Why this exists: returning a `ReadableStream<UIMessageChunk>` from a
1681
- * `streamResponse: true` arc route used to crash with
1682
- * `chunk must be a string or Buffer` because Fastify cannot serialise
1683
- * `UIMessageChunk` JSON objects directly. Every arc-ai host (sniffer,
1684
- * spawn, downstream) reimplemented the same `JsonToSseTransformStream` +
1685
- * SSE header boilerplate. `pipeUIMessageStreamToReply()` is that
1686
- * boilerplate, owned once.
1687
- *
1688
- * arc stays peer-coupled to nothing: the helper accepts any
1689
- * `ReadableStream<unknown>` and JSON-stringifies each chunk. It works
1690
- * with `UIMessageChunk` from the AI SDK without importing it.
1691
- */
1692
- /**
1693
- * Canonical SSE headers for AI-SDK-style UI message streams.
1694
- *
1695
- * `x-vercel-ai-ui-message-stream: v1` is the marker the Vercel AI SDK
1696
- * client uses to detect the v1 UI message stream protocol (vs the older
1697
- * data stream protocol). Surfaced as a constant so hosts don't memorise it.
1698
- */
1699
- const UI_MESSAGE_STREAM_HEADERS = {
1700
- "content-type": "text/event-stream",
1701
- "cache-control": "no-cache",
1702
- connection: "keep-alive",
1703
- "x-accel-buffering": "no",
1704
- "x-vercel-ai-ui-message-stream": "v1"
1705
- };
1706
- /**
1707
- * Pipe a Web `ReadableStream` of JSON-serialisable chunks (typically
1708
- * `UIMessageChunk` from the Vercel AI SDK) into a Fastify reply as an
1709
- * SSE event stream.
1710
- *
1711
- * - Sets {@link UI_MESSAGE_STREAM_HEADERS} (or the caller's override)
1712
- * before the first chunk.
1713
- * - Writes each chunk as `data: ${JSON.stringify(chunk)}\n\n`.
1714
- * - Cancels the source and closes the reply when the client disconnects.
1715
- * - Resolves once the stream is fully written.
1716
- *
1717
- * @example
1718
- * ```ts
1719
- * import { pipeUIMessageStreamToReply } from '@classytic/arc/utils';
1720
- * import { streamText, convertToUIMessageStream } from 'ai';
1721
- *
1722
- * defineResource({
1723
- * name: 'chat',
1724
- * routes: [{
1725
- * method: 'POST', path: '/', streamResponse: true,
1726
- * handler: async (req, reply) => {
1727
- * const result = streamText({ model, messages: req.body.messages });
1728
- * await pipeUIMessageStreamToReply(reply, result.toUIMessageStream());
1729
- * },
1730
- * }],
1731
- * });
1732
- * ```
1733
- */
1734
- async function pipeUIMessageStreamToReply(reply, stream, options = {}) {
1735
- const serialize = options.serialize ?? defaultSerialize;
1736
- const headers = options.headers ?? UI_MESSAGE_STREAM_HEADERS;
1737
- for (const [key, value] of Object.entries(headers)) reply.header(key, value);
1738
- const reader = stream.getReader();
1739
- let cancelled = false;
1740
- const onAbort = () => {
1741
- cancelled = true;
1742
- reader.cancel().catch(() => {});
1743
- };
1744
- reply.request.raw.once("close", onAbort);
1745
- options.signal?.addEventListener("abort", onAbort, { once: true });
1746
- async function* sseFrames() {
1747
- try {
1748
- while (true) {
1749
- const { value, done } = await reader.read();
1750
- if (done) break;
1751
- yield `data: ${serialize(value)}\n\n`;
1752
- }
1753
- } catch (err) {
1754
- if (!cancelled) yield `event: error\ndata: ${JSON.stringify({ message: String(err) })}\n\n`;
1755
- } finally {
1756
- reply.request.raw.removeListener("close", onAbort);
1757
- options.signal?.removeEventListener("abort", onAbort);
1758
- reader.releaseLock?.();
1759
- }
1760
- }
1761
- const nodeStream = Readable.from(sseFrames());
1762
- await reply.send(nodeStream);
1763
- }
1764
- function defaultSerialize(chunk) {
1765
- return typeof chunk === "string" ? chunk : JSON.stringify(chunk);
1766
- }
1767
- /**
1768
- * Heuristic — does this value look like a Web `ReadableStream`?
1769
- *
1770
- * Used by `createCrudRouter` to auto-pipe `streamResponse: true` handlers
1771
- * that return a UI message stream instead of writing to `reply.raw`
1772
- * directly. Cross-realm safe (no `instanceof`) — checks the duck shape
1773
- * the Streams spec mandates: `getReader`, `cancel`, and the `locked`
1774
- * getter.
1775
- */
1776
- function isReadableStream(value) {
1777
- if (value === null || typeof value !== "object") return false;
1778
- const obj = value;
1779
- return typeof obj.getReader === "function" && typeof obj.cancel === "function" && typeof obj.locked === "boolean";
1780
- }
1781
- //#endregion
1782
- //#region src/utils/userHelpers.ts
1783
- /**
1784
- * Extract a user ID from a user object. Accepts `id` or `_id` — returns
1785
- * `undefined` when neither is present. Used by arc's controllers to
1786
- * populate `createdBy` / `updatedBy` fields and for cache scoping.
1787
- *
1788
- * @example
1789
- * ```ts
1790
- * import { getUserId } from '@classytic/arc/utils';
1791
- * const uid = getUserId(request.user);
1792
- * ```
1793
- */
1794
- function getUserId(user) {
1795
- if (!user) return void 0;
1796
- const id = user.id ?? user._id;
1797
- return id ? String(id) : void 0;
1798
- }
1799
- //#endregion
1800
- export { CircuitState as A, defineGuard as C, CircuitBreaker as D, withCompensation as E, validateResourceConfig as F, simpleEqualityMatcher as I, createCircuitBreakerRegistry as M, assertValidConfig as N, CircuitBreakerError as O, formatValidationErrors as P, handleRaw as S, defineCompensation as T, paginationSchema as _, createStateMachine as a, ArcQueryParser as b, bareListResponse as c, errorDetailSchema$1 as d, getDefaultCrudSchemas as f, offsetListResponse as g, listResponse as h, pipeUIMessageStreamToReply as i, createCircuitBreaker as j, CircuitBreakerRegistry as k, deleteResponse as l, keysetListResponse as m, UI_MESSAGE_STREAM_HEADERS as n, scheduleBackground as o, getListQueryParams as p, isReadableStream as r, aggregateListResponse as s, getUserId as t, errorContractSchema$1 as u, queryParams as v, defineErrorMapper as w, createQueryParser as x, responses as y };
1691
+ export { createQueryParser as A, keysetListResponse as C, queryParams as D, paginationSchema as E, scheduleBackground as M, simpleEqualityMatcher as N, responses as O, getListQueryParams as S, offsetListResponse as T, bareListResponse as _, defineCompensation as a, errorDetailSchema$1 as b, CircuitBreakerError as c, createCircuitBreaker as d, createCircuitBreakerRegistry as f, aggregateListResponse as g, validateResourceConfig as h, defineErrorMapper as i, getUserId as j, ArcQueryParser as k, CircuitBreakerRegistry as l, formatValidationErrors as m, handleRaw as n, withCompensation as o, assertValidConfig as p, defineGuard as r, CircuitBreaker as s, createStateMachine as t, CircuitState as u, deleteResponse as v, listResponse as w, getDefaultCrudSchemas as x, errorContractSchema$1 as y };