@nifrajs/core 1.13.0 → 2.1.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 (233) hide show
  1. package/README.md +37 -1
  2. package/dist/assurance.d.ts +11 -2
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +20 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +54 -2
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +240 -5
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/durable-adapters.d.ts +241 -0
  11. package/dist/durable-adapters.d.ts.map +1 -0
  12. package/dist/durable-adapters.js +697 -0
  13. package/dist/durable-adapters.js.map +1 -0
  14. package/dist/durable-execution.d.ts +349 -0
  15. package/dist/durable-execution.d.ts.map +1 -0
  16. package/dist/durable-execution.js +1173 -0
  17. package/dist/durable-execution.js.map +1 -0
  18. package/dist/effect-ledger.d.ts +9 -0
  19. package/dist/effect-ledger.d.ts.map +1 -0
  20. package/dist/effect-ledger.js +8 -0
  21. package/dist/effect-ledger.js.map +1 -0
  22. package/dist/effect-lifecycle.d.ts +42 -0
  23. package/dist/effect-lifecycle.d.ts.map +1 -0
  24. package/dist/effect-lifecycle.js +66 -0
  25. package/dist/effect-lifecycle.js.map +1 -0
  26. package/dist/effect-scope.d.ts +75 -0
  27. package/dist/effect-scope.d.ts.map +1 -0
  28. package/dist/effect-scope.js +177 -0
  29. package/dist/effect-scope.js.map +1 -0
  30. package/dist/idempotency-plugin.d.ts +13 -0
  31. package/dist/idempotency-plugin.d.ts.map +1 -0
  32. package/dist/idempotency-plugin.js +16 -0
  33. package/dist/idempotency-plugin.js.map +1 -0
  34. package/dist/index.d.ts +6 -35
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +6 -31
  37. package/dist/index.js.map +1 -1
  38. package/dist/internal/capability-runtime.d.ts +62 -1
  39. package/dist/internal/capability-runtime.d.ts.map +1 -1
  40. package/dist/internal/capability-runtime.js +11 -2
  41. package/dist/internal/capability-runtime.js.map +1 -1
  42. package/dist/internal/effect-execution.d.ts +16 -0
  43. package/dist/internal/effect-execution.d.ts.map +1 -0
  44. package/dist/internal/effect-execution.js +50 -0
  45. package/dist/internal/effect-execution.js.map +1 -0
  46. package/dist/internal/safe-equal.d.ts +20 -0
  47. package/dist/internal/safe-equal.d.ts.map +1 -0
  48. package/dist/internal/safe-equal.js +31 -0
  49. package/dist/internal/safe-equal.js.map +1 -0
  50. package/dist/ledger.d.ts +14 -6
  51. package/dist/ledger.d.ts.map +1 -1
  52. package/dist/ledger.js +31 -5
  53. package/dist/ledger.js.map +1 -1
  54. package/dist/mcp.d.ts +7 -0
  55. package/dist/mcp.d.ts.map +1 -0
  56. package/dist/mcp.js +56 -0
  57. package/dist/mcp.js.map +1 -0
  58. package/dist/node-direct.d.ts +5 -0
  59. package/dist/node-direct.d.ts.map +1 -0
  60. package/dist/node-direct.js +27 -0
  61. package/dist/node-direct.js.map +1 -0
  62. package/dist/reconciliation-worker.d.ts +108 -0
  63. package/dist/reconciliation-worker.d.ts.map +1 -0
  64. package/dist/reconciliation-worker.js +239 -0
  65. package/dist/reconciliation-worker.js.map +1 -0
  66. package/dist/reflection.d.ts +5 -0
  67. package/dist/reflection.d.ts.map +1 -1
  68. package/dist/reflection.js +2 -0
  69. package/dist/reflection.js.map +1 -1
  70. package/dist/server/client-ip.d.ts +35 -0
  71. package/dist/server/client-ip.d.ts.map +1 -0
  72. package/dist/server/client-ip.js +42 -0
  73. package/dist/server/client-ip.js.map +1 -0
  74. package/dist/server/context.d.ts +47 -2
  75. package/dist/server/context.d.ts.map +1 -1
  76. package/dist/server/contract.d.ts +14 -4
  77. package/dist/server/contract.d.ts.map +1 -1
  78. package/dist/server/contract.js +8 -2
  79. package/dist/server/contract.js.map +1 -1
  80. package/dist/server/http.d.ts +14 -0
  81. package/dist/server/http.d.ts.map +1 -0
  82. package/dist/server/http.js +45 -0
  83. package/dist/server/http.js.map +1 -0
  84. package/dist/server/idempotency-lane.d.ts +45 -0
  85. package/dist/server/idempotency-lane.d.ts.map +1 -0
  86. package/dist/server/idempotency-lane.js +205 -0
  87. package/dist/server/idempotency-lane.js.map +1 -0
  88. package/dist/server/install.d.ts +21 -0
  89. package/dist/server/install.d.ts.map +1 -0
  90. package/dist/server/install.js +21 -0
  91. package/dist/server/install.js.map +1 -0
  92. package/dist/server/ledger-lane.d.ts +53 -0
  93. package/dist/server/ledger-lane.d.ts.map +1 -0
  94. package/dist/server/ledger-lane.js +82 -0
  95. package/dist/server/ledger-lane.js.map +1 -0
  96. package/dist/server/mcp-hook.d.ts +28 -0
  97. package/dist/server/mcp-hook.d.ts.map +1 -0
  98. package/dist/server/mcp-hook.js +2 -0
  99. package/dist/server/mcp-hook.js.map +1 -0
  100. package/dist/server/node-outcome-hook.d.ts +9 -0
  101. package/dist/server/node-outcome-hook.d.ts.map +1 -0
  102. package/dist/server/node-outcome-hook.js +2 -0
  103. package/dist/server/node-outcome-hook.js.map +1 -0
  104. package/dist/server/node-outcome.d.ts +37 -0
  105. package/dist/server/node-outcome.d.ts.map +1 -0
  106. package/dist/server/node-outcome.js +75 -0
  107. package/dist/server/node-outcome.js.map +1 -0
  108. package/dist/server/plugin.d.ts +78 -0
  109. package/dist/server/plugin.d.ts.map +1 -0
  110. package/dist/server/plugin.js +57 -0
  111. package/dist/server/plugin.js.map +1 -0
  112. package/dist/server/query.d.ts +18 -0
  113. package/dist/server/query.d.ts.map +1 -0
  114. package/dist/server/query.js +102 -0
  115. package/dist/server/query.js.map +1 -0
  116. package/dist/server/registry.d.ts +31 -6
  117. package/dist/server/registry.d.ts.map +1 -1
  118. package/dist/server/request-context.d.ts +51 -0
  119. package/dist/server/request-context.d.ts.map +1 -0
  120. package/dist/server/request-context.js +184 -0
  121. package/dist/server/request-context.js.map +1 -0
  122. package/dist/server/respond.d.ts +17 -0
  123. package/dist/server/respond.d.ts.map +1 -0
  124. package/dist/server/respond.js +81 -0
  125. package/dist/server/respond.js.map +1 -0
  126. package/dist/server/runtime-core.d.ts +36 -0
  127. package/dist/server/runtime-core.d.ts.map +1 -0
  128. package/dist/server/runtime-core.js +51 -0
  129. package/dist/server/runtime-core.js.map +1 -0
  130. package/dist/server/server-types.d.ts +203 -0
  131. package/dist/server/server-types.d.ts.map +1 -0
  132. package/dist/server/server-types.js +2 -0
  133. package/dist/server/server-types.js.map +1 -0
  134. package/dist/server/server.d.ts +119 -345
  135. package/dist/server/server.d.ts.map +1 -1
  136. package/dist/server/server.js +247 -833
  137. package/dist/server/server.js.map +1 -1
  138. package/dist/server/sse-hook.d.ts +10 -0
  139. package/dist/server/sse-hook.d.ts.map +1 -0
  140. package/dist/server/sse-hook.js +2 -0
  141. package/dist/server/sse-hook.js.map +1 -0
  142. package/dist/server/sse.d.ts +7 -23
  143. package/dist/server/sse.d.ts.map +1 -1
  144. package/dist/server/sse.js +18 -0
  145. package/dist/server/sse.js.map +1 -1
  146. package/dist/server/transport-lane.d.ts +28 -0
  147. package/dist/server/transport-lane.d.ts.map +1 -0
  148. package/dist/server/transport-lane.js +106 -0
  149. package/dist/server/transport-lane.js.map +1 -0
  150. package/dist/server/websocket.d.ts +23 -1
  151. package/dist/server/websocket.d.ts.map +1 -1
  152. package/dist/server/websocket.js +10 -1
  153. package/dist/server/websocket.js.map +1 -1
  154. package/dist/server/ws-hook.d.ts +8 -11
  155. package/dist/server/ws-hook.d.ts.map +1 -1
  156. package/dist/server/ws-hook.js +7 -14
  157. package/dist/server/ws-hook.js.map +1 -1
  158. package/dist/server.d.ts +21 -0
  159. package/dist/server.d.ts.map +1 -0
  160. package/dist/server.js +16 -0
  161. package/dist/server.js.map +1 -0
  162. package/dist/transport-codec-rich.d.ts +8 -0
  163. package/dist/transport-codec-rich.d.ts.map +1 -0
  164. package/dist/transport-codec-rich.js +14 -0
  165. package/dist/transport-codec-rich.js.map +1 -0
  166. package/dist/transport-codec.d.ts +31 -0
  167. package/dist/transport-codec.d.ts.map +1 -0
  168. package/dist/transport-codec.js +190 -0
  169. package/dist/transport-codec.js.map +1 -0
  170. package/dist/transport-plugin.d.ts +3 -0
  171. package/dist/transport-plugin.d.ts.map +1 -0
  172. package/dist/transport-plugin.js +3 -0
  173. package/dist/transport-plugin.js.map +1 -0
  174. package/dist/wire.d.ts +58 -0
  175. package/dist/wire.d.ts.map +1 -0
  176. package/dist/wire.js +445 -0
  177. package/dist/wire.js.map +1 -0
  178. package/dist/ws.d.ts +16 -7
  179. package/dist/ws.d.ts.map +1 -1
  180. package/dist/ws.js +25 -10
  181. package/dist/ws.js.map +1 -1
  182. package/package.json +122 -6
  183. package/src/assurance.ts +36 -1
  184. package/src/capabilities.ts +306 -5
  185. package/src/durable-adapters.ts +1039 -0
  186. package/src/durable-execution.ts +1676 -0
  187. package/src/effect-ledger.ts +9 -0
  188. package/src/effect-lifecycle.ts +120 -0
  189. package/src/effect-scope.ts +267 -0
  190. package/src/idempotency-plugin.ts +17 -0
  191. package/src/index.ts +6 -133
  192. package/src/internal/capability-runtime.ts +87 -1
  193. package/src/internal/effect-execution.ts +70 -0
  194. package/src/internal/safe-equal.ts +27 -0
  195. package/src/ledger.ts +45 -11
  196. package/src/mcp.ts +65 -0
  197. package/src/node-direct.ts +37 -0
  198. package/src/reconciliation-worker.ts +372 -0
  199. package/src/reflection.ts +7 -0
  200. package/src/server/client-ip.ts +60 -0
  201. package/src/server/context.ts +50 -2
  202. package/src/server/contract.ts +26 -7
  203. package/src/server/http.ts +58 -0
  204. package/src/server/idempotency-lane.ts +321 -0
  205. package/src/server/install.ts +28 -0
  206. package/src/server/ledger-lane.ts +125 -0
  207. package/src/server/mcp-hook.ts +41 -0
  208. package/src/server/node-outcome-hook.ts +9 -0
  209. package/src/server/node-outcome.ts +112 -0
  210. package/src/server/plugin.ts +92 -0
  211. package/src/server/query.ts +119 -0
  212. package/src/server/registry.ts +36 -6
  213. package/src/server/request-context.ts +271 -0
  214. package/src/server/respond.ts +94 -0
  215. package/src/server/runtime-core.ts +74 -0
  216. package/src/server/server-types.ts +204 -0
  217. package/src/server/server.ts +448 -1314
  218. package/src/server/sse-hook.ts +14 -0
  219. package/src/server/sse.ts +26 -0
  220. package/src/server/transport-lane.ts +141 -0
  221. package/src/server/websocket.ts +35 -1
  222. package/src/server/ws-hook.ts +8 -19
  223. package/src/server.ts +92 -0
  224. package/src/transport-codec-rich.ts +20 -0
  225. package/src/transport-codec.ts +250 -0
  226. package/src/transport-plugin.ts +2 -0
  227. package/src/wire.ts +474 -0
  228. package/src/ws.ts +33 -10
  229. package/dist/invariants.d.ts +0 -70
  230. package/dist/invariants.d.ts.map +0 -1
  231. package/dist/invariants.js +0 -487
  232. package/dist/invariants.js.map +0 -1
  233. package/src/invariants.ts +0 -604
