@nifrajs/core 3.3.0 → 4.0.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 (331) hide show
  1. package/README.md +26 -0
  2. package/dist/assurance.d.ts +7 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +39 -3
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +20 -2
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +47 -15
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/diff.d.ts +6 -1
  11. package/dist/diff.d.ts.map +1 -1
  12. package/dist/diff.js +36 -2
  13. package/dist/diff.js.map +1 -1
  14. package/dist/durable-adapters.d.ts.map +1 -1
  15. package/dist/durable-adapters.js +16 -3
  16. package/dist/durable-adapters.js.map +1 -1
  17. package/dist/durable-effect.d.ts.map +1 -1
  18. package/dist/durable-effect.js +6 -3
  19. package/dist/durable-effect.js.map +1 -1
  20. package/dist/edge-kit.d.ts +2 -0
  21. package/dist/edge-kit.d.ts.map +1 -1
  22. package/dist/edge-kit.js +2 -0
  23. package/dist/edge-kit.js.map +1 -1
  24. package/dist/effect-lifecycle.d.ts.map +1 -1
  25. package/dist/effect-lifecycle.js +2 -1
  26. package/dist/effect-lifecycle.js.map +1 -1
  27. package/dist/effect-scope.d.ts.map +1 -1
  28. package/dist/effect-scope.js +1 -1
  29. package/dist/effect-scope.js.map +1 -1
  30. package/dist/errors.d.ts +1 -1
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/errors.js.map +1 -1
  33. package/dist/evidence.d.ts +36 -1
  34. package/dist/evidence.d.ts.map +1 -1
  35. package/dist/evidence.js +219 -13
  36. package/dist/evidence.js.map +1 -1
  37. package/dist/idempotency.d.ts +31 -0
  38. package/dist/idempotency.d.ts.map +1 -1
  39. package/dist/idempotency.js +48 -9
  40. package/dist/idempotency.js.map +1 -1
  41. package/dist/index.d.ts +9 -6
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +6 -3
  44. package/dist/index.js.map +1 -1
  45. package/dist/internal/code-unit-order.d.ts +7 -0
  46. package/dist/internal/code-unit-order.d.ts.map +1 -0
  47. package/dist/internal/code-unit-order.js +9 -0
  48. package/dist/internal/code-unit-order.js.map +1 -0
  49. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  50. package/dist/internal/durable-saga-protocol.js +2 -0
  51. package/dist/internal/durable-saga-protocol.js.map +1 -1
  52. package/dist/internal/route-catalog.d.ts +6 -6
  53. package/dist/internal/route-catalog.d.ts.map +1 -1
  54. package/dist/internal/route-catalog.js +20 -18
  55. package/dist/internal/route-catalog.js.map +1 -1
  56. package/dist/internal/route-compiler.d.ts +7 -6
  57. package/dist/internal/route-compiler.d.ts.map +1 -1
  58. package/dist/internal/route-compiler.js +36 -4
  59. package/dist/internal/route-compiler.js.map +1 -1
  60. package/dist/internal/route-execution.d.ts +7 -3
  61. package/dist/internal/route-execution.d.ts.map +1 -1
  62. package/dist/internal/route-execution.js.map +1 -1
  63. package/dist/internal/route-lanes.d.ts +1 -0
  64. package/dist/internal/route-lanes.d.ts.map +1 -1
  65. package/dist/internal/route-lanes.js +34 -29
  66. package/dist/internal/route-lanes.js.map +1 -1
  67. package/dist/internal/route-program.d.ts +8 -2
  68. package/dist/internal/route-program.d.ts.map +1 -1
  69. package/dist/internal/route-program.js +222 -20
  70. package/dist/internal/route-program.js.map +1 -1
  71. package/dist/ledger.d.ts.map +1 -1
  72. package/dist/ledger.js +8 -5
  73. package/dist/ledger.js.map +1 -1
  74. package/dist/manifest.d.ts +1 -1
  75. package/dist/manifest.d.ts.map +1 -1
  76. package/dist/manifest.js +52 -46
  77. package/dist/manifest.js.map +1 -1
  78. package/dist/mount.d.ts +36 -0
  79. package/dist/mount.d.ts.map +1 -1
  80. package/dist/mount.js +32 -0
  81. package/dist/mount.js.map +1 -1
  82. package/dist/reflection.d.ts +26 -0
  83. package/dist/reflection.d.ts.map +1 -1
  84. package/dist/reflection.js +65 -1
  85. package/dist/reflection.js.map +1 -1
  86. package/dist/response-observer.d.ts +1 -1
  87. package/dist/response-observer.d.ts.map +1 -1
  88. package/dist/response-observer.js.map +1 -1
  89. package/dist/router/overlap.d.ts +23 -0
  90. package/dist/router/overlap.d.ts.map +1 -0
  91. package/dist/router/overlap.js +224 -0
  92. package/dist/router/overlap.js.map +1 -0
  93. package/dist/router/pattern.d.ts +117 -2
  94. package/dist/router/pattern.d.ts.map +1 -1
  95. package/dist/router/pattern.js +283 -23
  96. package/dist/router/pattern.js.map +1 -1
  97. package/dist/router/router.d.ts +18 -4
  98. package/dist/router/router.d.ts.map +1 -1
  99. package/dist/router/router.js +51 -44
  100. package/dist/router/router.js.map +1 -1
  101. package/dist/server/auth.d.ts +53 -0
  102. package/dist/server/auth.d.ts.map +1 -0
  103. package/dist/server/auth.js +9 -0
  104. package/dist/server/auth.js.map +1 -0
  105. package/dist/server/bare-error-lane.d.ts +12 -7
  106. package/dist/server/bare-error-lane.d.ts.map +1 -1
  107. package/dist/server/bare-error-lane.js +13 -11
  108. package/dist/server/bare-error-lane.js.map +1 -1
  109. package/dist/server/body-lane.d.ts +20 -2
  110. package/dist/server/body-lane.d.ts.map +1 -1
  111. package/dist/server/body-lane.js +26 -7
  112. package/dist/server/body-lane.js.map +1 -1
  113. package/dist/server/body-parser.d.ts +68 -0
  114. package/dist/server/body-parser.d.ts.map +1 -0
  115. package/dist/server/body-parser.js +172 -0
  116. package/dist/server/body-parser.js.map +1 -0
  117. package/dist/server/body.d.ts.map +1 -1
  118. package/dist/server/body.js +62 -7
  119. package/dist/server/body.js.map +1 -1
  120. package/dist/server/client-ip.d.ts +8 -0
  121. package/dist/server/client-ip.d.ts.map +1 -1
  122. package/dist/server/client-ip.js +8 -0
  123. package/dist/server/client-ip.js.map +1 -1
  124. package/dist/server/context.d.ts +71 -6
  125. package/dist/server/context.d.ts.map +1 -1
  126. package/dist/server/contract.d.ts +15 -5
  127. package/dist/server/contract.d.ts.map +1 -1
  128. package/dist/server/contract.js +25 -4
  129. package/dist/server/contract.js.map +1 -1
  130. package/dist/server/cookies.d.ts +4 -1
  131. package/dist/server/cookies.d.ts.map +1 -1
  132. package/dist/server/cookies.js +40 -2
  133. package/dist/server/cookies.js.map +1 -1
  134. package/dist/server/core-copies.d.ts +2 -0
  135. package/dist/server/core-copies.d.ts.map +1 -0
  136. package/dist/server/core-copies.js +20 -0
  137. package/dist/server/core-copies.js.map +1 -0
  138. package/dist/server/edge.d.ts.map +1 -1
  139. package/dist/server/edge.js +2 -0
  140. package/dist/server/edge.js.map +1 -1
  141. package/dist/server/http.d.ts +11 -0
  142. package/dist/server/http.d.ts.map +1 -1
  143. package/dist/server/http.js +36 -0
  144. package/dist/server/http.js.map +1 -1
  145. package/dist/server/idempotency-lane.d.ts +6 -0
  146. package/dist/server/idempotency-lane.d.ts.map +1 -1
  147. package/dist/server/idempotency-lane.js +130 -73
  148. package/dist/server/idempotency-lane.js.map +1 -1
  149. package/dist/server/install.d.ts +4 -0
  150. package/dist/server/install.d.ts.map +1 -1
  151. package/dist/server/install.js +4 -0
  152. package/dist/server/install.js.map +1 -1
  153. package/dist/server/methods.d.ts +45 -0
  154. package/dist/server/methods.d.ts.map +1 -0
  155. package/dist/server/methods.js +37 -0
  156. package/dist/server/methods.js.map +1 -0
  157. package/dist/server/multipart.d.ts +63 -0
  158. package/dist/server/multipart.d.ts.map +1 -0
  159. package/dist/server/multipart.js +374 -0
  160. package/dist/server/multipart.js.map +1 -0
  161. package/dist/server/node-outcome-hook.d.ts +20 -0
  162. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  163. package/dist/server/node-outcome-hook.js +15 -6
  164. package/dist/server/node-outcome-hook.js.map +1 -1
  165. package/dist/server/not-found-answer.d.ts +40 -0
  166. package/dist/server/not-found-answer.d.ts.map +1 -0
  167. package/dist/server/not-found-answer.js +56 -0
  168. package/dist/server/not-found-answer.js.map +1 -0
  169. package/dist/server/not-found.d.ts +23 -0
  170. package/dist/server/not-found.d.ts.map +1 -0
  171. package/dist/server/not-found.js +81 -0
  172. package/dist/server/not-found.js.map +1 -0
  173. package/dist/server/plugin.d.ts +26 -1
  174. package/dist/server/plugin.d.ts.map +1 -1
  175. package/dist/server/plugin.js.map +1 -1
  176. package/dist/server/proto-guard.d.ts +18 -5
  177. package/dist/server/proto-guard.d.ts.map +1 -1
  178. package/dist/server/proto-guard.js +114 -9
  179. package/dist/server/proto-guard.js.map +1 -1
  180. package/dist/server/query.d.ts +7 -0
  181. package/dist/server/query.d.ts.map +1 -1
  182. package/dist/server/query.js +47 -0
  183. package/dist/server/query.js.map +1 -1
  184. package/dist/server/registry.d.ts +13 -2
  185. package/dist/server/registry.d.ts.map +1 -1
  186. package/dist/server/request-context.d.ts +11 -0
  187. package/dist/server/request-context.d.ts.map +1 -1
  188. package/dist/server/request-context.js +33 -19
  189. package/dist/server/request-context.js.map +1 -1
  190. package/dist/server/request-lineage.d.ts +8 -0
  191. package/dist/server/request-lineage.d.ts.map +1 -0
  192. package/dist/server/request-lineage.js +14 -0
  193. package/dist/server/request-lineage.js.map +1 -0
  194. package/dist/server/request-target.d.ts +31 -0
  195. package/dist/server/request-target.d.ts.map +1 -0
  196. package/dist/server/request-target.js +44 -0
  197. package/dist/server/request-target.js.map +1 -0
  198. package/dist/server/respond.d.ts +1 -1
  199. package/dist/server/respond.d.ts.map +1 -1
  200. package/dist/server/respond.js +16 -4
  201. package/dist/server/respond.js.map +1 -1
  202. package/dist/server/response-contract-lane.d.ts +19 -1
  203. package/dist/server/response-contract-lane.d.ts.map +1 -1
  204. package/dist/server/response-contract-lane.js +59 -14
  205. package/dist/server/response-contract-lane.js.map +1 -1
  206. package/dist/server/response-observer-runtime.d.ts +9 -5
  207. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  208. package/dist/server/response-observer-runtime.js +10 -6
  209. package/dist/server/response-observer-runtime.js.map +1 -1
  210. package/dist/server/runtime-core.d.ts +2 -0
  211. package/dist/server/runtime-core.d.ts.map +1 -1
  212. package/dist/server/runtime-core.js.map +1 -1
  213. package/dist/server/server-types.d.ts +55 -3
  214. package/dist/server/server-types.d.ts.map +1 -1
  215. package/dist/server/server.d.ts +194 -19
  216. package/dist/server/server.d.ts.map +1 -1
  217. package/dist/server/server.js +1342 -274
  218. package/dist/server/server.js.map +1 -1
  219. package/dist/server/sse.d.ts.map +1 -1
  220. package/dist/server/sse.js +10 -7
  221. package/dist/server/sse.js.map +1 -1
  222. package/dist/server/static-headers.d.ts +3 -0
  223. package/dist/server/static-headers.d.ts.map +1 -1
  224. package/dist/server/static-headers.js +19 -1
  225. package/dist/server/static-headers.js.map +1 -1
  226. package/dist/server/transport-lane.d.ts +8 -0
  227. package/dist/server/transport-lane.d.ts.map +1 -1
  228. package/dist/server/transport-lane.js +9 -8
  229. package/dist/server/transport-lane.js.map +1 -1
  230. package/dist/server/validation.d.ts.map +1 -1
  231. package/dist/server/validation.js +5 -1
  232. package/dist/server/validation.js.map +1 -1
  233. package/dist/server/websocket.d.ts +10 -3
  234. package/dist/server/websocket.d.ts.map +1 -1
  235. package/dist/server/websocket.js +7 -4
  236. package/dist/server/websocket.js.map +1 -1
  237. package/dist/server/ws-bun.d.ts +2 -0
  238. package/dist/server/ws-bun.d.ts.map +1 -1
  239. package/dist/server/ws-bun.js +6 -3
  240. package/dist/server/ws-bun.js.map +1 -1
  241. package/dist/server/ws-hook.d.ts +2 -1
  242. package/dist/server/ws-hook.d.ts.map +1 -1
  243. package/dist/server.d.ts +8 -5
  244. package/dist/server.d.ts.map +1 -1
  245. package/dist/server.js +5 -2
  246. package/dist/server.js.map +1 -1
  247. package/dist/single-copy-register.js +5 -0
  248. package/dist/single-copy-register.js.map +1 -1
  249. package/dist/single-copy.d.ts +46 -3
  250. package/dist/single-copy.d.ts.map +1 -1
  251. package/dist/single-copy.js +125 -12
  252. package/dist/single-copy.js.map +1 -1
  253. package/dist/tool-contract.d.ts +23 -0
  254. package/dist/tool-contract.d.ts.map +1 -1
  255. package/dist/tool-contract.js +37 -3
  256. package/dist/tool-contract.js.map +1 -1
  257. package/dist/transport-codec.d.ts.map +1 -1
  258. package/dist/transport-codec.js +2 -1
  259. package/dist/transport-codec.js.map +1 -1
  260. package/package.json +27 -2
  261. package/src/assurance.ts +52 -2
  262. package/src/capabilities.ts +81 -22
  263. package/src/diff.ts +54 -7
  264. package/src/durable-adapters.ts +20 -3
  265. package/src/durable-effect.ts +10 -3
  266. package/src/edge-kit.ts +7 -0
  267. package/src/effect-lifecycle.ts +5 -1
  268. package/src/effect-scope.ts +2 -1
  269. package/src/errors.ts +3 -0
  270. package/src/evidence.ts +302 -12
  271. package/src/idempotency.ts +80 -9
  272. package/src/index.ts +41 -3
  273. package/src/internal/code-unit-order.ts +8 -0
  274. package/src/internal/durable-saga-protocol.ts +2 -0
  275. package/src/internal/route-catalog.ts +19 -18
  276. package/src/internal/route-compiler.ts +62 -13
  277. package/src/internal/route-execution.ts +13 -3
  278. package/src/internal/route-lanes.ts +37 -28
  279. package/src/internal/route-program.ts +434 -20
  280. package/src/ledger.ts +11 -5
  281. package/src/manifest.ts +52 -46
  282. package/src/mount.ts +62 -0
  283. package/src/reflection.ts +84 -1
  284. package/src/response-observer.ts +1 -0
  285. package/src/router/overlap.ts +258 -0
  286. package/src/router/pattern.ts +349 -25
  287. package/src/router/router.ts +76 -51
  288. package/src/server/auth.ts +75 -0
  289. package/src/server/bare-error-lane.ts +12 -11
  290. package/src/server/body-lane.ts +46 -6
  291. package/src/server/body-parser.ts +241 -0
  292. package/src/server/body.ts +82 -9
  293. package/src/server/client-ip.ts +9 -0
  294. package/src/server/context.ts +219 -12
  295. package/src/server/contract.ts +54 -9
  296. package/src/server/cookies.ts +34 -2
  297. package/src/server/core-copies.ts +21 -0
  298. package/src/server/edge.ts +2 -0
  299. package/src/server/http.ts +34 -0
  300. package/src/server/idempotency-lane.ts +147 -72
  301. package/src/server/install.ts +6 -0
  302. package/src/server/methods.ts +143 -0
  303. package/src/server/multipart.ts +424 -0
  304. package/src/server/node-outcome-hook.ts +40 -7
  305. package/src/server/not-found-answer.ts +109 -0
  306. package/src/server/not-found.ts +121 -0
  307. package/src/server/plugin.ts +45 -1
  308. package/src/server/proto-guard.ts +128 -9
  309. package/src/server/query.ts +49 -0
  310. package/src/server/registry.ts +17 -2
  311. package/src/server/request-context.ts +44 -16
  312. package/src/server/request-lineage.ts +15 -0
  313. package/src/server/request-target.ts +46 -0
  314. package/src/server/respond.ts +14 -3
  315. package/src/server/response-contract-lane.ts +97 -16
  316. package/src/server/response-observer-runtime.ts +37 -8
  317. package/src/server/runtime-core.ts +2 -0
  318. package/src/server/server-types.ts +61 -2
  319. package/src/server/server.ts +1798 -355
  320. package/src/server/sse.ts +10 -8
  321. package/src/server/static-headers.ts +22 -1
  322. package/src/server/transport-lane.ts +17 -9
  323. package/src/server/validation.ts +7 -1
  324. package/src/server/websocket.ts +23 -9
  325. package/src/server/ws-bun.ts +8 -3
  326. package/src/server/ws-hook.ts +2 -1
  327. package/src/server.ts +39 -2
  328. package/src/single-copy-register.ts +5 -0
  329. package/src/single-copy.ts +160 -12
  330. package/src/tool-contract.ts +66 -3
  331. package/src/transport-codec.ts +2 -1
