@classytic/arc 2.40.0 → 2.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/dist/{BaseController-BqJtbiUA.mjs → BaseController-CYEwjyjN.mjs} +20 -13
  2. package/dist/{EventTransport-BPOQsEer.d.mts → EventTransport-CbUKDzZZ.d.mts} +25 -0
  3. package/dist/{EventTransport-BUkTwY_x.mjs → EventTransport-DSqD_Xsg.mjs} +11 -2
  4. package/dist/{HookSystem-96nzZLmk.mjs → HookSystem-CMF94SEA.mjs} +2 -2
  5. package/dist/{ResourceRegistry-B9WnuG2Q.mjs → ResourceRegistry-MGpXoxHZ.mjs} +2 -2
  6. package/dist/{actionPermissions-4OGbe3Hf.mjs → actionPermissions-BoPGQ5-4.mjs} +7 -6
  7. package/dist/audit/index.d.mts +2 -2
  8. package/dist/audit/index.mjs +10 -3
  9. package/dist/auth/audit.mjs +1 -1
  10. package/dist/auth/index.d.mts +2 -2
  11. package/dist/auth/index.mjs +12 -6
  12. package/dist/auth/redis-session.mjs +1 -1
  13. package/dist/{authorizationDecision-D_LFvMlm.mjs → authorizationDecision-DawKGoXb.mjs} +2 -2
  14. package/dist/{base-C-NL2fTi.d.mts → base-CxrV1LmG.d.mts} +10 -0
  15. package/dist/{buildHandler-BG_CHXqQ.mjs → buildHandler-CvxKOc0m.mjs} +4 -4
  16. package/dist/cache/index.d.mts +4 -2
  17. package/dist/cache/index.mjs +4 -4
  18. package/dist/{caching-Bqcn3Brl.mjs → caching-DbCBxlgw.mjs} +2 -2
  19. package/dist/cleanup/index.d.mts +2 -2
  20. package/dist/cleanup/index.mjs +4 -4
  21. package/dist/cli/commands/describe.d.mts +1 -1
  22. package/dist/cli/commands/describe.mjs +3 -3
  23. package/dist/cli/commands/docs.mjs +3 -3
  24. package/dist/cli/commands/doctor.mjs +1 -1
  25. package/dist/cli/commands/generate.mjs +2 -2
  26. package/dist/cli/commands/init.mjs +2 -2
  27. package/dist/cli/commands/introspect.mjs +2 -2
  28. package/dist/cli/index.mjs +3 -3
  29. package/dist/{constants-BQ2FbhPB.mjs → constants-D5Ubn1fe.mjs} +1 -1
  30. package/dist/context/index.mjs +36 -11
  31. package/dist/core/index.d.mts +2 -2
  32. package/dist/core/index.mjs +7 -7
  33. package/dist/{core-CsZR037F.mjs → core-5lazhva9.mjs} +3 -3
  34. package/dist/{createActionRouter-BR93gCKg.mjs → createActionRouter-DNqoaK81.mjs} +7 -7
  35. package/dist/{createAggregationRouter-CF40Aw95.mjs → createAggregationRouter-BcbQJf10.mjs} +7 -7
  36. package/dist/{createMcpServer-BVTs2S5G.d.mts → createMcpServer-CbDWkZ1T.d.mts} +1 -1
  37. package/dist/{defineAggregation-BDA2Tw7v.mjs → defineAggregation-D_eJjm5A.mjs} +1 -1
  38. package/dist/{defineResource-DaIySrQr.mjs → defineResource-DxkMTjRn.mjs} +18 -18
  39. package/dist/discovery/index.mjs +1 -1
  40. package/dist/docs/index.d.mts +1 -1
  41. package/dist/docs/index.mjs +3 -3
  42. package/dist/{elevation-CZy6n50b.mjs → elevation-CkvG0LG2.mjs} +4 -4
  43. package/dist/encryption/index.mjs +3 -3
  44. package/dist/{errorHandler-By9keRvX.mjs → errorHandler-D1GHKGlV.mjs} +3 -3
  45. package/dist/{errors-BHD3L8of.mjs → errors-BH7YU_9O.mjs} +1 -1
  46. package/dist/{eventPlugin-DfHaKEWQ.d.mts → eventPlugin-B52abJ8W.d.mts} +1 -1
  47. package/dist/{eventPlugin-DB26V7uX.mjs → eventPlugin-zZY-unhr.mjs} +5 -5
  48. package/dist/events/index.d.mts +4 -4
  49. package/dist/events/index.mjs +5 -5
  50. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  51. package/dist/events/transports/redis-stream-entry.mjs +15 -6
  52. package/dist/events/transports/redis.d.mts +1 -1
  53. package/dist/events/transports/redis.mjs +2 -2
  54. package/dist/factory/index.d.mts +5 -5
  55. package/dist/factory/index.mjs +5 -5
  56. package/dist/{fastify-augmentation-HncZMmze.d.mts → fastify-augmentation-Cq2nHVaW.d.mts} +3 -3
  57. package/dist/{fields-fA-Dkou5.mjs → fields-BHrkoF5y.mjs} +1 -1
  58. package/dist/{generate-BSSdtiOy.mjs → generate-BLRf2m3y.mjs} +2 -2
  59. package/dist/{headers-BFEEshDS.mjs → headers-BIXWKrzQ.mjs} +2 -2
  60. package/dist/{health-BlGHjkqZ.mjs → health-ZRHeDHXH.mjs} +10 -2
  61. package/dist/hooks/index.d.mts +1 -1
  62. package/dist/hooks/index.mjs +2 -2
  63. package/dist/idempotency/index.d.mts +1 -1
  64. package/dist/idempotency/index.mjs +4 -4
  65. package/dist/idempotency/redis.d.mts +1 -1
  66. package/dist/idempotency/redis.mjs +1 -1
  67. package/dist/{index-BtYoMip1.d.mts → index-BHdZD8zK.d.mts} +10 -2
  68. package/dist/{index-DbMglURO.d.mts → index-CFfLKMH4.d.mts} +1 -1
  69. package/dist/{index-eyg5WEcy.d.mts → index-CbJiYrfv.d.mts} +1 -1
  70. package/dist/{index-SCsOJxWB.d.mts → index-D4u76eQW.d.mts} +3 -3
  71. package/dist/{index-D3sIPcD4.d.mts → index-DHbyPo5j.d.mts} +6 -2
  72. package/dist/index.d.mts +5 -5
  73. package/dist/index.mjs +11 -11
  74. package/dist/{init-pl88PHzb.mjs → init-CpLkjU18.mjs} +1 -1
  75. package/dist/integrations/event-gateway.d.mts +1 -1
  76. package/dist/integrations/event-gateway.mjs +2 -2
  77. package/dist/integrations/index.d.mts +2 -2
  78. package/dist/integrations/index.mjs +1 -1
  79. package/dist/integrations/jobs/index.d.mts +1 -1
  80. package/dist/integrations/jobs/index.mjs +2 -2
  81. package/dist/integrations/mcp/index.d.mts +4 -4
  82. package/dist/integrations/mcp/index.mjs +2 -2
  83. package/dist/integrations/mcp/testing.d.mts +2 -2
  84. package/dist/integrations/mcp/testing.mjs +2 -2
  85. package/dist/integrations/streamline.mjs +3 -3
  86. package/dist/integrations/webhooks.d.mts +2 -2
  87. package/dist/integrations/webhooks.mjs +3 -3
  88. package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
  89. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  90. package/dist/integrations/websocket-redis.mjs +1 -1
  91. package/dist/integrations/websocket.mjs +12 -1
  92. package/dist/{interface-DjtFnZxH.mjs → interface-YzIbF7Ii.mjs} +1 -1
  93. package/dist/{jsonBody-Dx6jH_Nb.mjs → jsonBody-ELR9lfeU.mjs} +2 -2
  94. package/dist/{keys-Ds3ekBiu.mjs → keys-cpZCtcIE.mjs} +1 -1
  95. package/dist/{loadResources-E5UFlRrT.mjs → loadResources-BPfWiI5s.mjs} +2 -2
  96. package/dist/{loadResourcesFromEntry-C2jrJbWG.mjs → loadResourcesFromEntry-ixtfL3J2.mjs} +2 -2
  97. package/dist/logger/index.mjs +1 -1
  98. package/dist/{mcpPlugin-D-3OLxNz.mjs → mcpPlugin-BX5PujI0.mjs} +15 -15
  99. package/dist/{memory-CNvjxzB2.mjs → memory-Iotz0cCx.mjs} +2 -2
  100. package/dist/{metrics-P37sNV5H.mjs → metrics-NMsmpRmY.mjs} +2 -2
  101. package/dist/middleware/index.d.mts +1 -1
  102. package/dist/middleware/index.mjs +3 -3
  103. package/dist/migrations/index.mjs +2 -2
  104. package/dist/{module-CNcwCKUJ.mjs → module-DpLJG2_N.mjs} +1 -1
  105. package/dist/{multipartBody-Bl_gU3Wg.mjs → multipartBody-DtRWM6_U.mjs} +1 -1
  106. package/dist/{openapi-BYrL8zvY.mjs → openapi-CmFf3yq_.mjs} +6 -6
  107. package/dist/outbox-admin/index.d.mts +2 -2
  108. package/dist/outbox-admin/index.mjs +4 -4
  109. package/dist/{outbox-module-CzsKRBvO.d.mts → outbox-module-BHdUnuuK.d.mts} +6 -2
  110. package/dist/{outbox-module-6DMC_WNu.mjs → outbox-module-D2z5WkDY.mjs} +9 -6
  111. package/dist/permissions/index.d.mts +2 -2
  112. package/dist/permissions/index.mjs +5 -5
  113. package/dist/{permissions-C0GpDsB_.mjs → permissions-D8TBH44E.mjs} +12 -6
  114. package/dist/{pipe-4_XamfxI.mjs → pipe-FMWNheSO.mjs} +2 -2
  115. package/dist/pipeline/index.d.mts +1 -1
  116. package/dist/pipeline/index.mjs +2 -2
  117. package/dist/plugins/index.d.mts +6 -36
  118. package/dist/plugins/index.mjs +33 -22
  119. package/dist/plugins/response-cache.mjs +9 -2
  120. package/dist/plugins/tracing-entry.mjs +2 -2
  121. package/dist/{pluralize-BaQ62hOW.mjs → pluralize-CXWZIzTv.mjs} +1 -1
  122. package/dist/{preloadResources-DVgPCNcD.d.mts → preloadResources-CjS2OfpU.d.mts} +13 -8
  123. package/dist/{preloadResources-BAx5s9EF.mjs → preloadResources-f3boenVZ.mjs} +33 -26
  124. package/dist/presets/filesUpload.d.mts +2 -2
  125. package/dist/presets/filesUpload.mjs +5 -5
  126. package/dist/presets/index.d.mts +2 -2
  127. package/dist/presets/index.mjs +2 -2
  128. package/dist/presets/multiTenant.d.mts +38 -1
  129. package/dist/presets/multiTenant.mjs +24 -6
  130. package/dist/presets/search.d.mts +2 -2
  131. package/dist/presets/search.mjs +2 -2
  132. package/dist/{presets-CAaqVVcW.mjs → presets-ZJSPgmT2.mjs} +4 -4
  133. package/dist/{projection-qbCOEvsh.mjs → projection-BzIgzoKZ.mjs} +2 -2
  134. package/dist/{queryCachePlugin-2Pp9w-8D.mjs → queryCachePlugin-DZHPOm9p.mjs} +11 -5
  135. package/dist/{rateLimitKey-umrS6-vu.mjs → rateLimitKey-BvOvcBDi.mjs} +1 -1
  136. package/dist/{realtime-zzoNLDtj.mjs → realtime-8tIQWZz4.mjs} +9 -9
  137. package/dist/{redis-CduhKsJi.d.mts → redis-Bzi1y3F9.d.mts} +10 -2
  138. package/dist/{redis-stream-DSsPyYzd.d.mts → redis-stream-BPKscsTB.d.mts} +6 -6
  139. package/dist/{registerRoute-_6zHiz0x.mjs → registerRoute-Bsnk6NiZ.mjs} +6 -6
  140. package/dist/registry/index.d.mts +2 -2
  141. package/dist/registry/index.mjs +3 -3
  142. package/dist/{registry-CEpoRpLP.mjs → registry-DNkgGQcK.mjs} +2 -2
  143. package/dist/{renewingLease-B5Grm0pI.mjs → renewingLease-yF8BdW4b.mjs} +1 -1
  144. package/dist/{replyHelpers-9h-a592u.mjs → replyHelpers-CNdvMfM4.mjs} +2 -2
  145. package/dist/{requestContext-D4PuGhEw.mjs → requestContext-cDkAUr-P.mjs} +1 -1
  146. package/dist/{rolldown-runtime-DzE6ikSH.mjs → rolldown-runtime-eysyzFAF.mjs} +1 -1
  147. package/dist/{runtimeCapabilities-Bo7rSKjm.mjs → runtimeCapabilities-CkhS6jn6.mjs} +31 -5
  148. package/dist/{schedules-Dm21pqHU.mjs → schedules-j6uXXAeJ.mjs} +4 -4
  149. package/dist/{schemaConverter-DENbixyA.mjs → schemaConverter-BVA7cFRG.mjs} +1 -1
  150. package/dist/{schemaIR-C2qUNzC6.mjs → schemaIR-CTvPDoPB.mjs} +2 -2
  151. package/dist/schemas/index.mjs +1 -1
  152. package/dist/scim/index.mjs +2 -2
  153. package/dist/scope/index.mjs +5 -5
  154. package/dist/{scope-CdYxQVxt.mjs → scope-BUi1xV1I.mjs} +4 -4
  155. package/dist/{simpleEqualityMatcher-Cldubvul.mjs → simpleEqualityMatcher-C9bzq2gO.mjs} +1 -1
  156. package/dist/{sse-DbyXoQun.mjs → sse-BeqVKQKu.mjs} +5 -5
  157. package/dist/{sseStream-BfZtL5-m.mjs → sseStream-Ppzo-WUH.mjs} +2 -2
  158. package/dist/{store-helpers-BfyOd5i6.mjs → store-helpers-dW71XVWL.mjs} +1 -1
  159. package/dist/{streaming-CFwCzEvU.mjs → streaming-IcRFjWQU.mjs} +1 -1
  160. package/dist/{subscribe-helpers-KgVEcn8L.mjs → subscribe-helpers-DatDkcuG.mjs} +2 -2
  161. package/dist/sync/changelog.mjs +1 -1
  162. package/dist/testing/cleanupStoreContract.mjs +1 -1
  163. package/dist/testing/index.d.mts +5 -5
  164. package/dist/testing/index.mjs +10 -10
  165. package/dist/testing/outboxStoreContract.mjs +1 -1
  166. package/dist/testing/storageContract.mjs +1 -1
  167. package/dist/{transactionContext-46KyUADP.mjs → transactionContext-CPbdlnVw.mjs} +1 -1
  168. package/dist/{typeGuards-t_CVgRXN.mjs → typeGuards-BLjPgVBm.mjs} +1 -1
  169. package/dist/types/index.d.mts +2 -2
  170. package/dist/types/index.mjs +1 -1
  171. package/dist/types/storage.mjs +1 -1
  172. package/dist/{types-9rvxn5Ym.d.mts → types-CWQn9-IC.d.mts} +1 -1
  173. package/dist/{types-CNQyOrLk.mjs → types-CgenB1gv.mjs} +4 -4
  174. package/dist/{types-BVlowlm7.d.mts → types-DfVDvkdP.d.mts} +19 -1
  175. package/dist/usage/index.d.mts +58 -2
  176. package/dist/usage/index.mjs +55 -4
  177. package/dist/{userHelpers-DLueLQdQ.mjs → userHelpers-ChRvkuSZ.mjs} +1 -1
  178. package/dist/utils/index.d.mts +107 -5
  179. package/dist/utils/index.mjs +12 -12
  180. package/dist/{utils-CUFBrhq-.mjs → utils-CxcctrAh.mjs} +50 -9
  181. package/dist/{validate-DV3hRbsz.mjs → validate-DrYWnOzI.mjs} +2 -2
  182. package/dist/{versioning-P3V6ATyC.d.mts → versioning-D6JIK8Tk.d.mts} +58 -2
  183. package/dist/{versioning-BXwmj5SD.mjs → versioning-DKkT4hNJ.mjs} +3 -3
  184. package/dist/{workScope-CoGpuzDX.mjs → workScope-CxKqoIFn.mjs} +3 -4
  185. package/package.json +5 -5
  186. package/skills/arc/SKILL.md +8 -4
  187. package/skills/arc/references/production.md +94 -9
