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