package/src/evidence.ts CHANGED
@@ -14,11 +14,14 @@ import type {
14
14
  CapabilityFinding,
15
15
  } from "./capabilities.ts"
16
16
  import type { ResponseClassification } from "./classification.ts"
17
+ import { codeUnitOrder } from "./internal/code-unit-order.ts"
17
18
  import { evidenceProvenance } from "./internal/route-assurance.ts"
18
19
  import {
19
20
  type JsonSchema,
21
+ type ReflectedMount,
20
22
  type ReflectedRoute,
21
23
  type ReflectedSchemaField,
24
+ reflectMounts,
22
25
  reflectRoutes,
23
26
  type SchemaReflection,
24
27
  } from "./reflection.ts"
@@ -26,12 +29,15 @@ import {
26
29
  export interface ProjectEvidenceSchemaPart {
27
30
  readonly jsonSchema?: JsonSchema
28
31
  readonly fields?: readonly ReflectedSchemaField[]
32
+ /** The media types a body schema reads through a parser of its own. */
33
+ readonly mediaTypes?: readonly string[]
29
34
  }
30
35
 
31
36
  export interface ProjectEvidenceSchema {
32
37
  readonly bodyLimit?: number | "unlimited"
33
38
  readonly bodyLimitReason?: string
34
39
  readonly headers?: ProjectEvidenceSchemaPart
40
+ readonly cookies?: ProjectEvidenceSchemaPart
35
41
  readonly body?: ProjectEvidenceSchemaPart
36
42
  readonly query?: ProjectEvidenceSchemaPart
37
43
  readonly params?: ProjectEvidenceSchemaPart
@@ -50,6 +56,7 @@ export interface ProjectEvidenceRoute {
50
56
  readonly method: string
51
57
  readonly path: string
52
58
  readonly schema?: ProjectEvidenceSchema
59
+ readonly responseContract?: "warn" | "enforce"
53
60
  readonly assurance?: readonly AssuranceEvidence[]
54
61
  readonly capabilities?: readonly string[]
55
62
  readonly family?: boolean
@@ -93,6 +100,8 @@ export interface ProjectEvidenceCapabilities {
93
100
  export interface ProjectEvidenceSnapshot {
94
101
  readonly version: 1
95
102
  readonly routes: readonly ProjectEvidenceRoute[]
103
+ /** Mounted children whose routes are not in `routes`, present when there is at least one. */
104
+ readonly mounts?: readonly ReflectedMount[]
96
105
  readonly assurance?: ProjectEvidenceAssurance
97
106
  readonly capabilities?: ProjectEvidenceCapabilities
98
107
  }
@@ -100,8 +109,23 @@ export interface ProjectEvidenceSnapshot {
100
109
  export interface ProjectEvidenceOptions {
101
110
  readonly assurance?: AssuranceReport
102
111
  readonly capabilities?: CapabilityAssuranceReport
112
+ /**
113
+ * An existing reflection pass. Supplying it lets several offline projections share one pass
114
+ * without asking the runtime source for `.routes()` again. Validators are consumed only while
115
+ * building the token-only snapshot and never cross this seam.
116
+ */
117
+ readonly routes?: readonly ReflectedRoute[]
103
118
  /** Optional static source locations keyed by `${METHOD}\n${path}`. */
104
119
  readonly sourceLocations?: ReadonlyMap<string, readonly ProjectEvidenceSourceLocation[]>
120
+ /** An existing mount reflection, like `routes`. Default: `reflectMounts(source)`. */
121
+ readonly mounts?: readonly ReflectedMount[]
122
+ }
123
+
124
+ /** One token-only evidence snapshot in a composed application surface. */
125
+ export interface ProjectEvidenceCompositionPart {
126
+ readonly evidence: ProjectEvidenceSnapshot
127
+ /** Public pathname prefix to add when the child receives a stripped mount request. */
128
+ readonly pathPrefix?: string
105
129
  }
106
130
 
107
131
  const recordOf = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
@@ -114,6 +138,9 @@ function schemaPart(value: SchemaReflection | undefined): ProjectEvidenceSchemaP
114
138
  return Object.freeze({
115
139
  ...(value.jsonSchema !== undefined ? { jsonSchema: value.jsonSchema } : {}),
116
140
  ...(value.fields !== undefined ? { fields: Object.freeze([...value.fields]) } : {}),
141
+ ...(value.mediaTypes !== undefined
142
+ ? { mediaTypes: Object.freeze([...value.mediaTypes].sort()) }
143
+ : {}),
117
144
  })
118
145
  }
119
146
 
@@ -121,6 +148,7 @@ function schemaOf(route: ReflectedRoute): ProjectEvidenceSchema | undefined {
121
148
  const source = route.schema
122
149
  if (source === undefined) return undefined
123
150
  const headers = schemaPart(source.headers)
151
+ const cookies = schemaPart(source.cookies)
124
152
  const body = schemaPart(source.body)
125
153
  const query = schemaPart(source.query)
126
154
  const params = schemaPart(source.params)
@@ -135,6 +163,7 @@ function schemaOf(route: ReflectedRoute): ProjectEvidenceSchema | undefined {
135
163
  ...(source.bodyLimit !== undefined ? { bodyLimit: source.bodyLimit } : {}),
136
164
  ...(source.bodyLimitReason !== undefined ? { bodyLimitReason: source.bodyLimitReason } : {}),
137
165
  ...(headers !== undefined ? { headers } : {}),
166
+ ...(cookies !== undefined ? { cookies } : {}),
138
167
  ...(body !== undefined ? { body } : {}),
139
168
  ...(query !== undefined ? { query } : {}),
140
169
  ...(params !== undefined ? { params } : {}),
@@ -157,7 +186,7 @@ function assuranceEvidenceOf(values: readonly AssuranceEvidence[]): readonly Ass
157
186
  provenance: evidenceProvenance(item),
158
187
  }),
159
188
  )
160
- .sort((a, b) => a.id.localeCompare(b.id) || a.source.localeCompare(b.source)),
189
+ .sort((a, b) => codeUnitOrder(a.id, b.id) || codeUnitOrder(a.source, b.source)),
161
190
  )
162
191
  }
163
192
 
@@ -169,9 +198,9 @@ function capabilityEvidenceOf(
169
198
  .map((item) => Object.freeze({ id: item.id, kind: item.kind, source: item.source }))
170
199
  .sort(
171
200
  (a, b) =>
172
- a.id.localeCompare(b.id) ||
173
- a.kind.localeCompare(b.kind) ||
174
- a.source.localeCompare(b.source),
201
+ codeUnitOrder(a.id, b.id) ||
202
+ codeUnitOrder(a.kind, b.kind) ||
203
+ codeUnitOrder(a.source, b.source),
175
204
  ),
176
205
  )
177
206
  }
