@classytic/arc 2.19.0 → 2.20.0

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