@nifrajs/core 5.7.0 → 5.9.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 (223) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +9 -4
  3. package/dist/assurance.d.ts +26 -1
  4. package/dist/assurance.d.ts.map +1 -1
  5. package/dist/assurance.js +43 -11
  6. package/dist/assurance.js.map +1 -1
  7. package/dist/canonical-json.d.ts +11 -0
  8. package/dist/canonical-json.d.ts.map +1 -0
  9. package/dist/canonical-json.js +46 -0
  10. package/dist/canonical-json.js.map +1 -0
  11. package/dist/capabilities.d.ts.map +1 -1
  12. package/dist/capabilities.js +1 -8
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/data.d.ts.map +1 -1
  15. package/dist/data.js +1 -8
  16. package/dist/data.js.map +1 -1
  17. package/dist/diff.d.ts.map +1 -1
  18. package/dist/diff.js +2 -12
  19. package/dist/diff.js.map +1 -1
  20. package/dist/durable-adapters.d.ts +2 -0
  21. package/dist/durable-adapters.d.ts.map +1 -1
  22. package/dist/durable-adapters.js +41 -49
  23. package/dist/durable-adapters.js.map +1 -1
  24. package/dist/durable-saga.d.ts.map +1 -1
  25. package/dist/durable-saga.js +14 -20
  26. package/dist/durable-saga.js.map +1 -1
  27. package/dist/evidence.d.ts.map +1 -1
  28. package/dist/evidence.js +14 -68
  29. package/dist/evidence.js.map +1 -1
  30. package/dist/idempotency.d.ts.map +1 -1
  31. package/dist/idempotency.js +60 -23
  32. package/dist/idempotency.js.map +1 -1
  33. package/dist/index.d.ts +3 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/internal/control-character.d.ts +3 -0
  38. package/dist/internal/control-character.d.ts.map +1 -0
  39. package/dist/internal/control-character.js +10 -0
  40. package/dist/internal/control-character.js.map +1 -0
  41. package/dist/internal/durable-shared.d.ts.map +1 -1
  42. package/dist/internal/durable-shared.js +4 -1
  43. package/dist/internal/durable-shared.js.map +1 -1
  44. package/dist/internal/hex.d.ts +5 -0
  45. package/dist/internal/hex.d.ts.map +1 -0
  46. package/dist/internal/hex.js +14 -0
  47. package/dist/internal/hex.js.map +1 -0
  48. package/dist/internal/record.d.ts +3 -0
  49. package/dist/internal/record.d.ts.map +1 -0
  50. package/dist/internal/record.js +5 -0
  51. package/dist/internal/record.js.map +1 -0
  52. package/dist/internal/route-compiler.d.ts.map +1 -1
  53. package/dist/internal/route-compiler.js +2 -2
  54. package/dist/internal/route-compiler.js.map +1 -1
  55. package/dist/internal/route-execution.d.ts +4 -4
  56. package/dist/internal/route-execution.d.ts.map +1 -1
  57. package/dist/internal/route-execution.js +4 -0
  58. package/dist/internal/route-execution.js.map +1 -1
  59. package/dist/internal/route-lanes.d.ts +4 -3
  60. package/dist/internal/route-lanes.d.ts.map +1 -1
  61. package/dist/internal/route-lanes.js +16 -33
  62. package/dist/internal/route-lanes.js.map +1 -1
  63. package/dist/internal/schema-parts.d.ts +13 -0
  64. package/dist/internal/schema-parts.d.ts.map +1 -0
  65. package/dist/internal/schema-parts.js +15 -0
  66. package/dist/internal/schema-parts.js.map +1 -0
  67. package/dist/ledger.d.ts.map +1 -1
  68. package/dist/ledger.js +2 -14
  69. package/dist/ledger.js.map +1 -1
  70. package/dist/manifest.d.ts.map +1 -1
  71. package/dist/manifest.js +10 -37
  72. package/dist/manifest.js.map +1 -1
  73. package/dist/open-resolved.d.ts +18 -0
  74. package/dist/open-resolved.d.ts.map +1 -0
  75. package/dist/open-resolved.js +62 -0
  76. package/dist/open-resolved.js.map +1 -0
  77. package/dist/range.js +1 -1
  78. package/dist/range.js.map +1 -1
  79. package/dist/serve-static.d.ts +22 -0
  80. package/dist/serve-static.d.ts.map +1 -0
  81. package/dist/serve-static.js +187 -0
  82. package/dist/serve-static.js.map +1 -0
  83. package/dist/server/bare-error-lane.d.ts +2 -1
  84. package/dist/server/bare-error-lane.d.ts.map +1 -1
  85. package/dist/server/bare-error-lane.js +12 -3
  86. package/dist/server/bare-error-lane.js.map +1 -1
  87. package/dist/server/base64-answers.d.ts.map +1 -1
  88. package/dist/server/base64-answers.js +1 -4
  89. package/dist/server/base64-answers.js.map +1 -1
  90. package/dist/server/body-lane.d.ts.map +1 -1
  91. package/dist/server/body-lane.js +43 -38
  92. package/dist/server/body-lane.js.map +1 -1
  93. package/dist/server/body.d.ts.map +1 -1
  94. package/dist/server/body.js +4 -1
  95. package/dist/server/body.js.map +1 -1
  96. package/dist/server/bridge-markers.d.ts +1 -1
  97. package/dist/server/bridge-markers.js +1 -1
  98. package/dist/server/cookies.d.ts.map +1 -1
  99. package/dist/server/cookies.js +4 -1
  100. package/dist/server/cookies.js.map +1 -1
  101. package/dist/server/core-copies.js +1 -1
  102. package/dist/server/http.d.ts +5 -0
  103. package/dist/server/http.d.ts.map +1 -1
  104. package/dist/server/http.js +13 -0
  105. package/dist/server/http.js.map +1 -1
  106. package/dist/server/idempotency-lane.d.ts.map +1 -1
  107. package/dist/server/idempotency-lane.js +2 -0
  108. package/dist/server/idempotency-lane.js.map +1 -1
  109. package/dist/server/idle-timeout.d.ts +39 -0
  110. package/dist/server/idle-timeout.d.ts.map +1 -0
  111. package/dist/server/idle-timeout.js +95 -0
  112. package/dist/server/idle-timeout.js.map +1 -0
  113. package/dist/server/json-shared.d.ts +23 -0
  114. package/dist/server/json-shared.d.ts.map +1 -0
  115. package/dist/server/json-shared.js +33 -0
  116. package/dist/server/json-shared.js.map +1 -0
  117. package/dist/server/json-text.d.ts +1 -1
  118. package/dist/server/json-text.js +1 -1
  119. package/dist/server/node-outcome.js +1 -1
  120. package/dist/server/node-outcome.js.map +1 -1
  121. package/dist/server/request-context.d.ts +22 -1
  122. package/dist/server/request-context.d.ts.map +1 -1
  123. package/dist/server/request-context.js +44 -4
  124. package/dist/server/request-context.js.map +1 -1
  125. package/dist/server/request-local.d.ts +44 -0
  126. package/dist/server/request-local.d.ts.map +1 -0
  127. package/dist/server/request-local.js +91 -0
  128. package/dist/server/request-local.js.map +1 -0
  129. package/dist/server/respond.js +1 -1
  130. package/dist/server/respond.js.map +1 -1
  131. package/dist/server/response-contract-lane.d.ts +10 -2
  132. package/dist/server/response-contract-lane.d.ts.map +1 -1
  133. package/dist/server/response-contract-lane.js +93 -64
  134. package/dist/server/response-contract-lane.js.map +1 -1
  135. package/dist/server/runtime-core.d.ts +7 -4
  136. package/dist/server/runtime-core.d.ts.map +1 -1
  137. package/dist/server/runtime-core.js +9 -7
  138. package/dist/server/runtime-core.js.map +1 -1
  139. package/dist/server/server-types.d.ts +27 -0
  140. package/dist/server/server-types.d.ts.map +1 -1
  141. package/dist/server/server.d.ts +59 -59
  142. package/dist/server/server.d.ts.map +1 -1
  143. package/dist/server/server.js +869 -512
  144. package/dist/server/server.js.map +1 -1
  145. package/dist/server/transport-lane.d.ts.map +1 -1
  146. package/dist/server/transport-lane.js +2 -0
  147. package/dist/server/transport-lane.js.map +1 -1
  148. package/dist/server/webhook.js +1 -1
  149. package/dist/server/webhook.js.map +1 -1
  150. package/dist/server/websocket.d.ts +3 -1
  151. package/dist/server/websocket.d.ts.map +1 -1
  152. package/dist/server/websocket.js +14 -1
  153. package/dist/server/websocket.js.map +1 -1
  154. package/dist/server/ws-adapter-view.d.ts +54 -0
  155. package/dist/server/ws-adapter-view.d.ts.map +1 -0
  156. package/dist/server/ws-adapter-view.js +7 -0
  157. package/dist/server/ws-adapter-view.js.map +1 -0
  158. package/dist/server.d.ts +2 -1
  159. package/dist/server.d.ts.map +1 -1
  160. package/dist/server.js +2 -1
  161. package/dist/server.js.map +1 -1
  162. package/dist/single-copy.d.ts.map +1 -1
  163. package/dist/single-copy.js +1 -1
  164. package/dist/single-copy.js.map +1 -1
  165. package/dist/static-file.d.ts +156 -4
  166. package/dist/static-file.d.ts.map +1 -1
  167. package/dist/static-file.js +198 -4
  168. package/dist/static-file.js.map +1 -1
  169. package/dist/wire.d.ts.map +1 -1
  170. package/dist/wire.js +1 -3
  171. package/dist/wire.js.map +1 -1
  172. package/package.json +16 -1
  173. package/src/assurance.ts +66 -16
  174. package/src/canonical-json.ts +43 -0
  175. package/src/capabilities.ts +1 -8
  176. package/src/data.ts +2 -8
  177. package/src/diff.ts +2 -12
  178. package/src/durable-adapters.ts +53 -50
  179. package/src/durable-saga.ts +30 -23
  180. package/src/evidence.ts +25 -90
  181. package/src/idempotency.ts +62 -20
  182. package/src/index.ts +13 -2
  183. package/src/internal/control-character.ts +8 -0
  184. package/src/internal/durable-shared.ts +4 -1
  185. package/src/internal/hex.ts +14 -0
  186. package/src/internal/record.ts +4 -0
  187. package/src/internal/route-compiler.ts +2 -2
  188. package/src/internal/route-execution.ts +7 -20
  189. package/src/internal/route-lanes.ts +20 -44
  190. package/src/internal/schema-parts.ts +19 -0
  191. package/src/ledger.ts +2 -16
  192. package/src/manifest.ts +10 -38
  193. package/src/open-resolved.ts +64 -0
  194. package/src/range.ts +1 -1
  195. package/src/serve-static.ts +227 -0
  196. package/src/server/bare-error-lane.ts +11 -2
  197. package/src/server/base64-answers.ts +2 -6
  198. package/src/server/body-lane.ts +58 -51
  199. package/src/server/body.ts +4 -1
  200. package/src/server/bridge-markers.ts +1 -1
  201. package/src/server/cookies.ts +4 -1
  202. package/src/server/core-copies.ts +1 -1
  203. package/src/server/http.ts +12 -0
  204. package/src/server/idempotency-lane.ts +2 -0
  205. package/src/server/idle-timeout.ts +107 -0
  206. package/src/server/json-shared.ts +38 -0
  207. package/src/server/json-text.ts +1 -1
  208. package/src/server/node-outcome.ts +1 -1
  209. package/src/server/request-context.ts +62 -3
  210. package/src/server/request-local.ts +109 -0
  211. package/src/server/respond.ts +1 -1
  212. package/src/server/response-contract-lane.ts +112 -65
  213. package/src/server/runtime-core.ts +10 -8
  214. package/src/server/server-types.ts +27 -0
  215. package/src/server/server.ts +1230 -800
  216. package/src/server/transport-lane.ts +2 -0
  217. package/src/server/webhook.ts +1 -1
  218. package/src/server/websocket.ts +18 -1
  219. package/src/server/ws-adapter-view.ts +63 -0
  220. package/src/server.ts +12 -1
  221. package/src/single-copy.ts +1 -3
  222. package/src/static-file.ts +350 -4
  223. package/src/wire.ts +2 -4