@@ -180,7 +209,7 @@ const sortByRoute = <T extends { readonly method: string; readonly path: string
180
209
  values: readonly T[],
181
210
  ): readonly T[] =>
182
211
  Object.freeze(
183
- [...values].sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)),
212
+ [...values].sort((a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.method, b.method)),
184
213
  )
185
214
 
186
215
  function evidenceRouteOf(
@@ -192,6 +221,7 @@ function evidenceRouteOf(
192
221
  method: route.method.toUpperCase(),
193
222
  path: route.path,
194
223
  ...(schema !== undefined ? { schema } : {}),
224
+ ...(route.responseContract === undefined ? {} : { responseContract: route.responseContract }),
195
225
  ...(route.assurance !== undefined ? { assurance: assuranceEvidenceOf(route.assurance) } : {}),
196
226
  ...(route.capabilities !== undefined
197
227
  ? { capabilities: Object.freeze([...route.capabilities].sort()) }
@@ -224,9 +254,9 @@ function assuranceOf(report: AssuranceReport | undefined): ProjectEvidenceAssura
224
254
  findings: Object.freeze(
225
255
  [...report.findings].sort(
226
256
  (a, b) =>
227
- a.path.localeCompare(b.path) ||
228
- a.method.localeCompare(b.method) ||
229
- a.code.localeCompare(b.code),
257
+ codeUnitOrder(a.path, b.path) ||
258
+ codeUnitOrder(a.method, b.method) ||
259
+ codeUnitOrder(a.code, b.code),
230
260
  ),
231
261
  ),
232
262
  })
@@ -254,14 +284,259 @@ function capabilitiesOf(
254
284
  findings: Object.freeze(
255
285
  [...report.findings].sort(
256
286
  (a, b) =>
257
- a.path.localeCompare(b.path) ||
258
- a.method.localeCompare(b.method) ||
259
- a.code.localeCompare(b.code),
287
+ codeUnitOrder(a.path, b.path) ||
288
+ codeUnitOrder(a.method, b.method) ||
289
+ codeUnitOrder(a.code, b.code),
260
290
  ),
261
291
  ),
262
292
  })