@@ -1,6 +1,6 @@
1
1
 
2
2
  import { i as RequestScope } from "../types-Ddl9OjmE.mjs";
3
- import { l as PermissionCheck, u as PermissionContext } from "../base-C-NL2fTi.mjs";
3
+ import { l as PermissionCheck, u as PermissionContext } from "../base-CxrV1LmG.mjs";
4
4
  import { UsageBucket, UsageStore, usagePeriod } from "@classytic/repo-core/usage";
5
5
  import { FastifyPluginAsync, FastifyRequest } from "fastify";
6
6
  //#region src/usage/requireQuota.d.ts
@@ -40,6 +40,62 @@ declare class MemoryUsageStore implements UsageStore {
40
40
  clear(): void;
41
41
  }
42
42
  //#endregion
43
+ //#region src/usage/stores/redis.d.ts
44
+ /**
45
+ * The three commands this store needs. Fixed arity with an `unknown[]` tail
46
+ * where ioredis overloads one — see the note in `idempotency/stores/redis.ts`
47
+ * for why a narrower tail makes a real `Redis` unassignable.
48
+ */
49
+ interface UsageRedisLike {
50
+ hincrby(key: string, field: string, increment: number): Promise<number>;
51
+ hgetall(key: string): Promise<Record<string, string>>;
52
+ expire(key: string, seconds: number, ...args: unknown[]): Promise<number>;
53
+ /**
54
+ * Optional. When present, a retention-enabled increment sends `HINCRBY` and
55
+ * `EXPIRE` as ONE round trip instead of two. ioredis has it; a minimal
56
+ * wrapper may not, and the store falls back to two sequential calls.
57
+ */
58
+ pipeline?(): {
59
+ hincrby(key: string, field: string, increment: number): unknown;
60
+ expire(key: string, seconds: number): unknown;
61
+ exec(): Promise<unknown>;
62
+ };
63
+ }
64
+ interface RedisUsageStoreOptions {
65
+ /** Redis client (ioredis or compatible). */
66
+ redis: UsageRedisLike;
67
+ /**
68
+ * Key prefix (default: `'arc:usage:'`).
69
+ *
70
+ * Keys are `{prefix}{actor}:{period}`. `usagePeriod()` never emits a `:`,
71
+ * so the split is unambiguous for every canonical period; a host that
72
+ * passes its own period key should keep `:` out of it.
73
+ */
74
+ prefix?: string;
75
+ /**
76
+ * Seconds a bucket survives after its LAST write, or `undefined` (the
77
+ * default) to keep counters forever.
78
+ *
79
+ * The default is deliberate and it is not the usual Redis default. These
80
+ * counters are what quota decisions and invoices are computed from —
81
+ * expiring them by default would delete billing history on a schedule
82
+ * nobody chose, and the loss is silent because `summary()` answers `{}` for
83
+ * a bucket that never existed and one that expired alike. Set it once you
84
+ * know your retention window (e.g. `86400 * 400` to keep a year plus a
85
+ * grace period), and only if the counters here are not your system of
86
+ * record.
87
+ */
88
+ retentionSeconds?: number;
89
+ }
90
+ /** Period-bucketed usage counters in Redis hashes. See the module header. */
91
+ declare class RedisUsageStore implements UsageStore {
92
+ #private;
93
+ readonly name = "redis";
94
+ constructor(options: RedisUsageStoreOptions);
95
+ increment(bucket: UsageBucket, amount: number): Promise<void>;
96
+ summary(actor: string, period: string): Promise<Record<string, number>>;
97
+ }
98
+ //#endregion
43
99
  //#region src/usage/usagePlugin.d.ts
