@nifrajs/core 2.13.0 → 3.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 (177) hide show
  1. package/README.md +4 -0
  2. package/dist/assurance.d.ts +16 -8
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +33 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/binary.d.ts +32 -0
  7. package/dist/binary.d.ts.map +1 -1
  8. package/dist/binary.js +17 -0
  9. package/dist/binary.js.map +1 -1
  10. package/dist/durable-adapters.d.ts.map +1 -1
  11. package/dist/durable-adapters.js +21 -50
  12. package/dist/durable-adapters.js.map +1 -1
  13. package/dist/durable-execution.d.ts.map +1 -1
  14. package/dist/durable-execution.js +49 -69
  15. package/dist/durable-execution.js.map +1 -1
  16. package/dist/edge-kit.d.ts +27 -0
  17. package/dist/edge-kit.d.ts.map +1 -0
  18. package/dist/edge-kit.js +24 -0
  19. package/dist/edge-kit.js.map +1 -0
  20. package/dist/errors.d.ts +1 -1
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/durable-approval-protocol.d.ts +13 -0
  28. package/dist/internal/durable-approval-protocol.d.ts.map +1 -0
  29. package/dist/internal/durable-approval-protocol.js +66 -0
  30. package/dist/internal/durable-approval-protocol.js.map +1 -0
  31. package/dist/internal/durable-effect-protocol.d.ts +14 -0
  32. package/dist/internal/durable-effect-protocol.d.ts.map +1 -0
  33. package/dist/internal/durable-effect-protocol.js +16 -0
  34. package/dist/internal/durable-effect-protocol.js.map +1 -0
  35. package/dist/internal/durable-saga-protocol.d.ts +8 -0
  36. package/dist/internal/durable-saga-protocol.d.ts.map +1 -0
  37. package/dist/internal/durable-saga-protocol.js +21 -0
  38. package/dist/internal/durable-saga-protocol.js.map +1 -0
  39. package/dist/internal/route-catalog.d.ts +3 -0
  40. package/dist/internal/route-catalog.d.ts.map +1 -1
  41. package/dist/internal/route-catalog.js +5 -0
  42. package/dist/internal/route-catalog.js.map +1 -1
  43. package/dist/internal/route-execution.d.ts +11 -5
  44. package/dist/internal/route-execution.d.ts.map +1 -1
  45. package/dist/internal/route-execution.js +2 -1
  46. package/dist/internal/route-execution.js.map +1 -1
  47. package/dist/response-observer.d.ts +25 -0
  48. package/dist/response-observer.d.ts.map +1 -0
  49. package/dist/response-observer.js +40 -0
  50. package/dist/response-observer.js.map +1 -0
  51. package/dist/security/baseline.d.ts +68 -0
  52. package/dist/security/baseline.d.ts.map +1 -0
  53. package/dist/security/baseline.js +118 -0
  54. package/dist/security/baseline.js.map +1 -0
  55. package/dist/server/bare-error-lane.d.ts +26 -0
  56. package/dist/server/bare-error-lane.d.ts.map +1 -0
  57. package/dist/server/bare-error-lane.js +60 -0
  58. package/dist/server/bare-error-lane.js.map +1 -0
  59. package/dist/server/body.d.ts +7 -0
  60. package/dist/server/body.d.ts.map +1 -1
  61. package/dist/server/body.js +7 -0
  62. package/dist/server/body.js.map +1 -1
  63. package/dist/server/bridge-markers.d.ts +25 -0
  64. package/dist/server/bridge-markers.d.ts.map +1 -0
  65. package/dist/server/bridge-markers.js +25 -0
  66. package/dist/server/bridge-markers.js.map +1 -0
  67. package/dist/server/context.d.ts +6 -1
  68. package/dist/server/context.d.ts.map +1 -1
  69. package/dist/server/header-case.d.ts.map +1 -1
  70. package/dist/server/header-case.js +2 -1
  71. package/dist/server/header-case.js.map +1 -1
  72. package/dist/server/http.d.ts +36 -3
  73. package/dist/server/http.d.ts.map +1 -1
  74. package/dist/server/http.js +49 -3
  75. package/dist/server/http.js.map +1 -1
  76. package/dist/server/install.d.ts +6 -8
  77. package/dist/server/install.d.ts.map +1 -1
  78. package/dist/server/install.js +7 -0
  79. package/dist/server/install.js.map +1 -1
  80. package/dist/server/node-outcome-hook.d.ts +2 -1
  81. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  82. package/dist/server/node-outcome-hook.js.map +1 -1
  83. package/dist/server/node-outcome.d.ts +17 -2
  84. package/dist/server/node-outcome.d.ts.map +1 -1
  85. package/dist/server/node-outcome.js +37 -3
  86. package/dist/server/node-outcome.js.map +1 -1
  87. package/dist/server/plugin.d.ts +1 -1
  88. package/dist/server/plugin.d.ts.map +1 -1
  89. package/dist/server/query.d.ts +7 -1
  90. package/dist/server/query.d.ts.map +1 -1
  91. package/dist/server/query.js +8 -3
  92. package/dist/server/query.js.map +1 -1
  93. package/dist/server/registry.d.ts +11 -2
  94. package/dist/server/registry.d.ts.map +1 -1
  95. package/dist/server/request-context.d.ts +17 -4
  96. package/dist/server/request-context.d.ts.map +1 -1
  97. package/dist/server/request-context.js +82 -45
  98. package/dist/server/request-context.js.map +1 -1
  99. package/dist/server/respond.d.ts.map +1 -1
  100. package/dist/server/respond.js +19 -2
  101. package/dist/server/respond.js.map +1 -1
  102. package/dist/server/response-contract-lane.d.ts.map +1 -1
  103. package/dist/server/response-contract-lane.js +5 -2
  104. package/dist/server/response-contract-lane.js.map +1 -1
  105. package/dist/server/response-observer-runtime.d.ts +20 -0
  106. package/dist/server/response-observer-runtime.d.ts.map +1 -0
  107. package/dist/server/response-observer-runtime.js +95 -0
  108. package/dist/server/response-observer-runtime.js.map +1 -0
  109. package/dist/server/runtime-core.d.ts +60 -0
  110. package/dist/server/runtime-core.d.ts.map +1 -1
  111. package/dist/server/runtime-core.js +65 -1
  112. package/dist/server/runtime-core.js.map +1 -1
  113. package/dist/server/server-types.d.ts +18 -1
  114. package/dist/server/server-types.d.ts.map +1 -1
  115. package/dist/server/server.d.ts +89 -48
  116. package/dist/server/server.d.ts.map +1 -1
  117. package/dist/server/server.js +397 -276
  118. package/dist/server/server.js.map +1 -1
  119. package/dist/server/websocket.d.ts +20 -4
  120. package/dist/server/websocket.d.ts.map +1 -1
  121. package/dist/server/websocket.js +81 -11
  122. package/dist/server/websocket.js.map +1 -1
  123. package/dist/server/ws-bun.d.ts +1 -1
  124. package/dist/server/ws-bun.d.ts.map +1 -1
  125. package/dist/server/ws-bun.js +12 -8
  126. package/dist/server/ws-bun.js.map +1 -1
  127. package/dist/server.d.ts +2 -1
  128. package/dist/server.d.ts.map +1 -1
  129. package/dist/server.js +2 -1
  130. package/dist/server.js.map +1 -1
  131. package/dist/single-copy-register.d.ts +2 -0
  132. package/dist/single-copy-register.d.ts.map +1 -0
  133. package/dist/single-copy-register.js +24 -0
  134. package/dist/single-copy-register.js.map +1 -0
  135. package/dist/single-copy.d.ts +156 -0
  136. package/dist/single-copy.d.ts.map +1 -0
  137. package/dist/single-copy.js +470 -0
  138. package/dist/single-copy.js.map +1 -0
  139. package/package.json +24 -2
  140. package/src/assurance.ts +58 -1
  141. package/src/binary.ts +35 -0
  142. package/src/durable-adapters.ts +26 -51
  143. package/src/durable-execution.ts +46 -88
  144. package/src/edge-kit.ts +27 -0
  145. package/src/errors.ts +1 -0
  146. package/src/index.ts +1 -1
  147. package/src/internal/durable-approval-protocol.ts +92 -0
  148. package/src/internal/durable-effect-protocol.ts +37 -0
  149. package/src/internal/durable-saga-protocol.ts +55 -0
  150. package/src/internal/route-catalog.ts +6 -0
  151. package/src/internal/route-execution.ts +23 -16
  152. package/src/response-observer.ts +64 -0
  153. package/src/security/baseline.ts +172 -0
  154. package/src/server/bare-error-lane.ts +76 -0
  155. package/src/server/body.ts +8 -0
  156. package/src/server/bridge-markers.ts +24 -0
  157. package/src/server/context.ts +6 -1
  158. package/src/server/header-case.ts +3 -1
  159. package/src/server/http.ts +53 -3
  160. package/src/server/install.ts +14 -0
  161. package/src/server/node-outcome-hook.ts +2 -1
  162. package/src/server/node-outcome.ts +44 -3
  163. package/src/server/plugin.ts +1 -1
  164. package/src/server/query.ts +10 -4
  165. package/src/server/registry.ts +19 -4
  166. package/src/server/request-context.ts +106 -51
  167. package/src/server/respond.ts +26 -1
  168. package/src/server/response-contract-lane.ts +6 -2
  169. package/src/server/response-observer-runtime.ts +135 -0
  170. package/src/server/runtime-core.ts +98 -1
  171. package/src/server/server-types.ts +18 -1
  172. package/src/server/server.ts +607 -376
  173. package/src/server/websocket.ts +127 -17
  174. package/src/server/ws-bun.ts +26 -7
  175. package/src/server.ts +2 -1
  176. package/src/single-copy-register.ts +24 -0
  177. package/src/single-copy.ts +524 -0