@@ -0,0 +1,70 @@
1
+ /** Request-local owned-effect scope used by idempotency and every capability boundary. */
2
+
3
+ import {
4
+ createEffectEvidenceScope,
5
+ type EffectEvidenceScope,
6
+ type EffectScopeEvidence,
7
+ } from "../effect-scope.ts"
8
+
9
+ export type RequestEffectEvidence = EffectScopeEvidence
10
+
11
+ const scopeByRequest = new WeakMap<Request, EffectEvidenceScope>()
12
+
13
+ export function beginRequestEffectTracking(request: Request): EffectEvidenceScope {
14
+ const scope = createEffectEvidenceScope()
15
+ scopeByRequest.set(request, scope)
16
+ return scope
17
+ }
18
+
19
+ export function requestEffectScope(request: Request): EffectEvidenceScope | undefined {
20
+ return scopeByRequest.get(request)
21
+ }
22
+
23
+ export function requestEffectEvidence(request: Request): RequestEffectEvidence {
24
+ return (
25
+ scopeByRequest.get(request)?.evidence() ??
26
+ Object.freeze({ began: false, committed: false, ambiguous: false })
27
+ )
28
+ }
29
+
30
+ export function effectScopeForContext(context: object): EffectEvidenceScope | undefined {
31
+ const request = (context as { readonly req?: unknown }).req
32
+ return request instanceof Request ? scopeByRequest.get(request) : undefined
33
+ }
34
+
35
+ export function markEffect(context: object, committed: boolean): void {
36
+ const scope = effectScopeForContext(context)
37
+ if (committed) scope?.markCommitted()
38
+ else scope?.markBegan()
39
+ }
40
+
41
+ /** A legacy beacon cannot prove what happens after the call, so it is immediately ambiguous. */
42
+ export function markBeaconEffectBegan(context: object): void {
43
+ effectScopeForContext(context)?.markBegan()
44
+ }
45
+
46
+ export function markEffectExecuting(context: object): void {
47
+ effectScopeForContext(context)?.markBegan()
48
+ }
49
+
50
+ export function markEffectCommitted(context: object): void {
51
+ effectScopeForContext(context)?.markCommitted()
52
+ }
53
+
54
+ export function markEffectAmbiguous(context: object): void {
55
+ effectScopeForContext(context)?.markAmbiguous()
56
+ }
57
+
58
+ export function markRequestSafeToRetry(context: object): void {
59
+ const scope = effectScopeForContext(context)
60
+ if (scope === undefined) {
61
+ throw new Error(
62
+ "markIdempotencySafeToRetry() requires an active route with idempotency enabled",
63
+ )
64
+ }
65
+ scope.markSafeToRetry()
66
+ }
67
+
68
+ export function requestIsSafeToRetry(request: Request): boolean {
69
+ return scopeByRequest.get(request)?.safeToRetry() ?? false
70
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Constant-time string comparison for credential-shaped values - lease tokens, token hashes, and
3
+ * binding digests.
4
+ *
5
+ * `a !== b` returns as soon as it finds a differing character, so how long it takes reveals how much
6
+ * of a guess was correct. That is only exploitable when an attacker can submit guesses and measure,
7
+ * which is not the case on every path here - but these values are credentials, the comparison is on
8
+ * the verification path, and doing it correctly costs nothing. Doing it inconsistently is what makes
9
+ * the one place that matters easy to miss.
10
+ *
11
+ * Length is compared first, so a length difference is still observable. Every value compared through
12
+ * this helper is fixed-width by construction (a UUID, a SHA-256 hex/base64url digest), so length
13
+ * carries no secret. Content comparison always visits every character.
14
+ *
15
+ * Synchronous on purpose: the callers are store transitions and lease checks that are themselves
16
+ * synchronous. A hash-then-compare variant (which would also hide length) is available as
17
+ * `timingSafeEqualString` in `@nifrajs/middleware`, but it is async and would force those paths async.
18
+ */
19
+ export function safeEqual(a: string | undefined, b: string | undefined): boolean {
20
+ // Accepts `undefined` so optional binding fields keep their exact `!==` semantics at call sites:
21
+ // absent equals absent, and absent never equals present. Neither branch is secret-bearing.
22
+ if (a === undefined || b === undefined) return a === b
23
+ if (a.length !== b.length) return false
24
+ let diff = 0
25
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
26
+ return diff === 0
27
+ }
package/src/ledger.ts CHANGED
@@ -27,18 +27,24 @@ const EFFECT_PHASES: ReadonlySet<string> = new Set(["intent", "committed", "fail
27
27
  */
28
28
  export type EffectCost = Readonly<Record<string, number>>
29
29
 
30
- /** Caller-supplied fields for one entry. Everything else (`seq`, `at`) is assigned by the ledger. */
31
- export interface EffectEntryInput {
32
- /** Capability token (`db.write`, `payments.charge`). Must be a valid capability id. */
33
- readonly capability: string
34
- /** Default `"intent"`. Record `committed`/`failed`/`compensated` only after the outcome is known. */
35
- readonly phase?: EffectPhase
30
+ /** Token-only caller metadata shared by an effect intent and outcome. */
31
+ export interface EffectMetadata {
36
32
  /** Adapter/resource token (`repo:orders`, `provider:payments`). A token — never a value or a row. */
37
33
  readonly target?: string
38
34
  /** Dimensionless counters; see {@link EffectCost}. At most {@link MAX_COST_AXES} axes. */
39
35
  readonly cost?: EffectCost
40
36
  /** Optional keyed-HMAC digest of the effect payload (see {@link computeEffectDigest}); hex, 64 chars. */
41
37
  readonly digest?: string
38
+ }
39
+
40
+ /** Caller-supplied fields for one entry. Everything else (`seq`, `at`) is assigned by the ledger. */
41
+ export interface EffectEntryInput extends EffectMetadata {
42
+ /** Opaque execution correlation token shared by one intent and its terminal outcome. */
43
+ readonly effectId?: string
44
+ /** Capability token (`db.write`, `payments.charge`). Must be a valid capability id. */
45
+ readonly capability: string
46
+ /** Default `"intent"`. Record `committed`/`failed`/`compensated` only after the outcome is known. */
47
+ readonly phase?: EffectPhase
42
48
  /** Outcome error as a bounded token code — never a message, never a stack. */
43
49
  readonly error?: { readonly code: string }
44
50
  }
@@ -49,6 +55,7 @@ export interface EffectEntry {
49
55
  readonly seq: number
50
56
  /** Milliseconds from the ledger's (injectable) clock at append time. */
51
57
  readonly at: number
58
+ readonly effectId?: string
52
59
  readonly capability: string
53
60
  readonly phase: EffectPhase
54
61
  readonly target?: string
@@ -147,9 +154,12 @@ function printableToken(value: string, maxLength: number): boolean {
147
154
  }
148
155
 
149
156
  function validateCost(cost: EffectCost): EffectCost {
157
+ if (Object(cost) !== cost || Array.isArray(cost)) {
158
+ throw new TypeError("effect ledger: invalid cost")
159
+ }
150
160
  const keys = Object.keys(cost)
151
161
  if (keys.length > MAX_COST_AXES) {
152
- throw new TypeError(`effect ledger: cost carries more than ${MAX_COST_AXES} axes`)
162
+ throw new TypeError(`effect ledger: more than ${MAX_COST_AXES} cost axes`)
153
163
  }
154
164
  for (const key of keys) {
155
165
  if (!validCapabilityId(key)) {
@@ -157,12 +167,30 @@ function validateCost(cost: EffectCost): EffectCost {
157
167
  }
158
168
  const value = cost[key]
159
169
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
160
- throw new TypeError(`effect ledger: cost axis ${key} must be a finite non-negative number`)
170
+ throw new TypeError(`effect ledger: invalid cost axis ${key}`)
161
171
  }
162
172
  }
163
173
  return Object.freeze({ ...cost })
164
174
  }
165
175
 
176
+ /** Validate, copy, and freeze token-only effect metadata before it reaches a ledger or policy hook. */
177
+ export function normalizeEffectMetadata(input: EffectMetadata): EffectMetadata {
178
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
179
+ throw new TypeError("effect ledger: effect metadata must be an object")
180
+ }
181
+ if (input.target !== undefined && !printableToken(input.target, TARGET_MAX_LENGTH)) {
182
+ throw new TypeError("effect ledger: invalid target")
183
+ }
184
+ if (input.digest !== undefined && !DIGEST_HEX.test(input.digest)) {
185
+ throw new TypeError("effect ledger: invalid digest")
186
+ }
187
+ return Object.freeze({
188
+ ...(input.target !== undefined ? { target: input.target } : {}),
189
+ ...(input.cost !== undefined ? { cost: validateCost(input.cost) } : {}),
190
+ ...(input.digest !== undefined ? { digest: input.digest } : {}),
191
+ })
192
+ }
193
+
166
194
  const defaultClock = (): number => performance.now()
167
195
 
168
196
  class BoundedRequestLedger implements RequestLedger {
@@ -215,11 +243,14 @@ class BoundedRequestLedger implements RequestLedger {
215
243
  if (!EFFECT_PHASES.has(phase)) {
216
244
  throw new TypeError(`effect ledger: invalid phase ${JSON.stringify(phase)}`)
217
245
  }
246
+ if (input.effectId !== undefined && !printableToken(input.effectId, 64)) {
247
+ throw new TypeError("effect ledger: invalid effectId")
248
+ }
218
249
  if (input.target !== undefined && !printableToken(input.target, TARGET_MAX_LENGTH)) {
219
- throw new TypeError("effect ledger: target must be a bounded printable token")
250
+ throw new TypeError("effect ledger: invalid target")
220
251
  }
221
252
  if (input.digest !== undefined && !DIGEST_HEX.test(input.digest)) {
222
- throw new TypeError("effect ledger: digest must be 64 lowercase hex characters")
253
+ throw new TypeError("effect ledger: invalid digest")
223
254
  }
224
255
  if (input.error !== undefined && !ERROR_CODE.test(input.error.code)) {
225
256
  throw new TypeError("effect ledger: error code must be a bounded lowercase token")
@@ -232,6 +263,7 @@ class BoundedRequestLedger implements RequestLedger {
232
263
  const entry: EffectEntry = Object.freeze({
233
264
  seq: this.items.length,
234
265
  at,
266
+ ...(input.effectId !== undefined ? { effectId: input.effectId } : {}),
235
267
  capability: input.capability,
236
268
  phase,
237
269
  ...(input.target !== undefined ? { target: input.target } : {}),
@@ -293,7 +325,9 @@ function canonicalEntry(entry: EffectEntry): string {
293
325
  .map((key) => `${JSON.stringify(key)}:${JSON.stringify((entry.cost as EffectCost)[key])}`)
294
326
  .join(",")}}`
295
327
  return (
296
- `{"seq":${entry.seq},"at":${JSON.stringify(entry.at)},"capability":${JSON.stringify(entry.capability)},"phase":${JSON.stringify(entry.phase)}` +
328
+ `{"seq":${entry.seq},"at":${JSON.stringify(entry.at)}` +
329
+ `${entry.effectId !== undefined ? `,"effectId":${JSON.stringify(entry.effectId)}` : ""}` +
330
+ `,"capability":${JSON.stringify(entry.capability)},"phase":${JSON.stringify(entry.phase)}` +
297
331
  `${entry.target !== undefined ? `,"target":${JSON.stringify(entry.target)}` : ""}` +
298
332
  cost +
299
333
  `${entry.digest !== undefined ? `,"digest":${JSON.stringify(entry.digest)}` : ""}` +
package/src/mcp.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The opt-in MCP runtime plugin.
3
+ *
4
+ * `.use(mcp())` enables `.tool()`, `.resource()`, and `.prompt()`. Without it those methods throw at
5
+ * registration (fail-closed), so an ordinary HTTP app never evaluates or bundles the MCP wiring. This
6
+ * is the same install seam as `.use(idempotency())` / `.use(effectLedger())` - one consistent `.use()`
7
+ * opt-in, not a side-effect import.
8
+ */
9
+ import { INSTALL_MCP } from "./server/install.ts"
10
+ import type { McpRuntime } from "./server/mcp-hook.ts"
11
+ import type { IdentityPlugin } from "./server/plugin.ts"
12
+ import type { AnyServer } from "./server/server.ts"
13
+
14
+ const MCP_RUNTIME: McpRuntime = {
15
+ tool(name, config, handler) {
16
+ const path = `/_nifra/tool/${name}`
17
+ return {
18
+ path,
19
+ schema:
20
+ config.output !== undefined
21
+ ? { body: config.input, response: config.output }
22
+ : { body: config.input },
23
+ run: (context) => handler(context.body, context),
24
+ descriptor: {
25
+ name,
26
+ description: config.description,
27
+ ...(config.annotations !== undefined ? { annotations: config.annotations } : {}),
28
+ },
29
+ }
30
+ },
31
+ resource(uri, config, read) {
32
+ return {
33
+ uri,
34
+ name: config.name,
35
+ ...(config.description !== undefined ? { description: config.description } : {}),
36
+ ...(config.mimeType !== undefined ? { mimeType: config.mimeType } : {}),
37
+ read,
38
+ }
39
+ },
40
+ prompt(name, config, handler) {
41
+ return {
42
+ name,
43
+ description: config.description,
44
+ ...(config.arguments !== undefined ? { arguments: config.arguments } : {}),
45
+ handler,
46
+ }
47
+ },
48
+ }
49
+
50
+ /** The install seam a server exposes so the `mcp()` plugin can hand it the runtime. */
51
+ interface McpInstallable {
52
+ [INSTALL_MCP](runtime: McpRuntime): void
53
+ }
54
+
55
+ /**
56
+ * Enable MCP declarations on a server: `.use(mcp())` turns on `.tool()`, `.resource()`, and
57
+ * `.prompt()`. Applying it twice is a no-op (named plugin dedupe).
58
+ */
59
+ export function mcp(): IdentityPlugin {
60
+ const apply = <S extends AnyServer>(app: S): S => {
61
+ ;(app as unknown as McpInstallable)[INSTALL_MCP](MCP_RUNTIME)
62
+ return app
63
+ }
64
+ return Object.assign(apply, { pluginName: "nifra:mcp" }) as IdentityPlugin
65
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The opt-in Node-direct renderer plugin.
3
+ *
4
+ * `.use(nodeDirect())` enables `app.resolveNode()` for callers that invoke it themselves. Normal
5
+ * `@nifrajs/node` serving supplies the renderer directly, so it never needs this; only direct
6
+ * `resolveNode()` callers do. Same `.use()` install seam as `mcp()` / `idempotency()`.
7
+ */
8
+ import { INSTALL_NODE_DIRECT } from "./server/install.ts"
9
+ import { nodeOutcomeFromResponse, toNodeOutcome } from "./server/node-outcome.ts"
10
+ import type { NodeOutcomeRuntime } from "./server/node-outcome-hook.ts"
11
+ import type { IdentityPlugin } from "./server/plugin.ts"
12
+ import type { AnyServer } from "./server/server.ts"
13
+
14
+ const NODE_OUTCOME_RUNTIME: NodeOutcomeRuntime = {
15
+ toOutcome: toNodeOutcome,
16
+ fromResponse: nodeOutcomeFromResponse,
17
+ timeout: () => ({
18
+ kind: "response",
19
+ response: Response.json({ ok: false, error: "request_timeout" }, { status: 503 }),
20
+ }),
21
+ }
22
+
23
+ /** The install seam a server exposes so the `nodeDirect()` plugin can hand it the renderer. */
24
+ interface NodeDirectInstallable {
25
+ [INSTALL_NODE_DIRECT](runtime: NodeOutcomeRuntime): void
26
+ }
27
+
28
+ /** Enable `app.resolveNode()` for direct callers. Applying it twice is a no-op (named plugin dedupe). */
29
+ export function nodeDirect(): IdentityPlugin {
30
+ const apply = <S extends AnyServer>(app: S): S => {
31
+ ;(app as unknown as NodeDirectInstallable)[INSTALL_NODE_DIRECT](NODE_OUTCOME_RUNTIME)
32
+ return app
33
+ }
34
+ return Object.assign(apply, { pluginName: "nifra:node-direct" }) as IdentityPlugin
35
+ }
36
+
37
+ export type { NodeServeOutcome } from "./server/node-outcome.ts"
@@ -0,0 +1,372 @@
1
+ /** Leased, cursor-checkpointed, bounded reconciliation worker orchestration. */
2
+ import {
3
+ type DurableEffectStore,
4
+ type EffectReconciliationFinding,
5
+ reconcileEffectsPage,
6
+ reconcileSagasPage,
7
+ type SagaReconciliationFinding,
8
+ type SagaStore,
9
+ } from "./durable-execution.ts"
10
+ import { safeEqual } from "./internal/safe-equal.ts"
11
+
12
+ const TOKEN = /^[!-~]{1,128}$/
13
+
14
+ export interface ReconciliationLease {
15
+ readonly name: string
16
+ readonly owner: string
17
+ readonly token: string
18
+ readonly expiresAt: number
19
+ readonly cursor?: string
20
+ }
21
+
22
+ export interface ReconciliationLeaseStore {
23
+ readonly durability?: "memory" | "durable"
24
+ acquire(input: {
25
+ readonly name: string
26
+ readonly owner: string
27
+ readonly now: number
28
+ readonly leaseMs: number
29
+ }): ReconciliationLease | undefined | Promise<ReconciliationLease | undefined>
30
+ renew(input: {
31
+ readonly name: string
32
+ readonly owner: string
33
+ readonly token: string
34
+ readonly now: number
35
+ readonly leaseMs: number
36
+ }): boolean | Promise<boolean>
37
+ checkpoint(input: {
38
+ readonly name: string
39
+ readonly owner: string
40
+ readonly token: string
41
+ readonly cursor?: string
42
+ }): boolean | Promise<boolean>
43
+ release(input: {
44
+ readonly name: string
45
+ readonly owner: string
46
+ readonly token: string
47
+ }): boolean | Promise<boolean>
48
+ }
49
+
50
+ export type ReconciliationWorkerEvent =
51
+ | { readonly type: "acquired" | "released" | "lease-lost"; readonly name: string }
52
+ | {
53
+ readonly type: "page" | "checkpoint"
54
+ readonly name: string
55
+ readonly scanned: number
56
+ readonly handled: number
57
+ }
58
+ | { readonly type: "failed"; readonly name: string; readonly errorCode: "scan" | "handle" }
59
+
60
+ export interface ReconciliationWorkerOptions<Finding> {
61
+ readonly name: string
62
+ readonly owner: string
63
+ readonly leases: ReconciliationLeaseStore
64
+ readonly leaseMs?: number
65
+ readonly batchSize?: number
66
+ readonly maxPages?: number
67
+ readonly concurrency?: number
68
+ readonly signal?: AbortSignal
69
+ readonly now?: () => number
70
+ readonly scan: (input: {
71
+ readonly cursor?: string
72
+ readonly limit: number
73
+ readonly signal: AbortSignal
74
+ }) =>
75
+ | { readonly findings: readonly Finding[]; readonly cursor?: string }
76
+ | Promise<{
77
+ readonly findings: readonly Finding[]
78
+ readonly cursor?: string
79
+ }>
80
+ readonly filter?: (finding: Finding) => boolean
81
+ readonly handle: (finding: Finding, signal: AbortSignal) => void | PromiseLike<void>
82
+ readonly observe?: (event: ReconciliationWorkerEvent) => void
83
+ }
84
+
85
+ export interface ReconciliationWorkerResult {
86
+ readonly acquired: boolean
87
+ readonly pages: number
88
+ readonly scanned: number
89
+ readonly handled: number
90
+ }
91
+
92
+ function positive(value: number, label: string, max: number): number {
93
+ if (!Number.isSafeInteger(value) || value < 1 || value > max)
94
+ throw new RangeError(`${label} must be an integer between 1 and ${max}`)
95
+ return value
96
+ }
97
+
98
+ function timestamp(value: number): number {
99
+ if (!Number.isSafeInteger(value) || value < 0)
100
+ throw new RangeError("reconciliation clock must return a non-negative safe integer")
101
+ return value
102
+ }
103
+
104
+ function assertToken(value: string, label: string): void {
105
+ if (!TOKEN.test(value)) throw new TypeError(`${label} must be a bounded printable token`)
106
+ }
107
+
108
+ function throwIfAborted(signal: AbortSignal): void {
109
+ if (signal.aborted)
110
+ throw signal.reason instanceof Error ? signal.reason : new Error("reconciliation aborted")
111
+ }
112
+
113
+ async function boundedMap<T>(
114
+ values: readonly T[],
115
+ concurrency: number,
116
+ run: (value: T) => Promise<void>,
117
+ ): Promise<void> {
118
+ let next = 0
119
+ const worker = async (): Promise<void> => {
120
+ for (;;) {
121
+ const index = next++
122
+ if (index >= values.length) return
123
+ await run(values[index] as T)
124
+ }
125
+ }
126
+ await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => worker()))
127
+ }
128
+
129
+ export async function runReconciliationWorker<Finding>(
130
+ options: ReconciliationWorkerOptions<Finding>,
131
+ ): Promise<ReconciliationWorkerResult> {
132
+ assertToken(options.name, "reconciliation worker name")
133
+ assertToken(options.owner, "reconciliation worker owner")
134
+ const leaseMs = positive(options.leaseMs ?? 30_000, "reconciliation leaseMs", 86_400_000)
135
+ const batchSize = positive(options.batchSize ?? 100, "reconciliation batchSize", 1_000)
136
+ const maxPages = positive(options.maxPages ?? 10, "reconciliation maxPages", 1_000)
137
+ const concurrency = positive(options.concurrency ?? 4, "reconciliation concurrency", 64)
138
+ const now = options.now ?? Date.now
139
+ const signal = options.signal ?? new AbortController().signal
140
+ throwIfAborted(signal)
141
+ const lease = await options.leases.acquire({
142
+ name: options.name,
143
+ owner: options.owner,
144
+ now: timestamp(now()),
145
+ leaseMs,
146
+ })
147
+ if (lease === undefined) return { acquired: false, pages: 0, scanned: 0, handled: 0 }
148
+ if (
149
+ lease.name !== options.name ||
150
+ lease.owner !== options.owner ||
151
+ !TOKEN.test(lease.token) ||
152
+ !Number.isSafeInteger(lease.expiresAt) ||
153
+ lease.expiresAt <= 0 ||
154
+ (lease.cursor !== undefined && !/^[ -~]{1,1024}$/u.test(lease.cursor))
155
+ ) {
156
+ throw new TypeError("reconciliation lease store returned an invalid lease")
157
+ }
158
+ options.observe?.({ type: "acquired", name: options.name })
159
+ let cursor = lease.cursor
160
+ let pages = 0
161
+ let scanned = 0
162
+ let handled = 0
163
+ try {
164
+ while (pages < maxPages) {
165
+ throwIfAborted(signal)
166
+ const renewed = await options.leases.renew({
167
+ name: options.name,
168
+ owner: options.owner,
169
+ token: lease.token,
170
+ now: timestamp(now()),
171
+ leaseMs,
172
+ })
173
+ if (!renewed) {
174
+ options.observe?.({ type: "lease-lost", name: options.name })
175
+ throw new Error("reconciliation lease lost")
176
+ }
177
+ let page: Awaited<ReturnType<typeof options.scan>>
178
+ try {
179
+ page = await options.scan({
180
+ ...(cursor === undefined ? {} : { cursor }),
181
+ limit: batchSize,
182
+ signal,
183
+ })
184
+ } catch (error) {
185
+ options.observe?.({ type: "failed", name: options.name, errorCode: "scan" })
186
+ throw error
187
+ }
188
+ if (!Array.isArray(page.findings) || page.findings.length > batchSize)
189
+ throw new TypeError("reconciliation scan returned an invalid or oversized page")
190
+ pages++
191
+ scanned += page.findings.length
192
+ const selected =
193
+ options.filter === undefined ? page.findings : page.findings.filter(options.filter)
194
+ try {
195
+ await boundedMap(selected, concurrency, async (finding) => {
196
+ throwIfAborted(signal)
197
+ await options.handle(finding, signal)
198
+ handled++
199
+ })
200
+ } catch (error) {
201
+ options.observe?.({ type: "failed", name: options.name, errorCode: "handle" })
202
+ throw error
203
+ }
204
+ const renewedAfterWork = await options.leases.renew({
205
+ name: options.name,
206
+ owner: options.owner,
207
+ token: lease.token,
208
+ now: timestamp(now()),
209
+ leaseMs,
210
+ })
211
+ if (!renewedAfterWork) {
212
+ options.observe?.({ type: "lease-lost", name: options.name })
213
+ throw new Error("reconciliation lease expired during page handling")
214
+ }
215
+ options.observe?.({
216
+ type: "page",
217
+ name: options.name,
218
+ scanned: page.findings.length,
219
+ handled: selected.length,
220
+ })
221
+ const checkpointed = await options.leases.checkpoint({
222
+ name: options.name,
223
+ owner: options.owner,
224
+ token: lease.token,
225
+ ...(page.cursor === undefined ? {} : { cursor: page.cursor }),
226
+ })
227
+ if (!checkpointed) {
228
+ options.observe?.({ type: "lease-lost", name: options.name })
229
+ throw new Error("reconciliation lease lost before checkpoint")
230
+ }
231
+ options.observe?.({
232
+ type: "checkpoint",
233
+ name: options.name,
234
+ scanned,
235
+ handled,
236
+ })
237
+ cursor = page.cursor
238
+ if (cursor === undefined) break
239
+ }
240
+ return { acquired: true, pages, scanned, handled }
241
+ } finally {
242
+ if (
243
+ await options.leases.release({
244
+ name: options.name,
245
+ owner: options.owner,
246
+ token: lease.token,
247
+ })
248
+ ) {
249
+ options.observe?.({ type: "released", name: options.name })
250
+ }
251
+ }
252
+ }
253
+
254
+ interface MemoryLeaseRecord {
255
+ owner?: string
256
+ token?: string
257
+ expiresAt?: number
258
+ cursor?: string
259
+ }
260
+
261
+ export class MemoryReconciliationLeaseStore implements ReconciliationLeaseStore {
262
+ readonly durability = "memory" as const
263
+ private readonly records = new Map<string, MemoryLeaseRecord>()
264
+
265
+ acquire(
266
+ input: Parameters<ReconciliationLeaseStore["acquire"]>[0],
267
+ ): ReconciliationLease | undefined {
268
+ assertToken(input.name, "reconciliation lease name")
269
+ assertToken(input.owner, "reconciliation lease owner")
270
+ const current = this.records.get(input.name)
271
+ if (
272
+ current?.owner !== undefined &&
273
+ current.expiresAt !== undefined &&
274
+ current.expiresAt > input.now
275
+ ) {
276
+ return undefined
277
+ }
278
+ const token = crypto.randomUUID()
279
+ const expiresAt = input.now + input.leaseMs
280
+ if (!Number.isSafeInteger(expiresAt)) throw new RangeError("reconciliation lease overflow")
281
+ this.records.set(input.name, {
282
+ owner: input.owner,
283
+ token,
284
+ expiresAt,
285
+ ...(current?.cursor === undefined ? {} : { cursor: current.cursor }),
286
+ })
287
+ return Object.freeze({
288
+ name: input.name,
289
+ owner: input.owner,
290
+ token,
291
+ expiresAt,
292
+ ...(current?.cursor === undefined ? {} : { cursor: current.cursor }),
293
+ })
294
+ }
295
+
296
+ renew(input: Parameters<ReconciliationLeaseStore["renew"]>[0]): boolean {
297
+ const record = this.records.get(input.name)
298
+ if (
299
+ record?.owner !== input.owner ||
300
+ !safeEqual(record.token, input.token) ||
301
+ (record.expiresAt ?? 0) <= input.now
302
+ )
303
+ return false
304
+ const expiresAt = input.now + input.leaseMs
305
+ if (!Number.isSafeInteger(expiresAt)) throw new RangeError("reconciliation lease overflow")
306
+ record.expiresAt = expiresAt
307
+ return true
308
+ }
309
+
310
+ checkpoint(input: Parameters<ReconciliationLeaseStore["checkpoint"]>[0]): boolean {
311
+ const record = this.records.get(input.name)
312
+ if (record?.owner !== input.owner || !safeEqual(record.token, input.token)) return false
313
+ if (input.cursor === undefined) delete record.cursor
314
+ else record.cursor = input.cursor
315
+ return true
316
+ }
317
+
318
+ release(input: Parameters<ReconciliationLeaseStore["release"]>[0]): boolean {
319
+ const record = this.records.get(input.name)
320
+ if (record?.owner !== input.owner || !safeEqual(record.token, input.token)) return false
321
+ delete record.owner
322
+ delete record.token
323
+ delete record.expiresAt
324
+ return true
325
+ }
326
+ }
327
+
328
+ interface SpecializedWorkerOptions<Finding> {
329
+ readonly name: string
330
+ readonly owner: string
331
+ readonly leases: ReconciliationLeaseStore
332
+ readonly staleBefore: number
333
+ readonly leaseMs?: number
334
+ readonly batchSize?: number
335
+ readonly maxPages?: number
336
+ readonly concurrency?: number
337
+ readonly signal?: AbortSignal
338
+ readonly now?: () => number
339
+ readonly filter?: (finding: Finding) => boolean
340
+ readonly handle: (finding: Finding, signal: AbortSignal) => void | PromiseLike<void>
341
+ readonly observe?: (event: ReconciliationWorkerEvent) => void
342
+ }
343
+
344
+ export function runEffectReconciliationWorker(
345
+ store: DurableEffectStore,
346
+ options: SpecializedWorkerOptions<EffectReconciliationFinding>,
347
+ ): Promise<ReconciliationWorkerResult> {
348
+ return runReconciliationWorker({
349
+ ...options,
350
+ scan: ({ cursor, limit }) =>
351
+ reconcileEffectsPage(store, {
352
+ staleBefore: options.staleBefore,
353
+ ...(cursor === undefined ? {} : { cursor }),
354
+ limit,
355
+ }),
356
+ })
357
+ }
358
+
359
+ export function runSagaReconciliationWorker(
360
+ store: SagaStore,
361
+ options: SpecializedWorkerOptions<SagaReconciliationFinding>,
362
+ ): Promise<ReconciliationWorkerResult> {
363
+ return runReconciliationWorker({
364
+ ...options,
365
+ scan: ({ cursor, limit }) =>
366
+ reconcileSagasPage(store, {
367
+ staleBefore: options.staleBefore,
368
+ ...(cursor === undefined ? {} : { cursor }),
369
+ limit,
370
+ }),
371
+ })
372
+ }