@classytic/arc 2.41.1 → 2.43.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 (230) hide show
  1. package/dist/{EventTransport-CbUKDzZZ.d.mts → EventTransport-CiicKz2Z.d.mts} +1 -1
  2. package/dist/{EventTransport-DSqD_Xsg.mjs → EventTransport-tzA7CAB4.mjs} +22 -17
  3. package/dist/{HookSystem-CMF94SEA.mjs → HookSystem-B6UaKRbF.mjs} +2 -2
  4. package/dist/{QueryCache-DFA-QrU9.d.mts → QueryCache--5uAuYPW.d.mts} +7 -3
  5. package/dist/{ResourceRegistry-MGpXoxHZ.mjs → ResourceRegistry-CuCARu2B.mjs} +44 -13
  6. package/dist/actionPermissions-BotX1YOa.mjs +776 -0
  7. package/dist/audit/index.d.mts +43 -5
  8. package/dist/audit/index.mjs +48 -26
  9. package/dist/auth/audit.mjs +1 -1
  10. package/dist/auth/index.d.mts +43 -13
  11. package/dist/auth/index.mjs +130 -155
  12. package/dist/auth/redis-session.mjs +1 -1
  13. package/dist/{authorizationDecision-DawKGoXb.mjs → authorizationDecision-DQYrkl5f.mjs} +7 -102
  14. package/dist/{base-CxrV1LmG.d.mts → base-CZyixEQR.d.mts} +27 -5
  15. package/dist/boundedMap-BULzfXYU.mjs +57 -0
  16. package/dist/{buildHandler-CvxKOc0m.mjs → buildHandler-CRQ8EcX5.mjs} +4 -4
  17. package/dist/cache/index.d.mts +22 -34
  18. package/dist/cache/index.mjs +4 -4
  19. package/dist/{caching-DbCBxlgw.mjs → caching-Cc2M1DwO.mjs} +3 -3
  20. package/dist/claimed-B10RTfkM.mjs +8 -0
  21. package/dist/cleanup/index.d.mts +6 -10
  22. package/dist/cleanup/index.mjs +27 -73
  23. package/dist/cli/commands/describe.d.mts +4 -2
  24. package/dist/cli/commands/describe.mjs +7 -4
  25. package/dist/cli/commands/docs.mjs +3 -3
  26. package/dist/cli/commands/doctor.mjs +1 -1
  27. package/dist/cli/commands/generate.mjs +2 -2
  28. package/dist/cli/commands/init.mjs +2 -2
  29. package/dist/cli/commands/introspect.mjs +2 -2
  30. package/dist/cli/index.mjs +3 -3
  31. package/dist/{constants-D5Ubn1fe.mjs → constants-Cu7Mx7_U.mjs} +4 -2
  32. package/dist/context/index.d.mts +2 -5
  33. package/dist/context/index.mjs +18 -38
  34. package/dist/core/index.d.mts +3 -3
  35. package/dist/core/index.mjs +8 -8
  36. package/dist/{core-5lazhva9.mjs → core-Cb0LjccA.mjs} +5 -4
  37. package/dist/{createActionRouter-DNqoaK81.mjs → createActionRouter-xjqonMRO.mjs} +87 -39
  38. package/dist/{createAggregationRouter-BcbQJf10.mjs → createAggregationRouter-zRYWw_5D.mjs} +11 -8
  39. package/dist/{preloadResources-f3boenVZ.mjs → createApp-D-953ZAq.mjs} +299 -390
  40. package/dist/{createMcpServer-CbDWkZ1T.d.mts → createMcpServer-C9cxS8zt.d.mts} +1 -1
  41. package/dist/{defineAggregation-D_eJjm5A.mjs → defineAggregation-Bm8RjMbq.mjs} +1 -1
  42. package/dist/{defineResource-DxkMTjRn.mjs → defineResource-DIC7JZGU.mjs} +118 -1024
  43. package/dist/discovery/index.mjs +1 -1
  44. package/dist/docs/index.d.mts +10 -4
  45. package/dist/docs/index.mjs +4 -4
  46. package/dist/{elevation-BXCfVcNG.d.mts → elevation-BW2J3tNj.d.mts} +1 -2
  47. package/dist/{elevation-CkvG0LG2.mjs → elevation-DG8SrLN-.mjs} +41 -12
  48. package/dist/encryption/index.mjs +3 -3
  49. package/dist/{errorHandler-D1GHKGlV.mjs → errorHandler-BnfDviL1.mjs} +26 -5
  50. package/dist/{errors-BH7YU_9O.mjs → errors-CtrywMHP.mjs} +58 -23
  51. package/dist/{errors-BGdVwP25.d.mts → errors-JYc9vwqE.d.mts} +9 -0
  52. package/dist/{eventPlugin-B52abJ8W.d.mts → eventPlugin-BtmA4-3P.d.mts} +65 -9
  53. package/dist/{eventPlugin-zZY-unhr.mjs → eventPlugin-Q6BhzHzs.mjs} +82 -21
  54. package/dist/{subscribe-helpers-DatDkcuG.mjs → eventTypes-Cdhm6ZlV.mjs} +151 -17
  55. package/dist/events/index.d.mts +33 -8
  56. package/dist/events/index.mjs +49 -53
  57. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  58. package/dist/events/transports/redis-stream-entry.mjs +139 -157
  59. package/dist/events/transports/redis.d.mts +1 -1
  60. package/dist/events/transports/redis.mjs +2 -2
  61. package/dist/factory/index.d.mts +32 -17
  62. package/dist/factory/index.mjs +213 -32
  63. package/dist/{fastify-augmentation-Cq2nHVaW.d.mts → fastify-augmentation-DhUJ-mvo.d.mts} +11 -25
  64. package/dist/{fields-I_EeRWND.d.mts → fields-BBSpf8pS.d.mts} +1 -1
  65. package/dist/{fields-BHrkoF5y.mjs → fields-ToPYpMqS.mjs} +1 -1
  66. package/dist/filter-merge-IpGywvym.mjs +99 -0
  67. package/dist/{generate-BLRf2m3y.mjs → generate-DRTkLlIl.mjs} +2 -2
  68. package/dist/{headers-BIXWKrzQ.mjs → headers-BbkIF6vA.mjs} +2 -2
  69. package/dist/health-D-Ft6JM6.mjs +22 -0
  70. package/dist/{health-ZRHeDHXH.mjs → health-DqbsG-_w.mjs} +115 -55
  71. package/dist/hooks/index.d.mts +2 -2
  72. package/dist/hooks/index.mjs +2 -2
  73. package/dist/idempotency/index.d.mts +31 -7
  74. package/dist/idempotency/index.mjs +155 -98
  75. package/dist/idempotency/redis.d.mts +1 -1
  76. package/dist/idempotency/redis.mjs +48 -11
  77. package/dist/{index-BHdZD8zK.d.mts → index-Bxriq3so.d.mts} +84 -21
  78. package/dist/{preloadResources-CjS2OfpU.d.mts → index-C8zVGYsk.d.mts} +37 -38
  79. package/dist/{index-D4u76eQW.d.mts → index-CRoPtISf.d.mts} +23 -73
  80. package/dist/{index-CbJiYrfv.d.mts → index-Chgz3d0i.d.mts} +1 -1
  81. package/dist/index-JdbZAIsm.d.mts +510 -0
  82. package/dist/{index-DHbyPo5j.d.mts → index-mqaqZNpe.d.mts} +253 -254
  83. package/dist/index.d.mts +8 -8
  84. package/dist/index.mjs +11 -11
  85. package/dist/{init-CpLkjU18.mjs → init-B_oUYDXI.mjs} +1 -1
  86. package/dist/integrations/event-gateway.d.mts +8 -3
  87. package/dist/integrations/event-gateway.mjs +4 -4
  88. package/dist/integrations/index.d.mts +3 -3
  89. package/dist/integrations/index.mjs +1 -1
  90. package/dist/integrations/jobs/index.d.mts +1 -1
  91. package/dist/integrations/jobs/index.mjs +18 -5
  92. package/dist/integrations/mcp/index.d.mts +72 -59
  93. package/dist/integrations/mcp/index.mjs +19 -4
  94. package/dist/integrations/mcp/testing.d.mts +2 -2
  95. package/dist/integrations/mcp/testing.mjs +2 -2
  96. package/dist/integrations/streamline.d.mts +78 -136
  97. package/dist/integrations/streamline.mjs +116 -54
  98. package/dist/integrations/webhooks.d.mts +60 -120
  99. package/dist/integrations/webhooks.mjs +36 -80
  100. package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
  101. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  102. package/dist/integrations/websocket-redis.d.mts +1 -1
  103. package/dist/integrations/websocket-redis.mjs +1 -1
  104. package/dist/integrations/websocket.d.mts +3 -3
  105. package/dist/integrations/websocket.mjs +2 -2
  106. package/dist/{interface-DEWNOMza.d.mts → interface-C0395yUN.d.mts} +13 -1
  107. package/dist/{jsonBody-ELR9lfeU.mjs → jsonBody-B4YVGcql.mjs} +2 -2
  108. package/dist/{keys-cpZCtcIE.mjs → keys-B2KtgMLJ.mjs} +7 -18
  109. package/dist/{loadResources-BPfWiI5s.mjs → loadResources-Dn4lalw2.mjs} +3 -3
  110. package/dist/{loadResourcesFromEntry-ixtfL3J2.mjs → loadResourcesFromEntry-3sLYbuse.mjs} +2 -2
  111. package/dist/logger/index.d.mts +1 -8
  112. package/dist/logger/index.mjs +3 -153
  113. package/dist/logger-DXjfIqjV.mjs +160 -0
  114. package/dist/{mcpPlugin-BX5PujI0.mjs → mcpPlugin-C08HalN5.mjs} +433 -170
  115. package/dist/{memory-Iotz0cCx.mjs → memory-9Cp7611C.mjs} +36 -34
  116. package/dist/{metrics-NMsmpRmY.mjs → metrics-CWI11eRu.mjs} +39 -72
  117. package/dist/middleware/index.d.mts +2 -2
  118. package/dist/middleware/index.mjs +3 -3
  119. package/dist/migrations/index.mjs +2 -2
  120. package/dist/{module-DpLJG2_N.mjs → module-BSvOQC7E.mjs} +8 -8
  121. package/dist/multiTenant-cY0FkDiA.d.mts +104 -0
  122. package/dist/{multipartBody-DtRWM6_U.mjs → multipartBody-CXM2a-Dy.mjs} +46 -7
  123. package/dist/{openapi-CmFf3yq_.mjs → openapi-DT_G4VhY.mjs} +48 -10
  124. package/dist/outbox-admin/index.d.mts +3 -3
  125. package/dist/outbox-admin/index.mjs +6 -18
  126. package/dist/{outbox-module-D2z5WkDY.mjs → outbox-module-Bjgzl6tg.mjs} +203 -33
  127. package/dist/{outbox-module-BHdUnuuK.d.mts → outbox-module-gZCHoE4H.d.mts} +79 -6
  128. package/dist/permissions/index.d.mts +5 -4
  129. package/dist/permissions/index.mjs +8 -6
  130. package/dist/{permissions-D8TBH44E.mjs → permissions-Cm0uYHvd.mjs} +149 -33
  131. package/dist/{pipe-FMWNheSO.mjs → pipe-BqeGT6UO.mjs} +2 -2
  132. package/dist/pipeline/index.d.mts +2 -2
  133. package/dist/pipeline/index.mjs +2 -2
  134. package/dist/plan-FVR6LP59.mjs +565 -0
  135. package/dist/{BaseController-CYEwjyjN.mjs → plugin-BccET1tL.mjs} +1256 -345
  136. package/dist/plugins/index.d.mts +14 -11
  137. package/dist/plugins/index.mjs +44 -22
  138. package/dist/plugins/response-cache.mjs +8 -14
  139. package/dist/plugins/tracing-entry.mjs +2 -2
  140. package/dist/{pluralize-CXWZIzTv.mjs → pluralize-CVIOcgMF.mjs} +1 -1
  141. package/dist/presets/filesUpload.d.mts +2 -2
  142. package/dist/presets/filesUpload.mjs +5 -5
  143. package/dist/presets/index.d.mts +26 -31
  144. package/dist/presets/index.mjs +4 -4
  145. package/dist/presets/multiTenant.d.mts +2 -107
  146. package/dist/presets/multiTenant.mjs +22 -162
  147. package/dist/presets/search.d.mts +2 -2
  148. package/dist/presets/search.mjs +2 -2
  149. package/dist/{presets-ZJSPgmT2.mjs → presets-Cpvj6fGO.mjs} +99 -107
  150. package/dist/{projection-BzIgzoKZ.mjs → projection-Ce4aQhQk.mjs} +2 -2
  151. package/dist/{pushref-registry-Dov9l4qE.d.mts → pushref-registry-UxCkoA4C.d.mts} +1 -1
  152. package/dist/{queryCachePlugin-BzqxconG.d.mts → queryCachePlugin-C2lZjTxk.d.mts} +1 -1
  153. package/dist/{queryCachePlugin-DZHPOm9p.mjs → queryCachePlugin-CFWNH8aT.mjs} +12 -14
  154. package/dist/{rateLimitKey-BvOvcBDi.mjs → rateLimitKey-C4O-kPm0.mjs} +1 -1
  155. package/dist/{realtime-8tIQWZz4.mjs → realtime-DmPE9S54.mjs} +25 -21
  156. package/dist/redis-stream-BMk-bpQS.d.mts +219 -0
  157. package/dist/{redis-Bzi1y3F9.d.mts → redis-vLvtcGy_.d.mts} +13 -24
  158. package/dist/redisShapes-CQksY67q.d.mts +55 -0
  159. package/dist/registry/index.d.mts +9 -4
  160. package/dist/registry/index.mjs +4 -4
  161. package/dist/{registry-DNkgGQcK.mjs → registry-Bcz9G1bh.mjs} +10 -8
  162. package/dist/{renewingLease-yF8BdW4b.mjs → renewingLease-qODa7KJQ.mjs} +1 -1
  163. package/dist/{replyHelpers-CNdvMfM4.mjs → replyHelpers-CG_VtVgS.mjs} +2 -2
  164. package/dist/{requestContext-cDkAUr-P.mjs → requestContext-Dsu9P1bV.mjs} +1 -1
  165. package/dist/requirement-CbiLdQ1n.d.mts +22 -0
  166. package/dist/requirement-KDdoRcOP.mjs +35 -0
  167. package/dist/resourceRateLimit-Wf6d6FL9.d.mts +30 -0
  168. package/dist/resourceRateLimit-dSjVr1pk.mjs +180 -0
  169. package/dist/{rolldown-runtime-eysyzFAF.mjs → rolldown-runtime-D1w9Y1VJ.mjs} +1 -1
  170. package/dist/routeValidator-D36SW8qt.mjs +70 -0
  171. package/dist/{runtimeCapabilities-CkhS6jn6.mjs → runtimeCapabilities-0tccjRlj.mjs} +1 -1
  172. package/dist/{schedules-j6uXXAeJ.mjs → schedules-C-OpmHBq.mjs} +46 -37
  173. package/dist/{schemaConverter-BVA7cFRG.mjs → schemaConverter-BORek3O-.mjs} +2 -2
  174. package/dist/{schemaIR-CTvPDoPB.mjs → schemaIR-C7Vbp71I.mjs} +2 -2
  175. package/dist/schemas/index.d.mts +10 -0
  176. package/dist/schemas/index.mjs +8 -3
  177. package/dist/scim/index.mjs +2 -2
  178. package/dist/scope/index.d.mts +2 -2
  179. package/dist/scope/index.mjs +6 -6
  180. package/dist/{scope-BUi1xV1I.mjs → scope-DQthe88b.mjs} +4 -4
  181. package/dist/shutdown-MqA81z_P.mjs +59 -0
  182. package/dist/{simpleEqualityMatcher-C9bzq2gO.mjs → simpleEqualityMatcher-auW8QjXN.mjs} +1 -1
  183. package/dist/{sse-BeqVKQKu.mjs → sse-CYzQBVFe.mjs} +11 -15
  184. package/dist/{sseStream-Ppzo-WUH.mjs → sseStream-B5_Afiqn.mjs} +2 -2
  185. package/dist/{store-helpers-dW71XVWL.mjs → store-helpers-BOzk-CuX.mjs} +1 -1
  186. package/dist/{streaming-IcRFjWQU.mjs → streaming-DjTQhZrN.mjs} +1 -1
  187. package/dist/subscriberIdentity-CW39uA8G.mjs +28 -0
  188. package/dist/tenancy--ed9eH_z.mjs +43 -0
  189. package/dist/tenantPreparation-BKon6Yox.mjs +67 -0
  190. package/dist/testing/cleanupStoreContract.mjs +1 -1
  191. package/dist/testing/index.d.mts +193 -175
  192. package/dist/testing/index.mjs +286 -212
  193. package/dist/testing/outboxStoreContract.mjs +137 -2
  194. package/dist/testing/requiredPorts.d.mts +17 -0
  195. package/dist/testing/requiredPorts.mjs +65 -0
  196. package/dist/testing/storageContract.mjs +1 -1
  197. package/dist/timing-qczZ0YC-.mjs +46 -0
  198. package/dist/{transactionContext-CPbdlnVw.mjs → transactionContext-DjFkrNG6.mjs} +1 -1
  199. package/dist/{typeGuards-BLjPgVBm.mjs → typeGuards-CySF12G-.mjs} +1 -1
  200. package/dist/types/index.d.mts +4 -4
  201. package/dist/types/index.mjs +1 -1
  202. package/dist/types/storage.mjs +1 -1
  203. package/dist/{types-CWQn9-IC.d.mts → types-B4Aq4Sdj.d.mts} +22 -9
  204. package/dist/{types-DfVDvkdP.d.mts → types-BJ-2XqVF.d.mts} +189 -204
  205. package/dist/{types-CgenB1gv.mjs → types-Bk9NOgOC.mjs} +4 -4
  206. package/dist/usage/index.d.mts +2 -3
  207. package/dist/usage/index.mjs +5 -5
  208. package/dist/{userHelpers-ChRvkuSZ.mjs → userHelpers-aklawRaK.mjs} +1 -1
  209. package/dist/utils/index.d.mts +84 -223
  210. package/dist/utils/index.mjs +13 -12
  211. package/dist/{utils-CxcctrAh.mjs → utils-ec1oTo1n.mjs} +315 -404
  212. package/dist/{validate-DrYWnOzI.mjs → validate-9yzHC1e2.mjs} +6 -14
  213. package/dist/{versioning-D6JIK8Tk.d.mts → versioning-Bsb51gGT.d.mts} +28 -13
  214. package/dist/{versioning-DKkT4hNJ.mjs → versioning-fhhWHV74.mjs} +4 -4
  215. package/dist/{websocket-BOXVggMf.d.mts → websocket-Cs4awVyD.d.mts} +1 -1
  216. package/dist/{workScope-CxKqoIFn.mjs → workScope-zpPyge4t.mjs} +2 -2
  217. package/package.json +16 -12
  218. package/skills/arc/SKILL.md +13 -4
  219. package/skills/arc/references/auth.md +8 -5
  220. package/skills/arc/references/integrations.md +1 -1
  221. package/skills/arc/references/mcp.md +8 -6
  222. package/skills/arc/references/multi-tenancy.md +19 -14
  223. package/skills/arc/references/production.md +27 -10
  224. package/dist/actionPermissions-BoPGQ5-4.mjs +0 -350
  225. package/dist/index-CFfLKMH4.d.mts +0 -290
  226. package/dist/interface-YzIbF7Ii.mjs +0 -17
  227. package/dist/redis-stream-BPKscsTB.d.mts +0 -260
  228. package/dist/registerRoute-Bsnk6NiZ.mjs +0 -364
  229. package/dist/sync/changelog.d.mts +0 -3
  230. package/dist/sync/changelog.mjs +0 -3
