@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.
Files changed (175) hide show
  1. package/dist/{BaseController-B5GZfUBy.mjs → BaseController-CYaDvmOB.mjs} +16 -15
  2. package/dist/{EventTransport-BEVP50ka.d.mts → EventTransport-Cx60_s-6.d.mts} +37 -0
  3. package/dist/{EventTransport-pGNNDQ__.mjs → EventTransport-Gq7JWbWw.mjs} +39 -7
  4. package/dist/{HookSystem-CuhHvcyZ.mjs → HookSystem-C0ikzqQf.mjs} +2 -2
  5. package/dist/{ResourceRegistry-DFOmazXg.mjs → ResourceRegistry-DPcX--zk.mjs} +2 -2
  6. package/dist/{actionPermissions-CQQrR4tu.mjs → actionPermissions-jlFZ1it4.mjs} +6 -6
  7. package/dist/audit/index.d.mts +1 -1
  8. package/dist/audit/index.mjs +2 -2
  9. package/dist/auth/audit.mjs +1 -1
  10. package/dist/auth/index.d.mts +1 -1
  11. package/dist/auth/index.mjs +79 -28
  12. package/dist/auth/redis-session.mjs +1 -1
  13. package/dist/{authorizationDecision-DgyWdav8.mjs → authorizationDecision-BP8GHtjc.mjs} +2 -2
  14. package/dist/{buildHandler-D2DkqtiY.mjs → buildHandler-DjXOqIvX.mjs} +4 -4
  15. package/dist/cache/index.mjs +4 -4
  16. package/dist/{caching-BmC2T-pX.mjs → caching-a3ZEcJJs.mjs} +2 -2
  17. package/dist/cleanup/index.d.mts +2 -2
  18. package/dist/cleanup/index.mjs +4 -4
  19. package/dist/cli/commands/describe.d.mts +1 -1
  20. package/dist/cli/commands/describe.mjs +3 -3
  21. package/dist/cli/commands/docs.mjs +3 -3
  22. package/dist/cli/commands/doctor.mjs +1 -1
  23. package/dist/cli/commands/generate.mjs +2 -2
  24. package/dist/cli/commands/init.mjs +2 -2
  25. package/dist/cli/commands/introspect.mjs +2 -2
  26. package/dist/cli/index.mjs +3 -3
  27. package/dist/{constants-CUpamQMJ.mjs → constants-BONghE1Y.mjs} +1 -1
  28. package/dist/context/index.mjs +3 -3
  29. package/dist/core/index.d.mts +2 -2
  30. package/dist/core/index.mjs +7 -7
  31. package/dist/{core-C8GWw-Iu.mjs → core-8She7FgF.mjs} +3 -3
  32. package/dist/{createActionRouter-CwDU_j2S.mjs → createActionRouter-Bqi6Bhhx.mjs} +7 -7
  33. package/dist/{createAggregationRouter-d057xXX1.mjs → createAggregationRouter-BsYO7ktd.mjs} +7 -7
  34. package/dist/{defineAggregation-cCitY2FA.mjs → defineAggregation-0KBvqEEC.mjs} +1 -1
  35. package/dist/{defineResource-WcbE1kJL.mjs → defineResource-BMdyRelW.mjs} +18 -18
  36. package/dist/discovery/index.mjs +1 -1
  37. package/dist/docs/index.d.mts +1 -1
  38. package/dist/docs/index.mjs +3 -3
  39. package/dist/{elevation-Bc6Gafv9.mjs → elevation-C6woysGC.mjs} +4 -4
  40. package/dist/encryption/index.mjs +3 -3
  41. package/dist/{errorHandler-CB75oY5i.mjs → errorHandler-BdmIWPba.mjs} +4 -4
  42. package/dist/{errors-DsdZc1Xv.mjs → errors-2ExmOgIJ.mjs} +1 -1
  43. package/dist/{eventPlugin-B47sfCJ8.d.mts → eventPlugin-DFvHa4Nf.d.mts} +10 -1
  44. package/dist/{eventPlugin-DEIfV1iC.mjs → eventPlugin-DJwpGMzx.mjs} +6 -6
  45. package/dist/events/index.d.mts +4 -4
  46. package/dist/events/index.mjs +5 -5
  47. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  48. package/dist/events/transports/redis-stream-entry.mjs +1 -1
  49. package/dist/events/transports/redis.d.mts +1 -1
  50. package/dist/events/transports/redis.mjs +1 -1
  51. package/dist/factory/index.d.mts +4 -4
  52. package/dist/factory/index.mjs +5 -5
  53. package/dist/{fastify-augmentation-CdY8FINU.d.mts → fastify-augmentation-jYA2oHyL.d.mts} +3 -3
  54. package/dist/{fields-DvGz47Ul.mjs → fields-Cha2QCKH.mjs} +37 -2
  55. package/dist/{generate-D7CIIWhS.mjs → generate-BUP4Oydg.mjs} +2 -2
  56. package/dist/{headers-BFu1st_i.mjs → headers-DeXNifVj.mjs} +2 -2
  57. package/dist/{health-Cc_sb94d.mjs → health-Bh0N_FLk.mjs} +1 -1
  58. package/dist/hooks/index.d.mts +1 -1
  59. package/dist/hooks/index.mjs +2 -2
  60. package/dist/idempotency/index.mjs +4 -4
  61. package/dist/idempotency/redis.mjs +1 -1
  62. package/dist/{index-C01wWGJt.d.mts → index-B78WZom8.d.mts} +11 -2
  63. package/dist/{index-du18bJjH.d.mts → index-BRoDtjek.d.mts} +2 -4
  64. package/dist/{index-DCxxRwLC.d.mts → index-D2uIUSQk.d.mts} +1 -1
  65. package/dist/index.d.mts +3 -3
  66. package/dist/index.mjs +11 -11
  67. package/dist/{init-BMgU1wYR.mjs → init-Dg_Am2o_.mjs} +1 -1
  68. package/dist/integrations/event-gateway.d.mts +1 -1
  69. package/dist/integrations/event-gateway.mjs +2 -2
  70. package/dist/integrations/index.d.mts +1 -1
  71. package/dist/integrations/index.mjs +1 -1
  72. package/dist/integrations/jobs/index.mjs +2 -2
  73. package/dist/integrations/mcp/index.d.mts +2 -2
  74. package/dist/integrations/mcp/index.mjs +2 -2
  75. package/dist/integrations/mcp/testing.d.mts +1 -1
  76. package/dist/integrations/mcp/testing.mjs +2 -2
  77. package/dist/integrations/streamline.mjs +3 -3
  78. package/dist/integrations/webhooks.d.mts +2 -2
  79. package/dist/integrations/webhooks.mjs +3 -3
  80. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  81. package/dist/integrations/websocket-redis.mjs +1 -1
  82. package/dist/integrations/websocket.mjs +1 -1
  83. package/dist/{interface-CaPVHoVJ.mjs → interface-DYPVscCU.mjs} +1 -1
  84. package/dist/{jsonBody-BuzxOZV8.mjs → jsonBody-DVui0uJl.mjs} +2 -2
  85. package/dist/{keys-DoUI9-yP.mjs → keys-BSuxGS-B.mjs} +1 -1
  86. package/dist/{loadResources-CPlRWJCv.mjs → loadResources-CNd1DWnK.mjs} +2 -2
  87. package/dist/{loadResourcesFromEntry-CPCZiX9n.mjs → loadResourcesFromEntry-DNj8cpiY.mjs} +2 -2
  88. package/dist/logger/index.mjs +1 -1
  89. package/dist/{mcpPlugin-dxMxBPIg.mjs → mcpPlugin-KWsezMx7.mjs} +15 -15
  90. package/dist/{memory-BnafVem_.mjs → memory-BiPVaCPg.mjs} +2 -2
  91. package/dist/{metrics-CeAIcVMY.mjs → metrics-CsWaIVTa.mjs} +2 -2
  92. package/dist/middleware/index.d.mts +1 -1
  93. package/dist/middleware/index.mjs +3 -3
  94. package/dist/migrations/index.mjs +2 -2
  95. package/dist/{module-DIvG8qOg.mjs → module-BQXHxMEk.mjs} +1 -1
  96. package/dist/{multipartBody-BVCoaTwu.mjs → multipartBody-u6VrS_iH.mjs} +1 -1
  97. package/dist/{openapi-D_i6CifU.mjs → openapi-Dxc8RLy_.mjs} +6 -6
  98. package/dist/outbox-admin/index.d.mts +1 -1
  99. package/dist/outbox-admin/index.mjs +4 -4
  100. package/dist/{outbox-module-BbdaMyqy.d.mts → outbox-module-CQXu_O6o.d.mts} +2 -2
  101. package/dist/{outbox-module-CKaLL5BH.mjs → outbox-module-DyIqA5vo.mjs} +4 -4
  102. package/dist/permissions/index.mjs +5 -5
  103. package/dist/{permissions-DWkl60hx.mjs → permissions-H7Zcb82Z.mjs} +6 -6
  104. package/dist/{pipe-Dbs3GhOZ.mjs → pipe-SunhEaZ6.mjs} +2 -2
  105. package/dist/pipeline/index.d.mts +1 -1
  106. package/dist/pipeline/index.mjs +2 -2
  107. package/dist/plugins/index.d.mts +4 -4
  108. package/dist/plugins/index.mjs +16 -16
  109. package/dist/plugins/response-cache.mjs +2 -2
  110. package/dist/plugins/tracing-entry.mjs +2 -2
  111. package/dist/{pluralize-DgaYX91M.mjs → pluralize-Da7IT_qJ.mjs} +1 -1
  112. package/dist/{preloadResources-BYFfYnjZ.d.mts → preloadResources-BbBkNkqK.d.mts} +6 -6
  113. package/dist/{preloadResources-BUoGKPaS.mjs → preloadResources-DlhH81py.mjs} +21 -21
  114. package/dist/presets/filesUpload.d.mts +1 -1
  115. package/dist/presets/filesUpload.mjs +5 -5
  116. package/dist/presets/index.d.mts +1 -1
  117. package/dist/presets/index.mjs +2 -2
  118. package/dist/presets/multiTenant.d.mts +1 -1
  119. package/dist/presets/multiTenant.mjs +3 -3
  120. package/dist/presets/search.d.mts +1 -1
  121. package/dist/presets/search.mjs +2 -2
  122. package/dist/{presets-Gbv9mNKD.mjs → presets-CNyfaxGL.mjs} +4 -4
  123. package/dist/{projection-BunxoeON.mjs → projection-DSfEoooA.mjs} +2 -2
  124. package/dist/{queryCachePlugin-BYqmg89O.mjs → queryCachePlugin-oTsmVyEQ.mjs} +5 -5
  125. package/dist/{rateLimitKey-DlqmDHA5.mjs → rateLimitKey-CewdR-m6.mjs} +1 -1
  126. package/dist/{realtime-CiAp2-LH.mjs → realtime-CPmjjgqd.mjs} +9 -9
  127. package/dist/{redis-stream-mwLWlfHG.d.mts → redis-stream-eh0eOtjh.d.mts} +1 -1
  128. package/dist/{registerRoute-dnf-JJf-.mjs → registerRoute-XvcI_3sh.mjs} +10 -6
  129. package/dist/registry/index.d.mts +2 -2
  130. package/dist/registry/index.mjs +3 -3
  131. package/dist/{registry-ByC0UFNS.mjs → registry-UFEJQN8R.mjs} +2 -2
  132. package/dist/{renewingLease-C5Ta_ImD.mjs → renewingLease-DuRGVOMe.mjs} +1 -1
  133. package/dist/{replyHelpers-D1k0wHoO.mjs → replyHelpers-BHebarf0.mjs} +2 -2
  134. package/dist/{requestContext-DGM7EZRI.mjs → requestContext-DpmntRZy.mjs} +1 -1
  135. package/dist/{rolldown-runtime-DrZrabOW.mjs → rolldown-runtime-Add2L50I.mjs} +1 -1
  136. package/dist/{runtimeCapabilities-c4Sc85X-.mjs → runtimeCapabilities-D793Pxci.mjs} +1 -1
  137. package/dist/{schedules-CVtolEa-.mjs → schedules-DthM8Qag.mjs} +3 -3
  138. package/dist/{schemaConverter-C8zbW7tJ.mjs → schemaConverter-Db-MMsH6.mjs} +1 -1
  139. package/dist/{schemaIR-BfI425RH.mjs → schemaIR-CIXShch7.mjs} +2 -2
  140. package/dist/schemas/index.mjs +1 -1
  141. package/dist/scim/index.mjs +2 -2
  142. package/dist/scope/index.mjs +5 -5
  143. package/dist/{scope-DFSOKc4T.mjs → scope-D03sk17n.mjs} +4 -4
  144. package/dist/{simpleEqualityMatcher-Bhp7WrRF.mjs → simpleEqualityMatcher-B7qVjQBv.mjs} +1 -1
  145. package/dist/{sse-vRuwDd6N.mjs → sse-D4MYqkic.mjs} +5 -5
  146. package/dist/{sseStream-cWezGOk1.mjs → sseStream-BuMNPR6r.mjs} +2 -2
  147. package/dist/{store-helpers-bU8H5c-A.mjs → store-helpers-CIF4RG8s.mjs} +1 -1
  148. package/dist/{streaming-weWcFCk4.mjs → streaming-DkpW1HwJ.mjs} +1 -1
  149. package/dist/{subscribe-helpers-BrcG3NlV.mjs → subscribe-helpers-CBZOi6pk.mjs} +2 -2
  150. package/dist/sync/changelog.mjs +1 -1
  151. package/dist/testing/cleanupStoreContract.mjs +1 -1
  152. package/dist/testing/index.d.mts +4 -4
  153. package/dist/testing/index.mjs +10 -10
  154. package/dist/testing/outboxStoreContract.mjs +1 -1
  155. package/dist/testing/storageContract.mjs +1 -1
  156. package/dist/{transactionContext-Ca5peQpc.mjs → transactionContext-DMmvcfdo.mjs} +1 -1
  157. package/dist/{typeGuards-ALWme5WB.mjs → typeGuards-DHTnEs5X.mjs} +1 -1
  158. package/dist/types/index.d.mts +1 -1
  159. package/dist/types/index.mjs +1 -1
  160. package/dist/types/storage.mjs +1 -1
  161. package/dist/{types-BMa2EPtv.d.mts → types-BRU-Kd3t.d.mts} +1 -1
  162. package/dist/{types-lDs9oF4z.mjs → types-BqLyyH0o.mjs} +4 -4
  163. package/dist/{types-BXnTOsXP.d.mts → types-LQFwC37d.d.mts} +1 -1
  164. package/dist/usage/index.mjs +0 -0
  165. package/dist/{userHelpers-DU_Z5OBA.mjs → userHelpers-CXySQFRg.mjs} +1 -1
  166. package/dist/utils/index.d.mts +2 -2
  167. package/dist/utils/index.mjs +11 -11
  168. package/dist/{utils-sXGFLegw.mjs → utils-BbGG8XNT.mjs} +1230 -1230
  169. package/dist/{validate-qK_-7MVk.mjs → validate-CidSeUrx.mjs} +2 -2
  170. package/dist/{versioning-DEs-_hFi.d.mts → versioning-B843jOYX.d.mts} +1 -1
  171. package/dist/{versioning-Dtw0MyX4.mjs → versioning-C2r0eOZ4.mjs} +3 -3
  172. package/package.json +11 -9
  173. package/skills/arc/references/auth.md +5 -2
  174. package/skills/arc/references/production.md +733 -733
  175. package/skills/arc-code-review/references/migration-recipes.md +16 -7