263
293
  }
264
294
 
295
+ function trimTrailingSlashes(path: string): string {
296
+ // Index-scan instead of `/\/+$/`: path prefixes can be library input, and the
297
+ // unanchored-start trailing-run replacement can backtrack quadratically.
298
+ let end = path.length
299
+ while (end > 0 && path.charCodeAt(end - 1) === 47 /* '/' */) end--
300
+ return path.slice(0, end)
301
+ }
302
+
303
+ const composedPath = (prefix: string, path: string): string => {
304
+ if (path === "*") return path
305
+ if (!path.startsWith("/"))
306
+ throw new TypeError(`project evidence: route path must start with "/": ${path}`)
307
+ if (prefix === "") return path
308
+ if (!prefix.startsWith("/")) {
309
+ throw new TypeError(`project evidence: path prefix must start with "/": ${prefix}`)
310
+ }
311
+ const normalized = prefix === "/" ? "" : trimTrailingSlashes(prefix)
312
+ return normalized === "" ? path : path === "/" ? normalized : `${normalized}${path}`
313
+ }
314
+
315
+ function remapEvidenceSnapshot(
316
+ evidence: ProjectEvidenceSnapshot,
317
+ pathPrefix: string,
318
+ ): ProjectEvidenceSnapshot {
319
+ if (evidence.version !== 1) throw new Error("project evidence: unsupported snapshot version")
320
+ const route = (
321
+ method: string,
322
+ path: string,
323
+ ): { readonly method: string; readonly path: string } => ({
324
+ method: method.toUpperCase(),
325
+ path: composedPath(pathPrefix, path),
326
+ })
327
+ const routes = evidence.routes.map((item) => {
328
+ const key = route(item.method, item.path)
329
+ return Object.freeze({ ...item, ...key })
330
+ })
331
+ const mounts = evidence.mounts?.map((item) =>
332
+ Object.freeze({ ...item, path: composedPath(pathPrefix, item.path) }),
333
+ )
334
+ const assurance =
335
+ evidence.assurance === undefined
336
+ ? undefined
337
+ : Object.freeze({
338
+ ok: evidence.assurance.ok,
339
+ routes: Object.freeze(
340
+ evidence.assurance.routes.map((item) =>
341
+ Object.freeze({ ...item, ...route(item.method, item.path) }),
342
+ ),
343
+ ),
344
+ findings: Object.freeze(
345
+ evidence.assurance.findings.map((item) =>
346
+ Object.freeze({ ...item, ...route(item.method, item.path) }),
347
+ ),
348
+ ),
349
+ })
350
+ const capabilities =
351
+ evidence.capabilities === undefined
352
+ ? undefined
353
+ : Object.freeze({
354
+ ok: evidence.capabilities.ok,
355
+ routes: Object.freeze(
356
+ evidence.capabilities.routes.map((item) =>
357
+ Object.freeze({ ...item, ...route(item.method, item.path) }),
358
+ ),
359
+ ),
360
+ findings: Object.freeze(
361
+ evidence.capabilities.findings.map((item) =>
362
+ Object.freeze({ ...item, ...route(item.method, item.path) }),
363
+ ),
364
+ ),
365
+ })
366
+ return Object.freeze({
367
+ version: 1,
368
+ routes: Object.freeze(routes),
369
+ ...(mounts === undefined ? {} : { mounts: Object.freeze(mounts) }),
370
+ ...(assurance === undefined ? {} : { assurance }),
371
+ ...(capabilities === undefined ? {} : { capabilities }),
372
+ })
373
+ }
374
+
375
+ function routeKeyForEvidence(method: string, path: string): string {
376
+ return `${method.toUpperCase()}\n${path}`
377
+ }
378
+
379
+ function validateComposedReportRoutes(
380
+ report: ProjectEvidenceAssurance | ProjectEvidenceCapabilities,
381
+ routeKeys: ReadonlySet<string>,
382
+ kind: "assurance" | "capabilities",
383
+ ): void {
384
+ for (const route of report.routes) {
385
+ if (!routeKeys.has(routeKeyForEvidence(route.method, route.path))) {
386
+ throw new Error(
387
+ `project evidence: ${kind} route ${route.method} ${route.path} is not present in the composed route set`,
388
+ )
389
+ }
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Compose page and mounted-app evidence into one deterministic snapshot.
395
+ *
396
+ * This is an offline operation. It carries route contracts, assurance/capability tokens, and source
397
+ * locations only; it never invokes handlers or copies request/business data. A duplicate public
398
+ * method+path or a report that no longer describes a composed route is rejected rather than silently
399
+ * dropping one side of the trust boundary.
400
+ */
401
+ export function composeProjectEvidence(
402
+ parts: readonly ProjectEvidenceCompositionPart[],
403
+ ): ProjectEvidenceSnapshot {
404
+ const routes: ProjectEvidenceRoute[] = []
405
+ const routeKeys = new Set<string>()
406
+ const mounts: ReflectedMount[] = []
407
+ const assuranceReports: ProjectEvidenceAssurance[] = []
408
+ const capabilityReports: ProjectEvidenceCapabilities[] = []
409
+
410
+ for (const part of parts) {
411
+ const prefix = part.pathPrefix ?? ""
412
+ const mapped = remapEvidenceSnapshot(part.evidence, prefix)
413
+ for (const route of mapped.routes) {
414
+ const key = routeKeyForEvidence(route.method, route.path)
415
+ if (routeKeys.has(key)) {
416
+ throw new Error(`project evidence: duplicate composed route ${route.method} ${route.path}`)
417
+ }
418
+ routeKeys.add(key)
419
+ routes.push(route)
420
+ }
421
+ mounts.push(...(mapped.mounts ?? []))
422
+ if (mapped.assurance !== undefined) assuranceReports.push(mapped.assurance)
423
+ if (mapped.capabilities !== undefined) capabilityReports.push(mapped.capabilities)
424
+ }
425
+
426
+ const assurance =
427
+ assuranceReports.length === 0
428
+ ? undefined
429
+ : Object.freeze({
430
+ ok: assuranceReports.every((report) => report.ok),
431
+ routes: sortByRoute(assuranceReports.flatMap((report) => report.routes)),
432
+ findings: Object.freeze(
433
+ assuranceReports
434
+ .flatMap((report) => report.findings)
435
+ .sort(
436
+ (a, b) =>
437
+ codeUnitOrder(a.path, b.path) ||
438
+ codeUnitOrder(a.method, b.method) ||
439
+ codeUnitOrder(a.code, b.code),
440
+ ),
441
+ ),
442
+ })
443
+ const capabilities =
444
+ capabilityReports.length === 0
445
+ ? undefined
446
+ : Object.freeze({
447
+ ok: capabilityReports.every((report) => report.ok),
448
+ routes: sortByRoute(capabilityReports.flatMap((report) => report.routes)),
449
+ findings: Object.freeze(
450
+ capabilityReports
451
+ .flatMap((report) => report.findings)
452
+ .sort(
453
+ (a, b) =>
454
+ codeUnitOrder(a.path, b.path) ||
455
+ codeUnitOrder(a.method, b.method) ||
456
+ codeUnitOrder(a.code, b.code),
457
+ ),
458
+ ),
459
+ })
460
+ if (assurance !== undefined) validateComposedReportRoutes(assurance, routeKeys, "assurance")
461
+ if (capabilities !== undefined)
462
+ validateComposedReportRoutes(capabilities, routeKeys, "capabilities")
463
+
464
+ return Object.freeze({
465
+ version: 1,
466
+ routes: sortByRoute(routes),
467
+ ...mountsPart(mounts),
468
+ ...(assurance === undefined ? {} : { assurance }),
469
+ ...(capabilities === undefined ? {} : { capabilities }),
470
+ })
471
+ }
472
+
473
+ function schemaPartToReflection(
474
+ value: ProjectEvidenceSchemaPart | undefined,
475
+ ): SchemaReflection | undefined {
476
+ return value === undefined
477
+ ? undefined
478
+ : {
479
+ standard: undefined,
480
+ jsonSchema: value.jsonSchema,
481
+ fields: value.fields,
482
+ ...(value.mediaTypes !== undefined ? { mediaTypes: value.mediaTypes } : {}),
483
+ }
484
+ }
485
+
486
+ function schemaFromEvidence(schema: ProjectEvidenceSchema | undefined): ReflectedRoute["schema"] {
487
+ if (schema === undefined) return undefined
488
+ const errors = Object.fromEntries(
489
+ Object.entries(schema.errors ?? {}).map(([status, value]) => [
490
+ status,
491
+ schemaPartToReflection(value) as SchemaReflection,
492
+ ]),
493
+ )
494
+ const headers = schemaPartToReflection(schema.headers)
495
+ const cookies = schemaPartToReflection(schema.cookies)
496
+ const body = schemaPartToReflection(schema.body)
497
+ const query = schemaPartToReflection(schema.query)
498
+ const params = schemaPartToReflection(schema.params)
499
+ const response = schemaPartToReflection(schema.response)
500
+ const sse = schemaPartToReflection(schema.sse)
501
+ return {
502
+ ...(schema.bodyLimit !== undefined ? { bodyLimit: schema.bodyLimit } : {}),
503
+ ...(schema.bodyLimitReason !== undefined ? { bodyLimitReason: schema.bodyLimitReason } : {}),
504
+ ...(headers !== undefined ? { headers } : {}),
505
+ ...(cookies !== undefined ? { cookies } : {}),
506
+ ...(body !== undefined ? { body } : {}),
507
+ ...(query !== undefined ? { query } : {}),
508
+ ...(params !== undefined ? { params } : {}),
509
+ ...(response !== undefined ? { response } : {}),
510
+ ...(Object.keys(errors).length > 0 ? { errors } : {}),
511
+ ...(sse !== undefined ? { sse } : {}),
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Adapt the canonical token-only snapshot to the runtime-reflection shape used by projections.
517
+ * The returned schemas deliberately have no `standard` validator: this adapter is for offline
518
+ * contract views, not request validation or mock generation.
519
+ */
520
+ export function reflectedRoutesFromEvidence(
521
+ evidence: ProjectEvidenceSnapshot,
522
+ ): readonly ReflectedRoute[] {
523
+ if (evidence.version !== 1) throw new Error("project evidence: unsupported snapshot version")
524
+ return evidence.routes.map((route) => {
525
+ const schema = schemaFromEvidence(route.schema)
526
+ return {
527
+ method: route.method,
528
+ path: route.path,
529
+ ...(schema !== undefined ? { schema } : {}),
530
+ ...(route.responseContract === undefined ? {} : { responseContract: route.responseContract }),
531
+ ...(route.assurance !== undefined ? { assurance: route.assurance } : {}),
532
+ ...(route.capabilities !== undefined ? { capabilities: route.capabilities } : {}),
533
+ ...(route.family === true ? { family: true } : {}),
534
+ ...(route.classification !== undefined ? { classification: route.classification } : {}),
535
+ ...(route.tool !== undefined ? { tool: route.tool } : {}),
536
+ }
537
+ })
538
+ }
539
+
265
540
  /** Build the canonical snapshot from reflection and already-evaluated offline reports. */
266
541
  export function snapshotProjectEvidence(
267
542
  source: unknown,
@@ -270,21 +545,36 @@ export function snapshotProjectEvidence(
270
545
  const locations = options.sourceLocations
271
546
  const assurance = assuranceOf(options.assurance)
272
547
  const capabilities = capabilitiesOf(options.capabilities)
548
+ const reflected = options.routes ?? reflectRoutes(source)
273
549
  const routes = sortByRoute(
274
550
  // Importantly, this is the only reflection pass for this snapshot. Every projection can consume
275
551
  // the result instead of independently asking the app for `.routes()` and re-shaping schemas.
276
- reflectRoutes(source).map((route) =>
552
+ reflected.map((route) =>
277
553
  evidenceRouteOf(route, locations?.get(keyOf(route.method, route.path))),
278
554
  ),
279
555
  )
280
556
  return Object.freeze({
281
557
  version: 1,
282
558
  routes,
559
+ ...mountsPart(options.mounts ?? reflectMounts(source)),
283
560
  ...(assurance !== undefined ? { assurance } : {}),
284
561
  ...(capabilities !== undefined ? { capabilities } : {}),
285
562
  })
286
563
  }
287
564
 
565
+ /** The snapshot's `mounts` field, sorted, and absent when empty so a mount-free app's snapshot (and its
566
+ * manifest hash) is unchanged. */
567
+ const mountsPart = (
568
+ mounts: readonly ReflectedMount[],
569
+ ): { readonly mounts?: readonly ReflectedMount[] } =>
570
+ mounts.length === 0
571
+ ? {}
572
+ : {
573
+ mounts: Object.freeze(
574
+ [...mounts].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
575
+ ),
576
+ }
577
+
288
578
  function canonicalValue(value: unknown): string {
289
579
  if (value === null || typeof value === "string" || typeof value === "boolean") {
290
580
  return JSON.stringify(value)
@@ -16,6 +16,9 @@ export type IdempotencyScope = "request" | "durable"
16
16
  /** Default retention for a stored idempotent response: 24 hours. */
17
17
  export const DEFAULT_IDEMPOTENCY_TTL_MS = 86_400_000
18
18
 
19
+ /** Default lease on a key whose handler is still running, with a store that can renew it: 60 seconds. */
20
+ export const DEFAULT_IDEMPOTENCY_PENDING_TTL_MS = 60_000
21
+
19
22
  /** Canonical request header carrying the client-chosen idempotency key. */
20
23
  export const DEFAULT_IDEMPOTENCY_HEADER = "idempotency-key"
21
24
 
@@ -80,7 +83,14 @@ export interface IdempotencyEntryKey {
80
83
 
81
84
  export interface IdempotencyBeginInput extends IdempotencyEntryKey {
82
85
  readonly fingerprint: string
86
+ /** Retention for the completed response. */
83
87
  readonly ttlMs: number
88
+ /**
89
+ * Lease on a `new` reservation: it lapses after this long unless {@link IdempotencyStore.renew}
90
+ * extends it, and `complete` then keeps the response for `ttlMs`. Passed only to a store with
91
+ * `renew`; when absent, the pending reservation holds the key for `ttlMs`.
92
+ */
93
+ readonly pendingTtlMs?: number | undefined
84
94
  }
85
95
 
86
96
  export interface IdempotencyCompletionInput extends IdempotencyEntryKey {
@@ -94,6 +104,13 @@ export interface IdempotencyAbandonInput extends IdempotencyEntryKey {
94
104
  readonly reservation: string
95
105
  }
96
106
 
107
+ export interface IdempotencyRenewInput extends IdempotencyEntryKey {
108
+ /** Opaque ownership token returned by `begin(state:"new")`. */
109
+ readonly reservation: string
110
+ /** The new lease, from now. */
111
+ readonly ttlMs: number
112
+ }
113
+
97
114
  /**
98
115
  * Storage seam for idempotent responses. `begin` MUST be atomic: for one key, exactly one concurrent
99
116
  * caller sees `new`; the rest see `in-flight` (or `replay` once completed). The in-memory store gets
@@ -115,6 +132,12 @@ export interface IdempotencyStore {
115
132
  complete(input: IdempotencyCompletionInput): boolean | Promise<boolean>
116
133
  /** Release only the pending reservation owned by `reservation`. */
117
134
  abandon(input: IdempotencyAbandonInput): boolean | Promise<boolean>
135
+ /**
136
+ * Extend the pending reservation owned by `reservation` to `ttlMs` from now. Returns false when it
137
+ * is gone, lapsed, or completed. The server calls it while the handler runs, so with it a key
138
+ * whose process died frees after `pendingTtlMs` instead of `ttlMs`.
139
+ */
140
+ renew?(input: IdempotencyRenewInput): boolean | Promise<boolean>
118
141
  }
119
142
 
120
143
  /** A key must be a non-empty, bounded, control-char-free token. Fail closed on anything else. */
@@ -240,7 +263,14 @@ async function boundedResponseBytes(response: Response, maxBytes: number): Promi
240
263
  if (done) break
241
264
  total += value.byteLength
242
265
  if (total > maxBytes) {
243
- await reader.cancel()
266
+ // `Response.clone()` tees the body. Cancelling only the clone branch leaves the live branch
267
+ // unread, so a streaming producer can keep the tee pending forever. This path stores no
268
+ // response and the caller will replace it with a terminal error, so cancel both branches.
269
+ const liveBody = response.body
270
+ await Promise.allSettled([
271
+ reader.cancel(),
272
+ liveBody === null ? Promise.resolve() : liveBody.cancel(),
273
+ ])
244
274
  throw new IdempotencyResponseTooLargeError(maxBytes)
245
275
  }
246
276
  chunks.push(value)
@@ -305,6 +335,7 @@ export function responseFromStored(
305
335
  }
306
336
 
307
337
  interface MemoryEntry {
338
+ readonly namespace: string
308
339
  readonly fingerprint: string
309
340
  readonly reservation: string
310
341
  readonly ttlMs: number
@@ -317,6 +348,11 @@ export interface MemoryIdempotencyStoreOptions {
317
348
  readonly now?: () => number
318
349
  /** Hard memory bound. At capacity new keys fail closed; completed/pending entries are never evicted early. */
319
350
  readonly maxEntries?: number
351
+ /**
352
+ * Bound for one namespace, so a single tenant or principal cannot use up `maxEntries` for the
353
+ * rest: past it, only that namespace's new keys fail closed. Default: no separate bound.
354
+ */
355
+ readonly maxEntriesPerNamespace?: number
320
356
  }
321
357
 
322
358
  /**
@@ -327,8 +363,10 @@ export interface MemoryIdempotencyStoreOptions {
327
363
  export class MemoryIdempotencyStore implements IdempotencyStore {
328
364
  readonly durability = "memory" as const
329
365
  private readonly entries = new Map<string, MemoryEntry>()
366
+ private readonly perNamespace = new Map<string, number>()
330
367
  private readonly now: () => number
331
368
  private readonly maxEntries: number
369
+ private readonly maxPerNamespace: number
332
370
 
333
371
  constructor(options: MemoryIdempotencyStoreOptions = {}) {
334
372
  this.now = options.now ?? Date.now
@@ -336,6 +374,10 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
336
374
  if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
337
375
  throw new RangeError("idempotency: maxEntries must be a positive integer")
338
376
  }
377
+ this.maxPerNamespace = options.maxEntriesPerNamespace ?? this.maxEntries
378
+ if (!Number.isInteger(this.maxPerNamespace) || this.maxPerNamespace < 1) {
379
+ throw new RangeError("idempotency: maxEntriesPerNamespace must be a positive integer")
380
+ }
339
381
  }
340
382
 
341
383
  begin(input: IdempotencyBeginInput): IdempotencyBeginResult {
@@ -346,17 +388,19 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
346
388
  if (existing.response !== undefined) return { state: "replay", response: existing.response }
347
389
  return { state: "in-flight" }
348
390
  }
349
- if (existing !== undefined) this.entries.delete(storageKey)
350
- if (this.entries.size >= this.maxEntries) {
391
+ if (existing !== undefined) this.drop(storageKey, existing)
392
+ if (this.full(input.namespace)) {
351
393
  this.sweep()
352
- if (this.entries.size >= this.maxEntries) return { state: "capacity" }
394
+ if (this.full(input.namespace)) return { state: "capacity" }
353
395
  }
354
396
  const reservation = crypto.randomUUID()
397
+ this.perNamespace.set(input.namespace, (this.perNamespace.get(input.namespace) ?? 0) + 1)
355
398
  this.entries.set(storageKey, {
399
+ namespace: input.namespace,
356
400
  fingerprint: input.fingerprint,
357
401
  reservation,
358
402
  ttlMs: input.ttlMs,
359
- expiresAt: this.now() + input.ttlMs,
403
+ expiresAt: this.now() + (input.pendingTtlMs ?? input.ttlMs),
360
404
  response: undefined,
361
405
  })
362
406
  return { state: "new", reservation }
@@ -367,7 +411,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
367
411
  const entry = this.entries.get(key)
368
412
  if (entry === undefined || entry.reservation !== input.reservation) return false
369
413
  if (entry.expiresAt <= this.now()) {
370
- this.entries.delete(key)
414
+ this.drop(key, entry)
371
415
  return false
372
416
  }
373
417
  entry.response = input.response
@@ -379,7 +423,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
379
423
  const key = this.storageKey(input)
380
424
  const entry = this.entries.get(key)
381
425
  if (entry !== undefined && entry.expiresAt <= this.now()) {
382
- this.entries.delete(key)
426
+ this.drop(key, entry)
383
427
  return false
384
428
  }
385
429
  // Only drop a still-pending reservation; never evict a completed (replayable) response.
@@ -388,17 +432,30 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
388
432
  entry.response === undefined &&
389
433
  entry.reservation === input.reservation
390
434
  ) {
391
- this.entries.delete(key)
435
+ this.drop(key, entry)
392
436
  return true
393
437
  }
394
438
  return false
395
439
  }
396
440
 
441
+ renew(input: IdempotencyRenewInput): boolean {
442
+ const entry = this.entries.get(this.storageKey(input))
443
+ if (
444
+ entry?.reservation !== input.reservation ||
445
+ entry.response !== undefined ||
446
+ entry.expiresAt <= this.now()
447
+ ) {
448
+ return false
449
+ }
450
+ entry.expiresAt = this.now() + input.ttlMs
451
+ return true
452
+ }
453
+
397
454
  /** Evict expired entries. Callers may run this on an interval; access-time eviction covers the rest. */
398
455
  sweep(): void {
399
456
  const now = this.now()
400
457
  for (const [key, entry] of this.entries) {
401
- if (entry.expiresAt <= now) this.entries.delete(key)
458
+ if (entry.expiresAt <= now) this.drop(key, entry)
402
459
  }
403
460
  }
404
461
 
@@ -410,6 +467,20 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
410
467
  private storageKey(input: IdempotencyEntryKey): string {
411
468
  return `${input.namespace.length}:${input.namespace}${input.key}`
412
469
  }
470
+
471
+ private full(namespace: string): boolean {
472
+ return (
473
+ this.entries.size >= this.maxEntries ||
474
+ (this.perNamespace.get(namespace) ?? 0) >= this.maxPerNamespace
475
+ )
476
+ }
477
+
478
+ private drop(key: string, entry: MemoryEntry): void {
479
+ this.entries.delete(key)
480
+ const left = (this.perNamespace.get(entry.namespace) ?? 1) - 1
481
+ if (left > 0) this.perNamespace.set(entry.namespace, left)
482
+ else this.perNamespace.delete(entry.namespace)
483
+ }
413
484
  }
414
485
 
415
486
  /** Convenience factory mirroring the other core primitives' `create*` style. */