@@ -1,9 +1,9 @@
1
- /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
2
- import { n as getUserRoles } from "./userHelpers-ChRvkuSZ.mjs";
3
- import { n as convertRouteSchema } from "./schemaConverter-BVA7cFRG.mjs";
4
- import { t as resolveActionPermission } from "./actionPermissions-BoPGQ5-4.mjs";
5
- import { t as pluralize } from "./pluralize-CXWZIzTv.mjs";
6
- import { t as buildActionBodySchema } from "./createActionRouter-DNqoaK81.mjs";
1
+ /*! @classytic/arc v2.43.0 | MIT | https://classytic.com */
2
+ import { n as getUserRoles } from "./userHelpers-aklawRaK.mjs";
3
+ import { n as convertRouteSchema } from "./schemaConverter-BORek3O-.mjs";
4
+ import { t as resolveActionPermission } from "./actionPermissions-BotX1YOa.mjs";
5
+ import { t as pluralize } from "./pluralize-CVIOcgMF.mjs";
6
+ import { t as buildActionBodySchema } from "./createActionRouter-xjqonMRO.mjs";
7
7
  import fp from "fastify-plugin";
8
8
  //#region src/docs/openapi/canonical-schemas.ts
9
9
  /**
@@ -133,7 +133,17 @@ function buildPaginatedListSchema(itemRef) {
133
133
  total: { type: "integer" },
134
134
  pages: { type: "integer" },
135
135
  hasNext: { type: "boolean" },
136
- hasPrev: { type: "boolean" }
136
+ hasPrev: { type: "boolean" },
137
+ totalIsEstimate: {
138
+ type: "boolean",
139
+ description: "true when `total` is an estimate or a cap (render as e.g. '10,000+')."
140
+ },
141
+ countedAt: {
142
+ type: "string",
143
+ format: "date-time",
144
+ nullable: true,
145
+ description: "When `total` was counted; null when no count was taken."
146
+ }
137
147
  }
138
148
  },
139
149
  {
@@ -161,6 +171,17 @@ function buildPaginatedListSchema(itemRef) {
161
171
  type: "string",
162
172
  nullable: true,
163
173
  description: "Opaque cursor for the next page, or null when `hasMore` is false. Round-trip verbatim as the `after` query param."
174
+ },
175
+ prev: {
176
+ type: "string",
177
+ nullable: true,
178
+ description: "Opaque cursor for the previous page, or null on the first page. Round-trip verbatim as the `before` query param."
179
+ },
180
+ hasPrev: { type: "boolean" },
181
+ end: {
182
+ type: "string",
183
+ nullable: true,
184
+ description: "Cursor at this page's last row — resume a feed with `after` from here."
164
185
  }
165
186
  }
166
187
  },
@@ -379,7 +400,7 @@ function createOperation(resource, operation, summary, extras, requiresAuthOverr
379
400
  const requiresAuth = requiresAuthOverride !== void 0 ? requiresAuthOverride : typeof operationPermission === "function" && !isPublic;
380
401
  const permAnnotation = describePermissionForOpenApi(operationPermission);
381
402
  const descParts = [];
382
- if (permAnnotation) descParts.push(`**Permission**: ${permAnnotation.type === "public" ? "Public" : permAnnotation.type === "requireRoles" ? `Requires roles: ${(permAnnotation.roles ?? []).join(", ")}` : "Requires authentication"}`);
403
+ if (permAnnotation) descParts.push(`**Permission**: ${permAnnotation.type === "public" ? permAnnotation.rowFiltered ? "Public (row-filtered: a policy narrows which records anyone can see)" : "Public" : permAnnotation.type === "requireRoles" ? `Requires roles: ${(permAnnotation.roles ?? []).join(", ")}` : "Requires authentication"}`);
383
404
  if (resource.presets && resource.presets.length > 0) descParts.push(`**Presets**: ${resource.presets.join(", ")}`);
384
405
  const applicableSteps = (resource.pipelineSteps ?? []).filter((s) => {
385
406
  if (!s.operations) return true;
@@ -398,6 +419,7 @@ function createOperation(resource, operation, summary, extras, requiresAuthOverr
398
419
  type: s.type,
399
420
  name: s.name
400
421
  })) },
422
+ ...resource.idempotency?.required.some((o) => o === operation) && { "x-arc-idempotency": { required: true } },
401
423
  responses: buildErrorResponses({
402
424
  requiresAuth,
403
425
  permRoles: permAnnotation?.roles
@@ -421,7 +443,10 @@ function createOperation(resource, operation, summary, extras, requiresAuthOverr
421
443
  function describePermissionForOpenApi(check) {
422
444
  if (!check || typeof check !== "function") return void 0;
423
445
  const fn = check;
424
- if (fn._isPublic === true) return { type: "public" };
446
+ if (fn._isPublic === true) return fn._rowPolicy ? {
447
+ type: "public",
448
+ rowFiltered: true
449
+ } : { type: "public" };
425
450
  const result = { type: "requireAuth" };
426
451
  if (Array.isArray(fn._roles) && fn._roles.length > 0) {
427
452
  result.type = "requireRoles";
@@ -573,6 +598,8 @@ function emitMount(paths, resource, basePath, additionalSecurity, subsetActions,
573
598
  });
574
599
  return typeof effective === "function" && !effective._isPublic;
575
600
  });
601
+ const requiredActions = subsetActions.filter((a) => a.idempotency?.required === true).map((a) => a.name);
602
+ const handlerDedup = Object.fromEntries(subsetActions.flatMap((a) => a.idempotency?.required === true && a.idempotency.dedup === "handler" ? [[a.name, String(a.idempotency.owner)]] : []));
576
603
  if (!paths[actionPath]) paths[actionPath] = {};
577
604
  paths[actionPath].post = createOperation(resource, "action", `Perform action (${actionEnum.join(" / ")})`, {
578
605
  ...hasId ? { parameters: [{
@@ -583,6 +610,10 @@ function emitMount(paths, resource, basePath, additionalSecurity, subsetActions,
583
610
  description: "Resource ID"
584
611
  }] } : {},
585
612
  description: descLines.join("\n"),
613
+ ...requiredActions.length > 0 && { "x-arc-idempotency": {
614
+ requiredActions,
615
+ ...Object.keys(handlerDedup).length > 0 ? { handlerDedup } : {}
616
+ } },
586
617
  requestBody: {
587
618
  required: true,
588
619
  content: { "application/json": { schema: bodySchema } }
@@ -883,6 +914,13 @@ function appendCustomRoutePaths(paths, resource, basePath, additionalSecurity) {
883
914
  content: { "application/json": { schema } }
884
915
  };
885
916
  }
917
+ if (route.idempotency?.required === true) extras["x-arc-idempotency"] = {
918
+ required: true,
919
+ ...route.idempotency.dedup === "handler" ? {
920
+ dedup: "handler",
921
+ owner: route.idempotency.owner
922
+ } : {}
923
+ };
886
924
  paths[fullPath][method] = createOperation(resource, handlerName, route.summary ?? handlerName, extras, requiresAuthForRoute, additionalSecurity);
887
925
  }
888
926
  }
@@ -1004,4 +1042,4 @@ var openapi_default = fp(openApiPlugin, {
1004
1042
  fastify: "5.x"
1005
1043
  });
1006
1044
  //#endregion
1007
- export { openApiPlugin as n, openapi_default as r, buildOpenApiSpec as t };
1045
+ export { openapi_default as n, buildOpenApiSpec as t };
@@ -1,6 +1,6 @@
1
1
 
2
- import { l as PermissionCheck } from "../base-CxrV1LmG.mjs";
3
- import { t as ArcModule } from "../types-DfVDvkdP.mjs";
2
+ import { l as PermissionCheck } from "../base-CZyixEQR.mjs";
3
+ import { n as ArcModule, u as ModuleName } from "../types-BJ-2XqVF.mjs";
4
4
  import { OutboxStore } from "@classytic/primitives/outbox";
5
5
  //#region src/outbox-admin/module.d.ts
6
6
  /**
@@ -36,7 +36,7 @@ interface OutboxAdminModuleDeps {
36
36
  * Name the outbox module was composed under, used to resolve the relay and
37
37
  * declared as a `dependsOn` edge. Default `outbox`.
38
38
  */