@@ -1,1374 +1,1374 @@
1
- /*! @classytic/arc v2.37.0 | MIT | https://classytic.com */
2
- import { m as RESERVED_QUERY_PARAMS } from "./constants-CUpamQMJ.mjs";
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-DsdZc1Xv.mjs";
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
- //#region src/utils/circuitBreaker.ts
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
- * Circuit Breaker Pattern
10
- *
11
- * Wraps external service calls with failure protection.
12
- * Prevents cascading failures by "opening" the circuit when
13
- * a service is failing, allowing it time to recover.
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
- * States:
16
- * - CLOSED: Normal operation, requests pass through
17
- * - OPEN: Too many failures, all requests fail fast
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
- * import { CircuitBreaker } from '@classytic/arc/utils';
65
+ * // Use Arc default parser (auto-applied if no queryParser option)
66
+ * defineResource({ name: 'product', adapter: ... });
22
67
  *
23
- * const paymentBreaker = new CircuitBreaker(async (amount) => {
24
- * return await stripe.charges.create({ amount });
25
- * }, {
26
- * failureThreshold: 5,
27
- * resetTimeout: 30000,
28
- * timeout: 5000,
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
- * try {
32
- * const result = await paymentBreaker.call(100);
33
- * } catch (error) {
34
- * // Handle failure or circuit open
35
- * }
78
+ * // Use custom parser for SQL databases
79
+ * defineResource({
80
+ * name: 'user',
81
+ * adapter: ...,
82
+ * queryParser: new PgQueryParser(),
83
+ * });
36
84
  */
37
- const CircuitState = {
38
- CLOSED: "CLOSED",
39
- OPEN: "OPEN",
40
- HALF_OPEN: "HALF_OPEN"
41
- };
42
- var CircuitBreakerError = class extends Error {
43
- state;
44
- constructor(message, state) {
45
- super(message);
46
- this.name = "CircuitBreakerError";
47
- this.state = state;
48
- }
49
- };
50
- var CircuitBreaker = class {
51
- state = CircuitState.CLOSED;
52
- failures = 0;
53
- successes = 0;
54
- totalCalls = 0;
55
- nextAttempt = 0;
56
- lastCallAt = null;
57
- openedAt = null;
58
- halfOpenInFlight = 0;
59
- failureThreshold;
60
- resetTimeout;
61
- timeout;
62
- successThreshold;
63
- halfOpenMaxProbes;
64
- propagateAbort;
65
- fallback;
66
- onStateChange;
67
- onError;
68
- name;
69
- fn;
70
- constructor(fn, options = {}) {
71
- this.fn = fn;
72
- this.failureThreshold = options.failureThreshold ?? 5;
73
- this.resetTimeout = options.resetTimeout ?? 6e4;
74
- this.timeout = options.timeout ?? 1e4;
75
- this.successThreshold = options.successThreshold ?? 1;
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
- * Call the wrapped function with circuit breaker protection
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
- async call(...args) {
87
- this.totalCalls++;
88
- this.lastCallAt = Date.now();
89
- if (this.state === CircuitState.OPEN) {
90
- if (Date.now() < this.nextAttempt) {
91
- const error = new CircuitBreakerError(`Circuit breaker is OPEN for ${this.name}`, CircuitState.OPEN);
92
- if (this.fallback) return this.fallback(...args);
93
- throw error;
94
- }
95
- this.setState(CircuitState.HALF_OPEN);
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
- let isProbe = false;
98
- if (this.state === CircuitState.HALF_OPEN) {
99
- if (this.halfOpenInFlight >= this.halfOpenMaxProbes) {
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
- try {
107
- const result = await this.executeWithTimeout(args);
108
- this.onSuccess();
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
- * Execute function with timeout. With `propagateAbort`, the wrapped fn
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
- async executeWithTimeout(args) {
124
- const controller = this.propagateAbort ? new AbortController() : void 0;
125
- return new Promise((resolve, reject) => {
126
- const timeoutId = setTimeout(() => {
127
- controller?.abort(/* @__PURE__ */ new Error(`Request timeout after ${this.timeout}ms`));
128
- reject(/* @__PURE__ */ new Error(`Request timeout after ${this.timeout}ms`));
129
- }, this.timeout);
130
- (controller ? this.fn(...args, controller.signal) : this.fn(...args)).then((result) => {
131
- clearTimeout(timeoutId);
132
- resolve(result);
133
- }).catch((error) => {
134
- clearTimeout(timeoutId);
135
- reject(error);
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
- * Handle successful call
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
- onSuccess() {
143
- this.failures = 0;
144
- this.successes++;
145
- if (this.state === CircuitState.HALF_OPEN) {
146
- if (this.successes >= this.successThreshold) {
147
- this.setState(CircuitState.CLOSED);
148
- this.successes = 0;
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
- * Handle failed call
154
- */
155
- onFailure(error) {
156
- this.failures++;
157
- this.successes = 0;
158
- if (this.onError) this.onError(error);
159
- if (this.state === CircuitState.HALF_OPEN || this.failures >= this.failureThreshold) {
160
- this.setState(CircuitState.OPEN);
161
- this.nextAttempt = Date.now() + this.resetTimeout;
162
- this.openedAt = Date.now();
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
- * Change circuit state
167
- */
168
- setState(newState) {
169
- const oldState = this.state;
170
- if (oldState !== newState) {
171
- this.state = newState;
172
- if (this.onStateChange) this.onStateChange(oldState, newState);
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
- * Manually open the circuit
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
- open() {
179
- this.setState(CircuitState.OPEN);
180
- this.nextAttempt = Date.now() + this.resetTimeout;
181
- this.openedAt = Date.now();
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
- * Manually close the circuit
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
- close() {
187
- this.failures = 0;
188
- this.successes = 0;
189
- this.setState(CircuitState.CLOSED);
190
- this.openedAt = null;
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
- * Get current statistics
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
- getStats() {
196
- return {
197
- name: this.name,
198
- state: this.state,
199
- failures: this.failures,
200
- successes: this.successes,
201
- totalCalls: this.totalCalls,
202
- openedAt: this.openedAt,
203
- lastCallAt: this.lastCallAt
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
- * Get current state
208
- */
209
- getState() {
210
- return this.state;
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
- * Check if circuit is open
214
- */
215
- isOpen() {
216
- return this.state === CircuitState.OPEN;
217
- }
218
- /**
219
- * Check if circuit is closed
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
- * Register a circuit breaker
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
- register(name, fn, options) {
257
- const breaker = new CircuitBreaker(fn, {
258
- ...options,
259
- name
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
- this.breakers.set(name, breaker);
262
- return breaker;
263
- }
264
- /**
265
- * Get a circuit breaker by name
266
- */
267
- get(name) {
268
- return this.breakers.get(name);
269
- }
270
- /**
271
- * Get all breakers
272
- */
273
- getAll() {
274
- return this.breakers;
275
- }
276
- /**
277
- * Get statistics for all breakers
278
- */
279
- getAllStats() {
280
- const stats = {};
281
- for (const [name, breaker] of this.breakers.entries()) stats[name] = breaker.getStats();
282
- return stats;
283
- }
284
- /**
285
- * Reset all breakers
286
- */
287
- resetAll() {
288
- for (const breaker of this.breakers.values()) breaker.reset();
289
- }
290
- /**
291
- * Open all breakers
292
- */
293
- openAll() {
294
- for (const breaker of this.breakers.values()) breaker.open();
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
- * Close all breakers
298
- */
299
- closeAll() {
300
- for (const breaker of this.breakers.values()) breaker.close();
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 CircuitBreakerRegistry instance.
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 createCircuitBreakerRegistry() {
308
- return new CircuitBreakerRegistry();
489
+ function createQueryParser(options) {
490
+ return new ArcQueryParser(options);
309
491
  }
310
492
  //#endregion
311
- //#region src/utils/compensation.ts
493
+ //#region src/utils/responseSchemas.ts
312
494
  /**
313
- * Observability hooks must never alter the transaction: a throwing metrics
314
- * sink must not fail a step that succeeded, trigger compensation, or become
315
- * an unhandled rejection from a detached fire-and-forget chain. Containment
316
- * is reported via `hooks.onHookError` so broken sinks stay visible.
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
- * Run steps in order with automatic compensation on failure.
504
+ * Pagination schema - matches MongoKit/Arc runtime format
329
505
  *
330
- * @typeParam TCtx - Context type shared across steps (defaults to Record<string, unknown>)
506
+ * Runtime format (flat fields):
507
+ * { page, limit, total, pages, hasNext, hasPrev }
331
508
  */
332
- async function withCompensation(_name, steps, initialContext, hooks) {
333
- const ctx = { ...initialContext };
334
- const completedSteps = [];
335
- const results = {};
336
- const completed = [];
337
- for (const step of steps) {
338
- if (step.fireAndForget) {
339
- 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(() => {});
340
- continue;
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
- try {
343
- const result = await step.execute(ctx);
344
- completedSteps.push(step.name);
345
- results[step.name] = result;
346
- completed.push({
347
- step,
348
- result
349
- });
350
- safeHook("onStepComplete", () => hooks?.onStepComplete?.(step.name, result), hooks);
351
- } catch (err) {
352
- const error = err instanceof Error ? err : new Error(String(err));
353
- safeHook("onStepFailed", () => hooks?.onStepFailed?.(step.name, error), hooks);
354
- const compensationErrors = await rollback(ctx, completed, hooks);
355
- return {
356
- success: false,
357
- completedSteps,
358
- results,
359
- failedStep: step.name,
360
- error: error.message,
361
- ...compensationErrors.length > 0 ? { compensationErrors } : {}
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
- success: true,
367
- completedSteps,
368
- results
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
- async function rollback(ctx, completed, hooks) {
372
- const errors = [];
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
- name,
392
- execute: (initialContext, hooks) => withCompensation(name, steps, initialContext, hooks)
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
- //#endregion
396
- //#region src/utils/defineErrorMapper.ts
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
- preHandler,
432
- name,
433
- from(req) {
434
- const store = req[GUARD_STORE_KEY];
435
- 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.`);
436
- return store[name];
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
- //#endregion
441
- //#region src/utils/filter-dialect.ts
442
- /**
443
- * Default dialect — MongoDB / MongoKit operator shape. Emits `$`-prefixed
444
- * tokens and the `$options: 'i'` case-insensitive sibling. This is what
445
- * `ArcQueryParser` uses when no dialect is supplied, so behaviour is identical
446
- * to every prior arc version.
447
- */
448
- const MONGO_DIALECT = {
449
- operators: {
450
- eq: "$eq",
451
- ne: "$ne",
452
- gt: "$gt",
453
- gte: "$gte",
454
- lt: "$lt",
455
- lte: "$lte",
456
- in: "$in",
457
- nin: "$nin",
458
- like: "$regex",
459
- contains: "$regex",
460
- regex: "$regex",
461
- exists: "$exists"
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
- caseInsensitiveModifier: () => ({ $options: "i" })
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
- * Store-neutral reference dialect — emits UN-prefixed operator tokens as a
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
- const NEUTRAL_DIALECT = {
473
- operators: {
474
- eq: "eq",
475
- ne: "ne",
476
- gt: "gt",
477
- gte: "gte",
478
- lt: "lt",
479
- lte: "lte",
480
- in: "in",
481
- nin: "nin",
482
- like: "like",
483
- contains: "contains",
484
- regex: "regex",
485
- exists: "exists"
486
- },
487
- caseInsensitiveModifier: () => ({ caseInsensitive: true })
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
- * Wrap a raw Fastify handler with Arc's response shape and error handling.
493
- *
494
- * @param handler - Async function that receives `(request, reply)` and returns data.
495
- * The return value is sent raw (no envelope). If it returns `undefined`,
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 handleRaw(handler, statusCode = 200) {
500
- return async (request, reply) => {
501
- try {
502
- const result = await handler(request, reply);
503
- if (reply.sent) return;
504
- if (result === void 0 || result === null) reply.code(statusCode).send();
505
- else reply.code(statusCode).send(result);
506
- } catch (err) {
507
- if (reply.sent) return;
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
- * Arc Query Parser - Default URL-to-Query Parser
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
- * @example
535
- * // Use Arc default parser (auto-applied if no queryParser option)
536
- * defineResource({ name: 'product', adapter: ... });
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
- * // Use MongoKit's QueryParser (recommended for MongoDB - has $lookup, aggregations, etc.)
539
- * // Fail-closed by default (mongokit >=3.25): invalid/blocked query input
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
- * // Use custom parser for SQL databases
549
- * defineResource({
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
- const DANGEROUS_REGEX_PATTERNS = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
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
- * Arc's default query parser
906
+ * Circuit Breaker Pattern
567
907
  *
568
- * Converts URL query parameters to a structured query format:
569
- * - Pagination: ?page=1&limit=20
570
- * - Sorting: ?sort=-createdAt,name (- prefix = descending)
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
- * **NOTE on parser portability.** `ArcQueryParser` accepts both filter
585
- * forms. Other parser implementations (`@classytic/mongokit`'s
586
- * `QueryParser`, custom hosts-side parsers) may accept only one form —
587
- * MongoKit historically only accepts the bare form. If your host swaps
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
- * For advanced MongoDB features ($lookup, aggregations), use MongoKit's QueryParser.
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
- var ArcQueryParser = class {
594
- /**
595
- * PUBLIC, deliberately: `QueryResolver` reads it so it does not apply a second cap
596
- * on top of this one. Three layers capping independently, lowest winning silently,
597
- * is what made a 696-row chart of accounts render as 100.
598
- */
599
- maxLimit;
600
- defaultLimit;
601
- maxRegexLength;
602
- maxSearchLength;
603
- maxFilterDepth;
604
- _allowedFilterFields;
605
- _allowedSortFields;
606
- _allowedOperators;
607
- strictFilterFields;
608
- /** Allowed filter fields (used by MCP for auto-derive) */
609
- allowedFilterFields;
610
- /** Allowed sort fields (used by MCP for sort descriptions) */
611
- allowedSortFields;
612
- /** Allowed operators (used by MCP for operator descriptions) */
613
- allowedOperators;
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
- * Datastore dialect — owns the store-specific operator tokens + the
616
- * case-insensitive modifier. Defaults to Mongo (backward-compatible).
981
+ * Call the wrapped function with circuit breaker protection
617
982
  */
618
- dialect;
619
- /** Supported filter operators (neutral name → emitted token), from the dialect. */
620
- get operators() {
621
- return this.dialect.operators;
622
- }
623
- constructor(options = {}) {
624
- this.dialect = options.dialect ?? MONGO_DIALECT;
625
- this.maxLimit = options.maxLimit ?? 1e3;
626
- this.defaultLimit = options.defaultLimit ?? 20;
627
- this.maxRegexLength = options.maxRegexLength ?? 200;
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
- if (options.allowedSortFields) {
636
- this._allowedSortFields = new Set(options.allowedSortFields);
637
- this.allowedSortFields = options.allowedSortFields;
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
- if (options.allowedOperators) {
640
- this._allowedOperators = new Set(options.allowedOperators);
641
- this.allowedOperators = options.allowedOperators;
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
- * Parse URL query parameters into structured query options
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
- parse(query) {
648
- const q = query ?? {};
649
- const page = this.parseNumber(q.page, 1);
650
- const limit = Math.min(this.parseNumber(q.limit, this.defaultLimit), this.maxLimit);
651
- const after = this.parseString(q.after ?? q.cursor);
652
- const sort = this.parseSort(q.sort);
653
- const { populate, populateOptions } = this.parsePopulate(q.populate);
654
- const search = this.parseSearch(q.search);
655
- const select = this.parseSelect(q.select);
656
- return {
657
- filters: this.parseFilters(q),
658
- limit,
659
- sort,
660
- populate,
661
- populateOptions,
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
- * Parse populate parameter — handles both simple string and bracket notation.
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
- parsePopulate(value) {
685
- if (value === void 0 || value === null) return {};
686
- if (typeof value === "string") {
687
- const trimmed = value.trim();
688
- return trimmed.length > 0 ? { populate: trimmed } : {};
689
- }
690
- if (typeof value === "object" && !Array.isArray(value)) {
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
- parseSort(value) {
711
- if (!value) return void 0;
712
- const sortStr = String(value);
713
- const result = {};
714
- for (const field of sortStr.split(",")) {
715
- const trimmed = field.trim();
716
- if (!trimmed) continue;
717
- if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
718
- const fieldName = trimmed.startsWith("-") ? trimmed.slice(1) : trimmed;
719
- if (this._allowedSortFields && !this._allowedSortFields.has(fieldName)) continue;
720
- if (trimmed.startsWith("-")) result[fieldName] = -1;
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
- parseSelect(value) {
733
- if (!value) return void 0;
734
- const selectStr = String(value);
735
- const result = {};
736
- for (const field of selectStr.split(",")) {
737
- const trimmed = field.trim();
738
- if (!trimmed) continue;
739
- if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
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
- * Check if a value exceeds the maximum nesting depth.
747
- * Prevents filter bombs where deeply nested objects consume excessive memory/CPU.
1073
+ * Manually open the circuit
748
1074
  */
749
- exceedsDepth(obj, currentDepth = 0) {
750
- if (currentDepth > this.maxFilterDepth) return true;
751
- if (obj === null || obj === void 0) return false;
752
- if (Array.isArray(obj)) return obj.some((v) => this.exceedsDepth(v, currentDepth));
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
- * Parse all filter entries from the query, supporting BOTH the bare
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
- parseFilters(query) {
778
- const filters = {};
779
- const envelope = query.filter;
780
- if (typeof envelope === "object" && envelope !== null && !Array.isArray(envelope)) for (const [key, value] of Object.entries(envelope)) this.applyFilterEntry(filters, key, value);
781
- for (const [key, value] of Object.entries(query)) {
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
- * Apply one filter entry — used by both the bracket-envelope branch and
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
- applyFilterEntry(filters, key, value) {
794
- if (value === void 0 || value === null) return;
795
- if (!/^[a-zA-Z_][a-zA-Z0-9_.]*(?:\[[a-z]+\])?$/.test(key)) return;
796
- const bareKey = key.replace(/\[[a-z]+\]$/, "");
797
- if (this._allowedFilterFields && !this._allowedFilterFields.has(bareKey)) {
798
- if (this.strictFilterFields) throw new ValidationError(`Unknown filter field: ${bareKey}. Allowed: ${[...this._allowedFilterFields].sort().join(", ")}`);
799
- return;
800
- }
801
- if (this.exceedsDepth(value)) return;
802
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
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
- parseFilterValue(value, operator) {
837
- if (operator === "in" || operator === "nin") {
838
- if (Array.isArray(value)) return value.map((v) => this.coerceValue(v));
839
- if (typeof value === "string" && value.includes(",")) return value.split(",").map((v) => this.coerceValue(v.trim()));
840
- return [this.coerceValue(value)];
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
- coerceValue(value) {
850
- if (value === "true") return true;
851
- if (value === "false") return false;
852
- if (value === "null") return null;
853
- if (typeof value === "string") {
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
- * Generate OpenAPI-compatible JSON Schema for query parameters.
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
- getQuerySchema() {
865
- const operatorLines = Object.entries(this.operators).map(([op, mongoOp]) => {
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
- sanitizeRegex(pattern) {
947
- const truncated = pattern.length > this.maxRegexLength;
948
- let sanitized = pattern.slice(0, this.maxRegexLength);
949
- if (DANGEROUS_REGEX_PATTERNS.test(sanitized)) {
950
- sanitized = sanitized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
951
- log.warn(`regex pattern matched a ReDoS-dangerous shape and was escaped to a literal string. original="${pattern}" sanitized="${sanitized}"`);
952
- } else if (truncated) log.warn(`regex pattern exceeded maxRegexLength (${this.maxRegexLength}) and was truncated. original-length=${pattern.length}`);
953
- return sanitized;
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 new ArcQueryParser instance
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
- * Wire shape (post-2.12): no envelope. HTTP status discriminates success
968
- * vs error. Success-path schemas describe the data shape directly; the
969
- * error path uses the canonical `ErrorContract` JSON Schema imported
970
- * from `@classytic/repo-core/errors` — single source of truth shared
971
- * with every other consumer in the org.
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
- * Pagination schema - matches MongoKit/Arc runtime format
975
- *
976
- * Runtime format (flat fields):
977
- * { page, limit, total, pages, hasNext, hasPrev }
1146
+ * Circuit breaker registry for managing multiple breakers
978
1147
  */
979
- const paginationSchema = {
980
- type: "object",
981
- properties: {
982
- page: {
983
- type: "integer",
984
- example: 1
985
- },
986
- limit: {
987
- type: "integer",
988
- example: 20
989
- },
990
- total: {
991
- type: "integer",
992
- example: 100
993
- },
994
- pages: {
995
- type: "integer",
996
- example: 5
997
- },
998
- hasNext: {
999
- type: "boolean",
1000
- example: true
1001
- },
1002
- hasPrev: {
1003
- type: "boolean",
1004
- example: false
1005
- }
1006
- },
1007
- required: [
1008
- "page",
1009
- "limit",
1010
- "total",
1011
- "pages",
1012
- "hasNext",
1013
- "hasPrev"
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
- * List response schema — accepts every wire shape `toCanonicalList` can
1018
- * emit. Hosts who know their endpoint only ever produces one variant can
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 listResponse(itemSchema) {
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
- /** Delete response — flat shape mirroring the canonical delete envelope. */
1220
- function deleteResponse() {
1221
- return {
1222
- type: "object",
1223
- properties: {
1224
- message: {
1225
- type: "string",
1226
- example: "Deleted successfully"
1227
- },
1228
- id: {
1229
- type: "string",
1230
- example: "507f1f77bcf86cd799439011"
1231
- },
1232
- soft: {
1233
- type: "boolean",
1234
- example: false
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
- const ERROR_DESCRIPTIONS = {
1241
- 400: "Bad Request",
1242
- 401: "Unauthorized",
1243
- 403: "Forbidden",
1244
- 404: "Not Found",
1245
- 409: "Conflict",
1246
- 422: "Unprocessable Entity",
1247
- 429: "Too Many Requests",
1248
- 500: "Internal Server Error",
1249
- 503: "Service Unavailable"
1250
- };
1251
- /** Build an OpenAPI response entry for an `ErrorContract` at the given status. */
1252
- function errorResponse(status) {
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
- description: ERROR_DESCRIPTIONS[status] ?? "Error",
1255
- content: { "application/json": { schema: errorContractSchema$1 } }
1263
+ success: true,
1264
+ completedSteps,
1265
+ results
1256
1266
  };
1257
1267
  }
1258
- const responses = {
1259
- 200: (schema) => ({
1260
- description: "Successful response",
1261
- content: { "application/json": { schema } }
1262
- }),
1263
- 201: (schema) => ({
1264
- description: "Created successfully",
1265
- content: { "application/json": { schema: {
1266
- ...schema,
1267
- additionalProperties: true
1268
- } } }
1269
- }),
1270
- 400: errorResponse(400),
1271
- 401: errorResponse(401),
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
- * Get standard list query parameters schema
1311
- */
1312
- function getListQueryParams() {
1284
+ return errors;
1285
+ }
1286
+ function defineCompensation(name, steps) {
1313
1287
  return {
1314
- type: "object",
1315
- properties: {
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
- * Generic item schema that allows any properties.
1325
- * Used as default when no user schema is provided.
1326
- * Enables fast-json-stringify while still passing through all fields.
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
- const genericItemSchema = {
1329
- type: "object",
1330
- additionalProperties: true
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
- * Recursively strip `example` keys from a schema object.
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 stripExamples(schema) {
1339
- if (schema === null || typeof schema !== "object") return schema;
1340
- if (Array.isArray(schema)) return schema.map(stripExamples);
1341
- const result = {};
1342
- for (const [key, value] of Object.entries(schema)) {
1343
- if (key === "example") continue;
1344
- result[key] = stripExamples(value);
1345
- }
1346
- return result;
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
- * Get default response schemas for all CRUD operations.
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
- * Note: `example` properties are stripped from defaults so they work with
1359
- * any Fastify instance (not just createApp which adds `keywords: ['example']`).
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 getDefaultCrudSchemas() {
1362
- return stripExamples({
1363
- list: {
1364
- querystring: getListQueryParams(),
1365
- response: { 200: listResponse(genericItemSchema) }
1366
- },
1367
- get: { response: { 200: genericItemSchema } },
1368
- create: { response: { 201: genericItemSchema } },
1369
- update: { response: { 200: genericItemSchema } },
1370
- delete: { response: { 200: deleteResponse() } }
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 { createCircuitBreakerRegistry as A, defineCompensation as C, CircuitBreakerRegistry as D, CircuitBreakerError as E, CircuitState as O, defineErrorMapper as S, CircuitBreaker as T, createQueryParser as _, deleteResponse as a, NEUTRAL_DIALECT as b, getDefaultCrudSchemas as c, listResponse as d, offsetListResponse as f, ArcQueryParser as g, responses as h, bareListResponse as i, createCircuitBreaker as k, getListQueryParams as l, queryParams as m, scheduleBackground as n, errorContractSchema$1 as o, paginationSchema as p, aggregateListResponse as r, errorDetailSchema$1 as s, createStateMachine as t, keysetListResponse as u, handleRaw as v, withCompensation as w, defineGuard as x, MONGO_DIALECT as y };
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 };