44
100
  interface UsageTrackOptions {
45
101
  /** Count every completed request as `api.requests` (default: true). */
@@ -91,4 +147,4 @@ declare module "fastify" {
91
147
  declare const usagePluginFn: FastifyPluginAsync<UsagePluginOptions>;
92
148
  declare const _default: FastifyPluginAsync<UsagePluginOptions>;
93
149
  //#endregion
94
- export { MemoryUsageStore, type QuotaOptions, type UsageBucket, type UsageMeter, type UsagePluginOptions, type UsageStore, type UsageTrackOptions, _default as default, requireQuota, usagePeriod, usagePluginFn as usagePlugin };
150
+ export { MemoryUsageStore, type QuotaOptions, RedisUsageStore, type RedisUsageStoreOptions, type UsageBucket, type UsageMeter, type UsagePluginOptions, type UsageRedisLike, type UsageStore, type UsageTrackOptions, _default as default, requireQuota, usagePeriod, usagePluginFn as usagePlugin };
@@ -1,6 +1,7 @@
1
- /*! @classytic/arc v2.40.0 | MIT | https://classytic.com */
2
- import { t as ArcError } from "../errors-BHD3L8of.mjs";
3
- import { c as getOrgId, g as getUserId, i as getClientId } from "../types-CNQyOrLk.mjs";
1
+ /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
2
+ import { t as ArcError } from "../errors-BH7YU_9O.mjs";
3
+ import { c as getOrgId, g as getUserId, i as getClientId } from "../types-CgenB1gv.mjs";
4
+ import { n as declareRuntimeCapability } from "../runtimeCapabilities-CkhS6jn6.mjs";
4
5
  import { usagePeriod } from "@classytic/repo-core/usage";
5
6
  import fp from "fastify-plugin";
6
7
  //#region src/usage/requireQuota.ts
@@ -85,6 +86,51 @@ var MemoryUsageStore = class {
85
86
  }
86
87
  };
87
88
  //#endregion
89
+ //#region src/usage/stores/redis.ts
90
+ /** Period-bucketed usage counters in Redis hashes. See the module header. */
91
+ var RedisUsageStore = class {
92
+ name = "redis";
93
+ #redis;
94
+ #prefix;
95
+ #retentionSeconds;
96
+ constructor(options) {
97
+ this.#redis = options.redis;
98
+ this.#prefix = options.prefix ?? "arc:usage:";
99
+ const retention = options.retentionSeconds;
100
+ if (retention !== void 0 && (!Number.isFinite(retention) || retention <= 0)) throw new Error(`[arc-usage] RedisUsageStore: retentionSeconds must be a positive number, got ${String(retention)}. Omit it to keep counters forever.`);
101
+ this.#retentionSeconds = retention;
102
+ }
103
+ #key(actor, period) {
104
+ return `${this.#prefix}${actor}:${period}`;
105
+ }
106
+ async increment(bucket, amount) {
107
+ const key = this.#key(bucket.actor, bucket.period);
108
+ const ttl = this.#retentionSeconds;
109
+ if (ttl === void 0) {
110
+ await this.#redis.hincrby(key, bucket.kind, amount);
111
+ return;
112
+ }
113
+ const pipeline = this.#redis.pipeline?.();
114
+ if (pipeline) {
115
+ pipeline.hincrby(key, bucket.kind, amount);
116
+ pipeline.expire(key, ttl);
117
+ await pipeline.exec();
118
+ return;
119
+ }
120
+ await this.#redis.hincrby(key, bucket.kind, amount);
121
+ await this.#redis.expire(key, ttl);
122
+ }
123
+ async summary(actor, period) {
124
+ const raw = await this.#redis.hgetall(this.#key(actor, period));
125
+ const out = {};
126
+ for (const [kind, value] of Object.entries(raw ?? {})) {
127
+ const n = Number(value);
128
+ if (Number.isFinite(n)) out[kind] = n;
129
+ }
130
+ return out;
131
+ }
132
+ };
133
+ //#endregion
88
134
  //#region src/usage/usagePlugin.ts