39
- outboxModuleName?: string;
39
+ outboxModuleName?: ModuleName;
40
40
  /** Resource names this module supersedes. */
41
41
  owns?: readonly string[];
42
42
  /** Cap on `?limit`. Default 500 — a dead-letter list is for triage, not export. */
@@ -1,7 +1,8 @@
1
- /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
2
- import { i as NotFoundError, u as ValidationError } from "../errors-BH7YU_9O.mjs";
3
- import { t as defineResource } from "../defineResource-DxkMTjRn.mjs";
4
- import { n as getModuleExports, t as defineModule } from "../module-DpLJG2_N.mjs";
1
+ /*! @classytic/arc v2.43.0 | MIT | https://classytic.com */
2
+ import { i as NotFoundError, u as ValidationError } from "../errors-CtrywMHP.mjs";
3
+ import { t as defineResource } from "../defineResource-DIC7JZGU.mjs";
4
+ import { n as getModuleExports, t as defineModule } from "../module-BSvOQC7E.mjs";
5
+ import { r as readOutboxHealth } from "../health-D-Ft6JM6.mjs";
5
6
  //#region src/outbox-admin/module.ts
6
7
  /**
7
8
  * Strip the domain payload and the stack unless explicitly opted in.
@@ -51,20 +52,7 @@ function createOutboxAdminModule(deps) {
51
52
  path: "/health",
52
53
  summary: "Outbox health — pending, dead-letter and relay lag",
53
54
  permissions: view,
54
- rawHandler: async (_req, reply) => {
55
- const [pending, deadLetter, relayLagMs] = await Promise.all([
56
- deps.store.countByStatus("pending"),
57
- deps.store.countByStatus("dead_letter"),
58
- deps.store.oldestPendingAgeMs()
59
- ]);
60
- const healthy = deadLetter === 0 && (relayLagMs === null || relayLagMs < lagUnhealthyMs);
61
- return reply.send({
62
- pending,
63
- deadLetter,
64
- relayLagMs,
65
- healthy
66
- });
67
- }
55
+ rawHandler: async (_req, reply) => reply.send(await readOutboxHealth(deps.store, lagUnhealthyMs))
68
56
  },
69
57
  {
70
58
  method: "GET",
@@ -1,11 +1,15 @@
1
- /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
2
- import { t as transactionContext } from "./transactionContext-CPbdlnVw.mjs";
3
- import { n as requestContext } from "./requestContext-cDkAUr-P.mjs";
4
- import { i as resolveEventTransport } from "./EventTransport-DSqD_Xsg.mjs";
5
- import { n as createSafeGetOne, t as createIsDuplicateKeyError } from "./store-helpers-dW71XVWL.mjs";
1
+ /*! @classytic/arc v2.43.0 | MIT | https://classytic.com */
2
+ import { t as transactionContext } from "./transactionContext-DjFkrNG6.mjs";
3
+ import { n as requestContext } from "./requestContext-Dsu9P1bV.mjs";
4
+ import { n as evictToSize, r as touch } from "./boundedMap-BULzfXYU.mjs";
5
+ import { i as resolveEventTransport } from "./EventTransport-tzA7CAB4.mjs";
6
+ import { t as emitObserved } from "./eventPlugin-Q6BhzHzs.mjs";
7
+ import { n as canReportHealth, r as readOutboxHealth } from "./health-D-Ft6JM6.mjs";
8
+ import { n as createSafeGetOne, t as createIsDuplicateKeyError } from "./store-helpers-BOzk-CuX.mjs";
9
+ import { t as claimedAttempts } from "./claimed-B10RTfkM.mjs";
6
10
  import { and, anyOf, eq, lte, ne, or } from "@classytic/repo-core/filter";
