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