@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.
- package/CHANGELOG.md +105 -0
- package/README.md +9 -4
- package/dist/assurance.d.ts +26 -1
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +43 -11
- package/dist/assurance.js.map +1 -1
- package/dist/canonical-json.d.ts +11 -0
- package/dist/canonical-json.d.ts.map +1 -0
- package/dist/canonical-json.js +46 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +1 -8
- package/dist/capabilities.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +1 -8
- package/dist/data.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +2 -12
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts +2 -0
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +41 -49
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-saga.d.ts.map +1 -1
- package/dist/durable-saga.js +14 -20
- package/dist/durable-saga.js.map +1 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +14 -68
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +60 -23
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/control-character.d.ts +3 -0
- package/dist/internal/control-character.d.ts.map +1 -0
- package/dist/internal/control-character.js +10 -0
- package/dist/internal/control-character.js.map +1 -0
- package/dist/internal/durable-shared.d.ts.map +1 -1
- package/dist/internal/durable-shared.js +4 -1
- package/dist/internal/durable-shared.js.map +1 -1
- package/dist/internal/hex.d.ts +5 -0
- package/dist/internal/hex.d.ts.map +1 -0
- package/dist/internal/hex.js +14 -0
- package/dist/internal/hex.js.map +1 -0
- package/dist/internal/record.d.ts +3 -0
- package/dist/internal/record.d.ts.map +1 -0
- package/dist/internal/record.js +5 -0
- package/dist/internal/record.js.map +1 -0
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +2 -2
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +4 -4
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +4 -0
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +4 -3
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +16 -33
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/schema-parts.d.ts +13 -0
- package/dist/internal/schema-parts.d.ts.map +1 -0
- package/dist/internal/schema-parts.js +15 -0
- package/dist/internal/schema-parts.js.map +1 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +2 -14
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +10 -37
- package/dist/manifest.js.map +1 -1
- package/dist/open-resolved.d.ts +18 -0
- package/dist/open-resolved.d.ts.map +1 -0
- package/dist/open-resolved.js +62 -0
- package/dist/open-resolved.js.map +1 -0
- package/dist/range.js +1 -1
- package/dist/range.js.map +1 -1
- package/dist/serve-static.d.ts +22 -0
- package/dist/serve-static.d.ts.map +1 -0
- package/dist/serve-static.js +187 -0
- package/dist/serve-static.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +2 -1
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +12 -3
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/base64-answers.d.ts.map +1 -1
- package/dist/server/base64-answers.js +1 -4
- package/dist/server/base64-answers.js.map +1 -1
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +43 -38
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +4 -1
- package/dist/server/body.js.map +1 -1
- package/dist/server/bridge-markers.d.ts +1 -1
- package/dist/server/bridge-markers.js +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +4 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/http.d.ts +5 -0
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +13 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +2 -0
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/idle-timeout.d.ts +39 -0
- package/dist/server/idle-timeout.d.ts.map +1 -0
- package/dist/server/idle-timeout.js +95 -0
- package/dist/server/idle-timeout.js.map +1 -0
- package/dist/server/json-shared.d.ts +23 -0
- package/dist/server/json-shared.d.ts.map +1 -0
- package/dist/server/json-shared.js +33 -0
- package/dist/server/json-shared.js.map +1 -0
- package/dist/server/json-text.d.ts +1 -1
- package/dist/server/json-text.js +1 -1
- package/dist/server/node-outcome.js +1 -1
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/request-context.d.ts +22 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +44 -4
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-local.d.ts +44 -0
- package/dist/server/request-local.d.ts.map +1 -0
- package/dist/server/request-local.js +91 -0
- package/dist/server/request-local.js.map +1 -0
- package/dist/server/respond.js +1 -1
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +10 -2
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +93 -64
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/runtime-core.d.ts +7 -4
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +9 -7
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +27 -0
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +59 -59
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +869 -512
- package/dist/server/server.js.map +1 -1
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +2 -0
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/webhook.js +1 -1
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +3 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +14 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-adapter-view.d.ts +54 -0
- package/dist/server/ws-adapter-view.d.ts.map +1 -0
- package/dist/server/ws-adapter-view.js +7 -0
- package/dist/server/ws-adapter-view.js.map +1 -0
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +1 -1
- package/dist/single-copy.js.map +1 -1
- package/dist/static-file.d.ts +156 -4
- package/dist/static-file.d.ts.map +1 -1
- package/dist/static-file.js +198 -4
- package/dist/static-file.js.map +1 -1
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +1 -3
- package/dist/wire.js.map +1 -1
- package/package.json +16 -1
- package/src/assurance.ts +66 -16
- package/src/canonical-json.ts +43 -0
- package/src/capabilities.ts +1 -8
- package/src/data.ts +2 -8
- package/src/diff.ts +2 -12
- package/src/durable-adapters.ts +53 -50
- package/src/durable-saga.ts +30 -23
- package/src/evidence.ts +25 -90
- package/src/idempotency.ts +62 -20
- package/src/index.ts +13 -2
- package/src/internal/control-character.ts +8 -0
- package/src/internal/durable-shared.ts +4 -1
- package/src/internal/hex.ts +14 -0
- package/src/internal/record.ts +4 -0
- package/src/internal/route-compiler.ts +2 -2
- package/src/internal/route-execution.ts +7 -20
- package/src/internal/route-lanes.ts +20 -44
- package/src/internal/schema-parts.ts +19 -0
- package/src/ledger.ts +2 -16
- package/src/manifest.ts +10 -38
- package/src/open-resolved.ts +64 -0
- package/src/range.ts +1 -1
- package/src/serve-static.ts +227 -0
- package/src/server/bare-error-lane.ts +11 -2
- package/src/server/base64-answers.ts +2 -6
- package/src/server/body-lane.ts +58 -51
- package/src/server/body.ts +4 -1
- package/src/server/bridge-markers.ts +1 -1
- package/src/server/cookies.ts +4 -1
- package/src/server/core-copies.ts +1 -1
- package/src/server/http.ts +12 -0
- package/src/server/idempotency-lane.ts +2 -0
- package/src/server/idle-timeout.ts +107 -0
- package/src/server/json-shared.ts +38 -0
- package/src/server/json-text.ts +1 -1
- package/src/server/node-outcome.ts +1 -1
- package/src/server/request-context.ts +62 -3
- package/src/server/request-local.ts +109 -0
- package/src/server/respond.ts +1 -1
- package/src/server/response-contract-lane.ts +112 -65
- package/src/server/runtime-core.ts +10 -8
- package/src/server/server-types.ts +27 -0
- package/src/server/server.ts +1230 -800
- package/src/server/transport-lane.ts +2 -0
- package/src/server/webhook.ts +1 -1
- package/src/server/websocket.ts +18 -1
- package/src/server/ws-adapter-view.ts +63 -0
- package/src/server.ts +12 -1
- package/src/single-copy.ts +1 -3
- package/src/static-file.ts +350 -4
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
+
}
|
package/src/capabilities.ts
CHANGED
|
@@ -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
|
-
...(
|
|
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
|
}
|
package/src/durable-adapters.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
|
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.
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/src/durable-saga.ts
CHANGED
|
@@ -323,31 +323,48 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
|
|
|
323
323
|
return record
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
329
|
+
owned: (owner: string) => Promise<SagaRecord>,
|
|
330
330
|
): Promise<SagaRecord> => {
|
|
331
331
|
const owner = crypto.randomUUID()
|
|
332
332
|
try {
|
|
333
|
-
return await
|
|
333
|
+
return await owned(owner)
|
|
334
334
|
} finally {
|
|
335
335
|
await release(sagaId, owner)
|
|
336
336
|
}
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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> = {
|