7
11
  import { randomBytes } from "node:crypto";
8
- import { InvalidOutboxEventError, OutboxOwnershipError } from "@classytic/primitives/outbox";
12
+ import { InvalidOutboxEventError, OutboxConflictError, OutboxOwnershipError, outboxPayloadFingerprint } from "@classytic/primitives/outbox";
9
13
  import { update } from "@classytic/repo-core/update";
10
14
  import { hostname } from "node:os";
11
15
  //#region src/events/outbox/backoff.ts
@@ -35,9 +39,9 @@ import { hostname } from "node:os";
35
39
  * @example Tuning for a faster transport
36
40
  * ```typescript
37
41
  * exponentialBackoff({ attempt: 3, baseMs: 250, maxMs: 10_000, jitter: 0.3 });
38
- * // attempt=1 → ~250ms ±30%
39
- * // attempt=2 → ~500ms ±30%
40
- * // attempt=3 → ~1000ms ±30%
42
+ * // attempt=1 → 250–325ms (+0–30%: jitter only ever DELAYS, never schedules early)
43
+ * // attempt=2 → 500–650ms
44
+ * // attempt=3 → 1000–1300ms
41
45
  * // attempt=10 → capped at 10_000ms
42
46
  * ```
43
47
  */
@@ -54,6 +58,12 @@ function exponentialBackoff(options) {
54
58
  }
