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