package/src/assurance.ts CHANGED
@@ -88,11 +88,22 @@ export interface ContractCaseContext {
88
88
  export interface InvariantSetupResult {
89
89
  /** The isolated target for this run. Replaces `invariants.executor`. */
90
90
  readonly executor?: InvariantExecutor
91
+ /**
92
+ * Known-good requests that name what only exists once the target is up - a real slug, a row id,
93
+ * a signed-in session - keyed by `"METHOD /path"`. Merged over `invariants.witnesses`: a key in
94
+ * both takes this one. Validated as the static ones are.
95
+ */
96
+ readonly witnesses?: Readonly<Record<string, ContractWitness>>
97
+ /** Replaces `invariants.prepareRequest` for this run, so it can attach what setup created. */
98
+ readonly prepareRequest?: InvariantRunOptions["prepareRequest"]
91
99
  /** Called once the run ends, after a failure too. */
92
100
  readonly teardown?: () => void | Promise<void>
93
101
  }
94
102
 
95
- /** Provisions a throwaway L4 target. Called once, before the first L4 request - never on import. */
103
+ /**
104
+ * Provisions a throwaway L4 target. Called once per L4 run, before its first case is generated, so
105
+ * the witnesses it returns are known to every case - never on import.
106
+ */
96
107
  // biome-ignore lint/suspicious/noConfusingVoidType: `void` lets a setup that provisions nothing be written as `async () => {}`.
97
108
  export type InvariantSetup = () => Promise<InvariantSetupResult | undefined | void>
98
109
 
@@ -655,21 +666,7 @@ function assertInvariantsConfig(invariants: InvariantsConfig): void {
655
666
  if (invariants.executor === undefined && invariants.setup === undefined) {
656
667
  throw new TypeError("route assurance: invariants needs an executor or a setup")
657
668
  }
658
- const witnesses: unknown = invariants.witnesses
659
- if (witnesses !== undefined) {
660
- if (witnesses === null || typeof witnesses !== "object" || Array.isArray(witnesses)) {
661
- throw new TypeError(
662
- 'route assurance: invariants.witnesses must map "METHOD /path" to a witness',
663
- )
664
- }
665
- for (const [key, witness] of Object.entries(witnesses)) {
666
- if (witness === null || typeof witness !== "object" || Array.isArray(witness)) {
667
- throw new TypeError(
668
- `route assurance: invariants.witnesses[${JSON.stringify(key)}] must be an object`,
669
- )
670
- }
671
- }
672
- }
669
+ assertWitnesses(invariants.witnesses, "invariants.witnesses")
673
670
  const max = invariants.maxMutationsPerInput
674
671
  if (max !== undefined && (!Number.isSafeInteger(max) || max < 1)) {
675
672
  throw new RangeError(
@@ -678,6 +675,59 @@ function assertInvariantsConfig(invariants: InvariantsConfig): void {
678
675
  }
679
676
  }
680
677
 
678
+ function assertWitnesses(witnesses: unknown, label: string): void {
679
+ if (witnesses === undefined) return
680
+ if (witnesses === null || typeof witnesses !== "object" || Array.isArray(witnesses)) {
681
+ throw new TypeError(`route assurance: ${label} must map "METHOD /path" to a witness`)
682
+ }
683
+ for (const [key, witness] of Object.entries(witnesses)) {
684
+ if (witness === null || typeof witness !== "object" || Array.isArray(witness)) {
685
+ throw new TypeError(`route assurance: ${label}[${JSON.stringify(key)}] must be an object`)
686
+ }
687
+ }
688
+ }
689
+
690
+ /** One L4 run's options once `invariants.setup` has provisioned its target. */
691
+ export interface AppliedInvariantSetup {
692
+ /** The config with the setup's executor, witnesses and prepareRequest merged over it. */
693
+ readonly invariants: InvariantsConfig
694
+ /** The `"METHOD /path"` witness keys the setup returned, in its order. */
695
+ readonly setupWitnesses: readonly string[]
696
+ }
697
+
698
+ /**
699
+ * Merge what `invariants.setup` returned over the static `invariants`, validating it the way
700
+ * `defineAssuranceConfig` validates the static fields: its executor and prepareRequest replace the
701
+ * static ones, and its witnesses are merged over theirs, a key in both taking the setup's. For a
702
+ * harness that runs L4 itself; `nifra levels` calls it once setup resolves.
703
+ */
704
+ export function applyInvariantSetup(
705
+ invariants: InvariantsConfig,
706
+ result: InvariantSetupResult | undefined,
707
+ ): AppliedInvariantSetup {
708
+ if (result === undefined) return Object.freeze({ invariants, setupWitnesses: [] })
709
+ if (result === null || typeof result !== "object" || Array.isArray(result)) {
710
+ throw new TypeError("route assurance: invariants.setup() must resolve to an object or nothing")
711
+ }
712
+ for (const name of ["executor", "prepareRequest", "teardown"] as const) {
713
+ const value: unknown = result[name]
714
+ if (value !== undefined && typeof value !== "function") {
715
+ throw new TypeError(`route assurance: invariants.setup().${name} must be a function`)
716
+ }
717
+ }
718
+ assertWitnesses(result.witnesses, "invariants.setup().witnesses")
719
+ const setupWitnesses = Object.freeze(Object.keys(result.witnesses ?? {}))
720
+ const merged: InvariantsConfig = {
721
+ ...invariants,
722
+ ...(result.executor === undefined ? {} : { executor: result.executor }),
723
+ ...(result.prepareRequest === undefined ? {} : { prepareRequest: result.prepareRequest }),
724
+ ...(result.witnesses === undefined
725
+ ? {}
726
+ : { witnesses: Object.freeze({ ...invariants.witnesses, ...result.witnesses }) }),
727
+ }
728
+ return Object.freeze({ invariants: Object.freeze(merged), setupWitnesses })
729
+ }
730
+
681
731
  function frozenInvariantsConfig(invariants: InvariantsConfig): InvariantsConfig {
682
732
  const witnesses = invariants.witnesses
683
733
  return Object.freeze(
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Canonical JSON: object keys sorted by UTF-16 code unit, no whitespace, `undefined` members left out.
3
+ *
4
+ * Content hashes, manifest signatures, idempotency fingerprints and stored digests are all taken over
5
+ * these bytes, so the output for JSON data never changes. A value JSON cannot carry throws instead of
6
+ * hashing as something else: a non-finite number, an `undefined` or missing array item, a `Date`,
7
+ * `Map` or class instance, a function, a symbol, a bigint. A `JSON.rawJSON` value is written verbatim.
8
+ */
9
+
10
+ import { isPlainPrototype, isRawJson } from "./server/json-shared.ts"
11
+
12
+ /** The canonical JSON text of `value`. Throws a `TypeError` on a value JSON cannot carry. */
13
+ export function canonicalJson(value: unknown): string {
14
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
15
+ return JSON.stringify(value)
16
+ }
17
+ if (typeof value === "number") {
18
+ if (Number.isFinite(value)) return JSON.stringify(value)
19
+ } else if (Array.isArray(value)) {
20
+ // Each level is joined into a flat string: a `+=` rope builds faster, but on Bun it costs more
21
+ // once a caller reads it. An undefined or missing item recurses into the refusal below.
22
+ const items = new Array<string>(value.length)
23
+ for (let i = 0; i < value.length; i++) items[i] = canonicalJson(value[i])
24
+ return `[${items.join(",")}]`
25
+ } else if (typeof value === "object") {
26
+ const proto: unknown = Object.getPrototypeOf(value)
27
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: isRawJSON vouched for it, and a raw JSON value keeps its text in `rawJSON`.
28
+ if (proto === null && isRawJson?.(value) === true) return (value as { rawJSON: string }).rawJSON
29
+ if (isPlainPrototype(proto)) {
30
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a non-array object with a plain or null prototype; only its own string keys are read.
31
+ const record = value as Readonly<Record<string, unknown>>
32
+ const members: string[] = []
33
+ for (const key of Object.keys(record).sort()) {
34
+ const field = record[key]
35
+ if (field !== undefined) members.push(`${JSON.stringify(key)}:${canonicalJson(field)}`)
36
+ }
37
+ return `{${members.join(",")}}`
38
+ }
39
+ }
40
+ throw new TypeError(
41
+ `canonical JSON cannot encode ${typeof value === "object" ? "a non-plain object" : typeof value === "number" ? value : typeof value}`,
42
+ )
43
+ }
@@ -22,6 +22,7 @@ import {
22
22
  validCapabilityId,
23
23
  } from "./internal/capability-runtime.ts"
24
24
  import { codeUnitOrder } from "./internal/code-unit-order.ts"
25
+ import { hasControlCharacter } from "./internal/control-character.ts"
25
26
  import { effectScopeForContext } from "./internal/effect-execution.ts"
26
27
  import { NIFRA_ASSURANCE_IDS } from "./internal/route-assurance.ts"
27
28
  import {
@@ -264,14 +265,6 @@ function validSpecifier(value: string): boolean {
264
265
  return value.trim() === value && value !== "" && !hasControlCharacter(value)
265
266
  }
266
267
 
267
- function hasControlCharacter(value: string): boolean {
268
- for (let index = 0; index < value.length; index++) {
269
- const code = value.charCodeAt(index)
270
- if (code <= 31 || code === 127) return true
271
- }
272
- return false
273
- }
274
-
275
268
  /** Validate and freeze a capability/provenance policy. */
276
269
  export function defineCapabilityPolicy(policy: CapabilityPolicy): CapabilityPolicy {
277
270
  if (policy.provenance === undefined || policy.provenance === null) {
package/src/data.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /** Public, token-only data access contracts. Durable adapters and RLS policy enforcement stay outside
2
2
  * this package; this subpath defines the typed seam they implement. */
3
3
 
4
+ import { hasControlCharacter } from "./internal/control-character.ts"
5
+
4
6
  export const DATA_CAPABILITIES = Object.freeze({
5
7
  read: "db.read",
6
8
  write: "db.write",
@@ -179,14 +181,6 @@ function assertToken(value: string, label: string): string {
179
181
  return value
180
182
  }
181
183
 
182
- function hasControlCharacter(value: string): boolean {
183
- for (let index = 0; index < value.length; index++) {
184
- const code = value.charCodeAt(index)
185
- if (code <= 31 || code === 127) return true
186
- }
187
- return false
188
- }
189
-
190
184
  /** Create a bounded, opaque RLS/equivalent policy scope for a data adapter. */
191
185
  export function rlsScope(token: string, digest?: string): RlsScope {
192
186
  const scope = {
package/src/diff.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { type ProjectEvidenceSnapshot, reflectedRoutesFromEvidence } from "./evidence.ts"
22
+ import { mapSchemaParts } from "./internal/schema-parts.ts"
22
23
  import {
23
24
  type JsonSchema,
24
25
  type ReflectedRoute,
@@ -120,20 +121,9 @@ const snapshotRoute = (route: ReflectedRoute): RouteSnapshot => {
120
121
  const snapped = schemaSnapshot(reflection)
121
122
  if (snapped !== undefined) errors[status] = snapped
122
123
  }
123
- const body = schemaSnapshot(schema.body)
124
- const headers = schemaSnapshot(schema.headers)
125
- const cookies = schemaSnapshot(schema.cookies)
126
- const query = schemaSnapshot(schema.query)
127
- const params = schemaSnapshot(schema.params)
128
- const response = schemaSnapshot(schema.response)
129
124
  const sse = schemaSnapshot(schema.sse)
130
125
  const snapped: RouteSnapshotSchema = {
131
- ...(headers !== undefined ? { headers } : {}),
132
- ...(cookies !== undefined ? { cookies } : {}),
133
- ...(body !== undefined ? { body } : {}),
134
- ...(query !== undefined ? { query } : {}),
135
- ...(params !== undefined ? { params } : {}),
136
- ...(response !== undefined ? { response } : {}),
126
+ ...mapSchemaParts(schema, schemaSnapshot),
137
127
  ...(sse !== undefined ? { sse } : {}),
138
128
  ...(Object.keys(errors).length > 0 ? { errors } : {}),
139
129
  }
@@ -472,6 +472,29 @@ function recordFromRow<T>(row: Record<string, unknown> | undefined): T | undefin
472
472
  return clone(parseWire(text) as T)
473
473
  }
474
474
 
475
+ /** A scan page from up to `limit + 1` rows: the first `limit` as records, with a cursor at the last of
476
+ * them when a row is left over. */
477
+ function scanPage<T>(
478
+ rows: readonly Record<string, unknown>[],
479
+ limit: number,
480
+ ): ReconciliationScanPage<T> {
481
+ const page = rows.slice(0, limit)
482
+ return Object.freeze({
483
+ records: Object.freeze(page.map((row) => recordFromRow<T>(row) as T)),
484
+ ...(rows.length > limit ? { cursor: String(page.at(-1)?.id) } : {}),
485
+ })
486
+ }
487
+
488
+ function leaseFromRow(row: Record<string, unknown>): ReconciliationLease {
489
+ return Object.freeze({
490
+ name: String(row.name),
491
+ owner: String(row.owner),
492
+ token: String(row.token),
493
+ expiresAt: Number(row.expires_at),
494
+ ...(row.cursor === null || row.cursor === undefined ? {} : { cursor: String(row.cursor) }),
495
+ })
496
+ }
497
+
475
498
  function serializeRecord(record: unknown): string {
476
499
  return stringifyWire(record)
477
500
  }
@@ -564,12 +587,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
564
587
  sqlIdentifiers`SELECT id,payload FROM ${this.recordsTable} WHERE kind=$1 AND state = ANY($2::text[]) AND ($3::bigint IS NULL OR updated_at <= $3) AND ($4::text IS NULL OR id > $4) ORDER BY id LIMIT $5`,
565
588
  [kind, input.states, input.updatedBefore ?? null, input.cursor ?? null, input.limit + 1],
566
589
  )
567
- const hasMore = result.rows.length > input.limit
568
- const rows = result.rows.slice(0, input.limit)
569
- return Object.freeze({
570
- records: Object.freeze(rows.map((row) => recordFromRow<T>(row) as T)),
571
- ...(hasMore ? { cursor: String(rows.at(-1)?.id) } : {}),
572
- })
590
+ return scanPage<T>(result.rows, input.limit)
573
591
  }
574
592
  async acquireLease(
575
593
  input: Parameters<DurableRecordBackend["acquireLease"]>[0],
@@ -583,13 +601,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
583
601
  )
584
602
  const row = result.rows[0]
585
603
  if (row === undefined) return undefined
586
- return Object.freeze({
587
- name: String(row.name),
588
- owner: String(row.owner),
589
- token: String(row.token),
590
- expiresAt: Number(row.expires_at),
591
- ...(row.cursor === null || row.cursor === undefined ? {} : { cursor: String(row.cursor) }),
592
- })
604
+ return leaseFromRow(row)
593
605
  }
594
606
  async renewLease(input: Parameters<DurableRecordBackend["renewLease"]>[0]): Promise<boolean> {
595
607
  // nifra-expect sql-dynamic: interpolates a validated sqlIdentifier table name, which SQL cannot bind as a parameter
@@ -744,13 +756,10 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
744
756
  input.cursor ?? null,
745
757
  input.limit + 1,
746
758
  )
747
- const present = rows.filter((row): row is Record<string, unknown> => row !== undefined)
748
- const hasMore = present.length > input.limit
749
- const page = present.slice(0, input.limit)
750
- return Object.freeze({
751
- records: Object.freeze(page.map((row) => recordFromRow<T>(row) as T)),
752
- ...(hasMore ? { cursor: String(page.at(-1)?.id) } : {}),
753
- })
759
+ return scanPage<T>(
760
+ rows.filter((row): row is Record<string, unknown> => row !== undefined),
761
+ input.limit,
762
+ )
754
763
  }
755
764
  async acquireLease(
756
765
  input: Parameters<DurableRecordBackend["acquireLease"]>[0],
@@ -764,13 +773,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
764
773
  )
765
774
  .get(input.name, input.owner, token, expiresAt, input.now)
766
775
  if (row === undefined || row === null) return undefined
767
- return Object.freeze({
768
- name: String(row.name),
769
- owner: String(row.owner),
770
- token: String(row.token),
771
- expiresAt: Number(row.expires_at),
772
- ...(row.cursor === null || row.cursor === undefined ? {} : { cursor: String(row.cursor) }),
773
- })
776
+ return leaseFromRow(row)
774
777
  }
775
778
  async renewLease(input: Parameters<DurableRecordBackend["renewLease"]>[0]): Promise<boolean> {
776
779
  return (
@@ -963,42 +966,42 @@ export class DurableObjectRecordBackend implements DurableRecordBackend {
963
966
  })
964
967
  }
965
968
  async renewLease(input: Parameters<DurableRecordBackend["renewLease"]>[0]): Promise<boolean> {
966
- return await this.storage.transaction(async (txn) => {
967
- const key = this.leaseKey(input.name)
968
- const current = await txn.get<DurableObjectLeaseValue>(key)
969
- if (
970
- current?.owner !== input.owner ||
971
- !safeEqual(current.token, input.token) ||
972
- current.expiresAt <= input.now
973
- )
974
- return false
975
- await txn.put(key, { ...current, expiresAt: input.now + input.leaseMs })
976
- return true
977
- })
969
+ return await this.updateHeldLease(input, (current) =>
970
+ current.expiresAt <= input.now
971
+ ? undefined
972
+ : { ...current, expiresAt: input.now + input.leaseMs },
973
+ )
978
974
  }
979
975
  async checkpointLease(
980
976
  input: Parameters<DurableRecordBackend["checkpointLease"]>[0],
981
977
  ): Promise<boolean> {
982
- return await this.storage.transaction(async (txn) => {
983
- const key = this.leaseKey(input.name)
984
- const current = await txn.get<DurableObjectLeaseValue>(key)
985
- if (current?.owner !== input.owner || !safeEqual(current.token, input.token)) return false
978
+ return await this.updateHeldLease(input, (current) => {
986
979
  const next = { ...current }
987
980
  if (input.cursor === undefined) delete next.cursor
988
981
  else next.cursor = input.cursor
989
- await txn.put(key, next)
990
- return true
982
+ return next
991
983
  })
992
984
  }
993
985
  async releaseLease(input: Parameters<DurableRecordBackend["releaseLease"]>[0]): Promise<boolean> {
994
- return await this.storage.transaction(async (txn) => {
995
- const key = this.leaseKey(input.name)
996
- const current = await txn.get<DurableObjectLeaseValue>(key)
997
- if (current?.owner !== input.owner || !safeEqual(current.token, input.token)) return false
986
+ return await this.updateHeldLease(input, (current) => {
998
987
  const next = { ...current, expiresAt: 0 }
999
988
  delete next.owner
1000
989
  delete next.token
1001
- await txn.put(key, next)
990
+ return next
991
+ })
992
+ }
993
+ /** Write `next(current)` over the lease while `input`'s owner and token still hold it. */
994
+ private updateHeldLease(
995
+ input: { readonly name: string; readonly owner: string; readonly token: string },
996
+ next: (current: DurableObjectLeaseValue) => DurableObjectLeaseValue | undefined,
997
+ ): Promise<boolean> {
998
+ return this.storage.transaction(async (txn) => {
999
+ const key = this.leaseKey(input.name)
1000
+ const current = await txn.get<DurableObjectLeaseValue>(key)
1001
+ if (current?.owner !== input.owner || !safeEqual(current.token, input.token)) return false
1002
+ const value = next(current)
1003
+ if (value === undefined) return false
1004
+ await txn.put(key, value)
1002
1005
  return true
1003
1006
  })
1004
1007
  }
@@ -323,31 +323,48 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
323
323
  return record
324
324
  }
325
325
 
326
- const compensate = async <I, C extends Record<string, unknown>>(
327
- definition: SagaDefinition<I, C>,
326
+ /** Run `owned` under a fresh owner id, then give the saga up however it ended. */
327
+ const withFreshOwner = async (
328
328
  sagaId: string,
329
- runOptions: { readonly signal?: AbortSignal } = {},
329
+ owned: (owner: string) => Promise<SagaRecord>,
330
330
  ): Promise<SagaRecord> => {
331
331
  const owner = crypto.randomUUID()
332
332
  try {
333
- return await compensateOwned(definition, sagaId, runOptions, owner)
333
+ return await owned(owner)
334
334
  } finally {
335
335
  await release(sagaId, owner)
336
336
  }
337
337
  }
338
338
 
339
- const compensateOwned = async <I, C extends Record<string, unknown>>(
339
+ /** The stored saga, once `definition` and `sagaId` are well formed and `signal` has not fired. */
340
+ const loadFor = async <I, C extends Record<string, unknown>>(
340
341
  definition: SagaDefinition<I, C>,
341
342
  sagaId: string,
342
- runOptions: { readonly signal?: AbortSignal },
343
- owner: string,
343
+ signal: AbortSignal,
344
344
  ): Promise<SagaRecord> => {
345
345
  assertSagaDefinition(definition)
346
346
  assertToken(sagaId, "saga id", 128)
347
- const signal = signalOrNever(runOptions.signal)
348
347
  throwIfAborted(signal)
349
- let record = await load(sagaId)
348
+ const record = await load(sagaId)
350
349
  if (record.definition !== definition.name) throw new TypeError("saga definition mismatch")
350
+ return record
351
+ }
352
+
353
+ const compensate = <I, C extends Record<string, unknown>>(
354
+ definition: SagaDefinition<I, C>,
355
+ sagaId: string,
356
+ runOptions: { readonly signal?: AbortSignal } = {},
357
+ ): Promise<SagaRecord> =>
358
+ withFreshOwner(sagaId, (owner) => compensateOwned(definition, sagaId, runOptions, owner))
359
+
360
+ const compensateOwned = async <I, C extends Record<string, unknown>>(
361
+ definition: SagaDefinition<I, C>,
362
+ sagaId: string,
363
+ runOptions: { readonly signal?: AbortSignal },
364
+ owner: string,
365
+ ): Promise<SagaRecord> => {
366
+ const signal = signalOrNever(runOptions.signal)
367
+ let record = await loadFor(definition, sagaId, signal)
351
368
  if (record.state === "completed" || record.state === "compensated") return record
352
369
  record = await claim(record, owner)
353
370
  if (
@@ -461,18 +478,12 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
461
478
  return await save(record, { ...record, state: "compensated", updatedAt: now() })
462
479
  }
463
480
 
464
- const resume = async <I, C extends Record<string, unknown>>(
481
+ const resume = <I, C extends Record<string, unknown>>(
465
482
  definition: SagaDefinition<I, C>,
466
483
  sagaId: string,
467
484
  runOptions: { readonly signal?: AbortSignal } = {},
468
- ): Promise<SagaRecord> => {
469
- const owner = crypto.randomUUID()
470
- try {
471
- return await resumeOwned(definition, sagaId, runOptions, owner)
472
- } finally {
473
- await release(sagaId, owner)
474
- }
475
- }
485
+ ): Promise<SagaRecord> =>
486
+ withFreshOwner(sagaId, (owner) => resumeOwned(definition, sagaId, runOptions, owner))
476
487
 
477
488
  const resumeOwned = async <I, C extends Record<string, unknown>>(
478
489
  definition: SagaDefinition<I, C>,
@@ -480,12 +491,8 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
480
491
  runOptions: { readonly signal?: AbortSignal },
481
492
  owner: string,
482
493
  ): Promise<SagaRecord> => {
483
- assertSagaDefinition(definition)
484
- assertToken(sagaId, "saga id", 128)
485
494
  const signal = signalOrNever(runOptions.signal)
486
- throwIfAborted(signal)
487
- let record = await load(sagaId)
488
- if (record.definition !== definition.name) throw new TypeError("saga definition mismatch")
495
+ let record = await loadFor(definition, sagaId, signal)
489
496
  if (record.state !== "running") return record
490
497
  record = await claim(record, owner)
491
498
  const context: SagaRunContext<C> = {