55
59
  //#endregion
56
60
  //#region src/events/repository-outbox-adapter.ts
61
+ /** A row read back from the repository that carries its event. */
62
+ function isStoredRow(value) {
63
+ if (typeof value !== "object" || value === null) return false;
64
+ const row = value;
65
+ return typeof row.type === "string" && typeof row.event === "object" && row.event !== null;
66
+ }
57
67
  const DEFAULT_LEASE_MS$1 = 3e4;
58
68
  const DEFAULT_CLAIM_LIMIT = 100;
59
69
  const DEFAULT_PURGE_BATCH = 500;
@@ -110,10 +120,10 @@ function repositoryAsOutboxStore(repository, options) {
110
120
  limit
111
121
  }));
112
122
  if (candidates.length === 0) return [];
113
- return (await Promise.all(candidates.map((candidate) => {
123
+ const claimed = (await Promise.all(candidates.map((candidate) => {
114
124
  const claimNow = /* @__PURE__ */ new Date();
115
125
  const leaseExpiresAt = new Date(claimNow.getTime() + leaseMs);
116
- return r.findOneAndUpdate(and(eq(idField, candidate.event.meta.id), claimableFilter(claimNow)), update({
126
+ return r.findOneAndUpdate(and(eq(idField, candidate[idField]), claimableFilter(claimNow)), update({
117
127
  set: {
118
128
  leaseOwner: consumerId,
119
129
  leaseExpiresAt
@@ -123,9 +133,22 @@ function repositoryAsOutboxStore(repository, options) {
123
133
  fenceToken: 1
124
134
  }
125
135
  }), { returnDocument: "after" });
126
- }))).filter((doc) => doc !== null).filter((doc) => isWellFormed(doc.event)).map((doc) => ({
136
+ }))).filter((doc) => doc !== null);
137
+ await Promise.all(claimed.filter((doc) => !isWellFormed(doc.event)).map((doc) => r.findOneAndUpdate(and(eq(idField, doc[idField]), eq("leaseOwner", consumerId), eq("fenceToken", doc.fenceToken ?? 0)), update({ set: {
138
+ status: "dead_letter",
139
+ leaseOwner: null,
140
+ leaseExpiresAt: null,
141
+ lastFailedAt: /* @__PURE__ */ new Date(),
142
+ firstFailedAt: doc.firstFailedAt ?? /* @__PURE__ */ new Date(),
143
+ lastError: {
144
+ message: "outbox row holds no event with a type and meta.id",
145
+ code: "MALFORMED_EVENT"
146
+ }
147
+ } }), { returnDocument: "after" })));
148
+ return claimed.filter((doc) => isWellFormed(doc.event)).map((doc) => ({
127
149
  event: doc.event,
128
- fencingToken: doc.fenceToken ?? 0
150
+ fencingToken: doc.fenceToken ?? 0,
151
+ ...typeof doc.attempts === "number" ? { attempts: doc.attempts } : {}
129
152
  }));
130
153
  };
131
154
  return {
@@ -157,7 +180,7 @@ function repositoryAsOutboxStore(repository, options) {
157
180
  firstFailedAt: null,
158
181
  lastFailedAt: null,
159
182
  lastError: null,
160
- dedupeKey: options?.dedupeKey ?? null,
183
+ dedupeKey: options?.dedupeKey ? `${event.type}:${options.dedupeKey}` : null,
161
184
  partitionKey: options?.partitionKey ?? null,
162
185
  headers: options?.headers ? { ...options.headers } : null,
163
186
  createdAt: now
@@ -165,8 +188,11 @@ function repositoryAsOutboxStore(repository, options) {
165
188
  try {
166
189
  await r.create(doc, options?.session ? { session: options.session } : void 0);
167
190
  } catch (err) {
168
- if (isDuplicateKeyError(err)) return;
169
- throw err;
191
+ if (!isDuplicateKeyError(err)) throw err;
192
+ const existing = await safeGetOne(doc.dedupeKey === null ? eq(idField, event.meta.id) : or(eq(idField, event.meta.id), eq("dedupeKey", doc.dedupeKey)));
193
+ if (!isStoredRow(existing)) throw err;
194
+ if (existing.type === event.type && outboxPayloadFingerprint(existing.event) === outboxPayloadFingerprint(event)) return;
195
+ throw new OutboxConflictError(event.meta.id, event.type);
170
196
  }
171
197
  },
172
198
  async getPending(limit) {
@@ -183,6 +209,15 @@ function repositoryAsOutboxStore(repository, options) {
183
209
  return (await claimPendingFenced(options)).map((c) => c.event);
184
210
  },
185
211
  claimPendingFenced,
212
+ ...typeof repository.updateMany === "function" ? { async acknowledgeMany(eventIds) {
213
+ if (eventIds.length === 0) return;
214
+ await repository.updateMany?.(and(anyOf(idField, [...eventIds]), ne("status", "delivered")), update({ set: {
215
+ status: "delivered",
216
+ deliveredAt: /* @__PURE__ */ new Date(),
217
+ leaseOwner: null,
218
+ leaseExpiresAt: null
219
+ } }));
220
+ } } : {},
186
221
  async acknowledge(eventId, options) {
187
222
  const now = /* @__PURE__ */ new Date();
188
223
  const baseFilter = and(eq(idField, eventId), ne("status", "delivered"));
@@ -375,6 +410,7 @@ var EventOutbox = class {
375
410
  _consumerId;
376
411
  _leaseMs;
377
412
  _onError;
413
+ _onPublished;
378
414
  _usePublishMany;
379
415
  _sessionProvider;
380
416
  _traceContextProvider;
@@ -384,9 +420,11 @@ var EventOutbox = class {
384
420
  * process; resets on restart. Populated as failures occur and cleared on
385
421
  * successful ack or dead-letter transition; additionally hard-capped at
386
422
  * {@link MAX_TRACKED_ATTEMPTS} (oldest-write evicted first) so events
387
- * resolved by another relay worker can't accumulate forever. For durable
388
- * authoritative counts, apps can query the store directly inside
389
- * {@link OutboxFailurePolicy}.
423
+ * resolved by another relay worker can't accumulate forever.
424
+ *
425
+ * A FALLBACK only: when the store reports `attempts` on the fenced claim
426
+ * (`repositoryAsOutboxStore` and primitives' `MemoryOutboxStore` do), the
427
+ * durable count wins — see the failure path in `relayBatch`.
390
428
  */
391
429
  _attempts = /* @__PURE__ */ new Map();
392
430
  constructor(opts) {
@@ -398,6 +436,7 @@ var EventOutbox = class {
398
436
  this._consumerId = opts.consumerId ?? `relay-${Math.random().toString(36).slice(2, 10)}`;
399
437
  this._leaseMs = opts.leaseMs ?? DEFAULT_LEASE_MS;
400
438
  this._onError = opts.onError;
439
+ this._onPublished = opts.onPublished;
401
440
  this._usePublishMany = opts.usePublishMany ?? true;
402
441
  this._failurePolicy = opts.failurePolicy;
403
442
  this._sessionProvider = opts.sessionProvider;
@@ -516,13 +555,18 @@ var EventOutbox = class {
516
555
  if (!this._transport) return empty;
517
556
  const claimStartedAt = Date.now();
518
557
  const fenceTokens = /* @__PURE__ */ new Map();
558
+ const durableAttempts = /* @__PURE__ */ new Map();
519
559
  let pending;
520
560
  if (typeof this._store.claimPendingFenced === "function") pending = (await this._store.claimPendingFenced({
521
561
  limit: this._batchSize,
522
562
  consumerId: this._consumerId,
523
563
  leaseMs: this._leaseMs
524
564
  })).map((c) => {
525
- if (c.event?.meta?.id) fenceTokens.set(c.event.meta.id, c.fencingToken);
565
+ if (c.event?.meta?.id) {
566
+ fenceTokens.set(c.event.meta.id, c.fencingToken);
567
+ const attempts = claimedAttempts(c);
568
+ if (attempts !== void 0) durableAttempts.set(c.event.meta.id, attempts);
569
+ }
526
570
  return c.event;
527
571
  });
528
572
  else if (this._store.claimPending) pending = await this._store.claimPending({
@@ -555,8 +599,7 @@ var EventOutbox = class {
555
599
  const canFail = typeof this._store.fail === "function";
556
600
  let publishOutcomes;
557
601
  if (canPublishMany && valid.length > 0) try {
558
- const result = await this._transport.publishMany?.(valid);
559
- publishOutcomes = new Map(result);
602
+ publishOutcomes = batchOutcomes(valid, await this._transport.publishMany?.(valid));
560
603
  } catch (batchErr) {
561
604
  publishOutcomes = /* @__PURE__ */ new Map();
562
605
  const err = batchErr instanceof Error ? batchErr : new Error(String(batchErr));
@@ -572,6 +615,12 @@ var EventOutbox = class {
572
615
  if (!canFail) break;
573
616
  }
574
617
  }
618
+ if (this._onPublished) for (const event of valid) {
619
+ if (publishOutcomes.get(event.meta.id) !== null) continue;
620
+ try {
621
+ this._onPublished(event);
622
+ } catch {}
623
+ }
575
624
  let stopBatch = false;
576
625
  for (const event of valid) {
577
626
  if (stopBatch) break;
@@ -583,13 +632,10 @@ var EventOutbox = class {
583
632
  stopBatch = true;
584
633
  continue;
585
634
  }
586
- const attempts = (this._attempts.get(event.meta.id) ?? 0) + 1;
587
- this._attempts.delete(event.meta.id);
588
- this._attempts.set(event.meta.id, attempts);
589
- if (this._attempts.size > MAX_TRACKED_ATTEMPTS) {
590
- const oldest = this._attempts.keys().next().value;
591
- if (oldest !== void 0) this._attempts.delete(oldest);
592
- }
635
+ const localAttempts = (this._attempts.get(event.meta.id) ?? 0) + 1;
636
+ const attempts = Math.max(localAttempts, durableAttempts.get(event.meta.id) ?? 0);
637
+ touch(this._attempts, event.meta.id, attempts);
638
+ evictToSize(this._attempts, MAX_TRACKED_ATTEMPTS);
593
639
  const fenceToken = fenceTokens.get(event.meta.id);
594
640
  let failOpts = {
595
641
  consumerId: this._consumerId,
@@ -679,6 +725,22 @@ var EventOutbox = class {
679
725
  return this._store.purge(olderThanMs);
680
726
  }
681
727
  };
728
+ /**
729
+ * A `publishMany` result must name exactly the events it was given. A missing outcome would read as
730
+ * success and be ACKNOWLEDGED unpublished, so any mismatch fails the whole batch — nothing is acked.
731
+ */
732
+ function batchOutcomes(events, result) {
733
+ const problem = batchContractProblem(events, result);
734
+ if (!problem) return new Map(result);
735
+ const err = /* @__PURE__ */ new Error(`[arc outbox] transport publishMany ${problem} — the whole batch is failed, none acknowledged`);
736
+ return new Map(events.map((e) => [e.meta.id, err]));
737
+ }
738
+ function batchContractProblem(events, result) {
739
+ if (!(result instanceof Map)) return "returned no outcome map";
740
+ const ids = new Set(events.map((e) => e.meta.id));
741
+ for (const id of ids) if (!result.has(id)) return `returned no outcome for event '${id}'`;
742
+ for (const id of result.keys()) if (!ids.has(id)) return `returned an outcome for unknown event '${id}'`;
743
+ }
682
744
  function normalizeError(err) {
683
745
  if (err instanceof Error) return {
684
746
  message: err.message,
@@ -723,6 +785,10 @@ function normalizeError(err) {
723
785
  */
724
786
  /** Default relay cadence. Frequent enough that a retry is not a user-visible delay. */
725
787
  const DEFAULT_RELAY_EVERY_MS = 5e3;
788
+ /** How long the readiness check reuses a relay-health reading. See `health.probeCacheMs`. */
789
+ const DEFAULT_PROBE_CACHE_MS = 5e3;
790
+ /** How often an unhealthy outbox is logged. See `health.alertEveryMs`. */
791
+ const DEFAULT_HEALTH_ALERT_EVERY_MS = 3e5;
726
792
  /**
727
793
  * One relay identity per RELAY INSTANCE — `<name>:<hostname>:<pid>:<random>`.
728
794
  *
@@ -777,6 +843,35 @@ function mintConsumerId(name) {
777
843
  function createOutboxModule(options) {
778
844
  const name = options.name ?? "outbox";
779
845
  const relayEveryMs = options.relayEveryMs ?? DEFAULT_RELAY_EVERY_MS;
846
+ const lagUnhealthyMs = (options.health === false ? void 0 : options.health?.relayLagUnhealthyMs) ?? 3e5;
847
+ const probeCacheMs = (options.health === false ? void 0 : options.health?.probeCacheMs) ?? DEFAULT_PROBE_CACHE_MS;
848
+ if (!Number.isFinite(probeCacheMs) || probeCacheMs < 0) throw new Error(`Outbox module "${name}": health.probeCacheMs must be a non-negative number, got ${String(probeCacheMs)}.`);
849
+ const alertEveryMs = options.health === false ? 0 : options.health?.alertEveryMs ?? DEFAULT_HEALTH_ALERT_EVERY_MS;
850
+ if (!Number.isFinite(alertEveryMs) || alertEveryMs < 0) throw new Error(`Outbox module "${name}": health.alertEveryMs must be a non-negative number, got ${String(alertEveryMs)}.`);
851
+ let log;
852
+ /** The unmeasurable-store warning is said once, not every alert tick. */
853
+ let warnedUnmeasurable = false;
854
+ /**
855
+ * The readiness probe's reading, reused for `probeCacheMs` and shared by
856
+ * concurrent probes (single-flight). A failed or unmeasurable reading is
857
+ * cached as unhealthy for the same window — retrying a failing store on
858
+ * every probe is load on the thing that is already failing.
859
+ */
860
+ let probeReading;
861
+ let probeInFlight;
862
+ const probeHealthy = (read) => {
863
+ if (probeReading && Date.now() - probeReading.at < probeCacheMs) return Promise.resolve(probeReading.healthy);
864
+ probeInFlight ??= read().then((h) => h.healthy, () => false).then((healthy) => {
865
+ probeReading = {
866
+ at: Date.now(),
867
+ healthy
868
+ };
869
+ return healthy;
870
+ }).finally(() => {
871
+ probeInFlight = void 0;
872
+ });
873
+ return probeInFlight;
874
+ };
780
875
  /**
781
876
  * Built at bootstrap and reused by the schedule.
782
877
  *
@@ -785,9 +880,20 @@ function createOutboxModule(options) {
785
880
  * depends on for exactly-once publishing.
786
881
  */
787
882
  let exports;
883
+ /** Passes THIS module started (nudge, schedule) that have not settled — `drain()` waits on them. */
884
+ const inFlightPasses = /* @__PURE__ */ new Set();
885
+ const trackPass = (pass) => {
886
+ inFlightPasses.add(pass);
887
+ const untrack = () => {
888
+ inFlightPasses.delete(pass);
889
+ };
890
+ pass.then(untrack, untrack);
891
+ return pass;
892
+ };
788
893
  return {
789
894
  name,
790
895
  bootstrap: (fastify) => {
896
+ log = fastify.log;
791
897
  const store = typeof options.store === "function" ? options.store(fastify) : options.store;
792
898
  /**
793
899
  * The RAW transport, never `fastify.events`.
@@ -811,6 +917,7 @@ function createOutboxModule(options) {
811
917
  const relay = new EventOutbox({
812
918
  store,
813
919
  transport,
920
+ onPublished: (event) => emitObserved(fastify.events, event),
814
921
  ...options.batchSize !== void 0 ? { batchSize: options.batchSize } : {},
815
922
  ...options.leaseMs !== void 0 ? { leaseMs: options.leaseMs } : {},
816
923
  consumerId: options.consumerId ?? mintConsumerId(name),
@@ -835,22 +942,57 @@ function createOutboxModule(options) {
835
942
  const requestDrain = () => {
836
943
  if (drainQueued) return;
837
944
  drainQueued = true;
945
+ let passDone = () => {};
946
+ trackPass(new Promise((resolve) => passDone = resolve));
838
947
  setImmediate(() => {
839
948
  drainQueued = false;
840
949
  requestContext.storage.exit(() => {
841
950
  relay.relayBatch().catch((err) => {
842
951
  fastify.log.error({ err }, `outbox relay "${name}" requested drain failed`);
843
- });
952
+ }).finally(passDone);
844
953
  });
845
954
  });
846
955
  };
956
+ const drain = ({ maxPasses = 100 } = {}) => requestContext.storage.exit(async () => {
957
+ const total = {
958
+ passes: 0,
959
+ relayed: 0,
960
+ publishFailed: 0,
961
+ deadLettered: 0,
962
+ drained: false
963
+ };
964
+ while (total.passes < maxPasses) {
965
+ if (inFlightPasses.size > 0) await Promise.allSettled([...inFlightPasses]);
966
+ const r = await relay.relayBatch();
967
+ total.passes++;
968
+ total.relayed += r.relayed;
969
+ total.publishFailed += r.publishFailed;
970
+ total.deadLettered += r.deadLettered;
971
+ if (r.attempted === 0 && inFlightPasses.size === 0) {
972
+ total.drained = true;
973
+ break;
974
+ }
975
+ }
976
+ return total;
977
+ });
978
+ const health = async () => {
979
+ if (!canReportHealth(store)) throw new Error(`Outbox module "${name}": relay health needs BOTH countByStatus and oldestPendingAgeMs on the store, and it is missing at least one.`);
980
+ return readOutboxHealth(store, lagUnhealthyMs);
981
+ };
847
982
  exports = {
848
983
  store,
849
984
  relay,
850
- requestDrain
985
+ requestDrain,
986
+ drain,
987
+ health
851
988
  };
852
989
  return exports;
853
990
  },
991
+ ...options.health === false ? {} : { healthChecks: [{
992
+ name: `${name}.relay`,
993
+ critical: false,
994
+ check: () => exports ? probeHealthy(exports.health) : Promise.resolve(false)
995
+ }] },
854
996
  /**
855
997
  * The relay runs on the module's OWN schedule arm, so the host stops owning the tick — and
856
998
  * a host that forgets to start one cannot exist.
@@ -861,9 +1003,37 @@ function createOutboxModule(options) {
861
1003
  every: relayEveryMs,
862
1004
  runOnStart: options.runOnStart ?? true,
863
1005
  handler: async () => {
864
- await exports?.relay.relay();
1006
+ if (exports) await trackPass(exports.relay.relay());
1007
+ }
1008
+ }, ...alertEveryMs > 0 ? [{
1009
+ name: `${name}.health`,
1010
+ kind: "relay",
1011
+ every: alertEveryMs,
1012
+ handler: async () => {
1013
+ if (!exports || !log) return;
1014
+ if (!canReportHealth(exports.store)) {
1015
+ if (!warnedUnmeasurable) {
1016
+ warnedUnmeasurable = true;
1017
+ log.warn({
1018
+ marker: "OUTBOX_HEALTH_UNMEASURABLE",
1019
+ outbox: name
1020
+ }, `outbox "${name}" store lacks countByStatus or oldestPendingAgeMs — relay stalls cannot be alerted on`);
1021
+ }
1022
+ return;
1023
+ }
1024
+ const h = await exports.health();
1025
+ if (h.deadLetter > 0) log.warn({
1026
+ marker: "OUTBOX_DEAD_LETTER",
1027
+ outbox: name,
1028
+ ...h
1029
+ }, `outbox "${name}" has dead-lettered events — inspect and replay them`);
1030
+ if (h.relayLagMs !== null && h.relayLagMs >= lagUnhealthyMs) log.error({
1031
+ marker: "OUTBOX_RELAY_LAG",
1032
+ outbox: name,
1033
+ ...h
1034
+ }, `outbox "${name}" relay is behind — events are committed but not delivered`);
865
1035
  }
866
- }]
1036
+ }] : []]
867
1037
  };
868
1038
  }
869
1039
  //#endregion