89
135
  function actorFromScope(scope) {
90
136
  if (!scope) return void 0;
@@ -113,6 +159,11 @@ const usagePluginFn = async (fastify, opts) => {
113
159
  return;
114
160
  }
115
161
  const store = opts.store ?? new MemoryUsageStore();
162
+ declareRuntimeCapability(fastify, {
163
+ subsystem: "usage.store",
164
+ durability: opts.store ? "shared" : "memory",
165
+ detail: "default MemoryUsageStore: counters are replica-local"
166
+ });
116
167
  const track = {
117
168
  requests: true,
118
169
  egress: false,
@@ -166,4 +217,4 @@ var usagePlugin_default = fp(usagePluginFn, {
166
217
  fastify: "5.x"
167
218
  });
168
219
  //#endregion
169
- export { MemoryUsageStore, usagePlugin_default as default, requireQuota, usagePeriod, usagePluginFn as usagePlugin };
220
+ export { MemoryUsageStore, RedisUsageStore, usagePlugin_default as default, requireQuota, usagePeriod, usagePluginFn as usagePlugin };
@@ -1,4 +1,4 @@
1
- /*! @classytic/arc v2.40.0 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
2
2
  //#region src/utils/userHelpers.ts
3
3
  /**
4
4
  * Extract a user ID from a user object. Accepts `id` or `_id` — returns
@@ -1,7 +1,7 @@
1
1
 
2
2
  import { F as getHeader, I as requireSingleHeaderValue, P as ArcHeaders } from "../types-Ddl9OjmE.mjs";
3
- import { f as getUserId, m as normalizeRoles, p as getUserRoles, t as AnyRecord } from "../base-C-NL2fTi.mjs";
4
- import { $ as OpenApiSchemas, S as QueryParserInterface, b as ParsedQuery } from "../index-D3sIPcD4.mjs";
3
+ import { f as getUserId, m as normalizeRoles, p as getUserRoles, t as AnyRecord } from "../base-CxrV1LmG.mjs";
4
+ import { $ as OpenApiSchemas, S as QueryParserInterface, b as ParsedQuery } from "../index-DHbyPo5j.mjs";
5
5
  import { n as ErrorMapper } from "../errorHandler--4rSMMvq.mjs";
6
6
  import { a as NotFoundError, c as RateLimitError, d as ValidationError, f as createDomainError, i as ForbiddenError, l as ServiceUnavailableError, m as isArcError, n as ConflictError, o as OrgAccessDeniedError, p as createError, r as ErrorOptions, s as OrgRequiredError, t as ArcError, u as UnauthorizedError } from "../errors-BGdVwP25.mjs";
7
7
  import { scheduleBackground } from "@classytic/repo-core/cache";
@@ -54,6 +54,53 @@ type CircuitState = (typeof CircuitState)[keyof typeof CircuitState];
54
54
  * Promise<any>` would silently disable type checking everywhere `T` flows.
55
55
  */
56
56
  type AnyAsyncFn = (...args: never[]) => Promise<unknown>;
57
+ /**
58
+ * Cluster-wide failure counting for a circuit.
59
+ *
60
+ * ## What this shares, and what it deliberately does not
61
+ *
62
+ * It shares the failure SIGNAL, not the state machine. Each replica still owns
63
+ * its own CLOSED/OPEN/HALF_OPEN transitions and its own probe scheduling; what
64
+ * becomes cluster-wide is the count that decides when to trip.
65
+ *
66
+ * That is the whole of the problem worth solving. Per-replica counting means a
67
+ * downstream outage costs `failureThreshold × replicas` real requests before
68
+ * anything opens — at 5 and 50 that is 250 errors a failing payment gateway
69
+ * absorbs while every pod independently rediscovers the same outage. Sharing
70
+ * the count collapses it: the first replica trips at the threshold, and every
71
+ * other replica trips on its NEXT failure, because the shared window is
72
+ * already over the line.
73
+ *
74
+ * Sharing the full state machine instead would mean electing which replica
75
+ * probes in HALF_OPEN — consensus, plus a lock, plus a read on the hot path.
76
+ * The remaining gap without it is bounded and small: one extra failure per
77
+ * replica, once, versus `failureThreshold` per replica every time.
78
+ *
79
+ * ## Cost
80
+ *
81
+ * ONLY the failure path calls this. A successful call never touches the
82
+ * network, which matters because success is the common case and this wraps
83
+ * things like a payment charge. `reset` is called when a circuit closes.
84
+ *
85
+ * ## Failure of the store itself
86
+ *
87
+ * Never propagates. If the shared counter is unreachable the breaker falls
88
+ * back to its local count — degraded to the old behaviour, which is the
89
+ * correct direction: a Redis blip must not disable the protection that exists
90
+ * for a downstream outage, and the two often arrive together.
91
+ */
92
+ interface CircuitBreakerSharedState {
93
+ /** Store name for diagnostics. */
94
+ readonly name: string;
95
+ /**
96
+ * Record one failure for `circuit` and return the CLUSTER-wide count within
97
+ * the current window. Implementations expire the window so a long-ago
98
+ * outage does not trip a healthy circuit.
99
+ */
100
+ recordFailure(circuit: string): Promise<number>;
101
+ /** Clear the window — called when this replica closes the circuit. */
102
+ reset(circuit: string): Promise<void>;
103
+ }
57
104
  interface CircuitBreakerOptions {
58
105
  /**
59
106
  * Number of failures before opening circuit
@@ -117,6 +164,19 @@ interface CircuitBreakerOptions {
117
164
  * Name for logging/monitoring
118
165
  */
119
166
  name?: string;
167
+ /**
168
+ * Cluster-wide failure counting — see {@link CircuitBreakerSharedState}.
169
+ *
170
+ * Omit for a single process, where the local count already IS the cluster
171
+ * count. Under a multi-replica deployment this is what stops every replica
172
+ * absorbing the full threshold before anything trips.
173
+ *
174
+ * Requires a stable {@link CircuitBreakerOptions.name}: it is the key every
175
+ * replica must agree on. A breaker with `sharedState` and no `name` throws
176
+ * at construction rather than silently counting under a generated id that no
177
+ * other replica shares.
178
+ */
179
+ sharedState?: CircuitBreakerSharedState;
120
180
  }
121
181
  interface CircuitBreakerStats {
122
182
  name?: string;
@@ -150,6 +210,7 @@ declare class CircuitBreaker<T extends AnyAsyncFn> {
150
210
  private readonly onStateChange?;
151
211
  private readonly onError?;
152
212
  private readonly name;
213
+ private readonly sharedState?;
153
214
  private readonly fn;
154
215
  constructor(fn: T, options?: CircuitBreakerOptions);
155
216
  /**
@@ -171,6 +232,10 @@ declare class CircuitBreaker<T extends AnyAsyncFn> {
171
232
  * Handle failed call
172
233
  */
173
234
  private onFailure;
235
+ /** Open the circuit and schedule the next probe. */
236
+ private trip;
237
+ /** Best-effort clear of the cluster failure window. Never throws. */
238
+ private clearSharedWindow;
174
239
  /**
175
240
  * Change circuit state
176
241
  */
@@ -254,6 +319,42 @@ declare class CircuitBreakerRegistry {
254
319
  */
255
320
  declare function createCircuitBreakerRegistry(): CircuitBreakerRegistry;
256
321
  //#endregion
322
+ //#region src/utils/circuitBreakerRedis.d.ts
323
+ /**
324
+ * The two commands this needs. Variadic tail is `unknown[]` for the same
325
+ * reason as every other `*Like` shape in arc — ioredis types its commands as
326
+ * overloads mixing literal tokens and an optional callback, which a narrower
327
+ * tail matches none of. Pinned by `tests/types/ioredis-assignability.test.ts`.
328
+ */
329
+ interface CircuitBreakerRedisLike {
330
+ incr(key: string): Promise<number>;
331
+ pexpire(key: string, milliseconds: number, ...args: unknown[]): Promise<number>;
332
+ del(...keys: string[]): Promise<number>;
333
+ }
334
+ interface RedisCircuitBreakerStateOptions {
335
+ /** Redis client (ioredis or compatible). */
336
+ redis: CircuitBreakerRedisLike;
337
+ /**
338
+ * Key prefix (default: `'arc:circuit:'`). Keys are `{prefix}{circuitName}`,
339
+ * so the circuit's `name` is what every replica must agree on.
340
+ */
341
+ prefix?: string;
342
+ /**
343
+ * Counting window in ms (default: `60_000`, matching the breaker's default
344
+ * `resetTimeout`). The period over which `failureThreshold` failures mean
345
+ * the downstream is down.
346
+ */
347
+ windowMs?: number;
348
+ }
349
+ /** Fixed-window cluster failure counter. See the module header. */
350
+ declare class RedisCircuitBreakerState implements CircuitBreakerSharedState {
351
+ #private;
352
+ readonly name = "redis";
353
+ constructor(options: RedisCircuitBreakerStateOptions);
354
+ recordFailure(circuit: string): Promise<number>;
355
+ reset(circuit: string): Promise<void>;
356
+ }
357
+ //#endregion
257
358
  //#region src/utils/compensation.d.ts
258
359
  /**
259
360
  * Compensating Transaction — In-Process Rollback Primitive
@@ -1152,8 +1253,9 @@ interface RuntimeCapabilityDeclaration {
1152
1253
  }
1153
1254
  /**
1154
1255
  * Declare a runtime capability. Callable from ANY registration context —
1155
- * arc's own plugins, host `plugins()` callbacks, module `plugins` phases —
1156
- * which is the point: the audit sees what the constructor-time guard cannot.
1256
+ * arc's own plugins, host `plugins()` callbacks, module `plugins` phases,
1257
+ * and encapsulated children of any of them — which is the point: the audit
1258
+ * sees what the constructor-time guard cannot.
1157
1259
  */
1158
1260
  declare function declareRuntimeCapability(fastify: FastifyInstance, decl: RuntimeCapabilityDeclaration): void;
1159
1261
  //#endregion
@@ -1540,4 +1642,4 @@ declare function hasEvents(instance: FastifyInstance): instance is FastifyInstan
1540
1642
  events: EventsDecorator;
1541
1643
  };
1542
1644
  //#endregion
1543
- export { ArcError, type ArcHeaders, ArcQueryParser, type ArcQueryParserOptions, CircuitBreaker, CircuitBreakerError, type CircuitBreakerOptions, CircuitBreakerRegistry, type CircuitBreakerStats, CircuitState, type CompensationDefinition, type CompensationError, type CompensationHooks, type CompensationResult, type CompensationStep, ConflictError, type ErrorOptions, type EventsDecorator, type FilterDialect, ForbiddenError, type Guard, type GuardConfig, type JsonSchema, type JsonSchemaTarget, MONGO_DIALECT, NEUTRAL_DIALECT, NotFoundError, OrgAccessDeniedError, OrgRequiredError, type PipeUIMessageStreamOptions, RateLimitError, type RuntimeCapabilityDeclaration, type SchemaIo, ServiceUnavailableError, type SseStream, type SseStreamOptions, type StateMachine, type TransitionConfig, UI_MESSAGE_STREAM_HEADERS, UnauthorizedError, ValidationError, aggregateListResponse, bareListResponse, convertOpenApiSchemas, convertRouteSchema, createCircuitBreaker, createCircuitBreakerRegistry, createDomainError, createError, createQueryParser, createStateMachine, declareRuntimeCapability, defineCompensation, defineErrorMapper, defineGuard, deleteResponse, errorContractSchema, errorDetailSchema, forwardedStreamHeaders, getDefaultCrudSchemas, getHeader, getListQueryParams, getUserId, getUserRoles, handleRaw, hasEvents, isArcError, isJsonSchema, isReadableStream, isZodSchema, keysetListResponse, listResponse, normalizeRoles, offsetListResponse, openSseStream, paginationSchema, pipeUIMessageStreamToReply, promoteStreamTokenToHeader, queryParams, requireSingleHeaderValue, responses, scheduleBackground, simpleEqualityMatcher, toJsonSchema, withCompensation };
1645
+ export { ArcError, type ArcHeaders, ArcQueryParser, type ArcQueryParserOptions, CircuitBreaker, CircuitBreakerError, type CircuitBreakerOptions, type CircuitBreakerRedisLike, CircuitBreakerRegistry, type CircuitBreakerSharedState, type CircuitBreakerStats, CircuitState, type CompensationDefinition, type CompensationError, type CompensationHooks, type CompensationResult, type CompensationStep, ConflictError, type ErrorOptions, type EventsDecorator, type FilterDialect, ForbiddenError, type Guard, type GuardConfig, type JsonSchema, type JsonSchemaTarget, MONGO_DIALECT, NEUTRAL_DIALECT, NotFoundError, OrgAccessDeniedError, OrgRequiredError, type PipeUIMessageStreamOptions, RateLimitError, RedisCircuitBreakerState, type RedisCircuitBreakerStateOptions, type RuntimeCapabilityDeclaration, type SchemaIo, ServiceUnavailableError, type SseStream, type SseStreamOptions, type StateMachine, type TransitionConfig, UI_MESSAGE_STREAM_HEADERS, UnauthorizedError, ValidationError, aggregateListResponse, bareListResponse, convertOpenApiSchemas, convertRouteSchema, createCircuitBreaker, createCircuitBreakerRegistry, createDomainError, createError, createQueryParser, createStateMachine, declareRuntimeCapability, defineCompensation, defineErrorMapper, defineGuard, deleteResponse, errorContractSchema, errorDetailSchema, forwardedStreamHeaders, getDefaultCrudSchemas, getHeader, getListQueryParams, getUserId, getUserRoles, handleRaw, hasEvents, isArcError, isJsonSchema, isReadableStream, isZodSchema, keysetListResponse, listResponse, normalizeRoles, offsetListResponse, openSseStream, paginationSchema, pipeUIMessageStreamToReply, promoteStreamTokenToHeader, queryParams, requireSingleHeaderValue, responses, scheduleBackground, simpleEqualityMatcher, toJsonSchema, withCompensation };
@@ -1,12 +1,12 @@
1
- /*! @classytic/arc v2.40.0 | MIT | https://classytic.com */
2
- import { a as OrgAccessDeniedError, c as ServiceUnavailableError, d as createDomainError, f as createError, i as NotFoundError, l as UnauthorizedError, n as ConflictError, o as OrgRequiredError, p as isArcError, r as ForbiddenError, s as RateLimitError, t as ArcError, u as ValidationError } from "../errors-BHD3L8of.mjs";
3
- import { n as requireSingleHeaderValue, t as getHeader } from "../headers-BFEEshDS.mjs";
4
- import { n as getUserRoles, r as normalizeRoles, t as getUserId } from "../userHelpers-DLueLQdQ.mjs";
5
- import { t as simpleEqualityMatcher } from "../simpleEqualityMatcher-Cldubvul.mjs";
6
- import { A as scheduleBackground, C as paginationSchema, D as createQueryParser, E as ArcQueryParser, O as MONGO_DIALECT, S as offsetListResponse, T as responses, _ as errorDetailSchema, a as defineCompensation, b as keysetListResponse, c as CircuitBreakerError, d as createCircuitBreaker, f as createCircuitBreakerRegistry, g as errorContractSchema, h as deleteResponse, i as defineErrorMapper, k as NEUTRAL_DIALECT, l as CircuitBreakerRegistry, m as bareListResponse, n as handleRaw, o as withCompensation, p as aggregateListResponse, r as defineGuard, s as CircuitBreaker, t as createStateMachine, u as CircuitState, v as getDefaultCrudSchemas, w as queryParams, x as listResponse, y as getListQueryParams } from "../utils-CUFBrhq-.mjs";
7
- import { a as isZodSchema, i as isJsonSchema, n as convertRouteSchema, o as toJsonSchema, t as convertOpenApiSchemas } from "../schemaConverter-DENbixyA.mjs";
8
- import { a as promoteStreamTokenToHeader, i as pipeUIMessageStreamToReply, n as forwardedStreamHeaders, r as isReadableStream, t as UI_MESSAGE_STREAM_HEADERS } from "../streaming-CFwCzEvU.mjs";
9
- import { n as declareRuntimeCapability } from "../runtimeCapabilities-Bo7rSKjm.mjs";
10
- import { t as openSseStream } from "../sseStream-BfZtL5-m.mjs";
11
- import { t as hasEvents } from "../typeGuards-t_CVgRXN.mjs";
12
- export { ArcError, ArcQueryParser, CircuitBreaker, CircuitBreakerError, CircuitBreakerRegistry, CircuitState, ConflictError, ForbiddenError, MONGO_DIALECT, NEUTRAL_DIALECT, NotFoundError, OrgAccessDeniedError, OrgRequiredError, RateLimitError, ServiceUnavailableError, UI_MESSAGE_STREAM_HEADERS, UnauthorizedError, ValidationError, aggregateListResponse, bareListResponse, convertOpenApiSchemas, convertRouteSchema, createCircuitBreaker, createCircuitBreakerRegistry, createDomainError, createError, createQueryParser, createStateMachine, declareRuntimeCapability, defineCompensation, defineErrorMapper, defineGuard, deleteResponse, errorContractSchema, errorDetailSchema, forwardedStreamHeaders, getDefaultCrudSchemas, getHeader, getListQueryParams, getUserId, getUserRoles, handleRaw, hasEvents, isArcError, isJsonSchema, isReadableStream, isZodSchema, keysetListResponse, listResponse, normalizeRoles, offsetListResponse, openSseStream, paginationSchema, pipeUIMessageStreamToReply, promoteStreamTokenToHeader, queryParams, requireSingleHeaderValue, responses, scheduleBackground, simpleEqualityMatcher, toJsonSchema, withCompensation };
1
+ /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
2
+ import { a as OrgAccessDeniedError, c as ServiceUnavailableError, d as createDomainError, f as createError, i as NotFoundError, l as UnauthorizedError, n as ConflictError, o as OrgRequiredError, p as isArcError, r as ForbiddenError, s as RateLimitError, t as ArcError, u as ValidationError } from "../errors-BH7YU_9O.mjs";
3
+ import { n as requireSingleHeaderValue, t as getHeader } from "../headers-BIXWKrzQ.mjs";
4
+ import { n as getUserRoles, r as normalizeRoles, t as getUserId } from "../userHelpers-ChRvkuSZ.mjs";
5
+ import { t as simpleEqualityMatcher } from "../simpleEqualityMatcher-C9bzq2gO.mjs";
6
+ import { A as NEUTRAL_DIALECT, C as offsetListResponse, D as ArcQueryParser, E as responses, O as createQueryParser, S as listResponse, T as queryParams, _ as errorContractSchema, a as defineCompensation, b as getListQueryParams, c as CircuitBreaker, d as CircuitState, f as createCircuitBreaker, g as deleteResponse, h as bareListResponse, i as defineErrorMapper, j as scheduleBackground, k as MONGO_DIALECT, l as CircuitBreakerError, m as aggregateListResponse, n as handleRaw, o as withCompensation, p as createCircuitBreakerRegistry, r as defineGuard, s as RedisCircuitBreakerState, t as createStateMachine, u as CircuitBreakerRegistry, v as errorDetailSchema, w as paginationSchema, x as keysetListResponse, y as getDefaultCrudSchemas } from "../utils-CxcctrAh.mjs";
7
+ import { a as isZodSchema, i as isJsonSchema, n as convertRouteSchema, o as toJsonSchema, t as convertOpenApiSchemas } from "../schemaConverter-BVA7cFRG.mjs";
8
+ import { a as promoteStreamTokenToHeader, i as pipeUIMessageStreamToReply, n as forwardedStreamHeaders, r as isReadableStream, t as UI_MESSAGE_STREAM_HEADERS } from "../streaming-IcRFjWQU.mjs";
9
+ import { n as declareRuntimeCapability } from "../runtimeCapabilities-CkhS6jn6.mjs";
10
+ import { t as openSseStream } from "../sseStream-Ppzo-WUH.mjs";
11
+ import { t as hasEvents } from "../typeGuards-BLjPgVBm.mjs";
12
+ export { ArcError, ArcQueryParser, CircuitBreaker, CircuitBreakerError, CircuitBreakerRegistry, CircuitState, ConflictError, ForbiddenError, MONGO_DIALECT, NEUTRAL_DIALECT, NotFoundError, OrgAccessDeniedError, OrgRequiredError, RateLimitError, RedisCircuitBreakerState, ServiceUnavailableError, UI_MESSAGE_STREAM_HEADERS, UnauthorizedError, ValidationError, aggregateListResponse, bareListResponse, convertOpenApiSchemas, convertRouteSchema, createCircuitBreaker, createCircuitBreakerRegistry, createDomainError, createError, createQueryParser, createStateMachine, declareRuntimeCapability, defineCompensation, defineErrorMapper, defineGuard, deleteResponse, errorContractSchema, errorDetailSchema, forwardedStreamHeaders, getDefaultCrudSchemas, getHeader, getListQueryParams, getUserId, getUserRoles, handleRaw, hasEvents, isArcError, isJsonSchema, isReadableStream, isZodSchema, keysetListResponse, listResponse, normalizeRoles, offsetListResponse, openSseStream, paginationSchema, pipeUIMessageStreamToReply, promoteStreamTokenToHeader, queryParams, requireSingleHeaderValue, responses, scheduleBackground, simpleEqualityMatcher, toJsonSchema, withCompensation };
@@ -1,7 +1,7 @@
1
- /*! @classytic/arc v2.40.0 | MIT | https://classytic.com */
2
- import { m as RESERVED_QUERY_PARAMS } from "./constants-BQ2FbhPB.mjs";
1
+ /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
2
+ import { m as RESERVED_QUERY_PARAMS } from "./constants-D5Ubn1fe.mjs";
3
3
  import { arcLog } from "./logger/index.mjs";
4
- import { t as ArcError, u as ValidationError } from "./errors-BHD3L8of.mjs";
4
+ import { t as ArcError, u as ValidationError } from "./errors-BH7YU_9O.mjs";
5
5
  import { scheduleBackground } from "@classytic/repo-core/cache";
6
6
  import { errorContractSchema as errorContractSchema$1, errorDetailSchema as errorDetailSchema$1 } from "@classytic/repo-core/errors";
7
7
  //#region src/utils/filter-dialect.ts
@@ -971,9 +971,12 @@ var CircuitBreaker = class {
971
971
  onStateChange;
972
972
  onError;
973
973
  name;
974
+ sharedState;
974
975
  fn;
975
976
  constructor(fn, options = {}) {
976
977
  this.fn = fn;
978
+ if (options.sharedState && !options.name) throw new Error("[arc] CircuitBreaker: `sharedState` requires an explicit `name` — it is the key every replica counts under. Without it each replica would count under its own key.");
979
+ this.sharedState = options.sharedState;
977
980
  this.failureThreshold = options.failureThreshold ?? 5;
978
981
  this.resetTimeout = options.resetTimeout ?? 6e4;
979
982
  this.timeout = options.timeout ?? 1e4;
@@ -1051,6 +1054,7 @@ var CircuitBreaker = class {
1051
1054
  if (this.successes >= this.successThreshold) {
1052
1055
  this.setState(CircuitState.CLOSED);
1053
1056
  this.successes = 0;
1057
+ this.clearSharedWindow();
1054
1058
  }
1055
1059
  }
1056
1060
  }
@@ -1061,11 +1065,23 @@ var CircuitBreaker = class {
1061
1065
  this.failures++;
1062
1066
  this.successes = 0;
1063
1067
  if (this.onError) this.onError(error);
1064
- if (this.state === CircuitState.HALF_OPEN || this.failures >= this.failureThreshold) {
1065
- this.setState(CircuitState.OPEN);
1066
- this.nextAttempt = Date.now() + this.resetTimeout;
1067
- this.openedAt = Date.now();
1068
- }
1068
+ const trippedLocally = this.state === CircuitState.HALF_OPEN || this.failures >= this.failureThreshold;
1069
+ if (trippedLocally) this.trip();
1070
+ if (!this.sharedState) return;
1071
+ this.sharedState.recordFailure(this.name).then((clusterFailures) => {
1072
+ if (!trippedLocally && this.state === CircuitState.CLOSED && clusterFailures >= this.failureThreshold) this.trip();
1073
+ }).catch(() => {});
1074
+ }
1075
+ /** Open the circuit and schedule the next probe. */
1076
+ trip() {
1077
+ this.setState(CircuitState.OPEN);
1078
+ this.nextAttempt = Date.now() + this.resetTimeout;
1079
+ this.openedAt = Date.now();
1080
+ }
1081
+ /** Best-effort clear of the cluster failure window. Never throws. */
1082
+ clearSharedWindow() {
1083
+ if (!this.sharedState) return;
1084
+ this.sharedState.reset(this.name).catch(() => {});
1069
1085
  }
1070
1086
  /**
1071
1087
  * Change circuit state
@@ -1213,6 +1229,31 @@ function createCircuitBreakerRegistry() {
1213
1229
  return new CircuitBreakerRegistry();
1214
1230
  }
1215
1231
  //#endregion
1232
+ //#region src/utils/circuitBreakerRedis.ts
1233
+ /** Fixed-window cluster failure counter. See the module header. */
1234
+ var RedisCircuitBreakerState = class {
1235
+ name = "redis";
1236
+ #redis;
1237
+ #prefix;
1238
+ #windowMs;
1239
+ constructor(options) {
1240
+ this.#redis = options.redis;
1241
+ this.#prefix = options.prefix ?? "arc:circuit:";
1242
+ const windowMs = options.windowMs ?? 6e4;
1243
+ if (!Number.isFinite(windowMs) || windowMs <= 0) throw new Error(`[arc] RedisCircuitBreakerState: windowMs must be a positive number, got ${String(options.windowMs)}.`);
1244
+ this.#windowMs = windowMs;
1245
+ }
1246
+ async recordFailure(circuit) {
1247
+ const key = this.#prefix + circuit;
1248
+ const count = await this.#redis.incr(key);
1249
+ if (count === 1) await this.#redis.pexpire(key, this.#windowMs);
1250
+ return count;
1251
+ }
1252
+ async reset(circuit) {
1253
+ await this.#redis.del(this.#prefix + circuit);
1254
+ }
1255
+ };
1256
+ //#endregion
1216
1257
  //#region src/utils/compensation.ts
1217
1258
  /**
1218
1259
  * Observability hooks must never alter the transaction: a throwing metrics
@@ -1480,4 +1521,4 @@ function createStateMachine(name, transitions = {}, options = {}) {
1480
1521
  };
1481
1522
  }
1482
1523
  //#endregion
1483
- 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 };
1524
+ export { NEUTRAL_DIALECT as A, offsetListResponse as C, ArcQueryParser as D, responses as E, createQueryParser as O, listResponse as S, queryParams as T, errorContractSchema$1 as _, defineCompensation as a, getListQueryParams as b, CircuitBreaker as c, CircuitState as d, createCircuitBreaker as f, deleteResponse as g, bareListResponse as h, defineErrorMapper as i, scheduleBackground as j, MONGO_DIALECT as k, CircuitBreakerError as l, aggregateListResponse as m, handleRaw as n, withCompensation as o, createCircuitBreakerRegistry as p, defineGuard as r, RedisCircuitBreakerState as s, createStateMachine as t, CircuitBreakerRegistry as u, errorDetailSchema$1 as v, paginationSchema as w, keysetListResponse as x, getDefaultCrudSchemas as y };
@@ -1,5 +1,5 @@
1
- /*! @classytic/arc v2.40.0 | MIT | https://classytic.com */
2
- import { t as __exportAll } from "./rolldown-runtime-DzE6ikSH.mjs";
1
+ /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
2
+ import { t as __exportAll } from "./rolldown-runtime-eysyzFAF.mjs";
3
3
  //#region src/core/aggregation/normalize.ts
4
4
  /**
5
5
  * Framework cap on URL-driven aggregation limits when the host opts
@@ -1,6 +1,62 @@
1
1
 
2
- import { n as DomainEvent } from "./EventTransport-BPOQsEer.mjs";
2
+ import { n as DomainEvent } from "./EventTransport-CbUKDzZZ.mjs";
3
3
  import { FastifyPluginAsync, FastifyRequest } from "fastify";
4
+ //#region src/plugins/gracefulShutdown.d.ts
5
+ interface GracefulShutdownOptions {
6
+ /** Maximum time to wait for graceful shutdown in ms (default: 30000) */
7
+ timeout?: number;
8
+ /** Custom cleanup function called before exit */
9
+ onShutdown?: () => Promise<void> | void;
10
+ /** Signals to handle (default: ['SIGTERM', 'SIGINT']) */
11
+ signals?: NodeJS.Signals[];
12
+ /** Whether to log shutdown events (default: true) */
13
+ logEvents?: boolean;
14
+ /**
15
+ * Lame-duck window in ms between flipping readiness and closing the
16
+ * server (default: 0 — close immediately, the pre-2.41 behaviour).
17
+ *
18
+ * A load balancer keeps routing to an instance until its readiness
19
+ * probe has failed for a few intervals; closing the moment SIGTERM
20
+ * arrives is the classic rolling-deploy 502. Set this to exceed the LB's
21
+ * deregistration window (typically 5000–15000) so `/ready` reports
22
+ * `draining` while the instance still answers everything routed to it.
23
+ * Counts against `timeout`.
24
+ */
25
+ drainDelayMs?: number;
26
+ /**
27
+ * Called when shutdown times out or encounters an error.
28
+ * Defaults to `process.exit(1)` — appropriate for production but dangerous in:
29
+ * - **Tests**: kills the test runner. Pass `() => {}` or `() => { throw … }`.
30
+ * - **Shared runtimes** (e.g., serverless): may kill unrelated handlers.
31
+ *
32
+ * @param reason - `'timeout'` if shutdown exceeded `timeout` ms,
33
+ * `'error'` if `onShutdown` or `fastify.close()` threw.
34
+ */
35
+ onForceExit?: (reason: "timeout" | "error") => void;
36
+ }
37
+ /**
38
+ * Readable shutdown state — `fastify.shutdownState`. Set the moment a signal
39
+ * (or `fastify.shutdown()`) arrives, BEFORE the server closes, so readiness
40
+ * can start failing while the instance is still serving. `/live` ignores it:
41
+ * a draining process is alive, that is the whole point of the window.
42
+ */
43
+ interface ShutdownState {
44
+ /** True from the shutdown signal onward — never resets. */
45
+ readonly draining: boolean;
46
+ /** When draining began. */
47
+ readonly since?: Date;
48
+ }
49
+ declare const gracefulShutdownPlugin: FastifyPluginAsync<GracefulShutdownOptions>;
50
+ declare module "fastify" {
51
+ interface FastifyInstance {
52
+ /** Trigger graceful shutdown manually */
53
+ shutdown: () => Promise<void>;
54
+ /** Draining flag + start time; read by the health plugin's `/ready`. */
55
+ shutdownState: ShutdownState;
56
+ }
57
+ }
58
+ declare const _default$5: FastifyPluginAsync<GracefulShutdownOptions>;
59
+ //#endregion
4
60
  //#region src/plugins/caching.d.ts
5
61
  interface CachingRule {
6
62
  /** Path prefix to match (e.g., '/api/products') */
@@ -172,4 +228,4 @@ declare module "fastify" {
172
228
  declare const versioningPlugin: FastifyPluginAsync<VersioningOptions>;
173
229
  declare const _default: FastifyPluginAsync<VersioningOptions>;
174
230
  //#endregion
175
- export { CachingRule as _, MetricsCollector as a, metricsPlugin as c, _default$2 as d, realtimePlugin as f, CachingOptions as g, ssePlugin as h, MetricEntry as i, ChangeOperation as l, _default$3 as m, _default as n, MetricsOptions as o, SSEOptions as p, versioningPlugin as r, _default$1 as s, VersioningOptions as t, RealtimeOptions as u, _default$4 as v, cachingPlugin as y };
231
+ export { gracefulShutdownPlugin as C, _default$5 as S, CachingRule as _, MetricsCollector as a, GracefulShutdownOptions as b, metricsPlugin as c, _default$2 as d, realtimePlugin as f, CachingOptions as g, ssePlugin as h, MetricEntry as i, ChangeOperation as l, _default$3 as m, _default as n, MetricsOptions as o, SSEOptions as p, versioningPlugin as r, _default$1 as s, VersioningOptions as t, RealtimeOptions as u, _default$4 as v, ShutdownState as x, cachingPlugin as y };
@@ -1,6 +1,6 @@
1
- /*! @classytic/arc v2.40.0 | MIT | https://classytic.com */
2
- import { t as __exportAll } from "./rolldown-runtime-DzE6ikSH.mjs";
3
- import { d as createDomainError } from "./errors-BHD3L8of.mjs";
1
+ /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
2
+ import { t as __exportAll } from "./rolldown-runtime-eysyzFAF.mjs";
3
+ import { d as createDomainError } from "./errors-BH7YU_9O.mjs";
4
4
  import fp from "fastify-plugin";
5
5
  //#region src/plugins/versioning.ts
6
6
  var versioning_exports = /* @__PURE__ */ __exportAll({
@@ -1,5 +1,5 @@
1
- /*! @classytic/arc v2.40.0 | MIT | https://classytic.com */
2
- import { n as requestContext } from "./requestContext-D4PuGhEw.mjs";
1
+ /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
2
+ import { n as requestContext } from "./requestContext-cDkAUr-P.mjs";
3
3
  //#region src/context/workScope.ts
4
4
  /** Enter a scope for `fn`, or run it inside the active one. */
5
5
  function runWorkScope(seed, fn) {
@@ -20,8 +20,7 @@ function scopedValue(slot, create) {
20
20
  const store = requestContext.get();
21
21
  if (!store) return void 0;
22
22
  const slots = store;
23
- const existing = slots[slot];
24
- if (existing !== void 0) return existing;
23
+ if (Object.hasOwn(slots, slot)) return slots[slot];
25
24
  const created = create();
26
25
  slots[slot] = created;
27
26
  return created;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc",
3
- "version": "2.40.0",
3
+ "version": "2.41.1",
4
4
  "description": "Resource-oriented backend framework for Fastify - clean, minimal, powerful, tree-shakable",
5
5
  "type": "module",
6
6
  "exports": {
@@ -353,8 +353,8 @@
353
353
  "node": ">=22"
354
354
  },
355
355
  "peerDependencies": {
356
- "@classytic/primitives": ">=0.27.0",
357
- "@classytic/repo-core": ">=0.27.0",
356
+ "@classytic/primitives": ">=0.28.0",
357
+ "@classytic/repo-core": ">=0.28.0",
358
358
  "@classytic/streamline": ">=2.8.0",
359
359
  "@fastify/cors": ">=11.0.0",
360
360
  "@fastify/helmet": ">=13.0.0",
@@ -489,8 +489,8 @@
489
489
  "@classytic/gitkit": ">=0.1.0",
490
490
  "@classytic/mongokit": "^3.33.0",
491
491
  "@classytic/pgkit": "^0.1.0",
492
- "@classytic/primitives": "^0.27.0",
493
- "@classytic/repo-core": "^0.27.0",
492
+ "@classytic/primitives": "^0.28.0",
493
+ "@classytic/repo-core": "^0.28.0",
494
494
  "@classytic/sqlitekit": "^0.9.0",
495
495
  "@classytic/streamline": "^2.8.2",
496
496
  "@electric-sql/pglite": "^0.5.4",
@@ -725,7 +725,7 @@ await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
725
725
 
726
726
  ## Usage counters + plan limits (2.22)
727
727
 
728
- `@classytic/arc/usage` — `usagePlugin` decorates `fastify.usage` with per-actor, per-UTC-month counters (`record`/`summary`/`period`/`actorOf`; actor chain org → user → client → ip). Auto-tracks `api.requests` (default on) + `api.egress.bytes` (opt-in); recording is fail-safe (a throwing `UsageStore` never fails a request; `MemoryUsageStore` built in, Redis/kit backends are ~5 lines). Pair with `createApp({ rateLimit: { plan: { resolve, limits: { free: { max: 60 }, enterprise: false }, default: 'free' } } })` for per-plan ceilings — `false` = unlimited, unknown/throwing resolvers fall back to `default` then global `max`, and buckets follow the tenant key chain by default. Caveat: the limiter runs before route auth, so `resolve` sees the raw request.
728
+ `@classytic/arc/usage` — `usagePlugin` decorates `fastify.usage` with per-actor, per-UTC-month counters (`record`/`summary`/`period`/`actorOf`; actor chain org → user → client → ip). Auto-tracks `api.requests` (default on) + `api.egress.bytes` (opt-in); recording is fail-safe (a throwing `UsageStore` never fails a request). Stores: `MemoryUsageStore` (default, single-process) and `RedisUsageStore` (2.41 — `HINCRBY`, the right shape for a hot per-request counter) both from `@classytic/arc/usage`; or a kit's (`@classytic/mongokit/usage`) when counters belong beside domain data. `RedisUsageStore` keeps counters forever by default — `retentionSeconds` is opt-in, because expiring billing history on an unchosen schedule is silent (`summary()` returns `{}` for an expired bucket and a never-written one alike). Pair with `createApp({ rateLimit: { plan: { resolve, limits: { free: { max: 60 }, enterprise: false }, default: 'free' } } })` for per-plan ceilings — `false` = unlimited, unknown/throwing resolvers fall back to `default` then global `max`, and buckets follow the tenant key chain by default. Caveat: the limiter runs before route auth, so `resolve` sees the raw request.
729
729
 
730
730
  ## Runtime capability registry (2.33)
731
731
 
@@ -737,12 +737,16 @@ import { declareRuntimeCapability } from '@classytic/arc/utils';
737
737
  declareRuntimeCapability(fastify, {
738
738
  subsystem: 'billing.sequence-cache',
739
739
  durability: 'memory', // 'memory' | 'shared'
740
- accepted: true, // per-process BY DESIGN (logged, never fatal)
741
- detail: 'short-TTL cache; correctness from TTL, not shared state',
740
+ detail: 'invoice numbering cached per process',
741
+ // accepted: true, // ONLY when per-process is intended by design
742
+ // // (logged, never fatal). Must be stated by the
743
+ // // declarant — the audit never infers it.
742
744
  });
743
745
  ```
744
746
 
745
- `createApp`'s constructor-time guard only sees what it is passed; state a host wires inside `plugins()` was invisible to it and lived on a checklist. This makes the checklist enforcement. Arc's webhooks plugin declares its default in-memory store.
747
+ `createApp`'s constructor-time guard only sees what it is passed; state a host wires inside `plugins()` was invisible to it and lived on a checklist. This makes the checklist enforcement. Declare from anywhere a registration reaches — `plugins()`, a module phase, or your own encapsulated `fastify.register()`; all land in the one audit (2.41).
748
+
749
+ **Since 2.41 every memory-backed default arc ships declares itself**: `auth.sessions`, `usage.store`, `audit.store`, `cache.query`, `permissions.dynamic-cache` (pass `fastify:` to the matrix — it has no handle of its own), `websocket.adapter`, `websocket.pushref-store`, `webhooks.store`. `http.response-cache` declares `accepted` (per-replica by design). So a distributed app that booted before 2.41 can now fail on a default it was silently running replica-local — the error names each one, and `references/production.md` has the store-by-store fix table.
746
750
 
747
751
  ## Adapters
748
752