@classytic/arc 2.11.4 → 2.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/README.md +16 -12
  2. package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
  3. package/dist/EventTransport-CT_52aWU.d.mts +34 -0
  4. package/dist/EventTransport-DLWoUMHy.mjs +103 -0
  5. package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
  6. package/dist/audit/index.d.mts +2 -2
  7. package/dist/audit/index.mjs +1 -1
  8. package/dist/auth/audit.d.mts +199 -0
  9. package/dist/auth/audit.mjs +288 -0
  10. package/dist/auth/index.d.mts +3 -3
  11. package/dist/auth/index.mjs +117 -191
  12. package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
  13. package/dist/buildHandler-olo-gt94.mjs +610 -0
  14. package/dist/cache/index.mjs +3 -3
  15. package/dist/cli/commands/describe.d.mts +89 -13
  16. package/dist/cli/commands/describe.mjs +56 -2
  17. package/dist/cli/commands/docs.mjs +2 -2
  18. package/dist/cli/commands/generate.mjs +147 -48
  19. package/dist/cli/commands/init.d.mts +13 -0
  20. package/dist/cli/commands/init.mjs +130 -87
  21. package/dist/cli/commands/introspect.mjs +8 -1
  22. package/dist/context/index.mjs +1 -1
  23. package/dist/core/index.d.mts +3 -3
  24. package/dist/core/index.mjs +5 -5
  25. package/dist/core-D72ia0EH.mjs +1399 -0
  26. package/dist/{createActionRouter-CIKOcNA7.mjs → createActionRouter-CEvzKcy8.mjs} +7 -20
  27. package/dist/createAggregationRouter-CyecOxnO.mjs +114 -0
  28. package/dist/{createApp-C9bRrqlX.mjs → createApp-XX2-N0Yd.mjs} +28 -22
  29. package/dist/{defineEvent-D1Ky9M1D.mjs → defineEvent-D5h7EvAx.mjs} +1 -1
  30. package/dist/docs/index.d.mts +1 -1
  31. package/dist/docs/index.mjs +2 -2
  32. package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
  33. package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
  34. package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
  35. package/dist/errors-j4aJm1Wg.mjs +184 -0
  36. package/dist/{eventPlugin-Cts2-Tfj.mjs → eventPlugin-CaKTYkYM.mjs} +28 -4
  37. package/dist/{eventPlugin-DDJoNEPL.d.mts → eventPlugin-qXpqTebY.d.mts} +24 -1
  38. package/dist/events/index.d.mts +6 -6
  39. package/dist/events/index.mjs +11 -35
  40. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  41. package/dist/events/transports/redis.d.mts +1 -1
  42. package/dist/factory/index.d.mts +2 -2
  43. package/dist/factory/index.mjs +2 -2
  44. package/dist/{fields-BRjxOAFp.d.mts → fields-COhcH3fk.d.mts} +23 -2
  45. package/dist/hooks/index.d.mts +1 -1
  46. package/dist/hooks/index.mjs +1 -1
  47. package/dist/idempotency/index.d.mts +1 -1
  48. package/dist/idempotency/index.mjs +1 -20
  49. package/dist/idempotency/redis.mjs +1 -1
  50. package/dist/{index-rHjXmJar.d.mts → index-BTqLEvhu.d.mts} +163 -3
  51. package/dist/{index-CXXRbnf8.d.mts → index-BtW7qYwa.d.mts} +660 -326
  52. package/dist/{index-m8mOOlFW.d.mts → index-Ds61mrJE.d.mts} +50 -4
  53. package/dist/{index-D9t1KNaB.d.mts → index-Dz5IKsrE.d.mts} +360 -219
  54. package/dist/index.d.mts +6 -7
  55. package/dist/index.mjs +9 -10
  56. package/dist/integrations/event-gateway.d.mts +1 -1
  57. package/dist/integrations/event-gateway.mjs +1 -1
  58. package/dist/integrations/index.d.mts +1 -1
  59. package/dist/integrations/mcp/index.d.mts +2 -2
  60. package/dist/integrations/mcp/index.mjs +1 -1
  61. package/dist/integrations/mcp/testing.d.mts +1 -1
  62. package/dist/integrations/mcp/testing.mjs +1 -1
  63. package/dist/integrations/streamline.d.mts +60 -11
  64. package/dist/integrations/streamline.mjs +75 -85
  65. package/dist/integrations/websocket.mjs +2 -8
  66. package/dist/middleware/index.d.mts +1 -1
  67. package/dist/middleware/index.mjs +2 -2
  68. package/dist/migrations/index.d.mts +23 -3
  69. package/dist/migrations/index.mjs +0 -7
  70. package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
  71. package/dist/{openapi-D7G1V7ex.mjs → openapi-CiOMVW1p.mjs} +143 -13
  72. package/dist/org/index.d.mts +2 -2
  73. package/dist/org/index.mjs +1 -1
  74. package/dist/permissions/index.d.mts +3 -3
  75. package/dist/permissions/index.mjs +3 -3
  76. package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
  77. package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
  78. package/dist/pipeline/index.d.mts +1 -1
  79. package/dist/pipeline/index.mjs +1 -1
  80. package/dist/plugins/index.d.mts +16 -31
  81. package/dist/plugins/index.mjs +33 -13
  82. package/dist/plugins/response-cache.mjs +1 -1
  83. package/dist/plugins/tracing-entry.mjs +1 -1
  84. package/dist/presets/filesUpload.d.mts +4 -4
  85. package/dist/presets/filesUpload.mjs +6 -9
  86. package/dist/presets/index.d.mts +1 -1
  87. package/dist/presets/index.mjs +1 -1
  88. package/dist/presets/multiTenant.d.mts +1 -1
  89. package/dist/presets/multiTenant.mjs +2 -2
  90. package/dist/presets/search.d.mts +2 -2
  91. package/dist/presets/search.mjs +6 -8
  92. package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
  93. package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
  94. package/dist/{redis-stream-xTGxB2bm.d.mts → redis-stream-D6HzR1Z_.d.mts} +1 -1
  95. package/dist/registry/index.d.mts +1 -1
  96. package/dist/registry/index.mjs +2 -2
  97. package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
  98. package/dist/{resourceToTools-CxNmI6xF.mjs → resourceToTools-C5coh64w.mjs} +224 -71
  99. package/dist/{routerShared-BqLRb5l7.mjs → routerShared-D6_fEGHh.mjs} +40 -36
  100. package/dist/{schemaIR-Dy2p4MxS.mjs → schemaIR-7Vl611Qs.mjs} +1 -1
  101. package/dist/schemas/index.d.mts +100 -30
  102. package/dist/schemas/index.mjs +86 -29
  103. package/dist/scim/index.d.mts +264 -0
  104. package/dist/scim/index.mjs +963 -0
  105. package/dist/scope/index.d.mts +3 -3
  106. package/dist/scope/index.mjs +4 -4
  107. package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
  108. package/dist/{store-helpers-Cp4uKC1U.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
  109. package/dist/testing/index.d.mts +2 -8
  110. package/dist/testing/index.mjs +16 -24
  111. package/dist/types/index.d.mts +4 -4
  112. package/dist/{types-D7KpfiL1.d.mts → types-BvqwCCSx.d.mts} +73 -25
  113. package/dist/{types-DDyTPc6y.d.mts → types-CTYvcwHe.d.mts} +195 -1
  114. package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
  115. package/dist/{types-BQ9TJQNy.d.mts → types-DQHFc8PM.d.mts} +1 -1
  116. package/dist/utils/index.d.mts +2 -2
  117. package/dist/utils/index.mjs +5 -5
  118. package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
  119. package/dist/{versioning-DsglKfM_.d.mts → versioning-DTTvc80y.d.mts} +1 -1
  120. package/package.json +24 -34
  121. package/skills/arc/SKILL.md +147 -51
  122. package/skills/arc/references/agent-auth.md +238 -0
  123. package/skills/arc/references/api-reference.md +187 -0
  124. package/skills/arc/references/auth.md +354 -7
  125. package/skills/arc/references/enterprise-auth.md +94 -0
  126. package/skills/arc/references/events.md +8 -6
  127. package/skills/arc/references/mcp.md +2 -2
  128. package/skills/arc/references/multi-tenancy.md +11 -2
  129. package/skills/arc/references/production.md +10 -9
  130. package/skills/arc/references/scim.md +247 -0
  131. package/skills/arc/references/testing.md +1 -1
  132. package/skills/arc-code-review/SKILL.md +141 -0
  133. package/skills/arc-code-review/references/anti-patterns.md +911 -0
  134. package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
  135. package/skills/arc-code-review/references/migration-recipes.md +700 -0
  136. package/skills/arc-code-review/references/mongokit-migration.md +386 -0
  137. package/skills/arc-code-review/references/scaffolding.md +230 -0
  138. package/skills/arc-code-review/references/severity.md +127 -0
  139. package/dist/EventTransport-BFQjw9pB.mjs +0 -133
  140. package/dist/EventTransport-CYNUXdCJ.d.mts +0 -293
  141. package/dist/adapters/index.d.mts +0 -3
  142. package/dist/adapters/index.mjs +0 -2
  143. package/dist/adapters-DUUiiimH.mjs +0 -964
  144. package/dist/auth/mongoose.d.mts +0 -191
  145. package/dist/auth/mongoose.mjs +0 -73
  146. package/dist/core-CbcQRIch.mjs +0 -1054
  147. package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
  148. package/dist/errorHandler-DEWmGWPz.d.mts +0 -114
  149. package/dist/errors-D5c-5BJL.mjs +0 -232
  150. package/dist/index-Rg8axYPz.d.mts +0 -370
  151. /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
  152. /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
  153. /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
  154. /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
  155. /package/dist/{elevation-BQQXZ_VR.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
  156. /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
  157. /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
  158. /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
  159. /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
  160. /package/dist/{pluralize-CWP6MB39.mjs → pluralize-DQgqgifU.mjs} +0 -0
  161. /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
  162. /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
  163. /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
  164. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
  165. /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
  166. /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
@@ -1,7 +1,8 @@
1
- import { m as RESERVED_QUERY_PARAMS, t as CRUD_OPERATIONS } from "./constants-BhY1OHoH.mjs";
1
+ import { m as RESERVED_QUERY_PARAMS, t as CRUD_OPERATIONS } from "./constants-Cxde4rpC.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
- import { t as ArcError } from "./errors-D5c-5BJL.mjs";
4
- import { r as getAvailablePresets } from "./presets-Z7P5w4gF.mjs";
3
+ import { t as ArcError } from "./errors-j4aJm1Wg.mjs";
4
+ import { r as getAvailablePresets } from "./presets-BbkjdPeH.mjs";
5
+ import { errorContractSchema as errorContractSchema$1, errorDetailSchema as errorDetailSchema$1 } from "@classytic/repo-core/errors";
5
6
  //#region src/utils/simpleEqualityMatcher.ts
6
7
  /**
7
8
  * `simpleEqualityMatcher` — a minimal, dialect-agnostic flat-key equality
@@ -69,1471 +70,1387 @@ function simpleEqualityMatcher(item, filters) {
69
70
  return true;
70
71
  }
71
72
  //#endregion
72
- //#region src/utils/userHelpers.ts
73
+ //#region src/core/validateResourceConfig.ts
73
74
  /**
74
- * Extract a user ID from a user object. Accepts `id` or `_id` — returns
75
- * `undefined` when neither is present. Used by arc's controllers to
76
- * populate `createdBy` / `updatedBy` fields and for cache scoping.
75
+ * Resource Configuration Validator
76
+ *
77
+ * Fail-fast validation at definition time.
78
+ * Invalid configs throw immediately with clear, actionable errors.
77
79
  *
78
80
  * @example
79
- * ```ts
80
- * import { getUserId } from '@classytic/arc/utils';
81
- * const uid = getUserId(request.user);
82
- * ```
81
+ * const result = validateResourceConfig(config);
82
+ * if (!result.valid) {
83
+ * console.error(formatValidationErrors(result.errors));
84
+ * }
83
85
  */
84
- function getUserId(user) {
85
- if (!user) return void 0;
86
- const id = user.id ?? user._id;
87
- return id ? String(id) : void 0;
86
+ /**
87
+ * Validate a resource configuration
88
+ */
89
+ function validateResourceConfig(config, options = {}) {
90
+ const errors = [];
91
+ const warnings = [];
92
+ if (!config.name) errors.push({
93
+ field: "name",
94
+ message: "Resource name is required",
95
+ suggestion: "Add a unique resource name (e.g., \"product\", \"user\")"
96
+ });
97
+ else if (!/^[a-z][a-z0-9-]*$/i.test(config.name)) errors.push({
98
+ field: "name",
99
+ message: `Invalid resource name "${config.name}"`,
100
+ suggestion: "Use alphanumeric characters and hyphens, starting with a letter"
101
+ });
102
+ const crudRoutes = CRUD_OPERATIONS;
103
+ const disabledRoutes = new Set(config.disabledRoutes ?? []);
104
+ const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
105
+ if (!config.disableDefaultRoutes && enabledCrudRoutes.length > 0) {
106
+ if (!config.adapter) errors.push({
107
+ field: "adapter",
108
+ message: "Data adapter is required when CRUD routes are enabled",
109
+ suggestion: "Provide an adapter: createMongooseAdapter({ model, repository })"
110
+ });
111
+ else if (!config.adapter.repository) errors.push({
112
+ field: "adapter.repository",
113
+ message: "Adapter must provide a repository",
114
+ suggestion: "Ensure your adapter returns a valid StandardRepo (see @classytic/repo-core)"
115
+ });
116
+ } else if (!config.adapter && !config.routes?.length) warnings.push({
117
+ field: "config",
118
+ message: "Resource has no adapter and no routes",
119
+ suggestion: "Provide either adapter for CRUD or routes for custom logic"
120
+ });
121
+ if (config.controller && !options.skipControllerCheck && !config.disableDefaultRoutes) {
122
+ const ctrl = config.controller;
123
+ const requiredMethods = CRUD_OPERATIONS;
124
+ for (const method of requiredMethods) if (typeof ctrl[method] !== "function") errors.push({
125
+ field: `controller.${method}`,
126
+ message: `Missing required CRUD method "${method}"`,
127
+ suggestion: "Extend BaseController which implements IController interface"
128
+ });
129
+ }
130
+ if (config.controller && config.routes) validateRouteHandlers(config.controller, config.routes, errors);
131
+ if (config.permissions) validatePermissionKeys(config, options, errors, warnings);
132
+ if (config.presets && !options.allowUnknownPresets) validatePresets(config.presets, errors, warnings);
133
+ if (config.prefix) {
134
+ if (!config.prefix.startsWith("/")) errors.push({
135
+ field: "prefix",
136
+ message: `Prefix must start with "/" (got "${config.prefix}")`,
137
+ suggestion: `Change to "/${config.prefix}"`
138
+ });
139
+ if (config.prefix.endsWith("/") && config.prefix !== "/") warnings.push({
140
+ field: "prefix",
141
+ message: `Prefix should not end with "/" (got "${config.prefix}")`,
142
+ suggestion: `Change to "${config.prefix.slice(0, -1)}"`
143
+ });
144
+ }
145
+ if (config.routes) validateRoutes(config.routes, errors);
146
+ return {
147
+ valid: errors.length === 0,
148
+ errors,
149
+ warnings
150
+ };
151
+ }
152
+ function validateRouteHandlers(controller, routes, errors) {
153
+ const ctrl = controller;
154
+ for (const route of routes) if (typeof route.handler === "string") {
155
+ if (typeof ctrl[route.handler] !== "function") errors.push({
156
+ field: `routes[${route.method} ${route.path}]`,
157
+ message: `Handler "${route.handler}" not found on controller`,
158
+ suggestion: `Add method "${route.handler}" to controller or use a function handler`
159
+ });
160
+ }
161
+ }
162
+ function validatePermissionKeys(config, options, _errors, warnings) {
163
+ const validKeys = new Set([...CRUD_OPERATIONS, ...options.additionalPermissionKeys ?? []]);
164
+ for (const route of config.routes ?? []) if (typeof route.handler === "string") validKeys.add(route.handler);
165
+ for (const preset of config.presets ?? []) {
166
+ const presetName = typeof preset === "string" ? preset : preset.name;
167
+ if (presetName === "softDelete") {
168
+ validKeys.add("deleted");
169
+ validKeys.add("restore");
170
+ }
171
+ if (presetName === "slugLookup") validKeys.add("getBySlug");
172
+ if (presetName === "tree") {
173
+ validKeys.add("tree");
174
+ validKeys.add("children");
175
+ validKeys.add("getTree");
176
+ validKeys.add("getChildren");
177
+ }
178
+ }
179
+ for (const key of Object.keys(config.permissions ?? {})) if (!validKeys.has(key)) warnings.push({
180
+ field: `permissions.${key}`,
181
+ message: `Unknown permission key "${key}"`,
182
+ suggestion: `Valid keys: ${Array.from(validKeys).join(", ")}`
183
+ });
184
+ }
185
+ function validatePresets(presets, errors, warnings) {
186
+ const availablePresets = getAvailablePresets();
187
+ for (const preset of presets) {
188
+ if (typeof preset === "object" && ("middlewares" in preset || "routes" in preset)) continue;
189
+ const presetName = typeof preset === "string" ? preset : preset.name;
190
+ if (!availablePresets.includes(presetName)) errors.push({
191
+ field: "presets",
192
+ message: `Unknown preset "${presetName}"`,
193
+ suggestion: `Available presets: ${availablePresets.join(", ")}`
194
+ });
195
+ if (typeof preset === "object") validatePresetOptions(preset, warnings);
196
+ }
197
+ }
198
+ function validatePresetOptions(preset, warnings) {
199
+ const validOptions = {
200
+ slugLookup: ["slugField"],
201
+ tree: ["parentField"],
202
+ softDelete: ["deletedField"],
203
+ ownedByUser: ["ownerField"],
204
+ multiTenant: ["tenantField", "allowPublic"]
205
+ }[preset.name] ?? [];
206
+ const providedOptions = Object.keys(preset).filter((k) => k !== "name");
207
+ for (const opt of providedOptions) if (!validOptions.includes(opt)) warnings.push({
208
+ field: `presets[${preset.name}].${opt}`,
209
+ message: `Unknown option "${opt}" for preset "${preset.name}"`,
210
+ suggestion: validOptions.length > 0 ? `Valid options: ${validOptions.join(", ")}` : `Preset "${preset.name}" has no configurable options`
211
+ });
212
+ }
213
+ function validateRoutes(routes, errors) {
214
+ const validMethods = [
215
+ "GET",
216
+ "POST",
217
+ "PUT",
218
+ "PATCH",
219
+ "DELETE",
220
+ "OPTIONS",
221
+ "HEAD"
222
+ ];
223
+ const seenRoutes = /* @__PURE__ */ new Set();
224
+ for (const [i, route] of routes.entries()) {
225
+ if (!validMethods.includes(route.method)) errors.push({
226
+ field: `routes[${i}].method`,
227
+ message: `Invalid HTTP method "${route.method}"`,
228
+ suggestion: `Valid methods: ${validMethods.join(", ")}`
229
+ });
230
+ if (!route.path) errors.push({
231
+ field: `routes[${i}].path`,
232
+ message: "Route path is required"
233
+ });
234
+ else if (!route.path.startsWith("/")) errors.push({
235
+ field: `routes[${i}].path`,
236
+ message: `Route path must start with "/" (got "${route.path}")`,
237
+ suggestion: `Change to "/${route.path}"`
238
+ });
239
+ if (!route.handler) errors.push({
240
+ field: `routes[${i}].handler`,
241
+ message: "Route handler is required"
242
+ });
243
+ const routeKey = `${route.method} ${route.path}`;
244
+ if (seenRoutes.has(routeKey)) errors.push({
245
+ field: `routes[${i}]`,
246
+ message: `Duplicate route "${routeKey}"`
247
+ });
248
+ seenRoutes.add(routeKey);
249
+ }
250
+ }
251
+ /**
252
+ * Format validation errors for display
253
+ */
254
+ function formatValidationErrors(resourceName, result) {
255
+ const lines = [];
256
+ if (result.errors.length > 0) {
257
+ lines.push(`Resource "${resourceName}" validation failed:`);
258
+ lines.push("");
259
+ lines.push("ERRORS:");
260
+ for (const err of result.errors) {
261
+ lines.push(` ✗ ${err.field}: ${err.message}`);
262
+ if (err.suggestion) lines.push(` → ${err.suggestion}`);
263
+ }
264
+ }
265
+ if (result.warnings.length > 0) {
266
+ if (lines.length > 0) lines.push("");
267
+ lines.push("WARNINGS:");
268
+ for (const warn of result.warnings) {
269
+ lines.push(` ⚠ ${warn.field}: ${warn.message}`);
270
+ if (warn.suggestion) lines.push(` → ${warn.suggestion}`);
271
+ }
272
+ }
273
+ return lines.join("\n");
274
+ }
275
+ /**
276
+ * Validate and throw if invalid
277
+ */
278
+ function assertValidConfig(config, options) {
279
+ const result = validateResourceConfig(config, options);
280
+ if (!result.valid) {
281
+ const errorMsg = formatValidationErrors(config.name ?? "unknown", result);
282
+ throw new Error(errorMsg);
283
+ }
88
284
  }
89
285
  //#endregion
90
- //#region src/utils/queryParser.ts
286
+ //#region src/utils/circuitBreaker.ts
91
287
  /**
92
- * Arc Query Parser - Default URL-to-Query Parser
288
+ * Circuit Breaker Pattern
93
289
  *
94
- * Framework-agnostic query parser that converts URL parameters to query options.
95
- * This is Arc's built-in parser; users can swap in MongoKit's QueryParser,
96
- * pgkit's parser, or any custom parser implementing QueryParserInterface.
290
+ * Wraps external service calls with failure protection.
291
+ * Prevents cascading failures by "opening" the circuit when
292
+ * a service is failing, allowing it time to recover.
293
+ *
294
+ * States:
295
+ * - CLOSED: Normal operation, requests pass through
296
+ * - OPEN: Too many failures, all requests fail fast
297
+ * - HALF_OPEN: Testing if service recovered, limited requests
97
298
  *
98
299
  * @example
99
- * // Use Arc default parser (auto-applied if no queryParser option)
100
- * defineResource({ name: 'product', adapter: ... });
300
+ * import { CircuitBreaker } from '@classytic/arc/utils';
101
301
  *
102
- * // Use MongoKit's QueryParser (recommended for MongoDB - has $lookup, aggregations, etc.)
103
- * import { QueryParser } from '@classytic/mongokit';
104
- * defineResource({
105
- * name: 'product',
106
- * adapter: ...,
107
- * queryParser: new QueryParser(),
302
+ * const paymentBreaker = new CircuitBreaker(async (amount) => {
303
+ * return await stripe.charges.create({ amount });
304
+ * }, {
305
+ * failureThreshold: 5,
306
+ * resetTimeout: 30000,
307
+ * timeout: 5000,
108
308
  * });
109
309
  *
110
- * // Use custom parser for SQL databases
111
- * defineResource({
112
- * name: 'user',
113
- * adapter: ...,
114
- * queryParser: new PgQueryParser(),
115
- * });
116
- */
117
- const log = arcLog("queryParser");
118
- /**
119
- * Regex patterns that can cause catastrophic backtracking (ReDoS attacks)
120
- * Detects:
121
- * - Quantifiers: {n,m}
122
- * - Possessive quantifiers: *+, ++, ?+
123
- * - Nested quantifiers: (a+)+, (a*)*
124
- * - Backreferences: \1, \2, etc.
125
- */
126
- const DANGEROUS_REGEX_PATTERNS = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
127
- /**
128
- * Arc's default query parser
129
- *
130
- * Converts URL query parameters to a structured query format:
131
- * - Pagination: ?page=1&limit=20
132
- * - Sorting: ?sort=-createdAt,name (- prefix = descending)
133
- * - Filtering: ?status=active&price[gte]=100&price[lte]=500
134
- * - Search: ?search=keyword
135
- * - Populate: ?populate=author,category
136
- * - Field selection: ?select=name,price,status
137
- * - Keyset pagination: ?after=cursor_value
138
- *
139
- * For advanced MongoDB features ($lookup, aggregations), use MongoKit's QueryParser.
310
+ * try {
311
+ * const result = await paymentBreaker.call(100);
312
+ * } catch (error) {
313
+ * // Handle failure or circuit open
314
+ * }
140
315
  */
141
- var ArcQueryParser = class {
142
- maxLimit;
143
- defaultLimit;
144
- maxRegexLength;
145
- maxSearchLength;
146
- maxFilterDepth;
147
- _allowedFilterFields;
148
- _allowedSortFields;
149
- _allowedOperators;
150
- /** Allowed filter fields (used by MCP for auto-derive) */
151
- allowedFilterFields;
152
- /** Allowed sort fields (used by MCP for sort descriptions) */
153
- allowedSortFields;
154
- /** Allowed operators (used by MCP for operator descriptions) */
155
- allowedOperators;
156
- /** Supported filter operators */
157
- operators = {
158
- eq: "$eq",
159
- ne: "$ne",
160
- gt: "$gt",
161
- gte: "$gte",
162
- lt: "$lt",
163
- lte: "$lte",
164
- in: "$in",
165
- nin: "$nin",
166
- like: "$regex",
167
- contains: "$regex",
168
- regex: "$regex",
169
- exists: "$exists"
170
- };
171
- constructor(options = {}) {
172
- this.maxLimit = options.maxLimit ?? 1e3;
173
- this.defaultLimit = options.defaultLimit ?? 20;
174
- this.maxRegexLength = options.maxRegexLength ?? 200;
175
- this.maxSearchLength = options.maxSearchLength ?? 200;
176
- this.maxFilterDepth = options.maxFilterDepth ?? 10;
177
- if (options.allowedFilterFields) {
178
- this._allowedFilterFields = new Set(options.allowedFilterFields);
179
- this.allowedFilterFields = options.allowedFilterFields;
180
- }
181
- if (options.allowedSortFields) {
182
- this._allowedSortFields = new Set(options.allowedSortFields);
183
- this.allowedSortFields = options.allowedSortFields;
316
+ const CircuitState = {
317
+ CLOSED: "CLOSED",
318
+ OPEN: "OPEN",
319
+ HALF_OPEN: "HALF_OPEN"
320
+ };
321
+ var CircuitBreakerError = class extends Error {
322
+ state;
323
+ constructor(message, state) {
324
+ super(message);
325
+ this.name = "CircuitBreakerError";
326
+ this.state = state;
327
+ }
328
+ };
329
+ var CircuitBreaker = class {
330
+ state = CircuitState.CLOSED;
331
+ failures = 0;
332
+ successes = 0;
333
+ totalCalls = 0;
334
+ nextAttempt = 0;
335
+ lastCallAt = null;
336
+ openedAt = null;
337
+ failureThreshold;
338
+ resetTimeout;
339
+ timeout;
340
+ successThreshold;
341
+ fallback;
342
+ onStateChange;
343
+ onError;
344
+ name;
345
+ fn;
346
+ constructor(fn, options = {}) {
347
+ this.fn = fn;
348
+ this.failureThreshold = options.failureThreshold ?? 5;
349
+ this.resetTimeout = options.resetTimeout ?? 6e4;
350
+ this.timeout = options.timeout ?? 1e4;
351
+ this.successThreshold = options.successThreshold ?? 1;
352
+ this.fallback = options.fallback;
353
+ this.onStateChange = options.onStateChange;
354
+ this.onError = options.onError;
355
+ this.name = options.name ?? "CircuitBreaker";
356
+ }
357
+ /**
358
+ * Call the wrapped function with circuit breaker protection
359
+ */
360
+ async call(...args) {
361
+ this.totalCalls++;
362
+ this.lastCallAt = Date.now();
363
+ if (this.state === CircuitState.OPEN) {
364
+ if (Date.now() < this.nextAttempt) {
365
+ const error = new CircuitBreakerError(`Circuit breaker is OPEN for ${this.name}`, CircuitState.OPEN);
366
+ if (this.fallback) return this.fallback(...args);
367
+ throw error;
368
+ }
369
+ this.setState(CircuitState.HALF_OPEN);
184
370
  }
185
- if (options.allowedOperators) {
186
- this._allowedOperators = new Set(options.allowedOperators);
187
- this.allowedOperators = options.allowedOperators;
371
+ try {
372
+ const result = await this.executeWithTimeout(args);
373
+ this.onSuccess();
374
+ return result;
375
+ } catch (err) {
376
+ this.onFailure(err instanceof Error ? err : new Error(String(err)));
377
+ throw err;
188
378
  }
189
379
  }
190
380
  /**
191
- * Parse URL query parameters into structured query options
381
+ * Execute function with timeout
192
382
  */
193
- parse(query) {
194
- const q = query ?? {};
195
- const page = this.parseNumber(q.page, 1);
196
- const limit = Math.min(this.parseNumber(q.limit, this.defaultLimit), this.maxLimit);
197
- const after = this.parseString(q.after ?? q.cursor);
198
- const sort = this.parseSort(q.sort);
199
- const { populate, populateOptions } = this.parsePopulate(q.populate);
200
- const search = this.parseSearch(q.search);
201
- const select = this.parseSelect(q.select);
202
- return {
203
- filters: this.parseFilters(q),
204
- limit,
205
- sort,
206
- populate,
207
- populateOptions,
208
- search,
209
- page: after ? void 0 : page,
210
- after,
211
- select
212
- };
213
- }
214
- parseNumber(value, defaultValue) {
215
- if (value === void 0 || value === null) return defaultValue;
216
- const num = parseInt(String(value), 10);
217
- return Number.isNaN(num) ? defaultValue : Math.max(1, num);
218
- }
219
- parseString(value) {
220
- if (value === void 0 || value === null) return void 0;
221
- const str = String(value).trim();
222
- return str.length > 0 ? str : void 0;
383
+ async executeWithTimeout(args) {
384
+ return new Promise((resolve, reject) => {
385
+ const timeoutId = setTimeout(() => {
386
+ reject(/* @__PURE__ */ new Error(`Request timeout after ${this.timeout}ms`));
387
+ }, this.timeout);
388
+ this.fn(...args).then((result) => {
389
+ clearTimeout(timeoutId);
390
+ resolve(result);
391
+ }).catch((error) => {
392
+ clearTimeout(timeoutId);
393
+ reject(error);
394
+ });
395
+ });
223
396
  }
224
397
  /**
225
- * Parse populate parameter — handles both simple string and bracket notation.
226
- *
227
- * Simple: ?populate=author,category → { populate: 'author,category' }
228
- * Bracket: ?populate[author][select]=name,email → { populateOptions: [{ path: 'author', select: 'name email' }] }
398
+ * Handle successful call
229
399
  */
230
- parsePopulate(value) {
231
- if (value === void 0 || value === null) return {};
232
- if (typeof value === "string") {
233
- const trimmed = value.trim();
234
- return trimmed.length > 0 ? { populate: trimmed } : {};
235
- }
236
- if (typeof value === "object" && !Array.isArray(value)) {
237
- const obj = value;
238
- const keys = Object.keys(obj);
239
- if (keys.length === 0) return {};
240
- const options = [];
241
- for (const path of keys) {
242
- if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(path)) continue;
243
- const config = obj[path];
244
- if (typeof config === "object" && config !== null && !Array.isArray(config)) {
245
- const cfg = config;
246
- const option = { path };
247
- if (typeof cfg.select === "string") option.select = cfg.select.split(",").map((s) => s.trim()).filter(Boolean).join(" ");
248
- if (typeof cfg.match === "object" && cfg.match !== null) option.match = cfg.match;
249
- options.push(option);
250
- } else options.push({ path });
400
+ onSuccess() {
401
+ this.failures = 0;
402
+ this.successes++;
403
+ if (this.state === CircuitState.HALF_OPEN) {
404
+ if (this.successes >= this.successThreshold) {
405
+ this.setState(CircuitState.CLOSED);
406
+ this.successes = 0;
251
407
  }
252
- return options.length > 0 ? { populateOptions: options } : {};
253
408
  }
254
- return {};
255
409
  }
256
- parseSort(value) {
257
- if (!value) return void 0;
258
- const sortStr = String(value);
259
- const result = {};
260
- for (const field of sortStr.split(",")) {
261
- const trimmed = field.trim();
262
- if (!trimmed) continue;
263
- if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
264
- const fieldName = trimmed.startsWith("-") ? trimmed.slice(1) : trimmed;
265
- if (this._allowedSortFields && !this._allowedSortFields.has(fieldName)) continue;
266
- if (trimmed.startsWith("-")) result[fieldName] = -1;
267
- else result[fieldName] = 1;
410
+ /**
411
+ * Handle failed call
412
+ */
413
+ onFailure(error) {
414
+ this.failures++;
415
+ this.successes = 0;
416
+ if (this.onError) this.onError(error);
417
+ if (this.state === CircuitState.HALF_OPEN || this.failures >= this.failureThreshold) {
418
+ this.setState(CircuitState.OPEN);
419
+ this.nextAttempt = Date.now() + this.resetTimeout;
420
+ this.openedAt = Date.now();
268
421
  }
269
- return Object.keys(result).length > 0 ? result : void 0;
270
422
  }
271
- parseSearch(value) {
272
- if (!value) return void 0;
273
- const search = String(value).trim();
274
- if (search.length === 0) return void 0;
275
- if (search.length > this.maxSearchLength) return search.slice(0, this.maxSearchLength);
276
- return search;
423
+ /**
424
+ * Change circuit state
425
+ */
426
+ setState(newState) {
427
+ const oldState = this.state;
428
+ if (oldState !== newState) {
429
+ this.state = newState;
430
+ if (this.onStateChange) this.onStateChange(oldState, newState);
431
+ }
277
432
  }
278
- parseSelect(value) {
279
- if (!value) return void 0;
280
- const selectStr = String(value);
281
- const result = {};
282
- for (const field of selectStr.split(",")) {
283
- const trimmed = field.trim();
284
- if (!trimmed) continue;
285
- if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
286
- if (trimmed.startsWith("-")) result[trimmed.slice(1)] = 0;
287
- else result[trimmed] = 1;
288
- }
289
- return Object.keys(result).length > 0 ? result : void 0;
433
+ /**
434
+ * Manually open the circuit
435
+ */
436
+ open() {
437
+ this.setState(CircuitState.OPEN);
438
+ this.nextAttempt = Date.now() + this.resetTimeout;
439
+ this.openedAt = Date.now();
290
440
  }
291
441
  /**
292
- * Check if a value exceeds the maximum nesting depth.
293
- * Prevents filter bombs where deeply nested objects consume excessive memory/CPU.
442
+ * Manually close the circuit
294
443
  */
295
- exceedsDepth(obj, currentDepth = 0) {
296
- if (currentDepth > this.maxFilterDepth) return true;
297
- if (obj === null || obj === void 0) return false;
298
- if (Array.isArray(obj)) return obj.some((v) => this.exceedsDepth(v, currentDepth));
299
- if (typeof obj !== "object") return false;
300
- return Object.values(obj).some((v) => this.exceedsDepth(v, currentDepth + 1));
444
+ close() {
445
+ this.failures = 0;
446
+ this.successes = 0;
447
+ this.setState(CircuitState.CLOSED);
448
+ this.openedAt = null;
301
449
  }
302
- parseFilters(query) {
303
- const filters = {};
304
- for (const [key, value] of Object.entries(query)) {
305
- if (RESERVED_QUERY_PARAMS.has(key)) continue;
306
- if (value === void 0 || value === null) continue;
307
- if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(key)) continue;
308
- if (this._allowedFilterFields && !this._allowedFilterFields.has(key)) continue;
309
- if (this.exceedsDepth(value)) continue;
310
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
311
- const operatorObj = value;
312
- const operatorKeys = Object.keys(operatorObj);
313
- const allOperators = operatorKeys.every((op) => this.operators[op] && (!this._allowedOperators || this._allowedOperators.has(op)));
314
- const allKnownOperators = operatorKeys.every((op) => this.operators[op]);
315
- if (allOperators && operatorKeys.length > 0) {
316
- const mongoFilters = {};
317
- let needsCaseInsensitive = false;
318
- for (const [op, opValue] of Object.entries(operatorObj)) {
319
- const mongoOp = this.operators[op];
320
- if (mongoOp) {
321
- mongoFilters[mongoOp] = this.parseFilterValue(opValue, op);
322
- if (op === "contains" || op === "like") needsCaseInsensitive = true;
323
- }
324
- }
325
- if (needsCaseInsensitive) mongoFilters.$options = "i";
326
- filters[key] = mongoFilters;
327
- continue;
328
- }
329
- if (allKnownOperators && this._allowedOperators) continue;
330
- }
331
- const match = key.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)(?:\[([a-z]+)\])?$/);
332
- if (!match) continue;
333
- const [, fieldName, operator] = match;
334
- if (!fieldName) continue;
335
- if (operator && this.operators[operator] && (!this._allowedOperators || this._allowedOperators.has(operator))) {
336
- const mongoOp = this.operators[operator];
337
- const parsedValue = this.parseFilterValue(value, operator);
338
- if (!filters[fieldName]) filters[fieldName] = {};
339
- const fieldFilter = filters[fieldName];
340
- fieldFilter[mongoOp] = parsedValue;
341
- if (operator === "contains" || operator === "like") fieldFilter.$options = "i";
342
- } else if (!operator) filters[fieldName] = this.parseFilterValue(value);
343
- }
344
- return filters;
450
+ /**
451
+ * Get current statistics
452
+ */
453
+ getStats() {
454
+ return {
455
+ name: this.name,
456
+ state: this.state,
457
+ failures: this.failures,
458
+ successes: this.successes,
459
+ totalCalls: this.totalCalls,
460
+ openedAt: this.openedAt,
461
+ lastCallAt: this.lastCallAt
462
+ };
345
463
  }
346
- parseFilterValue(value, operator) {
347
- if (operator === "in" || operator === "nin") {
348
- if (Array.isArray(value)) return value.map((v) => this.coerceValue(v));
349
- if (typeof value === "string" && value.includes(",")) return value.split(",").map((v) => this.coerceValue(v.trim()));
350
- return [this.coerceValue(value)];
351
- }
352
- if (operator === "like" || operator === "contains" || operator === "regex") return this.sanitizeRegex(String(value));
353
- if (operator === "exists") {
354
- const str = String(value).toLowerCase();
355
- return str === "true" || str === "1";
356
- }
357
- return this.coerceValue(value);
464
+ /**
465
+ * Get current state
466
+ */
467
+ getState() {
468
+ return this.state;
358
469
  }
359
- coerceValue(value) {
360
- if (value === "true") return true;
361
- if (value === "false") return false;
362
- if (value === "null") return null;
363
- if (typeof value === "string") {
364
- const num = Number(value);
365
- if (!Number.isNaN(num) && value.trim() !== "") return num;
366
- }
367
- return value;
470
+ /**
471
+ * Check if circuit is open
472
+ */
473
+ isOpen() {
474
+ return this.state === CircuitState.OPEN;
368
475
  }
369
476
  /**
370
- * Generate OpenAPI-compatible JSON Schema for query parameters.
371
- * Arc's defineResource() auto-detects this method and uses it
372
- * to document list endpoint query parameters in OpenAPI/Swagger.
477
+ * Check if circuit is closed
373
478
  */
374
- getQuerySchema() {
375
- const operatorLines = Object.entries(this.operators).map(([op, mongoOp]) => {
376
- return ` ${op} → ${mongoOp}: ${{
377
- eq: "Equal (default when no operator specified)",
378
- ne: "Not equal",
379
- gt: "Greater than",
380
- gte: "Greater than or equal",
381
- lt: "Less than",
382
- lte: "Less than or equal",
383
- in: "In list (comma-separated values)",
384
- nin: "Not in list",
385
- like: "Pattern match (case-insensitive)",
386
- contains: "Contains substring (case-insensitive)",
387
- regex: "Regex pattern",
388
- exists: "Field exists (true/false)"
389
- }[op] || op}`;
390
- });
391
- return {
392
- type: "object",
393
- properties: {
394
- page: {
395
- type: "integer",
396
- description: "Page number for offset pagination",
397
- default: 1,
398
- minimum: 1
399
- },
400
- limit: {
401
- type: "integer",
402
- description: "Number of items per page",
403
- default: this.defaultLimit,
404
- minimum: 1,
405
- maximum: this.maxLimit
406
- },
407
- sort: {
408
- type: "string",
409
- description: "Sort fields (comma-separated). Prefix with - for descending. Example: -createdAt,name"
410
- },
411
- search: {
412
- type: "string",
413
- description: "Full-text search query",
414
- maxLength: this.maxSearchLength
415
- },
416
- select: {
417
- type: "string",
418
- description: "Fields to include/exclude (comma-separated). Prefix with - to exclude. Example: name,email,-password"
419
- },
420
- populate: {
421
- type: "string",
422
- description: "Fields to populate/join (comma-separated). Example: author,category"
423
- },
424
- after: {
425
- type: "string",
426
- description: "Cursor value for keyset pagination"
427
- },
428
- _filterOperators: {
429
- type: "string",
430
- description: ["Available filter operators (use as field[operator]=value):", ...operatorLines].join("\n")
431
- }
432
- }
433
- };
479
+ isClosed() {
480
+ return this.state === CircuitState.CLOSED;
434
481
  }
435
- sanitizeRegex(pattern) {
436
- const truncated = pattern.length > this.maxRegexLength;
437
- let sanitized = pattern.slice(0, this.maxRegexLength);
438
- if (DANGEROUS_REGEX_PATTERNS.test(sanitized)) {
439
- sanitized = sanitized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
440
- log.warn(`regex pattern matched a ReDoS-dangerous shape and was escaped to a literal string. original="${pattern}" sanitized="${sanitized}"`);
441
- } else if (truncated) log.warn(`regex pattern exceeded maxRegexLength (${this.maxRegexLength}) and was truncated. original-length=${pattern.length}`);
442
- return sanitized;
482
+ /**
483
+ * Reset statistics
484
+ */
485
+ reset() {
486
+ this.failures = 0;
487
+ this.successes = 0;
488
+ this.totalCalls = 0;
489
+ this.lastCallAt = null;
490
+ this.openedAt = null;
491
+ this.setState(CircuitState.CLOSED);
443
492
  }
444
493
  };
445
494
  /**
446
- * Create a new ArcQueryParser instance
495
+ * Create a circuit breaker with sensible defaults
496
+ *
497
+ * @example
498
+ * const emailBreaker = createCircuitBreaker(
499
+ * async (to, subject, body) => sendEmail(to, subject, body),
500
+ * { name: 'email-service' }
501
+ * );
447
502
  */
448
- function createQueryParser(options) {
449
- return new ArcQueryParser(options);
503
+ function createCircuitBreaker(fn, options) {
504
+ return new CircuitBreaker(fn, options);
450
505
  }
451
- //#endregion
452
- //#region src/utils/responseSchemas.ts
453
- /**
454
- * Base success response schema
455
- */
456
- const successResponseSchema = {
457
- type: "object",
458
- properties: { success: {
459
- type: "boolean",
460
- example: true
461
- } },
462
- required: ["success"]
463
- };
464
- /**
465
- * Error response schema
466
- */
467
- const errorResponseSchema = {
468
- type: "object",
469
- properties: {
470
- success: {
471
- type: "boolean",
472
- example: false
473
- },
474
- error: {
475
- type: "string",
476
- description: "Error message"
477
- },
478
- code: {
479
- type: "string",
480
- description: "Error code"
481
- },
482
- message: {
483
- type: "string",
484
- description: "Detailed message"
485
- }
486
- },
487
- required: ["success", "error"]
488
- };
489
506
  /**
490
- * Pagination schema - matches MongoKit/Arc runtime format
491
- *
492
- * Runtime format (flat fields):
493
- * { page, limit, total, pages, hasNext, hasPrev }
507
+ * Circuit breaker registry for managing multiple breakers
494
508
  */
495
- const paginationSchema = {
496
- type: "object",
497
- properties: {
498
- page: {
499
- type: "integer",
500
- example: 1
501
- },
502
- limit: {
503
- type: "integer",
504
- example: 20
505
- },
506
- total: {
507
- type: "integer",
508
- example: 100
509
- },
510
- pages: {
511
- type: "integer",
512
- example: 5
513
- },
514
- hasNext: {
515
- type: "boolean",
516
- example: true
517
- },
518
- hasPrev: {
519
- type: "boolean",
520
- example: false
521
- }
522
- },
523
- required: [
524
- "page",
525
- "limit",
526
- "total",
527
- "pages",
528
- "hasNext",
529
- "hasPrev"
530
- ]
509
+ var CircuitBreakerRegistry = class {
510
+ breakers = /* @__PURE__ */ new Map();
511
+ /**
512
+ * Register a circuit breaker
513
+ */
514
+ register(name, fn, options) {
515
+ const breaker = new CircuitBreaker(fn, {
516
+ ...options,
517
+ name
518
+ });
519
+ this.breakers.set(name, breaker);
520
+ return breaker;
521
+ }
522
+ /**
523
+ * Get a circuit breaker by name
524
+ */
525
+ get(name) {
526
+ return this.breakers.get(name);
527
+ }
528
+ /**
529
+ * Get all breakers
530
+ */
531
+ getAll() {
532
+ return this.breakers;
533
+ }
534
+ /**
535
+ * Get statistics for all breakers
536
+ */
537
+ getAllStats() {
538
+ const stats = {};
539
+ for (const [name, breaker] of this.breakers.entries()) stats[name] = breaker.getStats();
540
+ return stats;
541
+ }
542
+ /**
543
+ * Reset all breakers
544
+ */
545
+ resetAll() {
546
+ for (const breaker of this.breakers.values()) breaker.reset();
547
+ }
548
+ /**
549
+ * Open all breakers
550
+ */
551
+ openAll() {
552
+ for (const breaker of this.breakers.values()) breaker.open();
553
+ }
554
+ /**
555
+ * Close all breakers
556
+ */
557
+ closeAll() {
558
+ for (const breaker of this.breakers.values()) breaker.close();
559
+ }
531
560
  };
532
561
  /**
533
- * Wrap a data schema in a success response
562
+ * Create a new CircuitBreakerRegistry instance.
563
+ * Use this instead of a global singleton — attach to fastify.arc or pass explicitly.
534
564
  */
535
- function wrapResponse(dataSchema) {
536
- return {
537
- type: "object",
538
- properties: {
539
- success: {
540
- type: "boolean",
541
- example: true
542
- },
543
- data: dataSchema
544
- },
545
- required: ["success", "data"],
546
- additionalProperties: true
547
- };
565
+ function createCircuitBreakerRegistry() {
566
+ return new CircuitBreakerRegistry();
548
567
  }
568
+ //#endregion
569
+ //#region src/utils/compensation.ts
549
570
  /**
550
- * Create a list response schema with pagination - matches MongoKit/Arc runtime format
551
- *
552
- * Runtime format:
553
- * { success, docs: [...], page, limit, total, pages, hasNext, hasPrev }
571
+ * Run steps in order with automatic compensation on failure.
554
572
  *
555
- * Note: Uses 'docs' array (not 'data') with flat pagination fields
573
+ * @typeParam TCtx - Context type shared across steps (defaults to Record<string, unknown>)
556
574
  */
557
- function listResponse(itemSchema) {
575
+ async function withCompensation(_name, steps, initialContext, hooks) {
576
+ const ctx = { ...initialContext };
577
+ const completedSteps = [];
578
+ const results = {};
579
+ const completed = [];
580
+ for (const step of steps) {
581
+ if (step.fireAndForget) {
582
+ completedSteps.push(step.name);
583
+ step.execute(ctx).then((result) => hooks?.onStepComplete?.(step.name, result), () => {});
584
+ continue;
585
+ }
586
+ try {
587
+ const result = await step.execute(ctx);
588
+ completedSteps.push(step.name);
589
+ results[step.name] = result;
590
+ completed.push({
591
+ step,
592
+ result
593
+ });
594
+ hooks?.onStepComplete?.(step.name, result);
595
+ } catch (err) {
596
+ const error = err instanceof Error ? err : new Error(String(err));
597
+ hooks?.onStepFailed?.(step.name, error);
598
+ const compensationErrors = await rollback(ctx, completed, hooks);
599
+ return {
600
+ success: false,
601
+ completedSteps,
602
+ results,
603
+ failedStep: step.name,
604
+ error: error.message,
605
+ ...compensationErrors.length > 0 ? { compensationErrors } : {}
606
+ };
607
+ }
608
+ }
558
609
  return {
559
- type: "object",
560
- properties: {
561
- success: {
562
- type: "boolean",
563
- example: true
564
- },
565
- docs: {
566
- type: "array",
567
- items: itemSchema
568
- },
569
- page: {
570
- type: "integer",
571
- example: 1
572
- },
573
- limit: {
574
- type: "integer",
575
- example: 20
576
- },
577
- total: {
578
- type: "integer",
579
- example: 100
580
- },
581
- pages: {
582
- type: "integer",
583
- example: 5
584
- },
585
- hasNext: {
586
- type: "boolean",
587
- example: false
588
- },
589
- hasPrev: {
590
- type: "boolean",
591
- example: false
592
- }
593
- },
594
- required: ["success", "docs"],
595
- additionalProperties: true
610
+ success: true,
611
+ completedSteps,
612
+ results
613
+ };
614
+ }
615
+ async function rollback(ctx, completed, hooks) {
616
+ const errors = [];
617
+ for (let i = completed.length - 1; i >= 0; i--) {
618
+ const entry = completed[i];
619
+ if (!entry?.step.compensate) continue;
620
+ const compensateFn = entry.step.compensate;
621
+ try {
622
+ await compensateFn(ctx, entry.result);
623
+ hooks?.onCompensate?.(entry.step.name);
624
+ } catch (err) {
625
+ errors.push({
626
+ step: entry.step.name,
627
+ error: err instanceof Error ? err.message : String(err)
628
+ });
629
+ }
630
+ }
631
+ return errors;
632
+ }
633
+ function defineCompensation(name, steps) {
634
+ return {
635
+ name,
636
+ execute: (initialContext, hooks) => withCompensation(name, steps, initialContext, hooks)
596
637
  };
597
638
  }
639
+ //#endregion
640
+ //#region src/utils/defineErrorMapper.ts
598
641
  /**
599
- * Create a single item response schema
642
+ * Register an `ErrorMapper` with its domain-specific generic argument and
643
+ * have it assign cleanly into `ErrorMapper[]` (no `as unknown as ErrorMapper`).
644
+ *
645
+ * The returned mapper is identical at runtime — `type` and `toResponse` are
646
+ * passed through untouched. Only the declared type widens from
647
+ * `ErrorMapper<T>` to `ErrorMapper` so the array inference works.
600
648
  *
601
- * Runtime format: { success, data: {...} }
649
+ * Safety: the `errorHandlerPlugin` dispatches via `error instanceof mapper.type`
650
+ * before invoking `toResponse`, so the widened callback signature is never
651
+ * called with a non-`T` error at runtime. This helper codifies that invariant
652
+ * in one place.
602
653
  */
603
- function itemResponse(itemSchema) {
604
- return wrapResponse(itemSchema);
654
+ function defineErrorMapper(mapper) {
655
+ return mapper;
605
656
  }
657
+ //#endregion
658
+ //#region src/utils/defineGuard.ts
659
+ /** Hidden property key for guard context storage on the request object. */
660
+ const GUARD_STORE_KEY = "__arcGuardContext";
606
661
  /**
607
- * Create a create/update response schema
662
+ * Create a typed guard. See module JSDoc for usage.
608
663
  */
609
- function mutationResponse(itemSchema) {
664
+ function defineGuard(config) {
665
+ const { name, resolve } = config;
666
+ const preHandler = async (req, reply) => {
667
+ const ctx = await resolve(req, reply);
668
+ if (!reply.sent) {
669
+ const store = req[GUARD_STORE_KEY] ?? {};
670
+ store[name] = ctx;
671
+ req[GUARD_STORE_KEY] = store;
672
+ }
673
+ };
610
674
  return {
611
- type: "object",
612
- properties: {
613
- success: {
614
- type: "boolean",
615
- example: true
616
- },
617
- data: itemSchema,
618
- message: {
619
- type: "string",
620
- example: "Created successfully"
621
- }
622
- },
623
- required: ["success", "data"],
624
- additionalProperties: true
675
+ preHandler,
676
+ name,
677
+ from(req) {
678
+ const store = req[GUARD_STORE_KEY];
679
+ 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.`);
680
+ return store[name];
681
+ }
625
682
  };
626
683
  }
684
+ //#endregion
685
+ //#region src/utils/handleRaw.ts
627
686
  /**
628
- * Create a delete response schema
687
+ * Wrap a raw Fastify handler with Arc's response shape and error handling.
629
688
  *
630
- * Runtime format: { success, data: { message, id?, soft? } }
689
+ * @param handler - Async function that receives `(request, reply)` and returns data.
690
+ * The return value is sent raw (no envelope). If it returns `undefined`,
691
+ * the response body is empty (HTTP status only).
692
+ * @param statusCode - HTTP status code for successful responses (default: 200)
631
693
  */
632
- function deleteResponse() {
633
- return {
634
- type: "object",
635
- properties: {
636
- success: {
637
- type: "boolean",
638
- example: true
639
- },
640
- data: {
641
- type: "object",
642
- properties: {
643
- message: {
644
- type: "string",
645
- example: "Deleted successfully"
646
- },
647
- id: {
648
- type: "string",
649
- example: "507f1f77bcf86cd799439011"
650
- },
651
- soft: {
652
- type: "boolean",
653
- example: false
654
- }
655
- },
656
- required: ["message"]
694
+ function handleRaw(handler, statusCode = 200) {
695
+ return async (request, reply) => {
696
+ try {
697
+ const result = await handler(request, reply);
698
+ if (reply.sent) return;
699
+ if (result === void 0 || result === null) reply.code(statusCode).send();
700
+ else reply.code(statusCode).send(result);
701
+ } catch (err) {
702
+ if (reply.sent) return;
703
+ if (err instanceof ArcError) {
704
+ reply.code(err.statusCode).send({
705
+ error: err.message,
706
+ code: err.code,
707
+ ...err.details ? { details: err.details } : {}
708
+ });
709
+ return;
657
710
  }
658
- },
659
- required: ["success"],
660
- additionalProperties: true
661
- };
662
- }
663
- const responses = {
664
- 200: (schema) => ({
665
- description: "Successful response",
666
- content: { "application/json": { schema } }
667
- }),
668
- 201: (schema) => ({
669
- description: "Created successfully",
670
- content: { "application/json": { schema: mutationResponse(schema) } }
671
- }),
672
- 400: {
673
- description: "Bad Request",
674
- content: { "application/json": { schema: {
675
- ...errorResponseSchema,
676
- properties: {
677
- ...errorResponseSchema.properties,
678
- code: {
679
- type: "string",
680
- example: "VALIDATION_ERROR"
681
- },
682
- details: {
683
- type: "object",
684
- properties: { errors: {
685
- type: "array",
686
- items: {
687
- type: "object",
688
- properties: {
689
- field: { type: "string" },
690
- message: { type: "string" }
691
- }
692
- }
693
- } }
694
- }
695
- }
696
- } } }
697
- },
698
- 401: {
699
- description: "Unauthorized",
700
- content: { "application/json": { schema: {
701
- ...errorResponseSchema,
702
- properties: {
703
- ...errorResponseSchema.properties,
704
- code: {
705
- type: "string",
706
- example: "UNAUTHORIZED"
707
- }
708
- }
709
- } } }
710
- },
711
- 403: {
712
- description: "Forbidden",
713
- content: { "application/json": { schema: {
714
- ...errorResponseSchema,
715
- properties: {
716
- ...errorResponseSchema.properties,
717
- code: {
718
- type: "string",
719
- example: "FORBIDDEN"
720
- }
721
- }
722
- } } }
723
- },
724
- 404: {
725
- description: "Not Found",
726
- content: { "application/json": { schema: {
727
- ...errorResponseSchema,
728
- properties: {
729
- ...errorResponseSchema.properties,
730
- code: {
731
- type: "string",
732
- example: "NOT_FOUND"
733
- }
734
- }
735
- } } }
736
- },
737
- 409: {
738
- description: "Conflict",
739
- content: { "application/json": { schema: {
740
- ...errorResponseSchema,
741
- properties: {
742
- ...errorResponseSchema.properties,
743
- code: {
744
- type: "string",
745
- example: "CONFLICT"
746
- }
747
- }
748
- } } }
749
- },
750
- 500: {
751
- description: "Internal Server Error",
752
- content: { "application/json": { schema: {
753
- ...errorResponseSchema,
754
- properties: {
755
- ...errorResponseSchema.properties,
756
- code: {
757
- type: "string",
758
- example: "INTERNAL_ERROR"
759
- }
760
- }
761
- } } }
762
- }
763
- };
764
- const queryParams = {
765
- pagination: {
766
- page: {
767
- type: "integer",
768
- minimum: 1,
769
- default: 1,
770
- description: "Page number"
771
- },
772
- limit: {
773
- type: "integer",
774
- minimum: 1,
775
- maximum: 100,
776
- default: 20,
777
- description: "Items per page"
778
- }
779
- },
780
- sorting: { sort: {
781
- type: "string",
782
- description: "Sort field (prefix with - for descending)",
783
- example: "-createdAt"
784
- } },
785
- filtering: {
786
- select: {
787
- description: "Fields to include (space-separated or object)",
788
- example: "name email createdAt"
789
- },
790
- populate: {
791
- description: "Relations to populate (comma-separated string or bracket-notation object)",
792
- example: "author,category"
793
- }
794
- }
795
- };
796
- /**
797
- * Get standard list query parameters schema
798
- */
799
- function getListQueryParams() {
800
- return {
801
- type: "object",
802
- properties: {
803
- ...queryParams.pagination,
804
- ...queryParams.sorting,
805
- ...queryParams.filtering
806
- },
807
- additionalProperties: true
808
- };
809
- }
810
- /**
811
- * Generic item schema that allows any properties.
812
- * Used as default when no user schema is provided.
813
- * Enables fast-json-stringify while still passing through all fields.
814
- */
815
- const genericItemSchema = {
816
- type: "object",
817
- additionalProperties: true
818
- };
819
- /**
820
- * Recursively strip `example` keys from a schema object.
821
- * The `example` keyword is OpenAPI metadata — not standard JSON Schema —
822
- * and triggers Ajv strict mode errors when used on routes without the
823
- * `keywords: ['example']` AJV config (e.g., raw Fastify without createApp).
824
- */
825
- function stripExamples(schema) {
826
- if (schema === null || typeof schema !== "object") return schema;
827
- if (Array.isArray(schema)) return schema.map(stripExamples);
828
- const result = {};
829
- for (const [key, value] of Object.entries(schema)) {
830
- if (key === "example") continue;
831
- result[key] = stripExamples(value);
832
- }
833
- return result;
834
- }
835
- /**
836
- * Get default response schemas for all CRUD operations.
837
- *
838
- * When routes have response schemas, Fastify compiles them with
839
- * fast-json-stringify for 2-3x faster serialization and prevents
840
- * accidental field disclosure.
841
- *
842
- * These defaults use `additionalProperties: true` so all fields pass through.
843
- * Override with specific schemas for full serialization performance + safety.
844
- *
845
- * Note: `example` properties are stripped from defaults so they work with
846
- * any Fastify instance (not just createApp which adds `keywords: ['example']`).
847
- */
848
- function getDefaultCrudSchemas() {
849
- return stripExamples({
850
- list: {
851
- querystring: getListQueryParams(),
852
- response: { 200: listResponse(genericItemSchema) }
853
- },
854
- get: { response: { 200: itemResponse(genericItemSchema) } },
855
- create: { response: { 201: mutationResponse(genericItemSchema) } },
856
- update: { response: { 200: itemResponse(genericItemSchema) } },
857
- delete: { response: { 200: deleteResponse() } }
858
- });
859
- }
860
- //#endregion
861
- //#region src/core/validateResourceConfig.ts
862
- /**
863
- * Resource Configuration Validator
864
- *
865
- * Fail-fast validation at definition time.
866
- * Invalid configs throw immediately with clear, actionable errors.
867
- *
868
- * @example
869
- * const result = validateResourceConfig(config);
870
- * if (!result.valid) {
871
- * console.error(formatValidationErrors(result.errors));
872
- * }
873
- */
874
- /**
875
- * Validate a resource configuration
876
- */
877
- function validateResourceConfig(config, options = {}) {
878
- const errors = [];
879
- const warnings = [];
880
- if (!config.name) errors.push({
881
- field: "name",
882
- message: "Resource name is required",
883
- suggestion: "Add a unique resource name (e.g., \"product\", \"user\")"
884
- });
885
- else if (!/^[a-z][a-z0-9-]*$/i.test(config.name)) errors.push({
886
- field: "name",
887
- message: `Invalid resource name "${config.name}"`,
888
- suggestion: "Use alphanumeric characters and hyphens, starting with a letter"
889
- });
890
- const crudRoutes = CRUD_OPERATIONS;
891
- const disabledRoutes = new Set(config.disabledRoutes ?? []);
892
- const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
893
- if (!config.disableDefaultRoutes && enabledCrudRoutes.length > 0) {
894
- if (!config.adapter) errors.push({
895
- field: "adapter",
896
- message: "Data adapter is required when CRUD routes are enabled",
897
- suggestion: "Provide an adapter: createMongooseAdapter({ model, repository })"
898
- });
899
- else if (!config.adapter.repository) errors.push({
900
- field: "adapter.repository",
901
- message: "Adapter must provide a repository",
902
- suggestion: "Ensure your adapter returns a valid StandardRepo (see @classytic/repo-core)"
903
- });
904
- } else if (!config.adapter && !config.routes?.length) warnings.push({
905
- field: "config",
906
- message: "Resource has no adapter and no routes",
907
- suggestion: "Provide either adapter for CRUD or routes for custom logic"
908
- });
909
- if (config.controller && !options.skipControllerCheck && !config.disableDefaultRoutes) {
910
- const ctrl = config.controller;
911
- const requiredMethods = CRUD_OPERATIONS;
912
- for (const method of requiredMethods) if (typeof ctrl[method] !== "function") errors.push({
913
- field: `controller.${method}`,
914
- message: `Missing required CRUD method "${method}"`,
915
- suggestion: "Extend BaseController which implements IController interface"
916
- });
917
- }
918
- if (config.controller && config.routes) validateRouteHandlers(config.controller, config.routes, errors);
919
- if (config.permissions) validatePermissionKeys(config, options, errors, warnings);
920
- if (config.presets && !options.allowUnknownPresets) validatePresets(config.presets, errors, warnings);
921
- if (config.prefix) {
922
- if (!config.prefix.startsWith("/")) errors.push({
923
- field: "prefix",
924
- message: `Prefix must start with "/" (got "${config.prefix}")`,
925
- suggestion: `Change to "/${config.prefix}"`
926
- });
927
- if (config.prefix.endsWith("/") && config.prefix !== "/") warnings.push({
928
- field: "prefix",
929
- message: `Prefix should not end with "/" (got "${config.prefix}")`,
930
- suggestion: `Change to "${config.prefix.slice(0, -1)}"`
931
- });
932
- }
933
- if (config.routes) validateRoutes(config.routes, errors);
934
- return {
935
- valid: errors.length === 0,
936
- errors,
937
- warnings
938
- };
939
- }
940
- function validateRouteHandlers(controller, routes, errors) {
941
- const ctrl = controller;
942
- for (const route of routes) if (typeof route.handler === "string") {
943
- if (typeof ctrl[route.handler] !== "function") errors.push({
944
- field: `routes[${route.method} ${route.path}]`,
945
- message: `Handler "${route.handler}" not found on controller`,
946
- suggestion: `Add method "${route.handler}" to controller or use a function handler`
947
- });
948
- }
949
- }
950
- function validatePermissionKeys(config, options, _errors, warnings) {
951
- const validKeys = new Set([...CRUD_OPERATIONS, ...options.additionalPermissionKeys ?? []]);
952
- for (const route of config.routes ?? []) if (typeof route.handler === "string") validKeys.add(route.handler);
953
- for (const preset of config.presets ?? []) {
954
- const presetName = typeof preset === "string" ? preset : preset.name;
955
- if (presetName === "softDelete") {
956
- validKeys.add("deleted");
957
- validKeys.add("restore");
958
- }
959
- if (presetName === "slugLookup") validKeys.add("getBySlug");
960
- if (presetName === "tree") {
961
- validKeys.add("tree");
962
- validKeys.add("children");
963
- validKeys.add("getTree");
964
- validKeys.add("getChildren");
965
- }
966
- }
967
- for (const key of Object.keys(config.permissions ?? {})) if (!validKeys.has(key)) warnings.push({
968
- field: `permissions.${key}`,
969
- message: `Unknown permission key "${key}"`,
970
- suggestion: `Valid keys: ${Array.from(validKeys).join(", ")}`
971
- });
972
- }
973
- function validatePresets(presets, errors, warnings) {
974
- const availablePresets = getAvailablePresets();
975
- for (const preset of presets) {
976
- if (typeof preset === "object" && ("middlewares" in preset || "routes" in preset)) continue;
977
- const presetName = typeof preset === "string" ? preset : preset.name;
978
- if (!availablePresets.includes(presetName)) errors.push({
979
- field: "presets",
980
- message: `Unknown preset "${presetName}"`,
981
- suggestion: `Available presets: ${availablePresets.join(", ")}`
982
- });
983
- if (typeof preset === "object") validatePresetOptions(preset, warnings);
984
- }
985
- }
986
- function validatePresetOptions(preset, warnings) {
987
- const validOptions = {
988
- slugLookup: ["slugField"],
989
- tree: ["parentField"],
990
- softDelete: ["deletedField"],
991
- ownedByUser: ["ownerField"],
992
- multiTenant: ["tenantField", "allowPublic"]
993
- }[preset.name] ?? [];
994
- const providedOptions = Object.keys(preset).filter((k) => k !== "name");
995
- for (const opt of providedOptions) if (!validOptions.includes(opt)) warnings.push({
996
- field: `presets[${preset.name}].${opt}`,
997
- message: `Unknown option "${opt}" for preset "${preset.name}"`,
998
- suggestion: validOptions.length > 0 ? `Valid options: ${validOptions.join(", ")}` : `Preset "${preset.name}" has no configurable options`
999
- });
1000
- }
1001
- function validateRoutes(routes, errors) {
1002
- const validMethods = [
1003
- "GET",
1004
- "POST",
1005
- "PUT",
1006
- "PATCH",
1007
- "DELETE",
1008
- "OPTIONS",
1009
- "HEAD"
1010
- ];
1011
- const seenRoutes = /* @__PURE__ */ new Set();
1012
- for (const [i, route] of routes.entries()) {
1013
- if (!validMethods.includes(route.method)) errors.push({
1014
- field: `routes[${i}].method`,
1015
- message: `Invalid HTTP method "${route.method}"`,
1016
- suggestion: `Valid methods: ${validMethods.join(", ")}`
1017
- });
1018
- if (!route.path) errors.push({
1019
- field: `routes[${i}].path`,
1020
- message: "Route path is required"
1021
- });
1022
- else if (!route.path.startsWith("/")) errors.push({
1023
- field: `routes[${i}].path`,
1024
- message: `Route path must start with "/" (got "${route.path}")`,
1025
- suggestion: `Change to "/${route.path}"`
1026
- });
1027
- if (!route.handler) errors.push({
1028
- field: `routes[${i}].handler`,
1029
- message: "Route handler is required"
1030
- });
1031
- const routeKey = `${route.method} ${route.path}`;
1032
- if (seenRoutes.has(routeKey)) errors.push({
1033
- field: `routes[${i}]`,
1034
- message: `Duplicate route "${routeKey}"`
1035
- });
1036
- seenRoutes.add(routeKey);
1037
- }
1038
- }
1039
- /**
1040
- * Format validation errors for display
1041
- */
1042
- function formatValidationErrors(resourceName, result) {
1043
- const lines = [];
1044
- if (result.errors.length > 0) {
1045
- lines.push(`Resource "${resourceName}" validation failed:`);
1046
- lines.push("");
1047
- lines.push("ERRORS:");
1048
- for (const err of result.errors) {
1049
- lines.push(` ✗ ${err.field}: ${err.message}`);
1050
- if (err.suggestion) lines.push(` → ${err.suggestion}`);
1051
- }
1052
- }
1053
- if (result.warnings.length > 0) {
1054
- if (lines.length > 0) lines.push("");
1055
- lines.push("WARNINGS:");
1056
- for (const warn of result.warnings) {
1057
- lines.push(` ⚠ ${warn.field}: ${warn.message}`);
1058
- if (warn.suggestion) lines.push(` → ${warn.suggestion}`);
711
+ const error = err;
712
+ const code = error.statusCode ?? error.status ?? 500;
713
+ reply.code(code).send({
714
+ error: error.message ?? "Internal server error",
715
+ ...error.code && { code: error.code }
716
+ });
1059
717
  }
1060
- }
1061
- return lines.join("\n");
1062
- }
1063
- /**
1064
- * Validate and throw if invalid
1065
- */
1066
- function assertValidConfig(config, options) {
1067
- const result = validateResourceConfig(config, options);
1068
- if (!result.valid) {
1069
- const errorMsg = formatValidationErrors(config.name ?? "unknown", result);
1070
- throw new Error(errorMsg);
1071
- }
718
+ };
1072
719
  }
1073
720
  //#endregion
1074
- //#region src/utils/circuitBreaker.ts
721
+ //#region src/utils/queryParser.ts
1075
722
  /**
1076
- * Circuit Breaker Pattern
1077
- *
1078
- * Wraps external service calls with failure protection.
1079
- * Prevents cascading failures by "opening" the circuit when
1080
- * a service is failing, allowing it time to recover.
723
+ * Arc Query Parser - Default URL-to-Query Parser
1081
724
  *
1082
- * States:
1083
- * - CLOSED: Normal operation, requests pass through
1084
- * - OPEN: Too many failures, all requests fail fast
1085
- * - HALF_OPEN: Testing if service recovered, limited requests
725
+ * Framework-agnostic query parser that converts URL parameters to query options.
726
+ * This is Arc's built-in parser; users can swap in MongoKit's QueryParser,
727
+ * pgkit's parser, or any custom parser implementing QueryParserInterface.
1086
728
  *
1087
729
  * @example
1088
- * import { CircuitBreaker } from '@classytic/arc/utils';
730
+ * // Use Arc default parser (auto-applied if no queryParser option)
731
+ * defineResource({ name: 'product', adapter: ... });
1089
732
  *
1090
- * const paymentBreaker = new CircuitBreaker(async (amount) => {
1091
- * return await stripe.charges.create({ amount });
1092
- * }, {
1093
- * failureThreshold: 5,
1094
- * resetTimeout: 30000,
1095
- * timeout: 5000,
733
+ * // Use MongoKit's QueryParser (recommended for MongoDB - has $lookup, aggregations, etc.)
734
+ * import { QueryParser } from '@classytic/mongokit';
735
+ * defineResource({
736
+ * name: 'product',
737
+ * adapter: ...,
738
+ * queryParser: new QueryParser(),
1096
739
  * });
1097
740
  *
1098
- * try {
1099
- * const result = await paymentBreaker.call(100);
1100
- * } catch (error) {
1101
- * // Handle failure or circuit open
1102
- * }
741
+ * // Use custom parser for SQL databases
742
+ * defineResource({
743
+ * name: 'user',
744
+ * adapter: ...,
745
+ * queryParser: new PgQueryParser(),
746
+ * });
1103
747
  */
1104
- const CircuitState = {
1105
- CLOSED: "CLOSED",
1106
- OPEN: "OPEN",
1107
- HALF_OPEN: "HALF_OPEN"
1108
- };
1109
- var CircuitBreakerError = class extends Error {
1110
- state;
1111
- constructor(message, state) {
1112
- super(message);
1113
- this.name = "CircuitBreakerError";
1114
- this.state = state;
748
+ const log = arcLog("queryParser");
749
+ /**
750
+ * Regex patterns that can cause catastrophic backtracking (ReDoS attacks)
751
+ * Detects:
752
+ * - Quantifiers: {n,m}
753
+ * - Possessive quantifiers: *+, ++, ?+
754
+ * - Nested quantifiers: (a+)+, (a*)*
755
+ * - Backreferences: \1, \2, etc.
756
+ */
757
+ const DANGEROUS_REGEX_PATTERNS = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
758
+ /**
759
+ * Arc's default query parser
760
+ *
761
+ * Converts URL query parameters to a structured query format:
762
+ * - Pagination: ?page=1&limit=20
763
+ * - Sorting: ?sort=-createdAt,name (- prefix = descending)
764
+ * - Filtering: ?status=active&price[gte]=100&price[lte]=500
765
+ * - Search: ?search=keyword
766
+ * - Populate: ?populate=author,category
767
+ * - Field selection: ?select=name,price,status
768
+ * - Keyset pagination: ?after=cursor_value
769
+ *
770
+ * For advanced MongoDB features ($lookup, aggregations), use MongoKit's QueryParser.
771
+ */
772
+ var ArcQueryParser = class {
773
+ maxLimit;
774
+ defaultLimit;
775
+ maxRegexLength;
776
+ maxSearchLength;
777
+ maxFilterDepth;
778
+ _allowedFilterFields;
779
+ _allowedSortFields;
780
+ _allowedOperators;
781
+ /** Allowed filter fields (used by MCP for auto-derive) */
782
+ allowedFilterFields;
783
+ /** Allowed sort fields (used by MCP for sort descriptions) */
784
+ allowedSortFields;
785
+ /** Allowed operators (used by MCP for operator descriptions) */
786
+ allowedOperators;
787
+ /** Supported filter operators */
788
+ operators = {
789
+ eq: "$eq",
790
+ ne: "$ne",
791
+ gt: "$gt",
792
+ gte: "$gte",
793
+ lt: "$lt",
794
+ lte: "$lte",
795
+ in: "$in",
796
+ nin: "$nin",
797
+ like: "$regex",
798
+ contains: "$regex",
799
+ regex: "$regex",
800
+ exists: "$exists"
801
+ };
802
+ constructor(options = {}) {
803
+ this.maxLimit = options.maxLimit ?? 1e3;
804
+ this.defaultLimit = options.defaultLimit ?? 20;
805
+ this.maxRegexLength = options.maxRegexLength ?? 200;
806
+ this.maxSearchLength = options.maxSearchLength ?? 200;
807
+ this.maxFilterDepth = options.maxFilterDepth ?? 10;
808
+ if (options.allowedFilterFields) {
809
+ this._allowedFilterFields = new Set(options.allowedFilterFields);
810
+ this.allowedFilterFields = options.allowedFilterFields;
811
+ }
812
+ if (options.allowedSortFields) {
813
+ this._allowedSortFields = new Set(options.allowedSortFields);
814
+ this.allowedSortFields = options.allowedSortFields;
815
+ }
816
+ if (options.allowedOperators) {
817
+ this._allowedOperators = new Set(options.allowedOperators);
818
+ this.allowedOperators = options.allowedOperators;
819
+ }
1115
820
  }
1116
- };
1117
- var CircuitBreaker = class {
1118
- state = CircuitState.CLOSED;
1119
- failures = 0;
1120
- successes = 0;
1121
- totalCalls = 0;
1122
- nextAttempt = 0;
1123
- lastCallAt = null;
1124
- openedAt = null;
1125
- failureThreshold;
1126
- resetTimeout;
1127
- timeout;
1128
- successThreshold;
1129
- fallback;
1130
- onStateChange;
1131
- onError;
1132
- name;
1133
- fn;
1134
- constructor(fn, options = {}) {
1135
- this.fn = fn;
1136
- this.failureThreshold = options.failureThreshold ?? 5;
1137
- this.resetTimeout = options.resetTimeout ?? 6e4;
1138
- this.timeout = options.timeout ?? 1e4;
1139
- this.successThreshold = options.successThreshold ?? 1;
1140
- this.fallback = options.fallback;
1141
- this.onStateChange = options.onStateChange;
1142
- this.onError = options.onError;
1143
- this.name = options.name ?? "CircuitBreaker";
821
+ /**
822
+ * Parse URL query parameters into structured query options
823
+ */
824
+ parse(query) {
825
+ const q = query ?? {};
826
+ const page = this.parseNumber(q.page, 1);
827
+ const limit = Math.min(this.parseNumber(q.limit, this.defaultLimit), this.maxLimit);
828
+ const after = this.parseString(q.after ?? q.cursor);
829
+ const sort = this.parseSort(q.sort);
830
+ const { populate, populateOptions } = this.parsePopulate(q.populate);
831
+ const search = this.parseSearch(q.search);
832
+ const select = this.parseSelect(q.select);
833
+ return {
834
+ filters: this.parseFilters(q),
835
+ limit,
836
+ sort,
837
+ populate,
838
+ populateOptions,
839
+ search,
840
+ page: after ? void 0 : page,
841
+ after,
842
+ select
843
+ };
844
+ }
845
+ parseNumber(value, defaultValue) {
846
+ if (value === void 0 || value === null) return defaultValue;
847
+ const num = parseInt(String(value), 10);
848
+ return Number.isNaN(num) ? defaultValue : Math.max(1, num);
849
+ }
850
+ parseString(value) {
851
+ if (value === void 0 || value === null) return void 0;
852
+ const str = String(value).trim();
853
+ return str.length > 0 ? str : void 0;
1144
854
  }
1145
855
  /**
1146
- * Call the wrapped function with circuit breaker protection
856
+ * Parse populate parameter handles both simple string and bracket notation.
857
+ *
858
+ * Simple: ?populate=author,category → { populate: 'author,category' }
859
+ * Bracket: ?populate[author][select]=name,email → { populateOptions: [{ path: 'author', select: 'name email' }] }
1147
860
  */
1148
- async call(...args) {
1149
- this.totalCalls++;
1150
- this.lastCallAt = Date.now();
1151
- if (this.state === CircuitState.OPEN) {
1152
- if (Date.now() < this.nextAttempt) {
1153
- const error = new CircuitBreakerError(`Circuit breaker is OPEN for ${this.name}`, CircuitState.OPEN);
1154
- if (this.fallback) return this.fallback(...args);
1155
- throw error;
861
+ parsePopulate(value) {
862
+ if (value === void 0 || value === null) return {};
863
+ if (typeof value === "string") {
864
+ const trimmed = value.trim();
865
+ return trimmed.length > 0 ? { populate: trimmed } : {};
866
+ }
867
+ if (typeof value === "object" && !Array.isArray(value)) {
868
+ const obj = value;
869
+ const keys = Object.keys(obj);
870
+ if (keys.length === 0) return {};
871
+ const options = [];
872
+ for (const path of keys) {
873
+ if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(path)) continue;
874
+ const config = obj[path];
875
+ if (typeof config === "object" && config !== null && !Array.isArray(config)) {
876
+ const cfg = config;
877
+ const option = { path };
878
+ if (typeof cfg.select === "string") option.select = cfg.select.split(",").map((s) => s.trim()).filter(Boolean).join(" ");
879
+ if (typeof cfg.match === "object" && cfg.match !== null) option.match = cfg.match;
880
+ options.push(option);
881
+ } else options.push({ path });
1156
882
  }
1157
- this.setState(CircuitState.HALF_OPEN);
883
+ return options.length > 0 ? { populateOptions: options } : {};
1158
884
  }
1159
- try {
1160
- const result = await this.executeWithTimeout(args);
1161
- this.onSuccess();
1162
- return result;
1163
- } catch (err) {
1164
- this.onFailure(err instanceof Error ? err : new Error(String(err)));
1165
- throw err;
885
+ return {};
886
+ }
887
+ parseSort(value) {
888
+ if (!value) return void 0;
889
+ const sortStr = String(value);
890
+ const result = {};
891
+ for (const field of sortStr.split(",")) {
892
+ const trimmed = field.trim();
893
+ if (!trimmed) continue;
894
+ if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
895
+ const fieldName = trimmed.startsWith("-") ? trimmed.slice(1) : trimmed;
896
+ if (this._allowedSortFields && !this._allowedSortFields.has(fieldName)) continue;
897
+ if (trimmed.startsWith("-")) result[fieldName] = -1;
898
+ else result[fieldName] = 1;
1166
899
  }
900
+ return Object.keys(result).length > 0 ? result : void 0;
1167
901
  }
1168
- /**
1169
- * Execute function with timeout
1170
- */
1171
- async executeWithTimeout(args) {
1172
- return new Promise((resolve, reject) => {
1173
- const timeoutId = setTimeout(() => {
1174
- reject(/* @__PURE__ */ new Error(`Request timeout after ${this.timeout}ms`));
1175
- }, this.timeout);
1176
- this.fn(...args).then((result) => {
1177
- clearTimeout(timeoutId);
1178
- resolve(result);
1179
- }).catch((error) => {
1180
- clearTimeout(timeoutId);
1181
- reject(error);
1182
- });
1183
- });
902
+ parseSearch(value) {
903
+ if (!value) return void 0;
904
+ const search = String(value).trim();
905
+ if (search.length === 0) return void 0;
906
+ if (search.length > this.maxSearchLength) return search.slice(0, this.maxSearchLength);
907
+ return search;
1184
908
  }
1185
- /**
1186
- * Handle successful call
1187
- */
1188
- onSuccess() {
1189
- this.failures = 0;
1190
- this.successes++;
1191
- if (this.state === CircuitState.HALF_OPEN) {
1192
- if (this.successes >= this.successThreshold) {
1193
- this.setState(CircuitState.CLOSED);
1194
- this.successes = 0;
1195
- }
909
+ parseSelect(value) {
910
+ if (!value) return void 0;
911
+ const selectStr = String(value);
912
+ const result = {};
913
+ for (const field of selectStr.split(",")) {
914
+ const trimmed = field.trim();
915
+ if (!trimmed) continue;
916
+ if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
917
+ if (trimmed.startsWith("-")) result[trimmed.slice(1)] = 0;
918
+ else result[trimmed] = 1;
1196
919
  }
920
+ return Object.keys(result).length > 0 ? result : void 0;
1197
921
  }
1198
922
  /**
1199
- * Handle failed call
923
+ * Check if a value exceeds the maximum nesting depth.
924
+ * Prevents filter bombs where deeply nested objects consume excessive memory/CPU.
1200
925
  */
1201
- onFailure(error) {
1202
- this.failures++;
1203
- this.successes = 0;
1204
- if (this.onError) this.onError(error);
1205
- if (this.state === CircuitState.HALF_OPEN || this.failures >= this.failureThreshold) {
1206
- this.setState(CircuitState.OPEN);
1207
- this.nextAttempt = Date.now() + this.resetTimeout;
1208
- this.openedAt = Date.now();
1209
- }
926
+ exceedsDepth(obj, currentDepth = 0) {
927
+ if (currentDepth > this.maxFilterDepth) return true;
928
+ if (obj === null || obj === void 0) return false;
929
+ if (Array.isArray(obj)) return obj.some((v) => this.exceedsDepth(v, currentDepth));
930
+ if (typeof obj !== "object") return false;
931
+ return Object.values(obj).some((v) => this.exceedsDepth(v, currentDepth + 1));
1210
932
  }
1211
- /**
1212
- * Change circuit state
1213
- */
1214
- setState(newState) {
1215
- const oldState = this.state;
1216
- if (oldState !== newState) {
1217
- this.state = newState;
1218
- if (this.onStateChange) this.onStateChange(oldState, newState);
933
+ parseFilters(query) {
934
+ const filters = {};
935
+ for (const [key, value] of Object.entries(query)) {
936
+ if (RESERVED_QUERY_PARAMS.has(key)) continue;
937
+ if (value === void 0 || value === null) continue;
938
+ if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(key)) continue;
939
+ if (this._allowedFilterFields && !this._allowedFilterFields.has(key)) continue;
940
+ if (this.exceedsDepth(value)) continue;
941
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
942
+ const operatorObj = value;
943
+ const operatorKeys = Object.keys(operatorObj);
944
+ const allOperators = operatorKeys.every((op) => this.operators[op] && (!this._allowedOperators || this._allowedOperators.has(op)));
945
+ const allKnownOperators = operatorKeys.every((op) => this.operators[op]);
946
+ if (allOperators && operatorKeys.length > 0) {
947
+ const mongoFilters = {};
948
+ let needsCaseInsensitive = false;
949
+ for (const [op, opValue] of Object.entries(operatorObj)) {
950
+ const mongoOp = this.operators[op];
951
+ if (mongoOp) {
952
+ mongoFilters[mongoOp] = this.parseFilterValue(opValue, op);
953
+ if (op === "contains" || op === "like") needsCaseInsensitive = true;
954
+ }
955
+ }
956
+ if (needsCaseInsensitive) mongoFilters.$options = "i";
957
+ filters[key] = mongoFilters;
958
+ continue;
959
+ }
960
+ if (allKnownOperators && this._allowedOperators) continue;
961
+ }
962
+ const match = key.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)(?:\[([a-z]+)\])?$/);
963
+ if (!match) continue;
964
+ const [, fieldName, operator] = match;
965
+ if (!fieldName) continue;
966
+ if (operator && this.operators[operator] && (!this._allowedOperators || this._allowedOperators.has(operator))) {
967
+ const mongoOp = this.operators[operator];
968
+ const parsedValue = this.parseFilterValue(value, operator);
969
+ if (!filters[fieldName]) filters[fieldName] = {};
970
+ const fieldFilter = filters[fieldName];
971
+ fieldFilter[mongoOp] = parsedValue;
972
+ if (operator === "contains" || operator === "like") fieldFilter.$options = "i";
973
+ } else if (!operator) filters[fieldName] = this.parseFilterValue(value);
1219
974
  }
975
+ return filters;
1220
976
  }
1221
- /**
1222
- * Manually open the circuit
1223
- */
1224
- open() {
1225
- this.setState(CircuitState.OPEN);
1226
- this.nextAttempt = Date.now() + this.resetTimeout;
1227
- this.openedAt = Date.now();
977
+ parseFilterValue(value, operator) {
978
+ if (operator === "in" || operator === "nin") {
979
+ if (Array.isArray(value)) return value.map((v) => this.coerceValue(v));
980
+ if (typeof value === "string" && value.includes(",")) return value.split(",").map((v) => this.coerceValue(v.trim()));
981
+ return [this.coerceValue(value)];
982
+ }
983
+ if (operator === "like" || operator === "contains" || operator === "regex") return this.sanitizeRegex(String(value));
984
+ if (operator === "exists") {
985
+ const str = String(value).toLowerCase();
986
+ return str === "true" || str === "1";
987
+ }
988
+ return this.coerceValue(value);
1228
989
  }
1229
- /**
1230
- * Manually close the circuit
1231
- */
1232
- close() {
1233
- this.failures = 0;
1234
- this.successes = 0;
1235
- this.setState(CircuitState.CLOSED);
1236
- this.openedAt = null;
990
+ coerceValue(value) {
991
+ if (value === "true") return true;
992
+ if (value === "false") return false;
993
+ if (value === "null") return null;
994
+ if (typeof value === "string") {
995
+ const num = Number(value);
996
+ if (!Number.isNaN(num) && value.trim() !== "") return num;
997
+ }
998
+ return value;
1237
999
  }
1238
1000
  /**
1239
- * Get current statistics
1001
+ * Generate OpenAPI-compatible JSON Schema for query parameters.
1002
+ * Arc's defineResource() auto-detects this method and uses it
1003
+ * to document list endpoint query parameters in OpenAPI/Swagger.
1240
1004
  */
1241
- getStats() {
1005
+ getQuerySchema() {
1006
+ const operatorLines = Object.entries(this.operators).map(([op, mongoOp]) => {
1007
+ return ` ${op} → ${mongoOp}: ${{
1008
+ eq: "Equal (default when no operator specified)",
1009
+ ne: "Not equal",
1010
+ gt: "Greater than",
1011
+ gte: "Greater than or equal",
1012
+ lt: "Less than",
1013
+ lte: "Less than or equal",
1014
+ in: "In list (comma-separated values)",
1015
+ nin: "Not in list",
1016
+ like: "Pattern match (case-insensitive)",
1017
+ contains: "Contains substring (case-insensitive)",
1018
+ regex: "Regex pattern",
1019
+ exists: "Field exists (true/false)"
1020
+ }[op] || op}`;
1021
+ });
1242
1022
  return {
1243
- name: this.name,
1244
- state: this.state,
1245
- failures: this.failures,
1246
- successes: this.successes,
1247
- totalCalls: this.totalCalls,
1248
- openedAt: this.openedAt,
1249
- lastCallAt: this.lastCallAt
1023
+ type: "object",
1024
+ properties: {
1025
+ page: {
1026
+ type: "integer",
1027
+ description: "Page number for offset pagination",
1028
+ default: 1,
1029
+ minimum: 1
1030
+ },
1031
+ limit: {
1032
+ type: "integer",
1033
+ description: "Number of items per page",
1034
+ default: this.defaultLimit,
1035
+ minimum: 1,
1036
+ maximum: this.maxLimit
1037
+ },
1038
+ sort: {
1039
+ type: "string",
1040
+ description: "Sort fields (comma-separated). Prefix with - for descending. Example: -createdAt,name"
1041
+ },
1042
+ search: {
1043
+ type: "string",
1044
+ description: "Full-text search query",
1045
+ maxLength: this.maxSearchLength
1046
+ },
1047
+ select: {
1048
+ type: "string",
1049
+ description: "Fields to include/exclude (comma-separated). Prefix with - to exclude. Example: name,email,-password"
1050
+ },
1051
+ populate: {
1052
+ type: "string",
1053
+ description: "Fields to populate/join (comma-separated). Example: author,category"
1054
+ },
1055
+ after: {
1056
+ type: "string",
1057
+ description: "Cursor value for keyset pagination"
1058
+ },
1059
+ _filterOperators: {
1060
+ type: "string",
1061
+ description: ["Available filter operators (use as field[operator]=value):", ...operatorLines].join("\n")
1062
+ }
1063
+ }
1250
1064
  };
1251
1065
  }
1252
- /**
1253
- * Get current state
1254
- */
1255
- getState() {
1256
- return this.state;
1257
- }
1258
- /**
1259
- * Check if circuit is open
1260
- */
1261
- isOpen() {
1262
- return this.state === CircuitState.OPEN;
1263
- }
1264
- /**
1265
- * Check if circuit is closed
1266
- */
1267
- isClosed() {
1268
- return this.state === CircuitState.CLOSED;
1269
- }
1270
- /**
1271
- * Reset statistics
1272
- */
1273
- reset() {
1274
- this.failures = 0;
1275
- this.successes = 0;
1276
- this.totalCalls = 0;
1277
- this.lastCallAt = null;
1278
- this.openedAt = null;
1279
- this.setState(CircuitState.CLOSED);
1066
+ sanitizeRegex(pattern) {
1067
+ const truncated = pattern.length > this.maxRegexLength;
1068
+ let sanitized = pattern.slice(0, this.maxRegexLength);
1069
+ if (DANGEROUS_REGEX_PATTERNS.test(sanitized)) {
1070
+ sanitized = sanitized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1071
+ log.warn(`regex pattern matched a ReDoS-dangerous shape and was escaped to a literal string. original="${pattern}" sanitized="${sanitized}"`);
1072
+ } else if (truncated) log.warn(`regex pattern exceeded maxRegexLength (${this.maxRegexLength}) and was truncated. original-length=${pattern.length}`);
1073
+ return sanitized;
1280
1074
  }
1281
1075
  };
1282
1076
  /**
1283
- * Create a circuit breaker with sensible defaults
1284
- *
1285
- * @example
1286
- * const emailBreaker = createCircuitBreaker(
1287
- * async (to, subject, body) => sendEmail(to, subject, body),
1288
- * { name: 'email-service' }
1289
- * );
1077
+ * Create a new ArcQueryParser instance
1290
1078
  */
1291
- function createCircuitBreaker(fn, options) {
1292
- return new CircuitBreaker(fn, options);
1079
+ function createQueryParser(options) {
1080
+ return new ArcQueryParser(options);
1293
1081
  }
1082
+ //#endregion
1083
+ //#region src/utils/responseSchemas.ts
1294
1084
  /**
1295
- * Circuit breaker registry for managing multiple breakers
1085
+ * JSON Schema definitions for arc API responses.
1086
+ *
1087
+ * Wire shape (post-2.12): no envelope. HTTP status discriminates success
1088
+ * vs error. Success-path schemas describe the data shape directly; the
1089
+ * error path uses the canonical `ErrorContract` JSON Schema imported
1090
+ * from `@classytic/repo-core/errors` — single source of truth shared
1091
+ * with every other consumer in the org.
1296
1092
  */
1297
- var CircuitBreakerRegistry = class {
1298
- breakers = /* @__PURE__ */ new Map();
1299
- /**
1300
- * Register a circuit breaker
1301
- */
1302
- register(name, fn, options) {
1303
- const breaker = new CircuitBreaker(fn, {
1304
- ...options,
1305
- name
1306
- });
1307
- this.breakers.set(name, breaker);
1308
- return breaker;
1309
- }
1310
- /**
1311
- * Get a circuit breaker by name
1312
- */
1313
- get(name) {
1314
- return this.breakers.get(name);
1315
- }
1316
- /**
1317
- * Get all breakers
1318
- */
1319
- getAll() {
1320
- return this.breakers;
1321
- }
1322
- /**
1323
- * Get statistics for all breakers
1324
- */
1325
- getAllStats() {
1326
- const stats = {};
1327
- for (const [name, breaker] of this.breakers.entries()) stats[name] = breaker.getStats();
1328
- return stats;
1329
- }
1330
- /**
1331
- * Reset all breakers
1332
- */
1333
- resetAll() {
1334
- for (const breaker of this.breakers.values()) breaker.reset();
1335
- }
1336
- /**
1337
- * Open all breakers
1338
- */
1339
- openAll() {
1340
- for (const breaker of this.breakers.values()) breaker.open();
1341
- }
1342
- /**
1343
- * Close all breakers
1344
- */
1345
- closeAll() {
1346
- for (const breaker of this.breakers.values()) breaker.close();
1347
- }
1093
+ /**
1094
+ * Pagination schema - matches MongoKit/Arc runtime format
1095
+ *
1096
+ * Runtime format (flat fields):
1097
+ * { page, limit, total, pages, hasNext, hasPrev }
1098
+ */
1099
+ const paginationSchema = {
1100
+ type: "object",
1101
+ properties: {
1102
+ page: {
1103
+ type: "integer",
1104
+ example: 1
1105
+ },
1106
+ limit: {
1107
+ type: "integer",
1108
+ example: 20
1109
+ },
1110
+ total: {
1111
+ type: "integer",
1112
+ example: 100
1113
+ },
1114
+ pages: {
1115
+ type: "integer",
1116
+ example: 5
1117
+ },
1118
+ hasNext: {
1119
+ type: "boolean",
1120
+ example: true
1121
+ },
1122
+ hasPrev: {
1123
+ type: "boolean",
1124
+ example: false
1125
+ }
1126
+ },
1127
+ required: [
1128
+ "page",
1129
+ "limit",
1130
+ "total",
1131
+ "pages",
1132
+ "hasNext",
1133
+ "hasPrev"
1134
+ ]
1348
1135
  };
1349
1136
  /**
1350
- * Create a new CircuitBreakerRegistry instance.
1351
- * Use this instead of a global singleton attach to fastify.arc or pass explicitly.
1137
+ * List response schema full union of every wire shape `toCanonicalList`
1138
+ * can emit. Hosts who know their endpoint only ever produces one variant
1139
+ * can pin to a narrower helper:
1140
+ * - `offsetListResponse(item)` — `{ method: 'offset', data, page, limit, total, pages, hasNext, hasPrev }`
1141
+ * - `keysetListResponse(item)` — `{ method: 'keyset', data, limit, hasMore, next: string|null }`
1142
+ * - `aggregateListResponse(item)` — `{ method: 'aggregate', ...offset fields }`
1143
+ * - `bareListResponse(item)` — `{ data }`
1144
+ *
1145
+ * The default `listResponse(item)` is the union (`oneOf`) of all four so
1146
+ * Fastify validation accepts any canonical kit shape — pre-2.13 this only
1147
+ * modelled offset and rejected keyset/aggregate/bare lists at the
1148
+ * response-schema gate.
1352
1149
  */
1353
- function createCircuitBreakerRegistry() {
1354
- return new CircuitBreakerRegistry();
1150
+ function listResponse(itemSchema) {
1151
+ return { oneOf: [
1152
+ offsetListResponse(itemSchema),
1153
+ keysetListResponse(itemSchema),
1154
+ aggregateListResponse(itemSchema),
1155
+ bareListResponse(itemSchema)
1156
+ ] };
1157
+ }
1158
+ /** Offset variant — `{ method: 'offset', data, page, limit, total, pages, hasNext, hasPrev }`. */
1159
+ function offsetListResponse(itemSchema) {
1160
+ return {
1161
+ type: "object",
1162
+ properties: {
1163
+ method: {
1164
+ type: "string",
1165
+ const: "offset",
1166
+ example: "offset"
1167
+ },
1168
+ data: {
1169
+ type: "array",
1170
+ items: itemSchema
1171
+ },
1172
+ page: {
1173
+ type: "integer",
1174
+ example: 1
1175
+ },
1176
+ limit: {
1177
+ type: "integer",
1178
+ example: 20
1179
+ },
1180
+ total: {
1181
+ type: "integer",
1182
+ example: 100
1183
+ },
1184
+ pages: {
1185
+ type: "integer",
1186
+ example: 5
1187
+ },
1188
+ hasNext: {
1189
+ type: "boolean",
1190
+ example: false
1191
+ },
1192
+ hasPrev: {
1193
+ type: "boolean",
1194
+ example: false
1195
+ }
1196
+ },
1197
+ required: [
1198
+ "method",
1199
+ "data",
1200
+ "page",
1201
+ "limit",
1202
+ "total",
1203
+ "pages",
1204
+ "hasNext",
1205
+ "hasPrev"
1206
+ ],
1207
+ additionalProperties: true
1208
+ };
1209
+ }
1210
+ /** Keyset variant — `{ method: 'keyset', data, limit, hasMore, next: string | null }`. */
1211
+ function keysetListResponse(itemSchema) {
1212
+ return {
1213
+ type: "object",
1214
+ properties: {
1215
+ method: {
1216
+ type: "string",
1217
+ const: "keyset",
1218
+ example: "keyset"
1219
+ },
1220
+ data: {
1221
+ type: "array",
1222
+ items: itemSchema
1223
+ },
1224
+ limit: {
1225
+ type: "integer",
1226
+ example: 20
1227
+ },
1228
+ hasMore: {
1229
+ type: "boolean",
1230
+ example: true
1231
+ },
1232
+ next: {
1233
+ type: ["string", "null"],
1234
+ description: "Cursor token for the next page, or null."
1235
+ }
1236
+ },
1237
+ required: [
1238
+ "method",
1239
+ "data",
1240
+ "limit",
1241
+ "hasMore",
1242
+ "next"
1243
+ ],
1244
+ additionalProperties: true
1245
+ };
1246
+ }
1247
+ /** Aggregate variant — same shape as offset, discriminated by `method: 'aggregate'`. */
1248
+ function aggregateListResponse(itemSchema) {
1249
+ return {
1250
+ type: "object",
1251
+ properties: {
1252
+ method: {
1253
+ type: "string",
1254
+ const: "aggregate",
1255
+ example: "aggregate"
1256
+ },
1257
+ data: {
1258
+ type: "array",
1259
+ items: itemSchema
1260
+ },
1261
+ page: { type: "integer" },
1262
+ limit: { type: "integer" },
1263
+ total: { type: "integer" },
1264
+ pages: { type: "integer" },
1265
+ hasNext: { type: "boolean" },
1266
+ hasPrev: { type: "boolean" }
1267
+ },
1268
+ required: [
1269
+ "method",
1270
+ "data",
1271
+ "page",
1272
+ "limit",
1273
+ "total",
1274
+ "pages",
1275
+ "hasNext",
1276
+ "hasPrev"
1277
+ ],
1278
+ additionalProperties: true
1279
+ };
1280
+ }
1281
+ /** Bare variant — `{ data }`, no `method` discriminant. */
1282
+ function bareListResponse(itemSchema) {
1283
+ return {
1284
+ type: "object",
1285
+ properties: { data: {
1286
+ type: "array",
1287
+ items: itemSchema
1288
+ } },
1289
+ required: ["data"],
1290
+ additionalProperties: true
1291
+ };
1292
+ }
1293
+ /** Delete response — flat shape mirroring the canonical delete envelope. */
1294
+ function deleteResponse() {
1295
+ return {
1296
+ type: "object",
1297
+ properties: {
1298
+ message: {
1299
+ type: "string",
1300
+ example: "Deleted successfully"
1301
+ },
1302
+ id: {
1303
+ type: "string",
1304
+ example: "507f1f77bcf86cd799439011"
1305
+ },
1306
+ soft: {
1307
+ type: "boolean",
1308
+ example: false
1309
+ }
1310
+ },
1311
+ additionalProperties: true
1312
+ };
1355
1313
  }
1356
- //#endregion
1357
- //#region src/utils/compensation.ts
1358
- /**
1359
- * Run steps in order with automatic compensation on failure.
1360
- *
1361
- * @typeParam TCtx - Context type shared across steps (defaults to Record<string, unknown>)
1362
- */
1363
- async function withCompensation(_name, steps, initialContext, hooks) {
1364
- const ctx = { ...initialContext };
1365
- const completedSteps = [];
1366
- const results = {};
1367
- const completed = [];
1368
- for (const step of steps) {
1369
- if (step.fireAndForget) {
1370
- completedSteps.push(step.name);
1371
- step.execute(ctx).then((result) => hooks?.onStepComplete?.(step.name, result), () => {});
1372
- continue;
1373
- }
1374
- try {
1375
- const result = await step.execute(ctx);
1376
- completedSteps.push(step.name);
1377
- results[step.name] = result;
1378
- completed.push({
1379
- step,
1380
- result
1381
- });
1382
- hooks?.onStepComplete?.(step.name, result);
1383
- } catch (err) {
1384
- const error = err instanceof Error ? err : new Error(String(err));
1385
- hooks?.onStepFailed?.(step.name, error);
1386
- const compensationErrors = await rollback(ctx, completed, hooks);
1387
- return {
1388
- success: false,
1389
- completedSteps,
1390
- results,
1391
- failedStep: step.name,
1392
- error: error.message,
1393
- ...compensationErrors.length > 0 ? { compensationErrors } : {}
1394
- };
1395
- }
1396
- }
1314
+ const ERROR_DESCRIPTIONS = {
1315
+ 400: "Bad Request",
1316
+ 401: "Unauthorized",
1317
+ 403: "Forbidden",
1318
+ 404: "Not Found",
1319
+ 409: "Conflict",
1320
+ 422: "Unprocessable Entity",
1321
+ 429: "Too Many Requests",
1322
+ 500: "Internal Server Error",
1323
+ 503: "Service Unavailable"
1324
+ };
1325
+ /** Build an OpenAPI response entry for an `ErrorContract` at the given status. */
1326
+ function errorResponse(status) {
1397
1327
  return {
1398
- success: true,
1399
- completedSteps,
1400
- results
1328
+ description: ERROR_DESCRIPTIONS[status] ?? "Error",
1329
+ content: { "application/json": { schema: errorContractSchema$1 } }
1401
1330
  };
1402
1331
  }
1403
- async function rollback(ctx, completed, hooks) {
1404
- const errors = [];
1405
- for (let i = completed.length - 1; i >= 0; i--) {
1406
- const entry = completed[i];
1407
- if (!entry?.step.compensate) continue;
1408
- const compensateFn = entry.step.compensate;
1409
- try {
1410
- await compensateFn(ctx, entry.result);
1411
- hooks?.onCompensate?.(entry.step.name);
1412
- } catch (err) {
1413
- errors.push({
1414
- step: entry.step.name,
1415
- error: err instanceof Error ? err.message : String(err)
1416
- });
1332
+ const responses = {
1333
+ 200: (schema) => ({
1334
+ description: "Successful response",
1335
+ content: { "application/json": { schema } }
1336
+ }),
1337
+ 201: (schema) => ({
1338
+ description: "Created successfully",
1339
+ content: { "application/json": { schema: {
1340
+ ...schema,
1341
+ additionalProperties: true
1342
+ } } }
1343
+ }),
1344
+ 400: errorResponse(400),
1345
+ 401: errorResponse(401),
1346
+ 403: errorResponse(403),
1347
+ 404: errorResponse(404),
1348
+ 409: errorResponse(409),
1349
+ 500: errorResponse(500)
1350
+ };
1351
+ const queryParams = {
1352
+ pagination: {
1353
+ page: {
1354
+ type: "integer",
1355
+ minimum: 1,
1356
+ default: 1,
1357
+ description: "Page number"
1358
+ },
1359
+ limit: {
1360
+ type: "integer",
1361
+ minimum: 1,
1362
+ maximum: 100,
1363
+ default: 20,
1364
+ description: "Items per page"
1365
+ }
1366
+ },
1367
+ sorting: { sort: {
1368
+ type: "string",
1369
+ description: "Sort field (prefix with - for descending)",
1370
+ example: "-createdAt"
1371
+ } },
1372
+ filtering: {
1373
+ select: {
1374
+ description: "Fields to include (space-separated or object)",
1375
+ example: "name email createdAt"
1376
+ },
1377
+ populate: {
1378
+ description: "Relations to populate (comma-separated string or bracket-notation object)",
1379
+ example: "author,category"
1417
1380
  }
1418
1381
  }
1419
- return errors;
1420
- }
1421
- function defineCompensation(name, steps) {
1382
+ };
1383
+ /**
1384
+ * Get standard list query parameters schema
1385
+ */
1386
+ function getListQueryParams() {
1422
1387
  return {
1423
- name,
1424
- execute: (initialContext, hooks) => withCompensation(name, steps, initialContext, hooks)
1388
+ type: "object",
1389
+ properties: {
1390
+ ...queryParams.pagination,
1391
+ ...queryParams.sorting,
1392
+ ...queryParams.filtering
1393
+ },
1394
+ additionalProperties: true
1425
1395
  };
1426
1396
  }
1427
- //#endregion
1428
- //#region src/utils/defineErrorMapper.ts
1429
1397
  /**
1430
- * Register an `ErrorMapper` with its domain-specific generic argument and
1431
- * have it assign cleanly into `ErrorMapper[]` (no `as unknown as ErrorMapper`).
1432
- *
1433
- * The returned mapper is identical at runtime — `type` and `toResponse` are
1434
- * passed through untouched. Only the declared type widens from
1435
- * `ErrorMapper<T>` to `ErrorMapper` so the array inference works.
1436
- *
1437
- * Safety: the `errorHandlerPlugin` dispatches via `error instanceof mapper.type`
1438
- * before invoking `toResponse`, so the widened callback signature is never
1439
- * called with a non-`T` error at runtime. This helper codifies that invariant
1440
- * in one place.
1398
+ * Generic item schema that allows any properties.
1399
+ * Used as default when no user schema is provided.
1400
+ * Enables fast-json-stringify while still passing through all fields.
1441
1401
  */
1442
- function defineErrorMapper(mapper) {
1443
- return mapper;
1444
- }
1445
- //#endregion
1446
- //#region src/utils/defineGuard.ts
1447
- /** Hidden property key for guard context storage on the request object. */
1448
- const GUARD_STORE_KEY = "__arcGuardContext";
1402
+ const genericItemSchema = {
1403
+ type: "object",
1404
+ additionalProperties: true
1405
+ };
1449
1406
  /**
1450
- * Create a typed guard. See module JSDoc for usage.
1407
+ * Recursively strip `example` keys from a schema object.
1408
+ * The `example` keyword is OpenAPI metadata — not standard JSON Schema —
1409
+ * and triggers Ajv strict mode errors when used on routes without the
1410
+ * `keywords: ['example']` AJV config (e.g., raw Fastify without createApp).
1451
1411
  */
1452
- function defineGuard(config) {
1453
- const { name, resolve } = config;
1454
- const preHandler = async (req, reply) => {
1455
- const ctx = await resolve(req, reply);
1456
- if (!reply.sent) {
1457
- const store = req[GUARD_STORE_KEY] ?? {};
1458
- store[name] = ctx;
1459
- req[GUARD_STORE_KEY] = store;
1460
- }
1461
- };
1462
- return {
1463
- preHandler,
1464
- name,
1465
- from(req) {
1466
- const store = req[GUARD_STORE_KEY];
1467
- 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.`);
1468
- return store[name];
1469
- }
1470
- };
1412
+ function stripExamples(schema) {
1413
+ if (schema === null || typeof schema !== "object") return schema;
1414
+ if (Array.isArray(schema)) return schema.map(stripExamples);
1415
+ const result = {};
1416
+ for (const [key, value] of Object.entries(schema)) {
1417
+ if (key === "example") continue;
1418
+ result[key] = stripExamples(value);
1419
+ }
1420
+ return result;
1471
1421
  }
1472
- //#endregion
1473
- //#region src/utils/envelope.ts
1474
1422
  /**
1475
- * Standard response envelope helper.
1423
+ * Get default response schemas for all CRUD operations.
1476
1424
  *
1477
- * Wraps a handler return value in arc's `{ success: true, data, ...meta }`
1478
- * shape. Pure utility no types module coupling, no runtime dependencies.
1425
+ * When routes have response schemas, Fastify compiles them with
1426
+ * fast-json-stringify for 2-3x faster serialization and prevents
1427
+ * accidental field disclosure.
1479
1428
  *
1480
- * @example
1481
- * ```ts
1482
- * import { envelope } from '@classytic/arc';
1429
+ * These defaults use `additionalProperties: true` so all fields pass through.
1430
+ * Override with specific schemas for full serialization performance + safety.
1483
1431
  *
1484
- * handler: async (req, reply) => {
1485
- * const results = await search(req.query.q);
1486
- * return envelope(results, { took: performance.now() - t0 });
1487
- * }
1488
- * ```
1489
- */
1490
- /**
1491
- * Wrap data in arc's standard `{ success: true, data }` envelope, with
1492
- * optional top-level meta keys merged in.
1432
+ * Note: `example` properties are stripped from defaults so they work with
1433
+ * any Fastify instance (not just createApp which adds `keywords: ['example']`).
1493
1434
  */
1494
- function envelope(data, meta) {
1495
- return {
1496
- success: true,
1497
- data,
1498
- ...meta
1499
- };
1435
+ function getDefaultCrudSchemas() {
1436
+ return stripExamples({
1437
+ list: {
1438
+ querystring: getListQueryParams(),
1439
+ response: { 200: listResponse(genericItemSchema) }
1440
+ },
1441
+ get: { response: { 200: genericItemSchema } },
1442
+ create: { response: { 201: genericItemSchema } },
1443
+ update: { response: { 200: genericItemSchema } },
1444
+ delete: { response: { 200: deleteResponse() } }
1445
+ });
1500
1446
  }
1501
1447
  //#endregion
1502
- //#region src/utils/handleRaw.ts
1448
+ //#region src/utils/runtime.ts
1503
1449
  /**
1504
- * Wrap a raw Fastify handler with Arc's response envelope and error handling.
1505
- *
1506
- * @param handler - Async function that receives `(request, reply)` and returns data.
1507
- * The return value is sent as `{ success: true, data }`. If it returns
1508
- * `undefined` or `null`, `{ success: true }` is sent (no `data` field).
1509
- * @param statusCode - HTTP status code for successful responses (default: 200)
1450
+ * Portable "run on next tick" scheduler. `setImmediate` is Node-only not
1451
+ * available in Bun workers, Deno, Cloudflare Workers, or edge runtimes.
1510
1452
  */
1511
- function handleRaw(handler, statusCode = 200) {
1512
- return async (request, reply) => {
1513
- try {
1514
- const result = await handler(request, reply);
1515
- if (reply.sent) return;
1516
- if (result === void 0 || result === null) reply.code(statusCode).send({ success: true });
1517
- else reply.code(statusCode).send({
1518
- success: true,
1519
- data: result
1520
- });
1521
- } catch (err) {
1522
- if (reply.sent) return;
1523
- if (err instanceof ArcError) {
1524
- reply.code(err.statusCode).send(err.toJSON());
1525
- return;
1526
- }
1527
- const error = err;
1528
- const code = error.statusCode ?? error.status ?? 500;
1529
- reply.code(code).send({
1530
- success: false,
1531
- error: error.message ?? "Internal server error",
1532
- ...error.code && { code: error.code }
1533
- });
1534
- }
1535
- };
1536
- }
1453
+ const scheduleBackground = typeof setImmediate === "function" ? (cb) => void setImmediate(cb) : (cb) => queueMicrotask(cb);
1537
1454
  //#endregion
1538
1455
  //#region src/utils/stateMachine.ts
1539
1456
  /**
@@ -1636,4 +1553,22 @@ function createStateMachine(name, transitions = {}, options = {}) {
1636
1553
  };
1637
1554
  }
1638
1555
  //#endregion
1639
- export { createQueryParser as A, mutationResponse as C, successResponseSchema as D, responses as E, simpleEqualityMatcher as M, wrapResponse as O, listResponse as S, queryParams as T, deleteResponse as _, defineErrorMapper as a, getListQueryParams as b, CircuitBreaker as c, CircuitState as d, createCircuitBreaker as f, validateResourceConfig as g, formatValidationErrors as h, defineGuard as i, getUserId as j, ArcQueryParser as k, CircuitBreakerError as l, assertValidConfig as m, handleRaw as n, defineCompensation as o, createCircuitBreakerRegistry as p, envelope as r, withCompensation as s, createStateMachine as t, CircuitBreakerRegistry as u, errorResponseSchema as v, paginationSchema as w, itemResponse as x, getDefaultCrudSchemas as y };
1556
+ //#region src/utils/userHelpers.ts
1557
+ /**
1558
+ * Extract a user ID from a user object. Accepts `id` or `_id` — returns
1559
+ * `undefined` when neither is present. Used by arc's controllers to
1560
+ * populate `createdBy` / `updatedBy` fields and for cache scoping.
1561
+ *
1562
+ * @example
1563
+ * ```ts
1564
+ * import { getUserId } from '@classytic/arc/utils';
1565
+ * const uid = getUserId(request.user);
1566
+ * ```
1567
+ */
1568
+ function getUserId(user) {
1569
+ if (!user) return void 0;
1570
+ const id = user.id ?? user._id;
1571
+ return id ? String(id) : void 0;
1572
+ }
1573
+ //#endregion
1574
+ export { assertValidConfig as A, withCompensation as C, CircuitState as D, CircuitBreakerRegistry as E, validateResourceConfig as M, simpleEqualityMatcher as N, createCircuitBreaker as O, defineCompensation as S, CircuitBreakerError as T, ArcQueryParser as _, bareListResponse as a, defineGuard as b, errorDetailSchema$1 as c, keysetListResponse as d, listResponse as f, responses as g, queryParams as h, aggregateListResponse as i, formatValidationErrors as j, createCircuitBreakerRegistry as k, getDefaultCrudSchemas as l, paginationSchema as m, createStateMachine as n, deleteResponse as o, offsetListResponse as p, scheduleBackground as r, errorContractSchema$1 as s, getUserId as t, getListQueryParams as u, createQueryParser as v, CircuitBreaker as w, defineErrorMapper as x, handleRaw as y };