@classytic/arc 2.37.0 → 2.38.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/dist/{BaseController-B5GZfUBy.mjs → BaseController-CYaDvmOB.mjs} +16 -15
- package/dist/{EventTransport-BEVP50ka.d.mts → EventTransport-Cx60_s-6.d.mts} +37 -0
- package/dist/{EventTransport-pGNNDQ__.mjs → EventTransport-Gq7JWbWw.mjs} +39 -7
- package/dist/{HookSystem-CuhHvcyZ.mjs → HookSystem-C0ikzqQf.mjs} +2 -2
- package/dist/{ResourceRegistry-DFOmazXg.mjs → ResourceRegistry-DPcX--zk.mjs} +2 -2
- package/dist/{actionPermissions-CQQrR4tu.mjs → actionPermissions-jlFZ1it4.mjs} +6 -6
- package/dist/audit/index.d.mts +1 -1
- package/dist/audit/index.mjs +2 -2
- package/dist/auth/audit.mjs +1 -1
- package/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.mjs +79 -28
- package/dist/auth/redis-session.mjs +1 -1
- package/dist/{authorizationDecision-DgyWdav8.mjs → authorizationDecision-BP8GHtjc.mjs} +2 -2
- package/dist/{buildHandler-D2DkqtiY.mjs → buildHandler-DjXOqIvX.mjs} +4 -4
- package/dist/cache/index.mjs +4 -4
- package/dist/{caching-BmC2T-pX.mjs → caching-a3ZEcJJs.mjs} +2 -2
- package/dist/cleanup/index.d.mts +2 -2
- package/dist/cleanup/index.mjs +4 -4
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/describe.mjs +3 -3
- package/dist/cli/commands/docs.mjs +3 -3
- package/dist/cli/commands/doctor.mjs +1 -1
- package/dist/cli/commands/generate.mjs +2 -2
- package/dist/cli/commands/init.mjs +2 -2
- package/dist/cli/commands/introspect.mjs +2 -2
- package/dist/cli/index.mjs +3 -3
- package/dist/{constants-CUpamQMJ.mjs → constants-BONghE1Y.mjs} +1 -1
- package/dist/context/index.mjs +3 -3
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +7 -7
- package/dist/{core-C8GWw-Iu.mjs → core-8She7FgF.mjs} +3 -3
- package/dist/{createActionRouter-CwDU_j2S.mjs → createActionRouter-Bqi6Bhhx.mjs} +7 -7
- package/dist/{createAggregationRouter-d057xXX1.mjs → createAggregationRouter-BsYO7ktd.mjs} +7 -7
- package/dist/{defineAggregation-cCitY2FA.mjs → defineAggregation-0KBvqEEC.mjs} +1 -1
- package/dist/{defineResource-WcbE1kJL.mjs → defineResource-BMdyRelW.mjs} +18 -18
- package/dist/discovery/index.mjs +1 -1
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +3 -3
- package/dist/{elevation-Bc6Gafv9.mjs → elevation-C6woysGC.mjs} +4 -4
- package/dist/encryption/index.mjs +3 -3
- package/dist/{errorHandler-CB75oY5i.mjs → errorHandler-BdmIWPba.mjs} +4 -4
- package/dist/{errors-DsdZc1Xv.mjs → errors-2ExmOgIJ.mjs} +1 -1
- package/dist/{eventPlugin-B47sfCJ8.d.mts → eventPlugin-DFvHa4Nf.d.mts} +10 -1
- package/dist/{eventPlugin-DEIfV1iC.mjs → eventPlugin-DJwpGMzx.mjs} +6 -6
- package/dist/events/index.d.mts +4 -4
- package/dist/events/index.mjs +5 -5
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +1 -1
- package/dist/factory/index.d.mts +4 -4
- package/dist/factory/index.mjs +5 -5
- package/dist/{fastify-augmentation-CdY8FINU.d.mts → fastify-augmentation-jYA2oHyL.d.mts} +3 -3
- package/dist/{fields-DvGz47Ul.mjs → fields-Cha2QCKH.mjs} +37 -2
- package/dist/{generate-D7CIIWhS.mjs → generate-BUP4Oydg.mjs} +2 -2
- package/dist/{headers-BFu1st_i.mjs → headers-DeXNifVj.mjs} +2 -2
- package/dist/{health-Cc_sb94d.mjs → health-Bh0N_FLk.mjs} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +2 -2
- package/dist/idempotency/index.mjs +4 -4
- package/dist/idempotency/redis.mjs +1 -1
- package/dist/{index-C01wWGJt.d.mts → index-B78WZom8.d.mts} +11 -2
- package/dist/{index-du18bJjH.d.mts → index-BRoDtjek.d.mts} +2 -4
- package/dist/{index-DCxxRwLC.d.mts → index-D2uIUSQk.d.mts} +1 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +11 -11
- package/dist/{init-BMgU1wYR.mjs → init-Dg_Am2o_.mjs} +1 -1
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +2 -2
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs/index.mjs +2 -2
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +2 -2
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +2 -2
- package/dist/integrations/streamline.mjs +3 -3
- package/dist/integrations/webhooks.d.mts +2 -2
- package/dist/integrations/webhooks.mjs +3 -3
- package/dist/integrations/websocket-pushref-redis.mjs +1 -1
- package/dist/integrations/websocket-redis.mjs +1 -1
- package/dist/integrations/websocket.mjs +1 -1
- package/dist/{interface-CaPVHoVJ.mjs → interface-DYPVscCU.mjs} +1 -1
- package/dist/{jsonBody-BuzxOZV8.mjs → jsonBody-DVui0uJl.mjs} +2 -2
- package/dist/{keys-DoUI9-yP.mjs → keys-BSuxGS-B.mjs} +1 -1
- package/dist/{loadResources-CPlRWJCv.mjs → loadResources-CNd1DWnK.mjs} +2 -2
- package/dist/{loadResourcesFromEntry-CPCZiX9n.mjs → loadResourcesFromEntry-DNj8cpiY.mjs} +2 -2
- package/dist/logger/index.mjs +1 -1
- package/dist/{mcpPlugin-dxMxBPIg.mjs → mcpPlugin-KWsezMx7.mjs} +15 -15
- package/dist/{memory-BnafVem_.mjs → memory-BiPVaCPg.mjs} +2 -2
- package/dist/{metrics-CeAIcVMY.mjs → metrics-CsWaIVTa.mjs} +2 -2
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +3 -3
- package/dist/migrations/index.mjs +2 -2
- package/dist/{module-DIvG8qOg.mjs → module-BQXHxMEk.mjs} +1 -1
- package/dist/{multipartBody-BVCoaTwu.mjs → multipartBody-u6VrS_iH.mjs} +1 -1
- package/dist/{openapi-D_i6CifU.mjs → openapi-Dxc8RLy_.mjs} +6 -6
- package/dist/outbox-admin/index.d.mts +1 -1
- package/dist/outbox-admin/index.mjs +4 -4
- package/dist/{outbox-module-BbdaMyqy.d.mts → outbox-module-CQXu_O6o.d.mts} +2 -2
- package/dist/{outbox-module-CKaLL5BH.mjs → outbox-module-DyIqA5vo.mjs} +4 -4
- package/dist/permissions/index.mjs +5 -5
- package/dist/{permissions-DWkl60hx.mjs → permissions-H7Zcb82Z.mjs} +6 -6
- package/dist/{pipe-Dbs3GhOZ.mjs → pipe-SunhEaZ6.mjs} +2 -2
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +2 -2
- package/dist/plugins/index.d.mts +4 -4
- package/dist/plugins/index.mjs +16 -16
- package/dist/plugins/response-cache.mjs +2 -2
- package/dist/plugins/tracing-entry.mjs +2 -2
- package/dist/{pluralize-DgaYX91M.mjs → pluralize-Da7IT_qJ.mjs} +1 -1
- package/dist/{preloadResources-BYFfYnjZ.d.mts → preloadResources-BbBkNkqK.d.mts} +6 -6
- package/dist/{preloadResources-BUoGKPaS.mjs → preloadResources-DlhH81py.mjs} +21 -21
- package/dist/presets/filesUpload.d.mts +1 -1
- package/dist/presets/filesUpload.mjs +5 -5
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +2 -2
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +3 -3
- package/dist/presets/search.d.mts +1 -1
- package/dist/presets/search.mjs +2 -2
- package/dist/{presets-Gbv9mNKD.mjs → presets-CNyfaxGL.mjs} +4 -4
- package/dist/{projection-BunxoeON.mjs → projection-DSfEoooA.mjs} +2 -2
- package/dist/{queryCachePlugin-BYqmg89O.mjs → queryCachePlugin-oTsmVyEQ.mjs} +5 -5
- package/dist/{rateLimitKey-DlqmDHA5.mjs → rateLimitKey-CewdR-m6.mjs} +1 -1
- package/dist/{realtime-CiAp2-LH.mjs → realtime-CPmjjgqd.mjs} +9 -9
- package/dist/{redis-stream-mwLWlfHG.d.mts → redis-stream-eh0eOtjh.d.mts} +1 -1
- package/dist/{registerRoute-dnf-JJf-.mjs → registerRoute-XvcI_3sh.mjs} +10 -6
- package/dist/registry/index.d.mts +2 -2
- package/dist/registry/index.mjs +3 -3
- package/dist/{registry-ByC0UFNS.mjs → registry-UFEJQN8R.mjs} +2 -2
- package/dist/{renewingLease-C5Ta_ImD.mjs → renewingLease-DuRGVOMe.mjs} +1 -1
- package/dist/{replyHelpers-D1k0wHoO.mjs → replyHelpers-BHebarf0.mjs} +2 -2
- package/dist/{requestContext-DGM7EZRI.mjs → requestContext-DpmntRZy.mjs} +1 -1
- package/dist/{rolldown-runtime-DrZrabOW.mjs → rolldown-runtime-Add2L50I.mjs} +1 -1
- package/dist/{runtimeCapabilities-c4Sc85X-.mjs → runtimeCapabilities-D793Pxci.mjs} +1 -1
- package/dist/{schedules-CVtolEa-.mjs → schedules-DthM8Qag.mjs} +3 -3
- package/dist/{schemaConverter-C8zbW7tJ.mjs → schemaConverter-Db-MMsH6.mjs} +1 -1
- package/dist/{schemaIR-BfI425RH.mjs → schemaIR-CIXShch7.mjs} +2 -2
- package/dist/schemas/index.mjs +1 -1
- package/dist/scim/index.mjs +2 -2
- package/dist/scope/index.mjs +5 -5
- package/dist/{scope-DFSOKc4T.mjs → scope-D03sk17n.mjs} +4 -4
- package/dist/{simpleEqualityMatcher-Bhp7WrRF.mjs → simpleEqualityMatcher-B7qVjQBv.mjs} +1 -1
- package/dist/{sse-vRuwDd6N.mjs → sse-D4MYqkic.mjs} +5 -5
- package/dist/{sseStream-cWezGOk1.mjs → sseStream-BuMNPR6r.mjs} +2 -2
- package/dist/{store-helpers-bU8H5c-A.mjs → store-helpers-CIF4RG8s.mjs} +1 -1
- package/dist/{streaming-weWcFCk4.mjs → streaming-DkpW1HwJ.mjs} +1 -1
- package/dist/{subscribe-helpers-BrcG3NlV.mjs → subscribe-helpers-CBZOi6pk.mjs} +2 -2
- package/dist/sync/changelog.mjs +1 -1
- package/dist/testing/cleanupStoreContract.mjs +1 -1
- package/dist/testing/index.d.mts +4 -4
- package/dist/testing/index.mjs +10 -10
- package/dist/testing/outboxStoreContract.mjs +1 -1
- package/dist/testing/storageContract.mjs +1 -1
- package/dist/{transactionContext-Ca5peQpc.mjs → transactionContext-DMmvcfdo.mjs} +1 -1
- package/dist/{typeGuards-ALWme5WB.mjs → typeGuards-DHTnEs5X.mjs} +1 -1
- package/dist/types/index.d.mts +1 -1
- package/dist/types/index.mjs +1 -1
- package/dist/types/storage.mjs +1 -1
- package/dist/{types-BMa2EPtv.d.mts → types-BRU-Kd3t.d.mts} +1 -1
- package/dist/{types-lDs9oF4z.mjs → types-BqLyyH0o.mjs} +4 -4
- package/dist/{types-BXnTOsXP.d.mts → types-LQFwC37d.d.mts} +1 -1
- package/dist/usage/index.mjs +0 -0
- package/dist/{userHelpers-DU_Z5OBA.mjs → userHelpers-CXySQFRg.mjs} +1 -1
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.mjs +11 -11
- package/dist/{utils-sXGFLegw.mjs → utils-BbGG8XNT.mjs} +1230 -1230
- package/dist/{validate-qK_-7MVk.mjs → validate-CidSeUrx.mjs} +2 -2
- package/dist/{versioning-DEs-_hFi.d.mts → versioning-B843jOYX.d.mts} +1 -1
- package/dist/{versioning-Dtw0MyX4.mjs → versioning-C2r0eOZ4.mjs} +3 -3
- package/package.json +11 -9
- package/skills/arc/references/auth.md +5 -2
- package/skills/arc/references/production.md +733 -733
- package/skills/arc-code-review/references/migration-recipes.md +16 -7
|
@@ -1,1374 +1,1374 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import { m as RESERVED_QUERY_PARAMS } from "./constants-
|
|
1
|
+
/*! @classytic/arc v2.38.0 | MIT | https://classytic.com */
|
|
2
|
+
import { m as RESERVED_QUERY_PARAMS } from "./constants-BONghE1Y.mjs";
|
|
3
3
|
import { arcLog } from "./logger/index.mjs";
|
|
4
|
-
import { t as ArcError, u as ValidationError } from "./errors-
|
|
5
|
-
import { errorContractSchema as errorContractSchema$1, errorDetailSchema as errorDetailSchema$1 } from "@classytic/repo-core/errors";
|
|
4
|
+
import { t as ArcError, u as ValidationError } from "./errors-2ExmOgIJ.mjs";
|
|
6
5
|
import { scheduleBackground } from "@classytic/repo-core/cache";
|
|
7
|
-
|
|
6
|
+
import { errorContractSchema as errorContractSchema$1, errorDetailSchema as errorDetailSchema$1 } from "@classytic/repo-core/errors";
|
|
7
|
+
//#region src/utils/filter-dialect.ts
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
9
|
+
* Default dialect — MongoDB / MongoKit operator shape. Emits `$`-prefixed
|
|
10
|
+
* tokens and the `$options: 'i'` case-insensitive sibling. This is what
|
|
11
|
+
* `ArcQueryParser` uses when no dialect is supplied, so behaviour is identical
|
|
12
|
+
* to every prior arc version.
|
|
13
|
+
*/
|
|
14
|
+
const MONGO_DIALECT = {
|
|
15
|
+
operators: {
|
|
16
|
+
eq: "$eq",
|
|
17
|
+
ne: "$ne",
|
|
18
|
+
gt: "$gt",
|
|
19
|
+
gte: "$gte",
|
|
20
|
+
lt: "$lt",
|
|
21
|
+
lte: "$lte",
|
|
22
|
+
in: "$in",
|
|
23
|
+
nin: "$nin",
|
|
24
|
+
like: "$regex",
|
|
25
|
+
contains: "$regex",
|
|
26
|
+
regex: "$regex",
|
|
27
|
+
exists: "$exists"
|
|
28
|
+
},
|
|
29
|
+
caseInsensitiveModifier: () => ({ $options: "i" })
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Store-neutral reference dialect — emits UN-prefixed operator tokens as a
|
|
33
|
+
* translation AST (`?price[gte]=40` → `{ price: { gte: 40 } }`, with
|
|
34
|
+
* `{ caseInsensitive: true }` for `like`/`contains`). A non-Mongo adapter can
|
|
35
|
+
* consume this directly instead of writing a full `QueryParserInterface`. Use
|
|
36
|
+
* as a starting point; adjust the token names to your store's vocabulary.
|
|
37
|
+
*/
|
|
38
|
+
const NEUTRAL_DIALECT = {
|
|
39
|
+
operators: {
|
|
40
|
+
eq: "eq",
|
|
41
|
+
ne: "ne",
|
|
42
|
+
gt: "gt",
|
|
43
|
+
gte: "gte",
|
|
44
|
+
lt: "lt",
|
|
45
|
+
lte: "lte",
|
|
46
|
+
in: "in",
|
|
47
|
+
nin: "nin",
|
|
48
|
+
like: "like",
|
|
49
|
+
contains: "contains",
|
|
50
|
+
regex: "regex",
|
|
51
|
+
exists: "exists"
|
|
52
|
+
},
|
|
53
|
+
caseInsensitiveModifier: () => ({ caseInsensitive: true })
|
|
54
|
+
};
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/utils/queryParser.ts
|
|
57
|
+
/**
|
|
58
|
+
* Arc Query Parser - Default URL-to-Query Parser
|
|
14
59
|
*
|
|
15
|
-
*
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
* - HALF_OPEN: Testing if service recovered, limited requests
|
|
60
|
+
* Framework-agnostic query parser that converts URL parameters to query options.
|
|
61
|
+
* This is Arc's built-in parser; users can swap in MongoKit's QueryParser,
|
|
62
|
+
* pgkit's parser, or any custom parser implementing QueryParserInterface.
|
|
19
63
|
*
|
|
20
64
|
* @example
|
|
21
|
-
*
|
|
65
|
+
* // Use Arc default parser (auto-applied if no queryParser option)
|
|
66
|
+
* defineResource({ name: 'product', adapter: ... });
|
|
22
67
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
68
|
+
* // Use MongoKit's QueryParser (recommended for MongoDB - has $lookup, aggregations, etc.)
|
|
69
|
+
* // Fail-closed by default (mongokit >=3.25): invalid/blocked query input
|
|
70
|
+
* // becomes HTTP 400 instead of a silently broadened result set.
|
|
71
|
+
* import { QueryParser } from '@classytic/mongokit';
|
|
72
|
+
* defineResource({
|
|
73
|
+
* name: 'product',
|
|
74
|
+
* adapter: ...,
|
|
75
|
+
* queryParser: new QueryParser(),
|
|
29
76
|
* });
|
|
30
77
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
78
|
+
* // Use custom parser for SQL databases
|
|
79
|
+
* defineResource({
|
|
80
|
+
* name: 'user',
|
|
81
|
+
* adapter: ...,
|
|
82
|
+
* queryParser: new PgQueryParser(),
|
|
83
|
+
* });
|
|
36
84
|
*/
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
this.halfOpenMaxProbes = options.halfOpenMaxProbes ?? 1;
|
|
77
|
-
this.propagateAbort = options.propagateAbort ?? false;
|
|
78
|
-
this.fallback = options.fallback;
|
|
79
|
-
this.onStateChange = options.onStateChange;
|
|
80
|
-
this.onError = options.onError;
|
|
81
|
-
this.name = options.name ?? "CircuitBreaker";
|
|
82
|
-
}
|
|
85
|
+
const log = arcLog("queryParser");
|
|
86
|
+
/**
|
|
87
|
+
* Regex patterns that can cause catastrophic backtracking (ReDoS attacks)
|
|
88
|
+
* Detects:
|
|
89
|
+
* - Quantifiers: {n,m}
|
|
90
|
+
* - Possessive quantifiers: *+, ++, ?+
|
|
91
|
+
* - Nested quantifiers: (a+)+, (a*)*
|
|
92
|
+
* - Backreferences: \1, \2, etc.
|
|
93
|
+
*/
|
|
94
|
+
const DANGEROUS_REGEX_PATTERNS = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
|
|
95
|
+
/**
|
|
96
|
+
* Arc's default query parser
|
|
97
|
+
*
|
|
98
|
+
* Converts URL query parameters to a structured query format:
|
|
99
|
+
* - Pagination: ?page=1&limit=20
|
|
100
|
+
* - Sorting: ?sort=-createdAt,name (- prefix = descending)
|
|
101
|
+
* - Filtering — TWO supported forms (both produce identical output):
|
|
102
|
+
* - Bare (default, terse): `?status=active&price[gte]=100&price[lte]=500`
|
|
103
|
+
* - Bracket envelope: `?filter[status]=active&filter[price][gte]=100`
|
|
104
|
+
* The envelope form matches the convention used by JSON:API / Stripe /
|
|
105
|
+
* most modern REST style guides, and is the safe choice on busy URLs
|
|
106
|
+
* that might otherwise collide with reserved meta keys (`page`, `sort`,
|
|
107
|
+
* `select`, `populate`, etc.). Operator notation (`[gte]`, `[lte]`,
|
|
108
|
+
* `[ne]`, `[in]`, ...) works inside either form.
|
|
109
|
+
* - Search: ?search=keyword
|
|
110
|
+
* - Populate: ?populate=author,category
|
|
111
|
+
* - Field selection: ?select=name,price,status
|
|
112
|
+
* - Keyset pagination: ?after=cursor_value
|
|
113
|
+
*
|
|
114
|
+
* **NOTE on parser portability.** `ArcQueryParser` accepts both filter
|
|
115
|
+
* forms. Other parser implementations (`@classytic/mongokit`'s
|
|
116
|
+
* `QueryParser`, custom hosts-side parsers) may accept only one form —
|
|
117
|
+
* MongoKit historically only accepts the bare form. If your host swaps
|
|
118
|
+
* `queryParser` to a different implementation, double-check which forms
|
|
119
|
+
* it understands before publishing URLs that mix the two.
|
|
120
|
+
*
|
|
121
|
+
* For advanced MongoDB features ($lookup, aggregations), use MongoKit's QueryParser.
|
|
122
|
+
*/
|
|
123
|
+
var ArcQueryParser = class {
|
|
83
124
|
/**
|
|
84
|
-
*
|
|
125
|
+
* PUBLIC, deliberately: `QueryResolver` reads it so it does not apply a second cap
|
|
126
|
+
* on top of this one. Three layers capping independently, lowest winning silently,
|
|
127
|
+
* is what made a 696-row chart of accounts render as 100.
|
|
85
128
|
*/
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
129
|
+
maxLimit;
|
|
130
|
+
defaultLimit;
|
|
131
|
+
maxRegexLength;
|
|
132
|
+
maxSearchLength;
|
|
133
|
+
maxFilterDepth;
|
|
134
|
+
_allowedFilterFields;
|
|
135
|
+
_allowedSortFields;
|
|
136
|
+
_allowedOperators;
|
|
137
|
+
strictFilterFields;
|
|
138
|
+
/** Allowed filter fields (used by MCP for auto-derive) */
|
|
139
|
+
allowedFilterFields;
|
|
140
|
+
/** Allowed sort fields (used by MCP for sort descriptions) */
|
|
141
|
+
allowedSortFields;
|
|
142
|
+
/** Allowed operators (used by MCP for operator descriptions) */
|
|
143
|
+
allowedOperators;
|
|
144
|
+
/**
|
|
145
|
+
* Datastore dialect — owns the store-specific operator tokens + the
|
|
146
|
+
* case-insensitive modifier. Defaults to Mongo (backward-compatible).
|
|
147
|
+
*/
|
|
148
|
+
dialect;
|
|
149
|
+
/** Supported filter operators (neutral name → emitted token), from the dialect. */
|
|
150
|
+
get operators() {
|
|
151
|
+
return this.dialect.operators;
|
|
152
|
+
}
|
|
153
|
+
constructor(options = {}) {
|
|
154
|
+
this.dialect = options.dialect ?? MONGO_DIALECT;
|
|
155
|
+
this.maxLimit = options.maxLimit ?? 1e3;
|
|
156
|
+
this.defaultLimit = options.defaultLimit ?? 20;
|
|
157
|
+
this.maxRegexLength = options.maxRegexLength ?? 200;
|
|
158
|
+
this.maxSearchLength = options.maxSearchLength ?? 200;
|
|
159
|
+
this.maxFilterDepth = options.maxFilterDepth ?? 10;
|
|
160
|
+
this.strictFilterFields = options.strictFilterFields ?? process.env.ARC_STRICT_QUERY_PARAMS === "true";
|
|
161
|
+
if (options.allowedFilterFields) {
|
|
162
|
+
this._allowedFilterFields = new Set(options.allowedFilterFields);
|
|
163
|
+
this.allowedFilterFields = options.allowedFilterFields;
|
|
96
164
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (this.fallback) return this.fallback(...args);
|
|
101
|
-
throw new CircuitBreakerError(`Circuit breaker is HALF_OPEN for ${this.name} — probe slots full`, CircuitState.HALF_OPEN);
|
|
102
|
-
}
|
|
103
|
-
this.halfOpenInFlight++;
|
|
104
|
-
isProbe = true;
|
|
165
|
+
if (options.allowedSortFields) {
|
|
166
|
+
this._allowedSortFields = new Set(options.allowedSortFields);
|
|
167
|
+
this.allowedSortFields = options.allowedSortFields;
|
|
105
168
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
this.
|
|
109
|
-
return result;
|
|
110
|
-
} catch (err) {
|
|
111
|
-
this.onFailure(err instanceof Error ? err : new Error(String(err)));
|
|
112
|
-
throw err;
|
|
113
|
-
} finally {
|
|
114
|
-
if (isProbe) this.halfOpenInFlight--;
|
|
169
|
+
if (options.allowedOperators) {
|
|
170
|
+
this._allowedOperators = new Set(options.allowedOperators);
|
|
171
|
+
this.allowedOperators = options.allowedOperators;
|
|
115
172
|
}
|
|
116
173
|
}
|
|
117
174
|
/**
|
|
118
|
-
*
|
|
119
|
-
* receives a trailing AbortSignal that fires on timeout — the breaker's
|
|
120
|
-
* timeout otherwise only rejects the CALLER while the underlying work
|
|
121
|
-
* keeps consuming sockets/pool slots to completion.
|
|
175
|
+
* Parse URL query parameters into structured query options
|
|
122
176
|
*/
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
177
|
+
parse(query) {
|
|
178
|
+
const q = query ?? {};
|
|
179
|
+
const page = this.parseNumber(q.page, 1);
|
|
180
|
+
const limit = Math.min(this.parseNumber(q.limit, this.defaultLimit), this.maxLimit);
|
|
181
|
+
const after = this.parseString(q.after ?? q.cursor);
|
|
182
|
+
const sort = this.parseSort(q.sort);
|
|
183
|
+
const { populate, populateOptions } = this.parsePopulate(q.populate);
|
|
184
|
+
const search = this.parseSearch(q.search);
|
|
185
|
+
const select = this.parseSelect(q.select);
|
|
186
|
+
return {
|
|
187
|
+
filters: this.parseFilters(q),
|
|
188
|
+
limit,
|
|
189
|
+
sort,
|
|
190
|
+
populate,
|
|
191
|
+
populateOptions,
|
|
192
|
+
search,
|
|
193
|
+
page: after ? void 0 : page,
|
|
194
|
+
after,
|
|
195
|
+
select
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
parseNumber(value, defaultValue) {
|
|
199
|
+
if (value === void 0 || value === null) return defaultValue;
|
|
200
|
+
const num = parseInt(String(value), 10);
|
|
201
|
+
return Number.isNaN(num) ? defaultValue : Math.max(1, num);
|
|
202
|
+
}
|
|
203
|
+
parseString(value) {
|
|
204
|
+
if (value === void 0 || value === null) return void 0;
|
|
205
|
+
const str = String(value).trim();
|
|
206
|
+
return str.length > 0 ? str : void 0;
|
|
138
207
|
}
|
|
139
208
|
/**
|
|
140
|
-
*
|
|
209
|
+
* Parse populate parameter — handles both simple string and bracket notation.
|
|
210
|
+
*
|
|
211
|
+
* Simple: ?populate=author,category → { populate: 'author,category' }
|
|
212
|
+
* Bracket: ?populate[author][select]=name,email → { populateOptions: [{ path: 'author', select: 'name email' }] }
|
|
141
213
|
*/
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
214
|
+
parsePopulate(value) {
|
|
215
|
+
if (value === void 0 || value === null) return {};
|
|
216
|
+
if (typeof value === "string") {
|
|
217
|
+
const trimmed = value.trim();
|
|
218
|
+
return trimmed.length > 0 ? { populate: trimmed } : {};
|
|
219
|
+
}
|
|
220
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
221
|
+
const obj = value;
|
|
222
|
+
const keys = Object.keys(obj);
|
|
223
|
+
if (keys.length === 0) return {};
|
|
224
|
+
const options = [];
|
|
225
|
+
for (const path of keys) {
|
|
226
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(path)) continue;
|
|
227
|
+
const config = obj[path];
|
|
228
|
+
if (typeof config === "object" && config !== null && !Array.isArray(config)) {
|
|
229
|
+
const cfg = config;
|
|
230
|
+
const option = { path };
|
|
231
|
+
if (typeof cfg.select === "string") option.select = cfg.select.split(",").map((s) => s.trim()).filter(Boolean).join(" ");
|
|
232
|
+
if (typeof cfg.match === "object" && cfg.match !== null) option.match = cfg.match;
|
|
233
|
+
options.push(option);
|
|
234
|
+
} else options.push({ path });
|
|
149
235
|
}
|
|
236
|
+
return options.length > 0 ? { populateOptions: options } : {};
|
|
150
237
|
}
|
|
238
|
+
return {};
|
|
151
239
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
this.
|
|
162
|
-
|
|
240
|
+
parseSort(value) {
|
|
241
|
+
if (!value) return void 0;
|
|
242
|
+
const sortStr = String(value);
|
|
243
|
+
const result = {};
|
|
244
|
+
for (const field of sortStr.split(",")) {
|
|
245
|
+
const trimmed = field.trim();
|
|
246
|
+
if (!trimmed) continue;
|
|
247
|
+
if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
|
|
248
|
+
const fieldName = trimmed.startsWith("-") ? trimmed.slice(1) : trimmed;
|
|
249
|
+
if (this._allowedSortFields && !this._allowedSortFields.has(fieldName)) continue;
|
|
250
|
+
if (trimmed.startsWith("-")) result[fieldName] = -1;
|
|
251
|
+
else result[fieldName] = 1;
|
|
163
252
|
}
|
|
253
|
+
return Object.keys(result).length > 0 ? result : void 0;
|
|
164
254
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
255
|
+
parseSearch(value) {
|
|
256
|
+
if (!value) return void 0;
|
|
257
|
+
const search = String(value).trim();
|
|
258
|
+
if (search.length === 0) return void 0;
|
|
259
|
+
if (search.length > this.maxSearchLength) return search.slice(0, this.maxSearchLength);
|
|
260
|
+
return search;
|
|
261
|
+
}
|
|
262
|
+
parseSelect(value) {
|
|
263
|
+
if (!value) return void 0;
|
|
264
|
+
const selectStr = String(value);
|
|
265
|
+
const result = {};
|
|
266
|
+
for (const field of selectStr.split(",")) {
|
|
267
|
+
const trimmed = field.trim();
|
|
268
|
+
if (!trimmed) continue;
|
|
269
|
+
if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
|
|
270
|
+
if (trimmed.startsWith("-")) result[trimmed.slice(1)] = 0;
|
|
271
|
+
else result[trimmed] = 1;
|
|
173
272
|
}
|
|
273
|
+
return Object.keys(result).length > 0 ? result : void 0;
|
|
174
274
|
}
|
|
175
275
|
/**
|
|
176
|
-
*
|
|
276
|
+
* Check if a value exceeds the maximum nesting depth.
|
|
277
|
+
* Prevents filter bombs where deeply nested objects consume excessive memory/CPU.
|
|
177
278
|
*/
|
|
178
|
-
|
|
179
|
-
this.
|
|
180
|
-
|
|
181
|
-
|
|
279
|
+
exceedsDepth(obj, currentDepth = 0) {
|
|
280
|
+
if (currentDepth > this.maxFilterDepth) return true;
|
|
281
|
+
if (obj === null || obj === void 0) return false;
|
|
282
|
+
if (Array.isArray(obj)) return obj.some((v) => this.exceedsDepth(v, currentDepth));
|
|
283
|
+
if (typeof obj !== "object") return false;
|
|
284
|
+
return Object.values(obj).some((v) => this.exceedsDepth(v, currentDepth + 1));
|
|
182
285
|
}
|
|
183
286
|
/**
|
|
184
|
-
*
|
|
287
|
+
* Parse all filter entries from the query, supporting BOTH the bare
|
|
288
|
+
* top-level form and the bracket-wrapped `filter[...]` envelope.
|
|
289
|
+
*
|
|
290
|
+
* Two equivalent ways to express the same query:
|
|
291
|
+
* - Bare (legacy, arc default): `?status=active&price[gte]=40`
|
|
292
|
+
* - Bracket envelope (REST convention): `?filter[status]=active&filter[price][gte]=40`
|
|
293
|
+
*
|
|
294
|
+
* Both forms parse to the same `filters: { status: 'active', price: { $gte: 40 } }`.
|
|
295
|
+
* The envelope form mirrors the convention used by most REST API style
|
|
296
|
+
* guides (JSON:API, Stripe API, etc.) and disambiguates filter fields
|
|
297
|
+
* from reserved meta params on busy URLs — a query string with a field
|
|
298
|
+
* literally named `page` or `sort` can't collide with the framework's
|
|
299
|
+
* meta keys when wrapped under `filter[...]`. The bare form stays as
|
|
300
|
+
* the default since it's the shortest path for simple queries; expect
|
|
301
|
+
* it to remain supported indefinitely.
|
|
302
|
+
*
|
|
303
|
+
* Precedence when the same key appears in both forms:
|
|
304
|
+
* `?status=closed&filter[status]=active` → bare wins (`status: closed`).
|
|
305
|
+
* Mixing should be rare; the deterministic rule avoids silent surprises.
|
|
185
306
|
*/
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
307
|
+
parseFilters(query) {
|
|
308
|
+
const filters = {};
|
|
309
|
+
const envelope = query.filter;
|
|
310
|
+
if (typeof envelope === "object" && envelope !== null && !Array.isArray(envelope)) for (const [key, value] of Object.entries(envelope)) this.applyFilterEntry(filters, key, value);
|
|
311
|
+
for (const [key, value] of Object.entries(query)) {
|
|
312
|
+
if (RESERVED_QUERY_PARAMS.has(key)) continue;
|
|
313
|
+
this.applyFilterEntry(filters, key, value);
|
|
314
|
+
}
|
|
315
|
+
return filters;
|
|
191
316
|
}
|
|
192
317
|
/**
|
|
193
|
-
*
|
|
318
|
+
* Apply one filter entry — used by both the bracket-envelope branch and
|
|
319
|
+
* the bare top-level branch of `parseFilters`. Centralising the field-
|
|
320
|
+
* validation + operator-conversion logic keeps the two forms in
|
|
321
|
+
* lockstep; a future regex/security tweak only changes one place.
|
|
194
322
|
*/
|
|
195
|
-
|
|
196
|
-
return
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
323
|
+
applyFilterEntry(filters, key, value) {
|
|
324
|
+
if (value === void 0 || value === null) return;
|
|
325
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_.]*(?:\[[a-z]+\])?$/.test(key)) return;
|
|
326
|
+
const bareKey = key.replace(/\[[a-z]+\]$/, "");
|
|
327
|
+
if (this._allowedFilterFields && !this._allowedFilterFields.has(bareKey)) {
|
|
328
|
+
if (this.strictFilterFields) throw new ValidationError(`Unknown filter field: ${bareKey}. Allowed: ${[...this._allowedFilterFields].sort().join(", ")}`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (this.exceedsDepth(value)) return;
|
|
332
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
333
|
+
const operatorObj = value;
|
|
334
|
+
const operatorKeys = Object.keys(operatorObj);
|
|
335
|
+
const allOperators = operatorKeys.every((op) => this.operators[op] && (!this._allowedOperators || this._allowedOperators.has(op)));
|
|
336
|
+
const allKnownOperators = operatorKeys.every((op) => this.operators[op]);
|
|
337
|
+
if (allOperators && operatorKeys.length > 0) {
|
|
338
|
+
const fieldOps = {};
|
|
339
|
+
let needsCaseInsensitive = false;
|
|
340
|
+
for (const [op, opValue] of Object.entries(operatorObj)) {
|
|
341
|
+
const token = this.operators[op];
|
|
342
|
+
if (token) {
|
|
343
|
+
fieldOps[token] = this.parseFilterValue(opValue, op);
|
|
344
|
+
if (op === "contains" || op === "like") needsCaseInsensitive = true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (needsCaseInsensitive) Object.assign(fieldOps, this.dialect.caseInsensitiveModifier());
|
|
348
|
+
filters[key] = fieldOps;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (allKnownOperators && this._allowedOperators) return;
|
|
352
|
+
}
|
|
353
|
+
const match = key.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)(?:\[([a-z]+)\])?$/);
|
|
354
|
+
if (!match) return;
|
|
355
|
+
const [, fieldName, operator] = match;
|
|
356
|
+
if (!fieldName) return;
|
|
357
|
+
if (operator && this.operators[operator] && (!this._allowedOperators || this._allowedOperators.has(operator))) {
|
|
358
|
+
const token = this.operators[operator];
|
|
359
|
+
const parsedValue = this.parseFilterValue(value, operator);
|
|
360
|
+
if (!filters[fieldName]) filters[fieldName] = {};
|
|
361
|
+
const fieldFilter = filters[fieldName];
|
|
362
|
+
fieldFilter[token] = parsedValue;
|
|
363
|
+
if (operator === "contains" || operator === "like") Object.assign(fieldFilter, this.dialect.caseInsensitiveModifier());
|
|
364
|
+
} else if (!operator) filters[fieldName] = this.parseFilterValue(value);
|
|
205
365
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
366
|
+
parseFilterValue(value, operator) {
|
|
367
|
+
if (operator === "in" || operator === "nin") {
|
|
368
|
+
if (Array.isArray(value)) return value.map((v) => this.coerceValue(v));
|
|
369
|
+
if (typeof value === "string" && value.includes(",")) return value.split(",").map((v) => this.coerceValue(v.trim()));
|
|
370
|
+
return [this.coerceValue(value)];
|
|
371
|
+
}
|
|
372
|
+
if (operator === "like" || operator === "contains" || operator === "regex") return this.sanitizeRegex(String(value));
|
|
373
|
+
if (operator === "exists") {
|
|
374
|
+
const str = String(value).toLowerCase();
|
|
375
|
+
return str === "true" || str === "1";
|
|
376
|
+
}
|
|
377
|
+
return this.coerceValue(value);
|
|
211
378
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
isClosed() {
|
|
222
|
-
return this.state === CircuitState.CLOSED;
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* Reset statistics
|
|
226
|
-
*/
|
|
227
|
-
reset() {
|
|
228
|
-
this.failures = 0;
|
|
229
|
-
this.successes = 0;
|
|
230
|
-
this.totalCalls = 0;
|
|
231
|
-
this.lastCallAt = null;
|
|
232
|
-
this.openedAt = null;
|
|
233
|
-
this.setState(CircuitState.CLOSED);
|
|
379
|
+
coerceValue(value) {
|
|
380
|
+
if (value === "true") return true;
|
|
381
|
+
if (value === "false") return false;
|
|
382
|
+
if (value === "null") return null;
|
|
383
|
+
if (typeof value === "string") {
|
|
384
|
+
const num = Number(value);
|
|
385
|
+
if (!Number.isNaN(num) && value.trim() !== "") return num;
|
|
386
|
+
}
|
|
387
|
+
return value;
|
|
234
388
|
}
|
|
235
|
-
};
|
|
236
|
-
/**
|
|
237
|
-
* Create a circuit breaker with sensible defaults
|
|
238
|
-
*
|
|
239
|
-
* @example
|
|
240
|
-
* const emailBreaker = createCircuitBreaker(
|
|
241
|
-
* async (to, subject, body) => sendEmail(to, subject, body),
|
|
242
|
-
* { name: 'email-service' }
|
|
243
|
-
* );
|
|
244
|
-
*/
|
|
245
|
-
function createCircuitBreaker(fn, options) {
|
|
246
|
-
return new CircuitBreaker(fn, options);
|
|
247
|
-
}
|
|
248
|
-
/**
|
|
249
|
-
* Circuit breaker registry for managing multiple breakers
|
|
250
|
-
*/
|
|
251
|
-
var CircuitBreakerRegistry = class {
|
|
252
|
-
breakers = /* @__PURE__ */ new Map();
|
|
253
389
|
/**
|
|
254
|
-
*
|
|
390
|
+
* Generate OpenAPI-compatible JSON Schema for query parameters.
|
|
391
|
+
* Arc's defineResource() auto-detects this method and uses it
|
|
392
|
+
* to document list endpoint query parameters in OpenAPI/Swagger.
|
|
255
393
|
*/
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
394
|
+
getQuerySchema() {
|
|
395
|
+
const operatorLines = Object.entries(this.operators).map(([op, mongoOp]) => {
|
|
396
|
+
return ` ${op} → ${mongoOp}: ${{
|
|
397
|
+
eq: "Equal (default when no operator specified)",
|
|
398
|
+
ne: "Not equal",
|
|
399
|
+
gt: "Greater than",
|
|
400
|
+
gte: "Greater than or equal",
|
|
401
|
+
lt: "Less than",
|
|
402
|
+
lte: "Less than or equal",
|
|
403
|
+
in: "In list (comma-separated values)",
|
|
404
|
+
nin: "Not in list",
|
|
405
|
+
like: "Pattern match (case-insensitive)",
|
|
406
|
+
contains: "Contains substring (case-insensitive)",
|
|
407
|
+
regex: "Regex pattern",
|
|
408
|
+
exists: "Field exists (true/false)"
|
|
409
|
+
}[op] || op}`;
|
|
260
410
|
});
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
411
|
+
return {
|
|
412
|
+
type: "object",
|
|
413
|
+
properties: {
|
|
414
|
+
page: {
|
|
415
|
+
type: "integer",
|
|
416
|
+
description: "Page number for offset pagination",
|
|
417
|
+
default: 1,
|
|
418
|
+
minimum: 1
|
|
419
|
+
},
|
|
420
|
+
limit: {
|
|
421
|
+
type: "integer",
|
|
422
|
+
/**
|
|
423
|
+
* The cap is DOCUMENTED here, not enforced — deliberately.
|
|
424
|
+
*
|
|
425
|
+
* Both layers that handle `limit` CLAMP it: this parser
|
|
426
|
+
* (`Math.min(…, this.maxLimit)`) and `QueryResolver`. Emitting
|
|
427
|
+
* `maximum` contradicted them, because arc layers this schema as the
|
|
428
|
+
* route's `listQuery` and Fastify validates the querystring BEFORE
|
|
429
|
+
* either runs — so the clamp was unreachable and `?limit=200` became a
|
|
430
|
+
* hard 400.
|
|
431
|
+
*
|
|
432
|
+
* That combination is worse than either policy alone. Asking for more
|
|
433
|
+
* rows than allowed is a benign over-ask with an obvious right answer
|
|
434
|
+
* (give the maximum); turning it into a validation failure means a UI
|
|
435
|
+
* passing a generous page size gets NOTHING, and a caller that does not
|
|
436
|
+
* inspect the error body renders it as an empty list — a 400 disguised
|
|
437
|
+
* as "no results", which is the hardest kind to find.
|
|
438
|
+
*
|
|
439
|
+
* A deployment wanting refusal instead of clamping should say so in the
|
|
440
|
+
* parser's policy, where both halves can agree, not in a schema that
|
|
441
|
+
* silently outranks them. Mirrors the same fix in mongokit's
|
|
442
|
+
* `buildQuerySchema`.
|
|
443
|
+
*/
|
|
444
|
+
description: `Number of items per page (values above ${this.maxLimit} are capped to ${this.maxLimit})`,
|
|
445
|
+
default: this.defaultLimit,
|
|
446
|
+
minimum: 1
|
|
447
|
+
},
|
|
448
|
+
sort: {
|
|
449
|
+
type: "string",
|
|
450
|
+
description: "Sort fields (comma-separated). Prefix with - for descending. Example: -createdAt,name"
|
|
451
|
+
},
|
|
452
|
+
search: {
|
|
453
|
+
type: "string",
|
|
454
|
+
description: "Full-text search query",
|
|
455
|
+
maxLength: this.maxSearchLength
|
|
456
|
+
},
|
|
457
|
+
select: {
|
|
458
|
+
type: "string",
|
|
459
|
+
description: "Fields to include/exclude (comma-separated). Prefix with - to exclude. Example: name,email,-password"
|
|
460
|
+
},
|
|
461
|
+
populate: {
|
|
462
|
+
type: "string",
|
|
463
|
+
description: "Fields to populate/join (comma-separated). Example: author,category"
|
|
464
|
+
},
|
|
465
|
+
after: {
|
|
466
|
+
type: "string",
|
|
467
|
+
description: "Cursor value for keyset pagination"
|
|
468
|
+
},
|
|
469
|
+
_filterOperators: {
|
|
470
|
+
type: "string",
|
|
471
|
+
description: ["Available filter operators (use as field[operator]=value):", ...operatorLines].join("\n")
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
};
|
|
295
475
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
476
|
+
sanitizeRegex(pattern) {
|
|
477
|
+
const truncated = pattern.length > this.maxRegexLength;
|
|
478
|
+
let sanitized = pattern.slice(0, this.maxRegexLength);
|
|
479
|
+
if (DANGEROUS_REGEX_PATTERNS.test(sanitized)) {
|
|
480
|
+
sanitized = sanitized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
481
|
+
log.warn(`regex pattern matched a ReDoS-dangerous shape and was escaped to a literal string. original="${pattern}" sanitized="${sanitized}"`);
|
|
482
|
+
} else if (truncated) log.warn(`regex pattern exceeded maxRegexLength (${this.maxRegexLength}) and was truncated. original-length=${pattern.length}`);
|
|
483
|
+
return sanitized;
|
|
301
484
|
}
|
|
302
485
|
};
|
|
303
486
|
/**
|
|
304
|
-
* Create a new
|
|
305
|
-
* Use this instead of a global singleton — attach to fastify.arc or pass explicitly.
|
|
487
|
+
* Create a new ArcQueryParser instance
|
|
306
488
|
*/
|
|
307
|
-
function
|
|
308
|
-
return new
|
|
489
|
+
function createQueryParser(options) {
|
|
490
|
+
return new ArcQueryParser(options);
|
|
309
491
|
}
|
|
310
492
|
//#endregion
|
|
311
|
-
//#region src/utils/
|
|
493
|
+
//#region src/utils/responseSchemas.ts
|
|
312
494
|
/**
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
495
|
+
* JSON Schema definitions for arc API responses.
|
|
496
|
+
*
|
|
497
|
+
* Wire shape (post-2.12): no envelope. HTTP status discriminates success
|
|
498
|
+
* vs error. Success-path schemas describe the data shape directly; the
|
|
499
|
+
* error path uses the canonical `ErrorContract` JSON Schema imported
|
|
500
|
+
* from `@classytic/repo-core/errors` — single source of truth shared
|
|
501
|
+
* with every other consumer in the org.
|
|
317
502
|
*/
|
|
318
|
-
function safeHook(name, invoke, hooks) {
|
|
319
|
-
try {
|
|
320
|
-
invoke();
|
|
321
|
-
} catch (err) {
|
|
322
|
-
try {
|
|
323
|
-
hooks?.onHookError?.(name, err instanceof Error ? err : new Error(String(err)));
|
|
324
|
-
} catch {}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
503
|
/**
|
|
328
|
-
*
|
|
504
|
+
* Pagination schema - matches MongoKit/Arc runtime format
|
|
329
505
|
*
|
|
330
|
-
*
|
|
506
|
+
* Runtime format (flat fields):
|
|
507
|
+
* { page, limit, total, pages, hasNext, hasPrev }
|
|
331
508
|
*/
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
509
|
+
const paginationSchema = {
|
|
510
|
+
type: "object",
|
|
511
|
+
properties: {
|
|
512
|
+
page: {
|
|
513
|
+
type: "integer",
|
|
514
|
+
example: 1
|
|
515
|
+
},
|
|
516
|
+
limit: {
|
|
517
|
+
type: "integer",
|
|
518
|
+
example: 20
|
|
519
|
+
},
|
|
520
|
+
total: {
|
|
521
|
+
type: "integer",
|
|
522
|
+
example: 100
|
|
523
|
+
},
|
|
524
|
+
pages: {
|
|
525
|
+
type: "integer",
|
|
526
|
+
example: 5
|
|
527
|
+
},
|
|
528
|
+
hasNext: {
|
|
529
|
+
type: "boolean",
|
|
530
|
+
example: true
|
|
531
|
+
},
|
|
532
|
+
hasPrev: {
|
|
533
|
+
type: "boolean",
|
|
534
|
+
example: false
|
|
341
535
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
536
|
+
},
|
|
537
|
+
required: [
|
|
538
|
+
"page",
|
|
539
|
+
"limit",
|
|
540
|
+
"total",
|
|
541
|
+
"pages",
|
|
542
|
+
"hasNext",
|
|
543
|
+
"hasPrev"
|
|
544
|
+
]
|
|
545
|
+
};
|
|
546
|
+
/**
|
|
547
|
+
* List response schema — accepts every wire shape `toCanonicalList` can
|
|
548
|
+
* emit. Hosts who know their endpoint only ever produces one variant can
|
|
549
|
+
* pin to a narrower helper (sharper OpenAPI docs, same serializer speed):
|
|
550
|
+
* - `offsetListResponse(item)` — `{ method: 'offset', data, page, limit, total, pages, hasNext, hasPrev }`
|
|
551
|
+
* - `keysetListResponse(item)` — `{ method: 'keyset', data, limit, hasMore, next: string|null }`
|
|
552
|
+
* - `aggregateListResponse(item)` — `{ method: 'aggregate', ...offset fields }`
|
|
553
|
+
* - `bareListResponse(item)` — `{ data }`
|
|
554
|
+
*
|
|
555
|
+
* SHAPE: one merged permissive object, NOT a `oneOf` union (2.22). The
|
|
556
|
+
* pre-2.22 union was semantically identical on the wire — every variant is
|
|
557
|
+
* `additionalProperties: true` and the property definitions never conflict
|
|
558
|
+
* (`next` only exists on keyset, the offset counters only on offset/
|
|
559
|
+
* aggregate; fast-json-stringify skips undefined optionals) — but
|
|
560
|
+
* fast-json-stringify implements top-level `oneOf` by running full AJV
|
|
561
|
+
* validation PER RESPONSE to pick the branch, on the hottest route of
|
|
562
|
+
* every arc app. The merge keeps the exact bytes (pinned by
|
|
563
|
+
* `tests/utils/list-response-serialization.test.ts`) and drops the
|
|
564
|
+
* per-response branch validation. Only `data` is required — the one field
|
|
565
|
+
* all four variants share.
|
|
566
|
+
*/
|
|
567
|
+
function listResponse(itemSchema) {
|
|
365
568
|
return {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
569
|
+
type: "object",
|
|
570
|
+
properties: {
|
|
571
|
+
method: {
|
|
572
|
+
type: "string",
|
|
573
|
+
description: "Pagination method discriminant — 'offset' | 'keyset' | 'aggregate'; absent on bare lists.",
|
|
574
|
+
example: "offset"
|
|
575
|
+
},
|
|
576
|
+
data: {
|
|
577
|
+
type: "array",
|
|
578
|
+
items: itemSchema
|
|
579
|
+
},
|
|
580
|
+
page: {
|
|
581
|
+
type: "integer",
|
|
582
|
+
example: 1
|
|
583
|
+
},
|
|
584
|
+
limit: {
|
|
585
|
+
type: "integer",
|
|
586
|
+
example: 20
|
|
587
|
+
},
|
|
588
|
+
total: {
|
|
589
|
+
type: "integer",
|
|
590
|
+
example: 100
|
|
591
|
+
},
|
|
592
|
+
pages: {
|
|
593
|
+
type: "integer",
|
|
594
|
+
example: 5
|
|
595
|
+
},
|
|
596
|
+
hasNext: {
|
|
597
|
+
type: "boolean",
|
|
598
|
+
example: false
|
|
599
|
+
},
|
|
600
|
+
hasPrev: {
|
|
601
|
+
type: "boolean",
|
|
602
|
+
example: false
|
|
603
|
+
},
|
|
604
|
+
hasMore: { type: "boolean" },
|
|
605
|
+
next: {
|
|
606
|
+
type: ["string", "null"],
|
|
607
|
+
description: "Cursor token for the next page, or null."
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
required: ["data"],
|
|
611
|
+
additionalProperties: true
|
|
369
612
|
};
|
|
370
613
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
for (let i = completed.length - 1; i >= 0; i--) {
|
|
374
|
-
const entry = completed[i];
|
|
375
|
-
if (!entry?.step.compensate) continue;
|
|
376
|
-
const compensateFn = entry.step.compensate;
|
|
377
|
-
try {
|
|
378
|
-
await compensateFn(ctx, entry.result);
|
|
379
|
-
safeHook("onCompensate", () => hooks?.onCompensate?.(entry.step.name), hooks);
|
|
380
|
-
} catch (err) {
|
|
381
|
-
errors.push({
|
|
382
|
-
step: entry.step.name,
|
|
383
|
-
error: err instanceof Error ? err.message : String(err)
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return errors;
|
|
388
|
-
}
|
|
389
|
-
function defineCompensation(name, steps) {
|
|
614
|
+
/** Offset variant — `{ method: 'offset', data, page, limit, total, pages, hasNext, hasPrev }`. */
|
|
615
|
+
function offsetListResponse(itemSchema) {
|
|
390
616
|
return {
|
|
391
|
-
|
|
392
|
-
|
|
617
|
+
type: "object",
|
|
618
|
+
properties: {
|
|
619
|
+
method: {
|
|
620
|
+
type: "string",
|
|
621
|
+
const: "offset",
|
|
622
|
+
example: "offset"
|
|
623
|
+
},
|
|
624
|
+
data: {
|
|
625
|
+
type: "array",
|
|
626
|
+
items: itemSchema
|
|
627
|
+
},
|
|
628
|
+
page: {
|
|
629
|
+
type: "integer",
|
|
630
|
+
example: 1
|
|
631
|
+
},
|
|
632
|
+
limit: {
|
|
633
|
+
type: "integer",
|
|
634
|
+
example: 20
|
|
635
|
+
},
|
|
636
|
+
total: {
|
|
637
|
+
type: "integer",
|
|
638
|
+
example: 100
|
|
639
|
+
},
|
|
640
|
+
pages: {
|
|
641
|
+
type: "integer",
|
|
642
|
+
example: 5
|
|
643
|
+
},
|
|
644
|
+
hasNext: {
|
|
645
|
+
type: "boolean",
|
|
646
|
+
example: false
|
|
647
|
+
},
|
|
648
|
+
hasPrev: {
|
|
649
|
+
type: "boolean",
|
|
650
|
+
example: false
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
required: [
|
|
654
|
+
"method",
|
|
655
|
+
"data",
|
|
656
|
+
"page",
|
|
657
|
+
"limit",
|
|
658
|
+
"total",
|
|
659
|
+
"pages",
|
|
660
|
+
"hasNext",
|
|
661
|
+
"hasPrev"
|
|
662
|
+
],
|
|
663
|
+
additionalProperties: true
|
|
393
664
|
};
|
|
394
665
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Register an `ErrorMapper` with its domain-specific generic argument and
|
|
399
|
-
* have it assign cleanly into `ErrorMapper[]` (no `as unknown as ErrorMapper`).
|
|
400
|
-
*
|
|
401
|
-
* The returned mapper is identical at runtime — `type` and `toResponse` are
|
|
402
|
-
* passed through untouched. Only the declared type widens from
|
|
403
|
-
* `ErrorMapper<T>` to `ErrorMapper` so the array inference works.
|
|
404
|
-
*
|
|
405
|
-
* Safety: the `errorHandlerPlugin` dispatches via `error instanceof mapper.type`
|
|
406
|
-
* before invoking `toResponse`, so the widened callback signature is never
|
|
407
|
-
* called with a non-`T` error at runtime. This helper codifies that invariant
|
|
408
|
-
* in one place.
|
|
409
|
-
*/
|
|
410
|
-
function defineErrorMapper(mapper) {
|
|
411
|
-
return mapper;
|
|
412
|
-
}
|
|
413
|
-
//#endregion
|
|
414
|
-
//#region src/utils/defineGuard.ts
|
|
415
|
-
/** Hidden property key for guard context storage on the request object. */
|
|
416
|
-
const GUARD_STORE_KEY = "__arcGuardContext";
|
|
417
|
-
/**
|
|
418
|
-
* Create a typed guard. See module JSDoc for usage.
|
|
419
|
-
*/
|
|
420
|
-
function defineGuard(config) {
|
|
421
|
-
const { name, resolve } = config;
|
|
422
|
-
const preHandler = async (req, reply) => {
|
|
423
|
-
const ctx = await resolve(req, reply);
|
|
424
|
-
if (!reply.sent) {
|
|
425
|
-
const store = req[GUARD_STORE_KEY] ?? {};
|
|
426
|
-
store[name] = ctx;
|
|
427
|
-
req[GUARD_STORE_KEY] = store;
|
|
428
|
-
}
|
|
429
|
-
};
|
|
666
|
+
/** Keyset variant — `{ method: 'keyset', data, limit, hasMore, next: string | null }`. */
|
|
667
|
+
function keysetListResponse(itemSchema) {
|
|
430
668
|
return {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
669
|
+
type: "object",
|
|
670
|
+
properties: {
|
|
671
|
+
method: {
|
|
672
|
+
type: "string",
|
|
673
|
+
const: "keyset",
|
|
674
|
+
example: "keyset"
|
|
675
|
+
},
|
|
676
|
+
data: {
|
|
677
|
+
type: "array",
|
|
678
|
+
items: itemSchema
|
|
679
|
+
},
|
|
680
|
+
limit: {
|
|
681
|
+
type: "integer",
|
|
682
|
+
example: 20
|
|
683
|
+
},
|
|
684
|
+
hasMore: {
|
|
685
|
+
type: "boolean",
|
|
686
|
+
example: true
|
|
687
|
+
},
|
|
688
|
+
next: {
|
|
689
|
+
type: ["string", "null"],
|
|
690
|
+
description: "Cursor token for the next page, or null."
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
required: [
|
|
694
|
+
"method",
|
|
695
|
+
"data",
|
|
696
|
+
"limit",
|
|
697
|
+
"hasMore",
|
|
698
|
+
"next"
|
|
699
|
+
],
|
|
700
|
+
additionalProperties: true
|
|
438
701
|
};
|
|
439
702
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
703
|
+
/** Aggregate variant — same shape as offset, discriminated by `method: 'aggregate'`. */
|
|
704
|
+
function aggregateListResponse(itemSchema) {
|
|
705
|
+
return {
|
|
706
|
+
type: "object",
|
|
707
|
+
properties: {
|
|
708
|
+
method: {
|
|
709
|
+
type: "string",
|
|
710
|
+
const: "aggregate",
|
|
711
|
+
example: "aggregate"
|
|
712
|
+
},
|
|
713
|
+
data: {
|
|
714
|
+
type: "array",
|
|
715
|
+
items: itemSchema
|
|
716
|
+
},
|
|
717
|
+
page: { type: "integer" },
|
|
718
|
+
limit: { type: "integer" },
|
|
719
|
+
total: { type: "integer" },
|
|
720
|
+
pages: { type: "integer" },
|
|
721
|
+
hasNext: { type: "boolean" },
|
|
722
|
+
hasPrev: { type: "boolean" }
|
|
723
|
+
},
|
|
724
|
+
required: [
|
|
725
|
+
"method",
|
|
726
|
+
"data",
|
|
727
|
+
"page",
|
|
728
|
+
"limit",
|
|
729
|
+
"total",
|
|
730
|
+
"pages",
|
|
731
|
+
"hasNext",
|
|
732
|
+
"hasPrev"
|
|
733
|
+
],
|
|
734
|
+
additionalProperties: true
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
/** Bare variant — `{ data }`, no `method` discriminant. */
|
|
738
|
+
function bareListResponse(itemSchema) {
|
|
739
|
+
return {
|
|
740
|
+
type: "object",
|
|
741
|
+
properties: { data: {
|
|
742
|
+
type: "array",
|
|
743
|
+
items: itemSchema
|
|
744
|
+
} },
|
|
745
|
+
required: ["data"],
|
|
746
|
+
additionalProperties: true
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
/** Delete response — flat shape mirroring the canonical delete envelope. */
|
|
750
|
+
function deleteResponse() {
|
|
751
|
+
return {
|
|
752
|
+
type: "object",
|
|
753
|
+
properties: {
|
|
754
|
+
message: {
|
|
755
|
+
type: "string",
|
|
756
|
+
example: "Deleted successfully"
|
|
757
|
+
},
|
|
758
|
+
id: {
|
|
759
|
+
type: "string",
|
|
760
|
+
example: "507f1f77bcf86cd799439011"
|
|
761
|
+
},
|
|
762
|
+
soft: {
|
|
763
|
+
type: "boolean",
|
|
764
|
+
example: false
|
|
765
|
+
}
|
|
766
|
+
},
|
|
767
|
+
additionalProperties: true
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
const ERROR_DESCRIPTIONS = {
|
|
771
|
+
400: "Bad Request",
|
|
772
|
+
401: "Unauthorized",
|
|
773
|
+
403: "Forbidden",
|
|
774
|
+
404: "Not Found",
|
|
775
|
+
409: "Conflict",
|
|
776
|
+
422: "Unprocessable Entity",
|
|
777
|
+
429: "Too Many Requests",
|
|
778
|
+
500: "Internal Server Error",
|
|
779
|
+
503: "Service Unavailable"
|
|
780
|
+
};
|
|
781
|
+
/** Build an OpenAPI response entry for an `ErrorContract` at the given status. */
|
|
782
|
+
function errorResponse(status) {
|
|
783
|
+
return {
|
|
784
|
+
description: ERROR_DESCRIPTIONS[status] ?? "Error",
|
|
785
|
+
content: { "application/json": { schema: errorContractSchema$1 } }
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
const responses = {
|
|
789
|
+
200: (schema) => ({
|
|
790
|
+
description: "Successful response",
|
|
791
|
+
content: { "application/json": { schema } }
|
|
792
|
+
}),
|
|
793
|
+
201: (schema) => ({
|
|
794
|
+
description: "Created successfully",
|
|
795
|
+
content: { "application/json": { schema: {
|
|
796
|
+
...schema,
|
|
797
|
+
additionalProperties: true
|
|
798
|
+
} } }
|
|
799
|
+
}),
|
|
800
|
+
400: errorResponse(400),
|
|
801
|
+
401: errorResponse(401),
|
|
802
|
+
403: errorResponse(403),
|
|
803
|
+
404: errorResponse(404),
|
|
804
|
+
409: errorResponse(409),
|
|
805
|
+
500: errorResponse(500)
|
|
806
|
+
};
|
|
807
|
+
const queryParams = {
|
|
808
|
+
pagination: {
|
|
809
|
+
page: {
|
|
810
|
+
type: "integer",
|
|
811
|
+
minimum: 1,
|
|
812
|
+
default: 1,
|
|
813
|
+
description: "Page number"
|
|
814
|
+
},
|
|
815
|
+
limit: {
|
|
816
|
+
type: "integer",
|
|
817
|
+
minimum: 1,
|
|
818
|
+
maximum: 100,
|
|
819
|
+
default: 20,
|
|
820
|
+
description: "Items per page"
|
|
821
|
+
}
|
|
462
822
|
},
|
|
463
|
-
|
|
823
|
+
sorting: { sort: {
|
|
824
|
+
type: "string",
|
|
825
|
+
description: "Sort field (prefix with - for descending)",
|
|
826
|
+
example: "-createdAt"
|
|
827
|
+
} },
|
|
828
|
+
filtering: {
|
|
829
|
+
select: {
|
|
830
|
+
description: "Fields to include (space-separated or object)",
|
|
831
|
+
example: "name email createdAt"
|
|
832
|
+
},
|
|
833
|
+
populate: {
|
|
834
|
+
description: "Relations to populate (comma-separated string or bracket-notation object)",
|
|
835
|
+
example: "author,category"
|
|
836
|
+
}
|
|
837
|
+
}
|
|
464
838
|
};
|
|
465
839
|
/**
|
|
466
|
-
*
|
|
467
|
-
* translation AST (`?price[gte]=40` → `{ price: { gte: 40 } }`, with
|
|
468
|
-
* `{ caseInsensitive: true }` for `like`/`contains`). A non-Mongo adapter can
|
|
469
|
-
* consume this directly instead of writing a full `QueryParserInterface`. Use
|
|
470
|
-
* as a starting point; adjust the token names to your store's vocabulary.
|
|
840
|
+
* Get standard list query parameters schema
|
|
471
841
|
*/
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
842
|
+
function getListQueryParams() {
|
|
843
|
+
return {
|
|
844
|
+
type: "object",
|
|
845
|
+
properties: {
|
|
846
|
+
...queryParams.pagination,
|
|
847
|
+
...queryParams.sorting,
|
|
848
|
+
...queryParams.filtering
|
|
849
|
+
},
|
|
850
|
+
additionalProperties: true
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Generic item schema that allows any properties.
|
|
855
|
+
* Used as default when no user schema is provided.
|
|
856
|
+
* Enables fast-json-stringify while still passing through all fields.
|
|
857
|
+
*/
|
|
858
|
+
const genericItemSchema = {
|
|
859
|
+
type: "object",
|
|
860
|
+
additionalProperties: true
|
|
488
861
|
};
|
|
489
|
-
//#endregion
|
|
490
|
-
//#region src/utils/handleRaw.ts
|
|
491
862
|
/**
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
* the response body is empty (HTTP status only).
|
|
497
|
-
* @param statusCode - HTTP status code for successful responses (default: 200)
|
|
863
|
+
* Recursively strip `example` keys from a schema object.
|
|
864
|
+
* The `example` keyword is OpenAPI metadata — not standard JSON Schema —
|
|
865
|
+
* and triggers Ajv strict mode errors when used on routes without the
|
|
866
|
+
* `keywords: ['example']` AJV config (e.g., raw Fastify without createApp).
|
|
498
867
|
*/
|
|
499
|
-
function
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
if (err instanceof ArcError) {
|
|
509
|
-
reply.code(err.statusCode).send({
|
|
510
|
-
error: err.message,
|
|
511
|
-
code: err.code,
|
|
512
|
-
...err.details ? { details: err.details } : {}
|
|
513
|
-
});
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
const error = err;
|
|
517
|
-
const code = error.statusCode ?? error.status ?? 500;
|
|
518
|
-
reply.code(code).send({
|
|
519
|
-
error: error.message ?? "Internal server error",
|
|
520
|
-
...error.code && { code: error.code }
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
};
|
|
868
|
+
function stripExamples(schema) {
|
|
869
|
+
if (schema === null || typeof schema !== "object") return schema;
|
|
870
|
+
if (Array.isArray(schema)) return schema.map(stripExamples);
|
|
871
|
+
const result = {};
|
|
872
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
873
|
+
if (key === "example") continue;
|
|
874
|
+
result[key] = stripExamples(value);
|
|
875
|
+
}
|
|
876
|
+
return result;
|
|
524
877
|
}
|
|
525
|
-
//#endregion
|
|
526
|
-
//#region src/utils/queryParser.ts
|
|
527
878
|
/**
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
* Framework-agnostic query parser that converts URL parameters to query options.
|
|
531
|
-
* This is Arc's built-in parser; users can swap in MongoKit's QueryParser,
|
|
532
|
-
* pgkit's parser, or any custom parser implementing QueryParserInterface.
|
|
879
|
+
* Get default response schemas for all CRUD operations.
|
|
533
880
|
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
881
|
+
* When routes have response schemas, Fastify compiles them with
|
|
882
|
+
* fast-json-stringify for 2-3x faster serialization and prevents
|
|
883
|
+
* accidental field disclosure.
|
|
537
884
|
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
* // becomes HTTP 400 instead of a silently broadened result set.
|
|
541
|
-
* import { QueryParser } from '@classytic/mongokit';
|
|
542
|
-
* defineResource({
|
|
543
|
-
* name: 'product',
|
|
544
|
-
* adapter: ...,
|
|
545
|
-
* queryParser: new QueryParser(),
|
|
546
|
-
* });
|
|
885
|
+
* These defaults use `additionalProperties: true` so all fields pass through.
|
|
886
|
+
* Override with specific schemas for full serialization performance + safety.
|
|
547
887
|
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
* name: 'user',
|
|
551
|
-
* adapter: ...,
|
|
552
|
-
* queryParser: new PgQueryParser(),
|
|
553
|
-
* });
|
|
554
|
-
*/
|
|
555
|
-
const log = arcLog("queryParser");
|
|
556
|
-
/**
|
|
557
|
-
* Regex patterns that can cause catastrophic backtracking (ReDoS attacks)
|
|
558
|
-
* Detects:
|
|
559
|
-
* - Quantifiers: {n,m}
|
|
560
|
-
* - Possessive quantifiers: *+, ++, ?+
|
|
561
|
-
* - Nested quantifiers: (a+)+, (a*)*
|
|
562
|
-
* - Backreferences: \1, \2, etc.
|
|
888
|
+
* Note: `example` properties are stripped from defaults so they work with
|
|
889
|
+
* any Fastify instance (not just createApp which adds `keywords: ['example']`).
|
|
563
890
|
*/
|
|
564
|
-
|
|
891
|
+
function getDefaultCrudSchemas() {
|
|
892
|
+
return stripExamples({
|
|
893
|
+
list: {
|
|
894
|
+
querystring: getListQueryParams(),
|
|
895
|
+
response: { 200: listResponse(genericItemSchema) }
|
|
896
|
+
},
|
|
897
|
+
get: { response: { 200: genericItemSchema } },
|
|
898
|
+
create: { response: { 201: genericItemSchema } },
|
|
899
|
+
update: { response: { 200: genericItemSchema } },
|
|
900
|
+
delete: { response: { 200: deleteResponse() } }
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
//#endregion
|
|
904
|
+
//#region src/utils/circuitBreaker.ts
|
|
565
905
|
/**
|
|
566
|
-
*
|
|
906
|
+
* Circuit Breaker Pattern
|
|
567
907
|
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
* - Filtering — TWO supported forms (both produce identical output):
|
|
572
|
-
* - Bare (default, terse): `?status=active&price[gte]=100&price[lte]=500`
|
|
573
|
-
* - Bracket envelope: `?filter[status]=active&filter[price][gte]=100`
|
|
574
|
-
* The envelope form matches the convention used by JSON:API / Stripe /
|
|
575
|
-
* most modern REST style guides, and is the safe choice on busy URLs
|
|
576
|
-
* that might otherwise collide with reserved meta keys (`page`, `sort`,
|
|
577
|
-
* `select`, `populate`, etc.). Operator notation (`[gte]`, `[lte]`,
|
|
578
|
-
* `[ne]`, `[in]`, ...) works inside either form.
|
|
579
|
-
* - Search: ?search=keyword
|
|
580
|
-
* - Populate: ?populate=author,category
|
|
581
|
-
* - Field selection: ?select=name,price,status
|
|
582
|
-
* - Keyset pagination: ?after=cursor_value
|
|
908
|
+
* Wraps external service calls with failure protection.
|
|
909
|
+
* Prevents cascading failures by "opening" the circuit when
|
|
910
|
+
* a service is failing, allowing it time to recover.
|
|
583
911
|
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
* `queryParser` to a different implementation, double-check which forms
|
|
589
|
-
* it understands before publishing URLs that mix the two.
|
|
912
|
+
* States:
|
|
913
|
+
* - CLOSED: Normal operation, requests pass through
|
|
914
|
+
* - OPEN: Too many failures, all requests fail fast
|
|
915
|
+
* - HALF_OPEN: Testing if service recovered, limited requests
|
|
590
916
|
*
|
|
591
|
-
*
|
|
917
|
+
* @example
|
|
918
|
+
* import { CircuitBreaker } from '@classytic/arc/utils';
|
|
919
|
+
*
|
|
920
|
+
* const paymentBreaker = new CircuitBreaker(async (amount) => {
|
|
921
|
+
* return await stripe.charges.create({ amount });
|
|
922
|
+
* }, {
|
|
923
|
+
* failureThreshold: 5,
|
|
924
|
+
* resetTimeout: 30000,
|
|
925
|
+
* timeout: 5000,
|
|
926
|
+
* });
|
|
927
|
+
*
|
|
928
|
+
* try {
|
|
929
|
+
* const result = await paymentBreaker.call(100);
|
|
930
|
+
* } catch (error) {
|
|
931
|
+
* // Handle failure or circuit open
|
|
932
|
+
* }
|
|
592
933
|
*/
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
934
|
+
const CircuitState = {
|
|
935
|
+
CLOSED: "CLOSED",
|
|
936
|
+
OPEN: "OPEN",
|
|
937
|
+
HALF_OPEN: "HALF_OPEN"
|
|
938
|
+
};
|
|
939
|
+
var CircuitBreakerError = class extends Error {
|
|
940
|
+
state;
|
|
941
|
+
constructor(message, state) {
|
|
942
|
+
super(message);
|
|
943
|
+
this.name = "CircuitBreakerError";
|
|
944
|
+
this.state = state;
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
var CircuitBreaker = class {
|
|
948
|
+
state = CircuitState.CLOSED;
|
|
949
|
+
failures = 0;
|
|
950
|
+
successes = 0;
|
|
951
|
+
totalCalls = 0;
|
|
952
|
+
nextAttempt = 0;
|
|
953
|
+
lastCallAt = null;
|
|
954
|
+
openedAt = null;
|
|
955
|
+
halfOpenInFlight = 0;
|
|
956
|
+
failureThreshold;
|
|
957
|
+
resetTimeout;
|
|
958
|
+
timeout;
|
|
959
|
+
successThreshold;
|
|
960
|
+
halfOpenMaxProbes;
|
|
961
|
+
propagateAbort;
|
|
962
|
+
fallback;
|
|
963
|
+
onStateChange;
|
|
964
|
+
onError;
|
|
965
|
+
name;
|
|
966
|
+
fn;
|
|
967
|
+
constructor(fn, options = {}) {
|
|
968
|
+
this.fn = fn;
|
|
969
|
+
this.failureThreshold = options.failureThreshold ?? 5;
|
|
970
|
+
this.resetTimeout = options.resetTimeout ?? 6e4;
|
|
971
|
+
this.timeout = options.timeout ?? 1e4;
|
|
972
|
+
this.successThreshold = options.successThreshold ?? 1;
|
|
973
|
+
this.halfOpenMaxProbes = options.halfOpenMaxProbes ?? 1;
|
|
974
|
+
this.propagateAbort = options.propagateAbort ?? false;
|
|
975
|
+
this.fallback = options.fallback;
|
|
976
|
+
this.onStateChange = options.onStateChange;
|
|
977
|
+
this.onError = options.onError;
|
|
978
|
+
this.name = options.name ?? "CircuitBreaker";
|
|
979
|
+
}
|
|
614
980
|
/**
|
|
615
|
-
*
|
|
616
|
-
* case-insensitive modifier. Defaults to Mongo (backward-compatible).
|
|
981
|
+
* Call the wrapped function with circuit breaker protection
|
|
617
982
|
*/
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
this.maxSearchLength = options.maxSearchLength ?? 200;
|
|
629
|
-
this.maxFilterDepth = options.maxFilterDepth ?? 10;
|
|
630
|
-
this.strictFilterFields = options.strictFilterFields ?? process.env.ARC_STRICT_QUERY_PARAMS === "true";
|
|
631
|
-
if (options.allowedFilterFields) {
|
|
632
|
-
this._allowedFilterFields = new Set(options.allowedFilterFields);
|
|
633
|
-
this.allowedFilterFields = options.allowedFilterFields;
|
|
983
|
+
async call(...args) {
|
|
984
|
+
this.totalCalls++;
|
|
985
|
+
this.lastCallAt = Date.now();
|
|
986
|
+
if (this.state === CircuitState.OPEN) {
|
|
987
|
+
if (Date.now() < this.nextAttempt) {
|
|
988
|
+
const error = new CircuitBreakerError(`Circuit breaker is OPEN for ${this.name}`, CircuitState.OPEN);
|
|
989
|
+
if (this.fallback) return this.fallback(...args);
|
|
990
|
+
throw error;
|
|
991
|
+
}
|
|
992
|
+
this.setState(CircuitState.HALF_OPEN);
|
|
634
993
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
this.
|
|
994
|
+
let isProbe = false;
|
|
995
|
+
if (this.state === CircuitState.HALF_OPEN) {
|
|
996
|
+
if (this.halfOpenInFlight >= this.halfOpenMaxProbes) {
|
|
997
|
+
if (this.fallback) return this.fallback(...args);
|
|
998
|
+
throw new CircuitBreakerError(`Circuit breaker is HALF_OPEN for ${this.name} — probe slots full`, CircuitState.HALF_OPEN);
|
|
999
|
+
}
|
|
1000
|
+
this.halfOpenInFlight++;
|
|
1001
|
+
isProbe = true;
|
|
638
1002
|
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
this.
|
|
1003
|
+
try {
|
|
1004
|
+
const result = await this.executeWithTimeout(args);
|
|
1005
|
+
this.onSuccess();
|
|
1006
|
+
return result;
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
this.onFailure(err instanceof Error ? err : new Error(String(err)));
|
|
1009
|
+
throw err;
|
|
1010
|
+
} finally {
|
|
1011
|
+
if (isProbe) this.halfOpenInFlight--;
|
|
642
1012
|
}
|
|
643
1013
|
}
|
|
644
1014
|
/**
|
|
645
|
-
*
|
|
1015
|
+
* Execute function with timeout. With `propagateAbort`, the wrapped fn
|
|
1016
|
+
* receives a trailing AbortSignal that fires on timeout — the breaker's
|
|
1017
|
+
* timeout otherwise only rejects the CALLER while the underlying work
|
|
1018
|
+
* keeps consuming sockets/pool slots to completion.
|
|
646
1019
|
*/
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
search,
|
|
663
|
-
page: after ? void 0 : page,
|
|
664
|
-
after,
|
|
665
|
-
select
|
|
666
|
-
};
|
|
667
|
-
}
|
|
668
|
-
parseNumber(value, defaultValue) {
|
|
669
|
-
if (value === void 0 || value === null) return defaultValue;
|
|
670
|
-
const num = parseInt(String(value), 10);
|
|
671
|
-
return Number.isNaN(num) ? defaultValue : Math.max(1, num);
|
|
672
|
-
}
|
|
673
|
-
parseString(value) {
|
|
674
|
-
if (value === void 0 || value === null) return void 0;
|
|
675
|
-
const str = String(value).trim();
|
|
676
|
-
return str.length > 0 ? str : void 0;
|
|
1020
|
+
async executeWithTimeout(args) {
|
|
1021
|
+
const controller = this.propagateAbort ? new AbortController() : void 0;
|
|
1022
|
+
return new Promise((resolve, reject) => {
|
|
1023
|
+
const timeoutId = setTimeout(() => {
|
|
1024
|
+
controller?.abort(/* @__PURE__ */ new Error(`Request timeout after ${this.timeout}ms`));
|
|
1025
|
+
reject(/* @__PURE__ */ new Error(`Request timeout after ${this.timeout}ms`));
|
|
1026
|
+
}, this.timeout);
|
|
1027
|
+
(controller ? this.fn(...args, controller.signal) : this.fn(...args)).then((result) => {
|
|
1028
|
+
clearTimeout(timeoutId);
|
|
1029
|
+
resolve(result);
|
|
1030
|
+
}).catch((error) => {
|
|
1031
|
+
clearTimeout(timeoutId);
|
|
1032
|
+
reject(error);
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
677
1035
|
}
|
|
678
1036
|
/**
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
* Simple: ?populate=author,category → { populate: 'author,category' }
|
|
682
|
-
* Bracket: ?populate[author][select]=name,email → { populateOptions: [{ path: 'author', select: 'name email' }] }
|
|
1037
|
+
* Handle successful call
|
|
683
1038
|
*/
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
const obj = value;
|
|
692
|
-
const keys = Object.keys(obj);
|
|
693
|
-
if (keys.length === 0) return {};
|
|
694
|
-
const options = [];
|
|
695
|
-
for (const path of keys) {
|
|
696
|
-
if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(path)) continue;
|
|
697
|
-
const config = obj[path];
|
|
698
|
-
if (typeof config === "object" && config !== null && !Array.isArray(config)) {
|
|
699
|
-
const cfg = config;
|
|
700
|
-
const option = { path };
|
|
701
|
-
if (typeof cfg.select === "string") option.select = cfg.select.split(",").map((s) => s.trim()).filter(Boolean).join(" ");
|
|
702
|
-
if (typeof cfg.match === "object" && cfg.match !== null) option.match = cfg.match;
|
|
703
|
-
options.push(option);
|
|
704
|
-
} else options.push({ path });
|
|
1039
|
+
onSuccess() {
|
|
1040
|
+
this.failures = 0;
|
|
1041
|
+
this.successes++;
|
|
1042
|
+
if (this.state === CircuitState.HALF_OPEN) {
|
|
1043
|
+
if (this.successes >= this.successThreshold) {
|
|
1044
|
+
this.setState(CircuitState.CLOSED);
|
|
1045
|
+
this.successes = 0;
|
|
705
1046
|
}
|
|
706
|
-
return options.length > 0 ? { populateOptions: options } : {};
|
|
707
1047
|
}
|
|
708
|
-
return {};
|
|
709
1048
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
else result[fieldName] = 1;
|
|
1049
|
+
/**
|
|
1050
|
+
* Handle failed call
|
|
1051
|
+
*/
|
|
1052
|
+
onFailure(error) {
|
|
1053
|
+
this.failures++;
|
|
1054
|
+
this.successes = 0;
|
|
1055
|
+
if (this.onError) this.onError(error);
|
|
1056
|
+
if (this.state === CircuitState.HALF_OPEN || this.failures >= this.failureThreshold) {
|
|
1057
|
+
this.setState(CircuitState.OPEN);
|
|
1058
|
+
this.nextAttempt = Date.now() + this.resetTimeout;
|
|
1059
|
+
this.openedAt = Date.now();
|
|
722
1060
|
}
|
|
723
|
-
return Object.keys(result).length > 0 ? result : void 0;
|
|
724
|
-
}
|
|
725
|
-
parseSearch(value) {
|
|
726
|
-
if (!value) return void 0;
|
|
727
|
-
const search = String(value).trim();
|
|
728
|
-
if (search.length === 0) return void 0;
|
|
729
|
-
if (search.length > this.maxSearchLength) return search.slice(0, this.maxSearchLength);
|
|
730
|
-
return search;
|
|
731
1061
|
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
if (
|
|
740
|
-
if (trimmed.startsWith("-")) result[trimmed.slice(1)] = 0;
|
|
741
|
-
else result[trimmed] = 1;
|
|
1062
|
+
/**
|
|
1063
|
+
* Change circuit state
|
|
1064
|
+
*/
|
|
1065
|
+
setState(newState) {
|
|
1066
|
+
const oldState = this.state;
|
|
1067
|
+
if (oldState !== newState) {
|
|
1068
|
+
this.state = newState;
|
|
1069
|
+
if (this.onStateChange) this.onStateChange(oldState, newState);
|
|
742
1070
|
}
|
|
743
|
-
return Object.keys(result).length > 0 ? result : void 0;
|
|
744
1071
|
}
|
|
745
1072
|
/**
|
|
746
|
-
*
|
|
747
|
-
* Prevents filter bombs where deeply nested objects consume excessive memory/CPU.
|
|
1073
|
+
* Manually open the circuit
|
|
748
1074
|
*/
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
if (typeof obj !== "object") return false;
|
|
754
|
-
return Object.values(obj).some((v) => this.exceedsDepth(v, currentDepth + 1));
|
|
1075
|
+
open() {
|
|
1076
|
+
this.setState(CircuitState.OPEN);
|
|
1077
|
+
this.nextAttempt = Date.now() + this.resetTimeout;
|
|
1078
|
+
this.openedAt = Date.now();
|
|
755
1079
|
}
|
|
756
1080
|
/**
|
|
757
|
-
*
|
|
758
|
-
* top-level form and the bracket-wrapped `filter[...]` envelope.
|
|
759
|
-
*
|
|
760
|
-
* Two equivalent ways to express the same query:
|
|
761
|
-
* - Bare (legacy, arc default): `?status=active&price[gte]=40`
|
|
762
|
-
* - Bracket envelope (REST convention): `?filter[status]=active&filter[price][gte]=40`
|
|
763
|
-
*
|
|
764
|
-
* Both forms parse to the same `filters: { status: 'active', price: { $gte: 40 } }`.
|
|
765
|
-
* The envelope form mirrors the convention used by most REST API style
|
|
766
|
-
* guides (JSON:API, Stripe API, etc.) and disambiguates filter fields
|
|
767
|
-
* from reserved meta params on busy URLs — a query string with a field
|
|
768
|
-
* literally named `page` or `sort` can't collide with the framework's
|
|
769
|
-
* meta keys when wrapped under `filter[...]`. The bare form stays as
|
|
770
|
-
* the default since it's the shortest path for simple queries; expect
|
|
771
|
-
* it to remain supported indefinitely.
|
|
772
|
-
*
|
|
773
|
-
* Precedence when the same key appears in both forms:
|
|
774
|
-
* `?status=closed&filter[status]=active` → bare wins (`status: closed`).
|
|
775
|
-
* Mixing should be rare; the deterministic rule avoids silent surprises.
|
|
1081
|
+
* Manually close the circuit
|
|
776
1082
|
*/
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
if (RESERVED_QUERY_PARAMS.has(key)) continue;
|
|
783
|
-
this.applyFilterEntry(filters, key, value);
|
|
784
|
-
}
|
|
785
|
-
return filters;
|
|
1083
|
+
close() {
|
|
1084
|
+
this.failures = 0;
|
|
1085
|
+
this.successes = 0;
|
|
1086
|
+
this.setState(CircuitState.CLOSED);
|
|
1087
|
+
this.openedAt = null;
|
|
786
1088
|
}
|
|
787
1089
|
/**
|
|
788
|
-
*
|
|
789
|
-
* the bare top-level branch of `parseFilters`. Centralising the field-
|
|
790
|
-
* validation + operator-conversion logic keeps the two forms in
|
|
791
|
-
* lockstep; a future regex/security tweak only changes one place.
|
|
1090
|
+
* Get current statistics
|
|
792
1091
|
*/
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
const operatorObj = value;
|
|
804
|
-
const operatorKeys = Object.keys(operatorObj);
|
|
805
|
-
const allOperators = operatorKeys.every((op) => this.operators[op] && (!this._allowedOperators || this._allowedOperators.has(op)));
|
|
806
|
-
const allKnownOperators = operatorKeys.every((op) => this.operators[op]);
|
|
807
|
-
if (allOperators && operatorKeys.length > 0) {
|
|
808
|
-
const fieldOps = {};
|
|
809
|
-
let needsCaseInsensitive = false;
|
|
810
|
-
for (const [op, opValue] of Object.entries(operatorObj)) {
|
|
811
|
-
const token = this.operators[op];
|
|
812
|
-
if (token) {
|
|
813
|
-
fieldOps[token] = this.parseFilterValue(opValue, op);
|
|
814
|
-
if (op === "contains" || op === "like") needsCaseInsensitive = true;
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
if (needsCaseInsensitive) Object.assign(fieldOps, this.dialect.caseInsensitiveModifier());
|
|
818
|
-
filters[key] = fieldOps;
|
|
819
|
-
return;
|
|
820
|
-
}
|
|
821
|
-
if (allKnownOperators && this._allowedOperators) return;
|
|
822
|
-
}
|
|
823
|
-
const match = key.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)(?:\[([a-z]+)\])?$/);
|
|
824
|
-
if (!match) return;
|
|
825
|
-
const [, fieldName, operator] = match;
|
|
826
|
-
if (!fieldName) return;
|
|
827
|
-
if (operator && this.operators[operator] && (!this._allowedOperators || this._allowedOperators.has(operator))) {
|
|
828
|
-
const token = this.operators[operator];
|
|
829
|
-
const parsedValue = this.parseFilterValue(value, operator);
|
|
830
|
-
if (!filters[fieldName]) filters[fieldName] = {};
|
|
831
|
-
const fieldFilter = filters[fieldName];
|
|
832
|
-
fieldFilter[token] = parsedValue;
|
|
833
|
-
if (operator === "contains" || operator === "like") Object.assign(fieldFilter, this.dialect.caseInsensitiveModifier());
|
|
834
|
-
} else if (!operator) filters[fieldName] = this.parseFilterValue(value);
|
|
1092
|
+
getStats() {
|
|
1093
|
+
return {
|
|
1094
|
+
name: this.name,
|
|
1095
|
+
state: this.state,
|
|
1096
|
+
failures: this.failures,
|
|
1097
|
+
successes: this.successes,
|
|
1098
|
+
totalCalls: this.totalCalls,
|
|
1099
|
+
openedAt: this.openedAt,
|
|
1100
|
+
lastCallAt: this.lastCallAt
|
|
1101
|
+
};
|
|
835
1102
|
}
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
}
|
|
842
|
-
if (operator === "like" || operator === "contains" || operator === "regex") return this.sanitizeRegex(String(value));
|
|
843
|
-
if (operator === "exists") {
|
|
844
|
-
const str = String(value).toLowerCase();
|
|
845
|
-
return str === "true" || str === "1";
|
|
846
|
-
}
|
|
847
|
-
return this.coerceValue(value);
|
|
1103
|
+
/**
|
|
1104
|
+
* Get current state
|
|
1105
|
+
*/
|
|
1106
|
+
getState() {
|
|
1107
|
+
return this.state;
|
|
848
1108
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
const num = Number(value);
|
|
855
|
-
if (!Number.isNaN(num) && value.trim() !== "") return num;
|
|
856
|
-
}
|
|
857
|
-
return value;
|
|
1109
|
+
/**
|
|
1110
|
+
* Check if circuit is open
|
|
1111
|
+
*/
|
|
1112
|
+
isOpen() {
|
|
1113
|
+
return this.state === CircuitState.OPEN;
|
|
858
1114
|
}
|
|
859
1115
|
/**
|
|
860
|
-
*
|
|
861
|
-
* Arc's defineResource() auto-detects this method and uses it
|
|
862
|
-
* to document list endpoint query parameters in OpenAPI/Swagger.
|
|
1116
|
+
* Check if circuit is closed
|
|
863
1117
|
*/
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
return ` ${op} → ${mongoOp}: ${{
|
|
867
|
-
eq: "Equal (default when no operator specified)",
|
|
868
|
-
ne: "Not equal",
|
|
869
|
-
gt: "Greater than",
|
|
870
|
-
gte: "Greater than or equal",
|
|
871
|
-
lt: "Less than",
|
|
872
|
-
lte: "Less than or equal",
|
|
873
|
-
in: "In list (comma-separated values)",
|
|
874
|
-
nin: "Not in list",
|
|
875
|
-
like: "Pattern match (case-insensitive)",
|
|
876
|
-
contains: "Contains substring (case-insensitive)",
|
|
877
|
-
regex: "Regex pattern",
|
|
878
|
-
exists: "Field exists (true/false)"
|
|
879
|
-
}[op] || op}`;
|
|
880
|
-
});
|
|
881
|
-
return {
|
|
882
|
-
type: "object",
|
|
883
|
-
properties: {
|
|
884
|
-
page: {
|
|
885
|
-
type: "integer",
|
|
886
|
-
description: "Page number for offset pagination",
|
|
887
|
-
default: 1,
|
|
888
|
-
minimum: 1
|
|
889
|
-
},
|
|
890
|
-
limit: {
|
|
891
|
-
type: "integer",
|
|
892
|
-
/**
|
|
893
|
-
* The cap is DOCUMENTED here, not enforced — deliberately.
|
|
894
|
-
*
|
|
895
|
-
* Both layers that handle `limit` CLAMP it: this parser
|
|
896
|
-
* (`Math.min(…, this.maxLimit)`) and `QueryResolver`. Emitting
|
|
897
|
-
* `maximum` contradicted them, because arc layers this schema as the
|
|
898
|
-
* route's `listQuery` and Fastify validates the querystring BEFORE
|
|
899
|
-
* either runs — so the clamp was unreachable and `?limit=200` became a
|
|
900
|
-
* hard 400.
|
|
901
|
-
*
|
|
902
|
-
* That combination is worse than either policy alone. Asking for more
|
|
903
|
-
* rows than allowed is a benign over-ask with an obvious right answer
|
|
904
|
-
* (give the maximum); turning it into a validation failure means a UI
|
|
905
|
-
* passing a generous page size gets NOTHING, and a caller that does not
|
|
906
|
-
* inspect the error body renders it as an empty list — a 400 disguised
|
|
907
|
-
* as "no results", which is the hardest kind to find.
|
|
908
|
-
*
|
|
909
|
-
* A deployment wanting refusal instead of clamping should say so in the
|
|
910
|
-
* parser's policy, where both halves can agree, not in a schema that
|
|
911
|
-
* silently outranks them. Mirrors the same fix in mongokit's
|
|
912
|
-
* `buildQuerySchema`.
|
|
913
|
-
*/
|
|
914
|
-
description: `Number of items per page (values above ${this.maxLimit} are capped to ${this.maxLimit})`,
|
|
915
|
-
default: this.defaultLimit,
|
|
916
|
-
minimum: 1
|
|
917
|
-
},
|
|
918
|
-
sort: {
|
|
919
|
-
type: "string",
|
|
920
|
-
description: "Sort fields (comma-separated). Prefix with - for descending. Example: -createdAt,name"
|
|
921
|
-
},
|
|
922
|
-
search: {
|
|
923
|
-
type: "string",
|
|
924
|
-
description: "Full-text search query",
|
|
925
|
-
maxLength: this.maxSearchLength
|
|
926
|
-
},
|
|
927
|
-
select: {
|
|
928
|
-
type: "string",
|
|
929
|
-
description: "Fields to include/exclude (comma-separated). Prefix with - to exclude. Example: name,email,-password"
|
|
930
|
-
},
|
|
931
|
-
populate: {
|
|
932
|
-
type: "string",
|
|
933
|
-
description: "Fields to populate/join (comma-separated). Example: author,category"
|
|
934
|
-
},
|
|
935
|
-
after: {
|
|
936
|
-
type: "string",
|
|
937
|
-
description: "Cursor value for keyset pagination"
|
|
938
|
-
},
|
|
939
|
-
_filterOperators: {
|
|
940
|
-
type: "string",
|
|
941
|
-
description: ["Available filter operators (use as field[operator]=value):", ...operatorLines].join("\n")
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
};
|
|
1118
|
+
isClosed() {
|
|
1119
|
+
return this.state === CircuitState.CLOSED;
|
|
945
1120
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1121
|
+
/**
|
|
1122
|
+
* Reset statistics
|
|
1123
|
+
*/
|
|
1124
|
+
reset() {
|
|
1125
|
+
this.failures = 0;
|
|
1126
|
+
this.successes = 0;
|
|
1127
|
+
this.totalCalls = 0;
|
|
1128
|
+
this.lastCallAt = null;
|
|
1129
|
+
this.openedAt = null;
|
|
1130
|
+
this.setState(CircuitState.CLOSED);
|
|
954
1131
|
}
|
|
955
1132
|
};
|
|
956
1133
|
/**
|
|
957
|
-
* Create a
|
|
958
|
-
*/
|
|
959
|
-
function createQueryParser(options) {
|
|
960
|
-
return new ArcQueryParser(options);
|
|
961
|
-
}
|
|
962
|
-
//#endregion
|
|
963
|
-
//#region src/utils/responseSchemas.ts
|
|
964
|
-
/**
|
|
965
|
-
* JSON Schema definitions for arc API responses.
|
|
1134
|
+
* Create a circuit breaker with sensible defaults
|
|
966
1135
|
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
970
|
-
*
|
|
971
|
-
*
|
|
1136
|
+
* @example
|
|
1137
|
+
* const emailBreaker = createCircuitBreaker(
|
|
1138
|
+
* async (to, subject, body) => sendEmail(to, subject, body),
|
|
1139
|
+
* { name: 'email-service' }
|
|
1140
|
+
* );
|
|
972
1141
|
*/
|
|
1142
|
+
function createCircuitBreaker(fn, options) {
|
|
1143
|
+
return new CircuitBreaker(fn, options);
|
|
1144
|
+
}
|
|
973
1145
|
/**
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
* Runtime format (flat fields):
|
|
977
|
-
* { page, limit, total, pages, hasNext, hasPrev }
|
|
1146
|
+
* Circuit breaker registry for managing multiple breakers
|
|
978
1147
|
*/
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1148
|
+
var CircuitBreakerRegistry = class {
|
|
1149
|
+
breakers = /* @__PURE__ */ new Map();
|
|
1150
|
+
/**
|
|
1151
|
+
* Register a circuit breaker
|
|
1152
|
+
*/
|
|
1153
|
+
register(name, fn, options) {
|
|
1154
|
+
const breaker = new CircuitBreaker(fn, {
|
|
1155
|
+
...options,
|
|
1156
|
+
name
|
|
1157
|
+
});
|
|
1158
|
+
this.breakers.set(name, breaker);
|
|
1159
|
+
return breaker;
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Get a circuit breaker by name
|
|
1163
|
+
*/
|
|
1164
|
+
get(name) {
|
|
1165
|
+
return this.breakers.get(name);
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Get all breakers
|
|
1169
|
+
*/
|
|
1170
|
+
getAll() {
|
|
1171
|
+
return this.breakers;
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Get statistics for all breakers
|
|
1175
|
+
*/
|
|
1176
|
+
getAllStats() {
|
|
1177
|
+
const stats = {};
|
|
1178
|
+
for (const [name, breaker] of this.breakers.entries()) stats[name] = breaker.getStats();
|
|
1179
|
+
return stats;
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Reset all breakers
|
|
1183
|
+
*/
|
|
1184
|
+
resetAll() {
|
|
1185
|
+
for (const breaker of this.breakers.values()) breaker.reset();
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Open all breakers
|
|
1189
|
+
*/
|
|
1190
|
+
openAll() {
|
|
1191
|
+
for (const breaker of this.breakers.values()) breaker.open();
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Close all breakers
|
|
1195
|
+
*/
|
|
1196
|
+
closeAll() {
|
|
1197
|
+
for (const breaker of this.breakers.values()) breaker.close();
|
|
1198
|
+
}
|
|
1015
1199
|
};
|
|
1016
1200
|
/**
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
1019
|
-
* pin to a narrower helper (sharper OpenAPI docs, same serializer speed):
|
|
1020
|
-
* - `offsetListResponse(item)` — `{ method: 'offset', data, page, limit, total, pages, hasNext, hasPrev }`
|
|
1021
|
-
* - `keysetListResponse(item)` — `{ method: 'keyset', data, limit, hasMore, next: string|null }`
|
|
1022
|
-
* - `aggregateListResponse(item)` — `{ method: 'aggregate', ...offset fields }`
|
|
1023
|
-
* - `bareListResponse(item)` — `{ data }`
|
|
1024
|
-
*
|
|
1025
|
-
* SHAPE: one merged permissive object, NOT a `oneOf` union (2.22). The
|
|
1026
|
-
* pre-2.22 union was semantically identical on the wire — every variant is
|
|
1027
|
-
* `additionalProperties: true` and the property definitions never conflict
|
|
1028
|
-
* (`next` only exists on keyset, the offset counters only on offset/
|
|
1029
|
-
* aggregate; fast-json-stringify skips undefined optionals) — but
|
|
1030
|
-
* fast-json-stringify implements top-level `oneOf` by running full AJV
|
|
1031
|
-
* validation PER RESPONSE to pick the branch, on the hottest route of
|
|
1032
|
-
* every arc app. The merge keeps the exact bytes (pinned by
|
|
1033
|
-
* `tests/utils/list-response-serialization.test.ts`) and drops the
|
|
1034
|
-
* per-response branch validation. Only `data` is required — the one field
|
|
1035
|
-
* all four variants share.
|
|
1201
|
+
* Create a new CircuitBreakerRegistry instance.
|
|
1202
|
+
* Use this instead of a global singleton — attach to fastify.arc or pass explicitly.
|
|
1036
1203
|
*/
|
|
1037
|
-
function
|
|
1038
|
-
return
|
|
1039
|
-
type: "object",
|
|
1040
|
-
properties: {
|
|
1041
|
-
method: {
|
|
1042
|
-
type: "string",
|
|
1043
|
-
description: "Pagination method discriminant — 'offset' | 'keyset' | 'aggregate'; absent on bare lists.",
|
|
1044
|
-
example: "offset"
|
|
1045
|
-
},
|
|
1046
|
-
data: {
|
|
1047
|
-
type: "array",
|
|
1048
|
-
items: itemSchema
|
|
1049
|
-
},
|
|
1050
|
-
page: {
|
|
1051
|
-
type: "integer",
|
|
1052
|
-
example: 1
|
|
1053
|
-
},
|
|
1054
|
-
limit: {
|
|
1055
|
-
type: "integer",
|
|
1056
|
-
example: 20
|
|
1057
|
-
},
|
|
1058
|
-
total: {
|
|
1059
|
-
type: "integer",
|
|
1060
|
-
example: 100
|
|
1061
|
-
},
|
|
1062
|
-
pages: {
|
|
1063
|
-
type: "integer",
|
|
1064
|
-
example: 5
|
|
1065
|
-
},
|
|
1066
|
-
hasNext: {
|
|
1067
|
-
type: "boolean",
|
|
1068
|
-
example: false
|
|
1069
|
-
},
|
|
1070
|
-
hasPrev: {
|
|
1071
|
-
type: "boolean",
|
|
1072
|
-
example: false
|
|
1073
|
-
},
|
|
1074
|
-
hasMore: { type: "boolean" },
|
|
1075
|
-
next: {
|
|
1076
|
-
type: ["string", "null"],
|
|
1077
|
-
description: "Cursor token for the next page, or null."
|
|
1078
|
-
}
|
|
1079
|
-
},
|
|
1080
|
-
required: ["data"],
|
|
1081
|
-
additionalProperties: true
|
|
1082
|
-
};
|
|
1083
|
-
}
|
|
1084
|
-
/** Offset variant — `{ method: 'offset', data, page, limit, total, pages, hasNext, hasPrev }`. */
|
|
1085
|
-
function offsetListResponse(itemSchema) {
|
|
1086
|
-
return {
|
|
1087
|
-
type: "object",
|
|
1088
|
-
properties: {
|
|
1089
|
-
method: {
|
|
1090
|
-
type: "string",
|
|
1091
|
-
const: "offset",
|
|
1092
|
-
example: "offset"
|
|
1093
|
-
},
|
|
1094
|
-
data: {
|
|
1095
|
-
type: "array",
|
|
1096
|
-
items: itemSchema
|
|
1097
|
-
},
|
|
1098
|
-
page: {
|
|
1099
|
-
type: "integer",
|
|
1100
|
-
example: 1
|
|
1101
|
-
},
|
|
1102
|
-
limit: {
|
|
1103
|
-
type: "integer",
|
|
1104
|
-
example: 20
|
|
1105
|
-
},
|
|
1106
|
-
total: {
|
|
1107
|
-
type: "integer",
|
|
1108
|
-
example: 100
|
|
1109
|
-
},
|
|
1110
|
-
pages: {
|
|
1111
|
-
type: "integer",
|
|
1112
|
-
example: 5
|
|
1113
|
-
},
|
|
1114
|
-
hasNext: {
|
|
1115
|
-
type: "boolean",
|
|
1116
|
-
example: false
|
|
1117
|
-
},
|
|
1118
|
-
hasPrev: {
|
|
1119
|
-
type: "boolean",
|
|
1120
|
-
example: false
|
|
1121
|
-
}
|
|
1122
|
-
},
|
|
1123
|
-
required: [
|
|
1124
|
-
"method",
|
|
1125
|
-
"data",
|
|
1126
|
-
"page",
|
|
1127
|
-
"limit",
|
|
1128
|
-
"total",
|
|
1129
|
-
"pages",
|
|
1130
|
-
"hasNext",
|
|
1131
|
-
"hasPrev"
|
|
1132
|
-
],
|
|
1133
|
-
additionalProperties: true
|
|
1134
|
-
};
|
|
1135
|
-
}
|
|
1136
|
-
/** Keyset variant — `{ method: 'keyset', data, limit, hasMore, next: string | null }`. */
|
|
1137
|
-
function keysetListResponse(itemSchema) {
|
|
1138
|
-
return {
|
|
1139
|
-
type: "object",
|
|
1140
|
-
properties: {
|
|
1141
|
-
method: {
|
|
1142
|
-
type: "string",
|
|
1143
|
-
const: "keyset",
|
|
1144
|
-
example: "keyset"
|
|
1145
|
-
},
|
|
1146
|
-
data: {
|
|
1147
|
-
type: "array",
|
|
1148
|
-
items: itemSchema
|
|
1149
|
-
},
|
|
1150
|
-
limit: {
|
|
1151
|
-
type: "integer",
|
|
1152
|
-
example: 20
|
|
1153
|
-
},
|
|
1154
|
-
hasMore: {
|
|
1155
|
-
type: "boolean",
|
|
1156
|
-
example: true
|
|
1157
|
-
},
|
|
1158
|
-
next: {
|
|
1159
|
-
type: ["string", "null"],
|
|
1160
|
-
description: "Cursor token for the next page, or null."
|
|
1161
|
-
}
|
|
1162
|
-
},
|
|
1163
|
-
required: [
|
|
1164
|
-
"method",
|
|
1165
|
-
"data",
|
|
1166
|
-
"limit",
|
|
1167
|
-
"hasMore",
|
|
1168
|
-
"next"
|
|
1169
|
-
],
|
|
1170
|
-
additionalProperties: true
|
|
1171
|
-
};
|
|
1172
|
-
}
|
|
1173
|
-
/** Aggregate variant — same shape as offset, discriminated by `method: 'aggregate'`. */
|
|
1174
|
-
function aggregateListResponse(itemSchema) {
|
|
1175
|
-
return {
|
|
1176
|
-
type: "object",
|
|
1177
|
-
properties: {
|
|
1178
|
-
method: {
|
|
1179
|
-
type: "string",
|
|
1180
|
-
const: "aggregate",
|
|
1181
|
-
example: "aggregate"
|
|
1182
|
-
},
|
|
1183
|
-
data: {
|
|
1184
|
-
type: "array",
|
|
1185
|
-
items: itemSchema
|
|
1186
|
-
},
|
|
1187
|
-
page: { type: "integer" },
|
|
1188
|
-
limit: { type: "integer" },
|
|
1189
|
-
total: { type: "integer" },
|
|
1190
|
-
pages: { type: "integer" },
|
|
1191
|
-
hasNext: { type: "boolean" },
|
|
1192
|
-
hasPrev: { type: "boolean" }
|
|
1193
|
-
},
|
|
1194
|
-
required: [
|
|
1195
|
-
"method",
|
|
1196
|
-
"data",
|
|
1197
|
-
"page",
|
|
1198
|
-
"limit",
|
|
1199
|
-
"total",
|
|
1200
|
-
"pages",
|
|
1201
|
-
"hasNext",
|
|
1202
|
-
"hasPrev"
|
|
1203
|
-
],
|
|
1204
|
-
additionalProperties: true
|
|
1205
|
-
};
|
|
1206
|
-
}
|
|
1207
|
-
/** Bare variant — `{ data }`, no `method` discriminant. */
|
|
1208
|
-
function bareListResponse(itemSchema) {
|
|
1209
|
-
return {
|
|
1210
|
-
type: "object",
|
|
1211
|
-
properties: { data: {
|
|
1212
|
-
type: "array",
|
|
1213
|
-
items: itemSchema
|
|
1214
|
-
} },
|
|
1215
|
-
required: ["data"],
|
|
1216
|
-
additionalProperties: true
|
|
1217
|
-
};
|
|
1204
|
+
function createCircuitBreakerRegistry() {
|
|
1205
|
+
return new CircuitBreakerRegistry();
|
|
1218
1206
|
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
}
|
|
1236
|
-
},
|
|
1237
|
-
additionalProperties: true
|
|
1238
|
-
};
|
|
1207
|
+
//#endregion
|
|
1208
|
+
//#region src/utils/compensation.ts
|
|
1209
|
+
/**
|
|
1210
|
+
* Observability hooks must never alter the transaction: a throwing metrics
|
|
1211
|
+
* sink must not fail a step that succeeded, trigger compensation, or become
|
|
1212
|
+
* an unhandled rejection from a detached fire-and-forget chain. Containment
|
|
1213
|
+
* is reported via `hooks.onHookError` so broken sinks stay visible.
|
|
1214
|
+
*/
|
|
1215
|
+
function safeHook(name, invoke, hooks) {
|
|
1216
|
+
try {
|
|
1217
|
+
invoke();
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
try {
|
|
1220
|
+
hooks?.onHookError?.(name, err instanceof Error ? err : new Error(String(err)));
|
|
1221
|
+
} catch {}
|
|
1222
|
+
}
|
|
1239
1223
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1224
|
+
/**
|
|
1225
|
+
* Run steps in order with automatic compensation on failure.
|
|
1226
|
+
*
|
|
1227
|
+
* @typeParam TCtx - Context type shared across steps (defaults to Record<string, unknown>)
|
|
1228
|
+
*/
|
|
1229
|
+
async function withCompensation(_name, steps, initialContext, hooks) {
|
|
1230
|
+
const ctx = { ...initialContext };
|
|
1231
|
+
const completedSteps = [];
|
|
1232
|
+
const results = {};
|
|
1233
|
+
const completed = [];
|
|
1234
|
+
for (const step of steps) {
|
|
1235
|
+
if (step.fireAndForget) {
|
|
1236
|
+
step.execute(ctx).then((result) => safeHook("onStepComplete", () => hooks?.onStepComplete?.(step.name, result), hooks), (err) => safeHook("onStepFailed", () => hooks?.onStepFailed?.(step.name, err instanceof Error ? err : new Error(String(err))), hooks)).catch(() => {});
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
try {
|
|
1240
|
+
const result = await step.execute(ctx);
|
|
1241
|
+
completedSteps.push(step.name);
|
|
1242
|
+
results[step.name] = result;
|
|
1243
|
+
completed.push({
|
|
1244
|
+
step,
|
|
1245
|
+
result
|
|
1246
|
+
});
|
|
1247
|
+
safeHook("onStepComplete", () => hooks?.onStepComplete?.(step.name, result), hooks);
|
|
1248
|
+
} catch (err) {
|
|
1249
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1250
|
+
safeHook("onStepFailed", () => hooks?.onStepFailed?.(step.name, error), hooks);
|
|
1251
|
+
const compensationErrors = await rollback(ctx, completed, hooks);
|
|
1252
|
+
return {
|
|
1253
|
+
success: false,
|
|
1254
|
+
completedSteps,
|
|
1255
|
+
results,
|
|
1256
|
+
failedStep: step.name,
|
|
1257
|
+
error: error.message,
|
|
1258
|
+
...compensationErrors.length > 0 ? { compensationErrors } : {}
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1253
1262
|
return {
|
|
1254
|
-
|
|
1255
|
-
|
|
1263
|
+
success: true,
|
|
1264
|
+
completedSteps,
|
|
1265
|
+
results
|
|
1256
1266
|
};
|
|
1257
1267
|
}
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
403: errorResponse(403),
|
|
1273
|
-
404: errorResponse(404),
|
|
1274
|
-
409: errorResponse(409),
|
|
1275
|
-
500: errorResponse(500)
|
|
1276
|
-
};
|
|
1277
|
-
const queryParams = {
|
|
1278
|
-
pagination: {
|
|
1279
|
-
page: {
|
|
1280
|
-
type: "integer",
|
|
1281
|
-
minimum: 1,
|
|
1282
|
-
default: 1,
|
|
1283
|
-
description: "Page number"
|
|
1284
|
-
},
|
|
1285
|
-
limit: {
|
|
1286
|
-
type: "integer",
|
|
1287
|
-
minimum: 1,
|
|
1288
|
-
maximum: 100,
|
|
1289
|
-
default: 20,
|
|
1290
|
-
description: "Items per page"
|
|
1291
|
-
}
|
|
1292
|
-
},
|
|
1293
|
-
sorting: { sort: {
|
|
1294
|
-
type: "string",
|
|
1295
|
-
description: "Sort field (prefix with - for descending)",
|
|
1296
|
-
example: "-createdAt"
|
|
1297
|
-
} },
|
|
1298
|
-
filtering: {
|
|
1299
|
-
select: {
|
|
1300
|
-
description: "Fields to include (space-separated or object)",
|
|
1301
|
-
example: "name email createdAt"
|
|
1302
|
-
},
|
|
1303
|
-
populate: {
|
|
1304
|
-
description: "Relations to populate (comma-separated string or bracket-notation object)",
|
|
1305
|
-
example: "author,category"
|
|
1268
|
+
async function rollback(ctx, completed, hooks) {
|
|
1269
|
+
const errors = [];
|
|
1270
|
+
for (let i = completed.length - 1; i >= 0; i--) {
|
|
1271
|
+
const entry = completed[i];
|
|
1272
|
+
if (!entry?.step.compensate) continue;
|
|
1273
|
+
const compensateFn = entry.step.compensate;
|
|
1274
|
+
try {
|
|
1275
|
+
await compensateFn(ctx, entry.result);
|
|
1276
|
+
safeHook("onCompensate", () => hooks?.onCompensate?.(entry.step.name), hooks);
|
|
1277
|
+
} catch (err) {
|
|
1278
|
+
errors.push({
|
|
1279
|
+
step: entry.step.name,
|
|
1280
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1281
|
+
});
|
|
1306
1282
|
}
|
|
1307
1283
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
*/
|
|
1312
|
-
function getListQueryParams() {
|
|
1284
|
+
return errors;
|
|
1285
|
+
}
|
|
1286
|
+
function defineCompensation(name, steps) {
|
|
1313
1287
|
return {
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
...queryParams.pagination,
|
|
1317
|
-
...queryParams.sorting,
|
|
1318
|
-
...queryParams.filtering
|
|
1319
|
-
},
|
|
1320
|
-
additionalProperties: true
|
|
1288
|
+
name,
|
|
1289
|
+
execute: (initialContext, hooks) => withCompensation(name, steps, initialContext, hooks)
|
|
1321
1290
|
};
|
|
1322
1291
|
}
|
|
1292
|
+
//#endregion
|
|
1293
|
+
//#region src/utils/defineErrorMapper.ts
|
|
1323
1294
|
/**
|
|
1324
|
-
*
|
|
1325
|
-
*
|
|
1326
|
-
*
|
|
1295
|
+
* Register an `ErrorMapper` with its domain-specific generic argument and
|
|
1296
|
+
* have it assign cleanly into `ErrorMapper[]` (no `as unknown as ErrorMapper`).
|
|
1297
|
+
*
|
|
1298
|
+
* The returned mapper is identical at runtime — `type` and `toResponse` are
|
|
1299
|
+
* passed through untouched. Only the declared type widens from
|
|
1300
|
+
* `ErrorMapper<T>` to `ErrorMapper` so the array inference works.
|
|
1301
|
+
*
|
|
1302
|
+
* Safety: the `errorHandlerPlugin` dispatches via `error instanceof mapper.type`
|
|
1303
|
+
* before invoking `toResponse`, so the widened callback signature is never
|
|
1304
|
+
* called with a non-`T` error at runtime. This helper codifies that invariant
|
|
1305
|
+
* in one place.
|
|
1327
1306
|
*/
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1307
|
+
function defineErrorMapper(mapper) {
|
|
1308
|
+
return mapper;
|
|
1309
|
+
}
|
|
1310
|
+
//#endregion
|
|
1311
|
+
//#region src/utils/defineGuard.ts
|
|
1312
|
+
/** Hidden property key for guard context storage on the request object. */
|
|
1313
|
+
const GUARD_STORE_KEY = "__arcGuardContext";
|
|
1332
1314
|
/**
|
|
1333
|
-
*
|
|
1334
|
-
* The `example` keyword is OpenAPI metadata — not standard JSON Schema —
|
|
1335
|
-
* and triggers Ajv strict mode errors when used on routes without the
|
|
1336
|
-
* `keywords: ['example']` AJV config (e.g., raw Fastify without createApp).
|
|
1315
|
+
* Create a typed guard. See module JSDoc for usage.
|
|
1337
1316
|
*/
|
|
1338
|
-
function
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1317
|
+
function defineGuard(config) {
|
|
1318
|
+
const { name, resolve } = config;
|
|
1319
|
+
const preHandler = async (req, reply) => {
|
|
1320
|
+
const ctx = await resolve(req, reply);
|
|
1321
|
+
if (!reply.sent) {
|
|
1322
|
+
const store = req[GUARD_STORE_KEY] ?? {};
|
|
1323
|
+
store[name] = ctx;
|
|
1324
|
+
req[GUARD_STORE_KEY] = store;
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
return {
|
|
1328
|
+
preHandler,
|
|
1329
|
+
name,
|
|
1330
|
+
from(req) {
|
|
1331
|
+
const store = req[GUARD_STORE_KEY];
|
|
1332
|
+
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.`);
|
|
1333
|
+
return store[name];
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1347
1336
|
}
|
|
1337
|
+
//#endregion
|
|
1338
|
+
//#region src/utils/handleRaw.ts
|
|
1348
1339
|
/**
|
|
1349
|
-
*
|
|
1350
|
-
*
|
|
1351
|
-
* When routes have response schemas, Fastify compiles them with
|
|
1352
|
-
* fast-json-stringify for 2-3x faster serialization and prevents
|
|
1353
|
-
* accidental field disclosure.
|
|
1354
|
-
*
|
|
1355
|
-
* These defaults use `additionalProperties: true` so all fields pass through.
|
|
1356
|
-
* Override with specific schemas for full serialization performance + safety.
|
|
1340
|
+
* Wrap a raw Fastify handler with Arc's response shape and error handling.
|
|
1357
1341
|
*
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1342
|
+
* @param handler - Async function that receives `(request, reply)` and returns data.
|
|
1343
|
+
* The return value is sent raw (no envelope). If it returns `undefined`,
|
|
1344
|
+
* the response body is empty (HTTP status only).
|
|
1345
|
+
* @param statusCode - HTTP status code for successful responses (default: 200)
|
|
1360
1346
|
*/
|
|
1361
|
-
function
|
|
1362
|
-
return
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1347
|
+
function handleRaw(handler, statusCode = 200) {
|
|
1348
|
+
return async (request, reply) => {
|
|
1349
|
+
try {
|
|
1350
|
+
const result = await handler(request, reply);
|
|
1351
|
+
if (reply.sent) return;
|
|
1352
|
+
if (result === void 0 || result === null) reply.code(statusCode).send();
|
|
1353
|
+
else reply.code(statusCode).send(result);
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
if (reply.sent) return;
|
|
1356
|
+
if (err instanceof ArcError) {
|
|
1357
|
+
reply.code(err.statusCode).send({
|
|
1358
|
+
error: err.message,
|
|
1359
|
+
code: err.code,
|
|
1360
|
+
...err.details ? { details: err.details } : {}
|
|
1361
|
+
});
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
const error = err;
|
|
1365
|
+
const code = error.statusCode ?? error.status ?? 500;
|
|
1366
|
+
reply.code(code).send({
|
|
1367
|
+
error: error.message ?? "Internal server error",
|
|
1368
|
+
...error.code && { code: error.code }
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
1372
|
}
|
|
1373
1373
|
//#endregion
|
|
1374
1374
|
//#region src/utils/stateMachine.ts
|
|
@@ -1472,4 +1472,4 @@ function createStateMachine(name, transitions = {}, options = {}) {
|
|
|
1472
1472
|
};
|
|
1473
1473
|
}
|
|
1474
1474
|
//#endregion
|
|
1475
|
-
export {
|
|
1475
|
+
export { scheduleBackground as A, paginationSchema as C, createQueryParser as D, ArcQueryParser as E, MONGO_DIALECT as O, offsetListResponse as S, responses as T, errorDetailSchema$1 as _, defineCompensation as a, keysetListResponse as b, CircuitBreakerError as c, createCircuitBreaker as d, createCircuitBreakerRegistry as f, errorContractSchema$1 as g, deleteResponse as h, defineErrorMapper as i, NEUTRAL_DIALECT as k, CircuitBreakerRegistry as l, bareListResponse as m, handleRaw as n, withCompensation as o, aggregateListResponse as p, defineGuard as r, CircuitBreaker as s, createStateMachine as t, CircuitState as u, getDefaultCrudSchemas as v, queryParams as w, listResponse as x, getListQueryParams as y };
|