package/src/binary.ts CHANGED
@@ -34,6 +34,23 @@ export const NIFRA_BINARY_HEADER = "x-nifra-binary"
34
34
  /** A `Response` a route declared as binary. `Jsonify` maps this to `Blob`. */
35
35
  export type BinaryResponse = Response & { readonly [NIFRA_BYTES]: true }
36
36
 
37
+ /**
38
+ * Marker on a raw response's TYPE carrying the payload it serializes to. Like {@link NIFRA_BYTES} it is
39
+ * a unique symbol declared *required*, so an ordinary `Response` cannot satisfy it by accident - an
40
+ * optional brand would be met by every `Response`, and every raw-response route would wrongly claim a
41
+ * typed body.
42
+ */
43
+ declare const NIFRA_RAW: unique symbol
44
+
45
+ /**
46
+ * A raw `Response` whose JSON body a route declared as `T`. Returning a bare `Response` drops a route
47
+ * out of the typed contract - the client reads its `data` as `never`, since a `Response`'s own
48
+ * properties say nothing about the payload. {@link raw} brands the `Response` with the shape it
49
+ * actually serializes, so `Jsonify` answers `Jsonify<T>` and the route stays inside the typed client.
50
+ * The brand is a phantom: nothing is added at runtime.
51
+ */
52
+ export type RawResponse<T> = Response & { readonly [NIFRA_RAW]: T }
53
+
37
54
  /** What can be sent as bytes without being re-encoded on the way out. */
38
55
  export type BinaryBody = ArrayBuffer | ArrayBufferView | Blob | ReadableStream<Uint8Array>
39
56
 
@@ -126,3 +143,21 @@ export function bytes(body: BinaryBody, options: BytesOptions = {}): BinaryRespo
126
143
  headers,
127
144
  }) as BinaryResponse
128
145
  }
146
+
147
+ /**
148
+ * Brand a hand-built `Response` with the payload type `T` its body serializes to, so a route that must
149
+ * return a `Response` directly still types `res.data` as `Jsonify<T>` on the client instead of `never`.
150
+ * Use it for the endpoints that build their own `Response` - a token minted by a third-party SDK, a
151
+ * redirect that also carries a JSON body, a hand-tuned cache header - but should still be part of the
152
+ * contract. Purely a type assertion: the `Response` is returned unchanged, nothing is added at runtime.
153
+ *
154
+ * app.get("/livekit-token", async (c) =>
155
+ * raw<{ token: string }>(Response.json({ token: await mint(c) })))
156
+ * // client: res.data is typed { token: string }
157
+ *
158
+ * The payload `T` is the type the body serializes to - it is not validated, so it is a promise the route
159
+ * must keep. For bytes prefer {@link bytes}, which also sets the media-type headers.
160
+ */
161
+ export function raw<T>(response: Response): RawResponse<T> {
162
+ return response as RawResponse<T>
163
+ }
@@ -13,6 +13,9 @@ import type {
13
13
  SagaRecord,
14
14
  SagaStore,
15
15
  } from "./durable-execution.ts"
16
+ import { durableApprovalProtocol } from "./internal/durable-approval-protocol.ts"
17
+ import { durableEffectProtocol } from "./internal/durable-effect-protocol.ts"
18
+ import { durableSagaProtocol } from "./internal/durable-saga-protocol.ts"
16
19
  import { safeEqual } from "./internal/safe-equal.ts"
17
20
  import type { ReconciliationLease, ReconciliationLeaseStore } from "./reconciliation-worker.ts"
18
21
  import { parse as parseWire, stringify as stringifyWire } from "./wire.ts"
@@ -93,20 +96,8 @@ export function createDurableExecutionAdapter(
93
96
  get: (effectId) => backend.get<DurableEffectRecord>("effect", effectId),
94
97
  async transition(input) {
95
98
  const current = await backend.get<DurableEffectRecord>("effect", input.effectId)
96
- if (
97
- current === undefined ||
98
- current.version !== input.version ||
99
- current.state !== input.from
100
- ) {
101
- return false
102
- }
103
- const next: DurableEffectRecord = {
104
- ...current,
105
- state: input.to,
106
- updatedAt: input.updatedAt,
107
- version: current.version + 1,
108
- ...(input.errorCode === undefined ? {} : { errorCode: input.errorCode }),
109
- }
99
+ const next = durableEffectProtocol.transition(current, input)
100
+ if (next === undefined) return false
110
101
  return await backend.compareAndSet("effect", input.effectId, input.version, next)
111
102
  },
112
103
  scan: (input) =>
@@ -119,46 +110,26 @@ export function createDurableExecutionAdapter(
119
110
  get: (approvalId) => backend.get<ApprovalRecord>("approval", approvalId),
120
111
  async decide(input) {
121
112
  const current = await backend.get<ApprovalRecord>("approval", input.approvalId)
122
- if (
123
- current === undefined ||
124
- current.tenantId !== input.tenantId ||
125
- current.state !== "pending" ||
126
- current.expiresAt <= input.now
127
- ) {
128
- return false
129
- }
130
- const decided: ApprovalRecord = {
131
- ...current,
132
- state: input.decision,
133
- decidedBy: input.decidedBy,
134
- updatedAt: input.now,
135
- version: current.version + 1,
136
- }
137
- return await backend.compareAndSet("approval", input.approvalId, current.version, decided)
113
+ const next = durableApprovalProtocol.decide(current, input)
114
+ if (current === undefined || next === undefined) return false
115
+ return await backend.compareAndSet("approval", input.approvalId, current.version, next)
138
116
  },
139
117
  async consume(input): Promise<ApprovalConsumeResult> {
140
118
  const current = await backend.get<ApprovalRecord>("approval", input.approvalId)
141
- if (current === undefined) return { state: "missing" }
142
- if (current.tenantId !== input.tenantId || current.principalId !== input.principalId)
143
- return { state: "binding" }
119
+ const transition = durableApprovalProtocol.consume(current, input)
144
120
  if (
145
- current.capability !== input.capability ||
146
- current.target !== input.target ||
147
- !safeEqual(current.digest, input.digest)
121
+ current === undefined ||
122
+ transition.result.state !== "consumed" ||
123
+ transition.next === undefined
148
124
  )
149
- return { state: "binding" }
150
- if (!safeEqual(current.tokenHash, input.tokenHash)) return { state: "token" }
151
- if (current.state === "consumed") return { state: "replay" }
152
- if (current.state === "denied") return { state: "denied" }
153
- if (current.state === "pending") return { state: "pending" }
154
- if (current.state === "expired" || current.expiresAt <= input.now) return { state: "expired" }
155
- const consumed = await backend.compareAndSet("approval", input.approvalId, current.version, {
156
- ...current,
157
- state: "consumed",
158
- updatedAt: input.now,
159
- version: current.version + 1,
160
- })
161
- return consumed ? { state: "consumed" } : { state: "replay" }
125
+ return transition.result
126
+ const consumed = await backend.compareAndSet(
127
+ "approval",
128
+ input.approvalId,
129
+ current.version,
130
+ transition.next,
131
+ )
132
+ return consumed ? transition.result : { state: "replay" }
162
133
  },
163
134
  }
164
135
 
@@ -166,8 +137,12 @@ export function createDurableExecutionAdapter(
166
137
  durability: "durable",
167
138
  create: (record) => backend.create("saga", record.sagaId, clone(record)),
168
139
  get: (sagaId) => backend.get<SagaRecord>("saga", sagaId),
169
- compareAndSet: (input) =>
170
- backend.compareAndSet("saga", input.sagaId, input.version, clone(input.record)),
140
+ compareAndSet: (input) => {
141
+ const next = durableSagaProtocol.transition(undefined, input)
142
+ return next === undefined
143
+ ? false
144
+ : backend.compareAndSet("saga", input.sagaId, input.version, clone(next))
145
+ },
171
146
  scan: (input) => backend.scan<SagaRecord>("saga", input as ReconciliationScanOptions<string>),
172
147
  }
173
148
 
@@ -13,7 +13,9 @@ import type {
13
13
  CapabilityExecutionJournal,
14
14
  } from "./internal/capability-runtime.ts"
15
15
  import { validCapabilityId } from "./internal/capability-runtime.ts"
16
- import { safeEqual } from "./internal/safe-equal.ts"
16
+ import { durableApprovalProtocol } from "./internal/durable-approval-protocol.ts"
17
+ import { durableEffectProtocol } from "./internal/durable-effect-protocol.ts"
18
+ import { durableSagaProtocol } from "./internal/durable-saga-protocol.ts"
17
19
 
18
20
  export type { CapabilityApprovalGate, CapabilityExecutionIdentity, CapabilityExecutionJournal }
19
21
 
@@ -208,8 +210,7 @@ export function createDurableEffectJournal(
208
210
  errorCode?: string,
209
211
  ): Promise<void> => {
210
212
  const record = await current(effectId)
211
- if (record.state !== from) throw new DurableEffectTransitionError(effectId, `${from}->${to}`)
212
- const accepted = await options.store.transition({
213
+ const next = durableEffectProtocol.transition(record, {
213
214
  effectId,
214
215
  version: record.version,
215
216
  from,
@@ -217,6 +218,15 @@ export function createDurableEffectJournal(
217
218
  updatedAt: now(),
218
219
  ...(errorCode === undefined ? {} : { errorCode }),
219
220
  })
221
+ if (next === undefined) throw new DurableEffectTransitionError(effectId, `${from}->${to}`)
222
+ const accepted = await options.store.transition({
223
+ effectId,
224
+ version: record.version,
225
+ from,
226
+ to,
227
+ updatedAt: next.updatedAt,
228
+ ...(errorCode === undefined ? {} : { errorCode }),
229
+ })
220
230
  if (!accepted) throw new DurableEffectTransitionError(effectId, `${from}->${to}`)
221
231
  }
222
232
 
@@ -261,7 +271,7 @@ export function createDurableEffectJournal(
261
271
  throw new TypeError("durable effect errorCode is invalid")
262
272
  const record = await current(effectId)
263
273
  const to = input.began ? "unknown" : "failed"
264
- const accepted = await options.store.transition({
274
+ const next = durableEffectProtocol.transition(record, {
265
275
  effectId,
266
276
  version: record.version,
267
277
  from: record.state,
@@ -269,6 +279,16 @@ export function createDurableEffectJournal(
269
279
  updatedAt: now(),
270
280
  errorCode: input.errorCode,
271
281
  })
282
+ if (next === undefined)
283
+ throw new DurableEffectTransitionError(effectId, `${record.state}->${to}`)
284
+ const accepted = await options.store.transition({
285
+ effectId,
286
+ version: record.version,
287
+ from: record.state,
288
+ to,
289
+ updatedAt: next.updatedAt,
290
+ errorCode: input.errorCode,
291
+ })
272
292
  if (!accepted) throw new DurableEffectTransitionError(effectId, `${record.state}->${to}`)
273
293
  },
274
294
  }
@@ -296,18 +316,10 @@ export class MemoryDurableEffectStore implements DurableEffectStore {
296
316
 
297
317
  transition(input: Parameters<DurableEffectStore["transition"]>[0]): boolean {
298
318
  const current = this.records.get(input.effectId)
299
- if (current === undefined || current.version !== input.version || current.state !== input.from)
300
- return false
301
- this.records.set(
302
- input.effectId,
303
- Object.freeze({
304
- ...current,
305
- state: input.to,
306
- updatedAt: input.updatedAt,
307
- version: current.version + 1,
308
- ...(input.errorCode === undefined ? {} : { errorCode: input.errorCode }),
309
- }),
310
- )
319
+ const next = durableEffectProtocol.transition(current, input)
320
+ if (next === undefined) return false
321
+ if (current === undefined) return false
322
+ this.records.set(input.effectId, Object.freeze(next))
311
323
  bucketMove(this.byState, current.state, input.to, input.effectId)
312
324
  return true
313
325
  }
@@ -801,64 +813,17 @@ export class MemoryApprovalStore implements ApprovalStore {
801
813
  }
802
814
  decide(input: Parameters<ApprovalStore["decide"]>[0]): boolean {
803
815
  const record = this.records.get(input.approvalId)
804
- if (
805
- record === undefined ||
806
- record.tenantId !== input.tenantId ||
807
- record.state !== "pending" ||
808
- record.expiresAt <= input.now
809
- )
810
- return false
811
- this.records.set(
812
- input.approvalId,
813
- Object.freeze({
814
- ...record,
815
- state: input.decision,
816
- decidedBy: input.decidedBy,
817
- updatedAt: input.now,
818
- version: record.version + 1,
819
- }),
820
- )
816
+ const next = durableApprovalProtocol.decide(record, input)
817
+ if (next === undefined) return false
818
+ this.records.set(input.approvalId, Object.freeze(next))
821
819
  return true
822
820
  }
823
821
  consume(input: Parameters<ApprovalStore["consume"]>[0]): ApprovalConsumeResult {
824
822
  const record = this.records.get(input.approvalId)
825
- if (record === undefined) return { state: "missing" }
826
- if (
827
- record.tenantId !== input.tenantId ||
828
- record.principalId !== input.principalId ||
829
- record.capability !== input.capability ||
830
- record.target !== input.target ||
831
- !safeEqual(record.digest, input.digest)
832
- )
833
- return { state: "binding" }
834
- if (!safeEqual(record.tokenHash, input.tokenHash)) return { state: "token" }
835
- if (record.state === "consumed") return { state: "replay" }
836
- if (record.expiresAt <= input.now || record.state === "expired") {
837
- if (record.state !== "expired")
838
- this.records.set(
839
- input.approvalId,
840
- Object.freeze({
841
- ...record,
842
- state: "expired",
843
- updatedAt: input.now,
844
- version: record.version + 1,
845
- }),
846
- )
847
- return { state: "expired" }
848
- }
849
- if (record.state === "pending") return { state: "pending" }
850
- if (record.state === "denied") return { state: "denied" }
851
- if (record.state !== "approved") return { state: "token" }
852
- this.records.set(
853
- input.approvalId,
854
- Object.freeze({
855
- ...record,
856
- state: "consumed",
857
- updatedAt: input.now,
858
- version: record.version + 1,
859
- }),
860
- )
861
- return { state: "consumed" }
823
+ const transition = durableApprovalProtocol.consume(record, input)
824
+ if (transition.next !== undefined)
825
+ this.records.set(input.approvalId, Object.freeze(transition.next))
826
+ return transition.result
862
827
  }
863
828
  list(): readonly ApprovalRecord[] {
864
829
  return Object.freeze(
@@ -1174,7 +1139,12 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
1174
1139
  current: SagaRecord,
1175
1140
  update: Omit<SagaRecord, "version">,
1176
1141
  ): Promise<SagaRecord> => {
1177
- const next = Object.freeze({ ...update, version: current.version + 1 })
1142
+ const next = durableSagaProtocol.transition(current, {
1143
+ sagaId: current.sagaId,
1144
+ version: current.version,
1145
+ record: Object.freeze({ ...update, version: current.version + 1 }),
1146
+ })
1147
+ if (next === undefined) throw new SagaConcurrencyError(current.sagaId)
1178
1148
  if (
1179
1149
  !(await options.store.compareAndSet({
1180
1150
  sagaId: current.sagaId,
@@ -1190,16 +1160,7 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
1190
1160
  index: number,
1191
1161
  step: SagaStepRecord,
1192
1162
  state = record.state,
1193
- ): Omit<SagaRecord, "version"> => ({
1194
- ...record,
1195
- state,
1196
- steps: Object.freeze(
1197
- record.steps.map((candidate, candidateIndex) =>
1198
- candidateIndex === index ? Object.freeze(step) : candidate,
1199
- ),
1200
- ),
1201
- updatedAt: now(),
1202
- })
1163
+ ): Omit<SagaRecord, "version"> => durableSagaProtocol.withStep(record, index, step, state, now())
1203
1164
  const effectScope = createEffectScope({
1204
1165
  ...(observer === undefined ? {} : { observers: [observer] }),
1205
1166
  })
@@ -1569,13 +1530,10 @@ export class MemorySagaStore implements SagaStore {
1569
1530
  }
1570
1531
  compareAndSet(input: Parameters<SagaStore["compareAndSet"]>[0]): boolean {
1571
1532
  const current = this.records.get(input.sagaId)
1572
- if (
1573
- current === undefined ||
1574
- current.version !== input.version ||
1575
- input.record.version !== input.version + 1
1576
- )
1577
- return false
1578
- this.records.set(input.sagaId, Object.freeze(cloneValue(input.record)))
1533
+ const next = durableSagaProtocol.transition(current, input)
1534
+ if (next === undefined) return false
1535
+ if (current === undefined) return false
1536
+ this.records.set(input.sagaId, Object.freeze(next))
1579
1537
  bucketMove(this.byState, current.state, input.record.state, input.sagaId)
1580
1538
  return true
1581
1539
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Edge kit - the moat-neutral free functions a compact fetch-handler server needs to enforce the
3
+ * SAME request trust boundary the full `Server` does, without pulling in the full builder.
4
+ *
5
+ * A minimal edge entrypoint (`@nifrajs/edge`) is not allowed to re-derive the body lane: the assembly
6
+ * order of the content-type dispatch, the bounded read, the Content-Length pre-reject, and the
7
+ * prototype-poisoning guard is a security contract, and a second copy of it would drift. This subpath
8
+ * exposes the one lane the fused body runner already calls, so a compact server reuses it verbatim:
9
+ *
10
+ * - {@link readBodyFramed} read + cap + proto-guard + content-type framing (JSON / urlencoded / 415)
11
+ * - {@link toResponse} render a {@link ResponseResult} (e.g. a 413 / 415 rejection) to a `Response`
12
+ * - {@link plainError} the structured rejection envelope the lane returns
13
+ * - {@link queryObjectOf} / {@link searchOf} query parse without a full WHATWG URL construction
14
+ *
15
+ * Everything here carries only structure - bytes, guards, and envelopes - never routing, lifecycle,
16
+ * or app state. It is the interface, not the framework.
17
+ */
18
+
19
+ export { plainError } from "./server/http.ts"
20
+ export type { ProtoPoisoning } from "./server/proto-guard.ts"
21
+ export { type QueryValue, queryObjectOf, searchOf } from "./server/query.ts"
22
+ export { readBodyFramed } from "./server/request-context.ts"
23
+ export { toResponse } from "./server/respond.ts"
24
+ export type { ResponseResult } from "./server/runtime-core.ts"
25
+ export { EMPTY_RESPONSE_CONTROLS } from "./server/runtime-core.ts"
26
+ export type { CtxSet, MaybePromise, RequestSource } from "./server/server.ts"
27
+ export { plainValidationError } from "./server/server.ts"
package/src/errors.ts CHANGED
@@ -26,6 +26,7 @@ export type RouteConfigErrorCode =
26
26
  | "INVALID_METHOD"
27
27
  | "INVALID_ASSURANCE"
28
28
  | "INVALID_IDEMPOTENCY"
29
+ | "INVALID_WS_SEND_VALIDATION"
29
30
  | "INVALID_BODY_LIMIT"
30
31
 
31
32
  /**
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
11
11
  * `check:publish` asserts it equals `@nifrajs/core`'s package version.
12
12
  */
13
- export const VERSION = "2.13.0" as const
13
+ export const VERSION = "3.1.0" as const
14
14
 
15
15
  export type Version = typeof VERSION
16
16
 
@@ -0,0 +1,92 @@
1
+ import type { ApprovalConsumeResult, ApprovalRecord, ApprovalStore } from "../durable-execution.ts"
2
+ import { safeEqual } from "./safe-equal.ts"
3
+
4
+ export type DurableApprovalDecisionInput = Parameters<ApprovalStore["decide"]>[0]
5
+ export type DurableApprovalConsumeInput = Parameters<ApprovalStore["consume"]>[0]
6
+
7
+ export interface DurableApprovalConsumeTransition {
8
+ readonly result: ApprovalConsumeResult
9
+ readonly next?: ApprovalRecord
10
+ }
11
+
12
+ export interface DurableApprovalProtocol {
13
+ decide(
14
+ current: ApprovalRecord | undefined,
15
+ input: DurableApprovalDecisionInput,
16
+ ): ApprovalRecord | undefined
17
+ consume(
18
+ current: ApprovalRecord | undefined,
19
+ input: DurableApprovalConsumeInput,
20
+ ): DurableApprovalConsumeTransition
21
+ }
22
+
23
+ function decideApproval(
24
+ current: ApprovalRecord | undefined,
25
+ input: DurableApprovalDecisionInput,
26
+ ): ApprovalRecord | undefined {
27
+ if (
28
+ current === undefined ||
29
+ current.tenantId !== input.tenantId ||
30
+ current.state !== "pending" ||
31
+ current.expiresAt <= input.now
32
+ ) {
33
+ return undefined
34
+ }
35
+ return Object.freeze({
36
+ ...current,
37
+ state: input.decision,
38
+ decidedBy: input.decidedBy,
39
+ updatedAt: input.now,
40
+ version: current.version + 1,
41
+ })
42
+ }
43
+
44
+ function consumeApproval(
45
+ current: ApprovalRecord | undefined,
46
+ input: DurableApprovalConsumeInput,
47
+ ): DurableApprovalConsumeTransition {
48
+ if (current === undefined) return { result: { state: "missing" } }
49
+ if (
50
+ current.tenantId !== input.tenantId ||
51
+ current.principalId !== input.principalId ||
52
+ current.capability !== input.capability ||
53
+ current.target !== input.target ||
54
+ !safeEqual(current.digest, input.digest)
55
+ ) {
56
+ return { result: { state: "binding" } }
57
+ }
58
+ if (!safeEqual(current.tokenHash, input.tokenHash)) return { result: { state: "token" } }
59
+ if (current.state === "consumed") return { result: { state: "replay" } }
60
+ if (current.state === "denied") return { result: { state: "denied" } }
61
+ if (current.state === "pending") return { result: { state: "pending" } }
62
+ if (current.state === "expired" || current.expiresAt <= input.now) {
63
+ return {
64
+ result: { state: "expired" },
65
+ ...(current.state === "expired"
66
+ ? {}
67
+ : {
68
+ next: Object.freeze({
69
+ ...current,
70
+ state: "expired" as const,
71
+ updatedAt: input.now,
72
+ version: current.version + 1,
73
+ }),
74
+ }),
75
+ }
76
+ }
77
+ if (current.state !== "approved") return { result: { state: "token" } }
78
+ return {
79
+ result: { state: "consumed" },
80
+ next: Object.freeze({
81
+ ...current,
82
+ state: "consumed" as const,
83
+ updatedAt: input.now,
84
+ version: current.version + 1,
85
+ }),
86
+ }
87
+ }
88
+
89
+ export const durableApprovalProtocol: DurableApprovalProtocol = Object.freeze({
90
+ decide: decideApproval,
91
+ consume: consumeApproval,
92
+ })
@@ -0,0 +1,37 @@
1
+ import type { DurableEffectRecord, DurableEffectState } from "../durable-execution.ts"
2
+
3
+ export interface DurableEffectTransitionInput {
4
+ readonly effectId: string
5
+ readonly version: number
6
+ readonly from: DurableEffectState
7
+ readonly to: DurableEffectState
8
+ readonly updatedAt: number
9
+ readonly errorCode?: string
10
+ }
11
+
12
+ export interface DurableEffectProtocol {
13
+ transition(
14
+ current: DurableEffectRecord | undefined,
15
+ input: DurableEffectTransitionInput,
16
+ ): DurableEffectRecord | undefined
17
+ }
18
+
19
+ function transitionEffect(
20
+ current: DurableEffectRecord | undefined,
21
+ input: DurableEffectTransitionInput,
22
+ ): DurableEffectRecord | undefined {
23
+ if (current === undefined || current.version !== input.version || current.state !== input.from) {
24
+ return undefined
25
+ }
26
+ return Object.freeze({
27
+ ...current,
28
+ state: input.to,
29
+ updatedAt: input.updatedAt,
30
+ version: current.version + 1,
31
+ ...(input.errorCode === undefined ? {} : { errorCode: input.errorCode }),
32
+ })
33
+ }
34
+
35
+ export const durableEffectProtocol: DurableEffectProtocol = Object.freeze({
36
+ transition: transitionEffect,
37
+ })
@@ -0,0 +1,55 @@
1
+ import type { SagaRecord, SagaState, SagaStepRecord, SagaStore } from "../durable-execution.ts"
2
+
3
+ export type DurableSagaTransitionInput = Parameters<SagaStore["compareAndSet"]>[0]
4
+
5
+ export interface DurableSagaProtocol {
6
+ transition(
7
+ current: SagaRecord | undefined,
8
+ input: DurableSagaTransitionInput,
9
+ ): SagaRecord | undefined
10
+ withStep(
11
+ record: SagaRecord,
12
+ index: number,
13
+ step: SagaStepRecord,
14
+ state: SagaState,
15
+ updatedAt: number,
16
+ ): Omit<SagaRecord, "version">
17
+ }
18
+
19
+ function transitionSaga(
20
+ current: SagaRecord | undefined,
21
+ input: DurableSagaTransitionInput,
22
+ ): SagaRecord | undefined {
23
+ if (
24
+ input.record.version !== input.version + 1 ||
25
+ (current !== undefined &&
26
+ (current.sagaId !== input.sagaId || current.version !== input.version))
27
+ ) {
28
+ return undefined
29
+ }
30
+ return Object.freeze({ ...input.record })
31
+ }
32
+
33
+ function withSagaStep(
34
+ record: SagaRecord,
35
+ index: number,
36
+ step: SagaStepRecord,
37
+ state: SagaState,
38
+ updatedAt: number,
39
+ ): Omit<SagaRecord, "version"> {
40
+ return {
41
+ ...record,
42
+ state,
43
+ steps: Object.freeze(
44
+ record.steps.map((candidate, candidateIndex) =>
45
+ candidateIndex === index ? Object.freeze(step) : candidate,
46
+ ),
47
+ ),
48
+ updatedAt,
49
+ }
50
+ }
51
+
52
+ export const durableSagaProtocol: DurableSagaProtocol = Object.freeze({
53
+ transition: transitionSaga,
54
+ withStep: withSagaStep,
55
+ })
@@ -80,6 +80,12 @@ export class RouteCatalog {
80
80
  return this.records
81
81
  }
82
82
 
83
+ /** Number of registered routes. Read at hook push time and at seal to detect an order-scoped hook
84
+ * that captured no route (see `Server.sealConfiguration`). */
85
+ get size(): number {
86
+ return this.records.length
87
+ }
88
+
83
89
  routeDescriptors(): ReadonlyArray<RouteDescriptor> {
84
90
  return this.descriptors
85
91
  }