@nifrajs/core 3.3.0 → 4.0.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 +26 -0
- package/dist/assurance.d.ts +7 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +39 -3
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +20 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +47 -15
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +6 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +36 -2
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +16 -3
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-effect.d.ts.map +1 -1
- package/dist/durable-effect.js +6 -3
- package/dist/durable-effect.js.map +1 -1
- package/dist/edge-kit.d.ts +2 -0
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +2 -0
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-lifecycle.d.ts.map +1 -1
- package/dist/effect-lifecycle.js +2 -1
- package/dist/effect-lifecycle.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +1 -1
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +36 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +219 -13
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts +31 -0
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +48 -9
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +7 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +9 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
- package/dist/internal/durable-saga-protocol.js +2 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -1
- package/dist/internal/route-catalog.d.ts +6 -6
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +20 -18
- package/dist/internal/route-catalog.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +7 -6
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +36 -4
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +7 -3
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +1 -0
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +34 -29
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +8 -2
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +222 -20
- package/dist/internal/route-program.js.map +1 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +8 -5
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +52 -46
- package/dist/manifest.js.map +1 -1
- package/dist/mount.d.ts +36 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +32 -0
- package/dist/mount.js.map +1 -1
- package/dist/reflection.d.ts +26 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +65 -1
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -1
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js.map +1 -1
- package/dist/router/overlap.d.ts +23 -0
- package/dist/router/overlap.d.ts.map +1 -0
- package/dist/router/overlap.js +224 -0
- package/dist/router/overlap.js.map +1 -0
- package/dist/router/pattern.d.ts +117 -2
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +283 -23
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts +18 -4
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +51 -44
- package/dist/router/router.js.map +1 -1
- package/dist/server/auth.d.ts +53 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +9 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +12 -7
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +13 -11
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/body-lane.d.ts +20 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +26 -7
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts +68 -0
- package/dist/server/body-parser.d.ts.map +1 -0
- package/dist/server/body-parser.js +172 -0
- package/dist/server/body-parser.js.map +1 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +62 -7
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +8 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js +8 -0
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +71 -6
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -5
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +25 -4
- package/dist/server/contract.js.map +1 -1
- package/dist/server/cookies.d.ts +4 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +40 -2
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.d.ts +2 -0
- package/dist/server/core-copies.d.ts.map +1 -0
- package/dist/server/core-copies.js +20 -0
- package/dist/server/core-copies.js.map +1 -0
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +36 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +130 -73
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +4 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +4 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/methods.d.ts +45 -0
- package/dist/server/methods.d.ts.map +1 -0
- package/dist/server/methods.js +37 -0
- package/dist/server/methods.js.map +1 -0
- package/dist/server/multipart.d.ts +63 -0
- package/dist/server/multipart.d.ts.map +1 -0
- package/dist/server/multipart.js +374 -0
- package/dist/server/multipart.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +20 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +15 -6
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/not-found-answer.d.ts +40 -0
- package/dist/server/not-found-answer.d.ts.map +1 -0
- package/dist/server/not-found-answer.js +56 -0
- package/dist/server/not-found-answer.js.map +1 -0
- package/dist/server/not-found.d.ts +23 -0
- package/dist/server/not-found.d.ts.map +1 -0
- package/dist/server/not-found.js +81 -0
- package/dist/server/not-found.js.map +1 -0
- package/dist/server/plugin.d.ts +26 -1
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +18 -5
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +114 -9
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts +7 -0
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +47 -0
- package/dist/server/query.js.map +1 -1
- package/dist/server/registry.d.ts +13 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +11 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +33 -19
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-lineage.d.ts +8 -0
- package/dist/server/request-lineage.d.ts.map +1 -0
- package/dist/server/request-lineage.js +14 -0
- package/dist/server/request-lineage.js.map +1 -0
- package/dist/server/request-target.d.ts +31 -0
- package/dist/server/request-target.d.ts.map +1 -0
- package/dist/server/request-target.js +44 -0
- package/dist/server/request-target.js.map +1 -0
- package/dist/server/respond.d.ts +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +16 -4
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +19 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +59 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts +9 -5
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -6
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +55 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +194 -19
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1342 -274
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +10 -7
- package/dist/server/sse.js.map +1 -1
- package/dist/server/static-headers.d.ts +3 -0
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +19 -1
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +9 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/validation.d.ts.map +1 -1
- package/dist/server/validation.js +5 -1
- package/dist/server/validation.js.map +1 -1
- package/dist/server/websocket.d.ts +10 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +7 -4
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +2 -0
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +6 -3
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +2 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +8 -5
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +5 -2
- package/dist/server.js.map +1 -1
- package/dist/single-copy-register.js +5 -0
- package/dist/single-copy-register.js.map +1 -1
- package/dist/single-copy.d.ts +46 -3
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +125 -12
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts +23 -0
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +37 -3
- package/dist/tool-contract.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +2 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +27 -2
- package/src/assurance.ts +52 -2
- package/src/capabilities.ts +81 -22
- package/src/diff.ts +54 -7
- package/src/durable-adapters.ts +20 -3
- package/src/durable-effect.ts +10 -3
- package/src/edge-kit.ts +7 -0
- package/src/effect-lifecycle.ts +5 -1
- package/src/effect-scope.ts +2 -1
- package/src/errors.ts +3 -0
- package/src/evidence.ts +302 -12
- package/src/idempotency.ts +80 -9
- package/src/index.ts +41 -3
- package/src/internal/code-unit-order.ts +8 -0
- package/src/internal/durable-saga-protocol.ts +2 -0
- package/src/internal/route-catalog.ts +19 -18
- package/src/internal/route-compiler.ts +62 -13
- package/src/internal/route-execution.ts +13 -3
- package/src/internal/route-lanes.ts +37 -28
- package/src/internal/route-program.ts +434 -20
- package/src/ledger.ts +11 -5
- package/src/manifest.ts +52 -46
- package/src/mount.ts +62 -0
- package/src/reflection.ts +84 -1
- package/src/response-observer.ts +1 -0
- package/src/router/overlap.ts +258 -0
- package/src/router/pattern.ts +349 -25
- package/src/router/router.ts +76 -51
- package/src/server/auth.ts +75 -0
- package/src/server/bare-error-lane.ts +12 -11
- package/src/server/body-lane.ts +46 -6
- package/src/server/body-parser.ts +241 -0
- package/src/server/body.ts +82 -9
- package/src/server/client-ip.ts +9 -0
- package/src/server/context.ts +219 -12
- package/src/server/contract.ts +54 -9
- package/src/server/cookies.ts +34 -2
- package/src/server/core-copies.ts +21 -0
- package/src/server/edge.ts +2 -0
- package/src/server/http.ts +34 -0
- package/src/server/idempotency-lane.ts +147 -72
- package/src/server/install.ts +6 -0
- package/src/server/methods.ts +143 -0
- package/src/server/multipart.ts +424 -0
- package/src/server/node-outcome-hook.ts +40 -7
- package/src/server/not-found-answer.ts +109 -0
- package/src/server/not-found.ts +121 -0
- package/src/server/plugin.ts +45 -1
- package/src/server/proto-guard.ts +128 -9
- package/src/server/query.ts +49 -0
- package/src/server/registry.ts +17 -2
- package/src/server/request-context.ts +44 -16
- package/src/server/request-lineage.ts +15 -0
- package/src/server/request-target.ts +46 -0
- package/src/server/respond.ts +14 -3
- package/src/server/response-contract-lane.ts +97 -16
- package/src/server/response-observer-runtime.ts +37 -8
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +61 -2
- package/src/server/server.ts +1798 -355
- package/src/server/sse.ts +10 -8
- package/src/server/static-headers.ts +22 -1
- package/src/server/transport-lane.ts +17 -9
- package/src/server/validation.ts +7 -1
- package/src/server/websocket.ts +23 -9
- package/src/server/ws-bun.ts +8 -3
- package/src/server/ws-hook.ts +2 -1
- package/src/server.ts +39 -2
- package/src/single-copy-register.ts +5 -0
- package/src/single-copy.ts +160 -12
- package/src/tool-contract.ts +66 -3
- package/src/transport-codec.ts +2 -1
package/src/evidence.ts
CHANGED
|
@@ -14,11 +14,14 @@ import type {
|
|
|
14
14
|
CapabilityFinding,
|
|
15
15
|
} from "./capabilities.ts"
|
|
16
16
|
import type { ResponseClassification } from "./classification.ts"
|
|
17
|
+
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
17
18
|
import { evidenceProvenance } from "./internal/route-assurance.ts"
|
|
18
19
|
import {
|
|
19
20
|
type JsonSchema,
|
|
21
|
+
type ReflectedMount,
|
|
20
22
|
type ReflectedRoute,
|
|
21
23
|
type ReflectedSchemaField,
|
|
24
|
+
reflectMounts,
|
|
22
25
|
reflectRoutes,
|
|
23
26
|
type SchemaReflection,
|
|
24
27
|
} from "./reflection.ts"
|
|
@@ -26,12 +29,15 @@ import {
|
|
|
26
29
|
export interface ProjectEvidenceSchemaPart {
|
|
27
30
|
readonly jsonSchema?: JsonSchema
|
|
28
31
|
readonly fields?: readonly ReflectedSchemaField[]
|
|
32
|
+
/** The media types a body schema reads through a parser of its own. */
|
|
33
|
+
readonly mediaTypes?: readonly string[]
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
export interface ProjectEvidenceSchema {
|
|
32
37
|
readonly bodyLimit?: number | "unlimited"
|
|
33
38
|
readonly bodyLimitReason?: string
|
|
34
39
|
readonly headers?: ProjectEvidenceSchemaPart
|
|
40
|
+
readonly cookies?: ProjectEvidenceSchemaPart
|
|
35
41
|
readonly body?: ProjectEvidenceSchemaPart
|
|
36
42
|
readonly query?: ProjectEvidenceSchemaPart
|
|
37
43
|
readonly params?: ProjectEvidenceSchemaPart
|
|
@@ -50,6 +56,7 @@ export interface ProjectEvidenceRoute {
|
|
|
50
56
|
readonly method: string
|
|
51
57
|
readonly path: string
|
|
52
58
|
readonly schema?: ProjectEvidenceSchema
|
|
59
|
+
readonly responseContract?: "warn" | "enforce"
|
|
53
60
|
readonly assurance?: readonly AssuranceEvidence[]
|
|
54
61
|
readonly capabilities?: readonly string[]
|
|
55
62
|
readonly family?: boolean
|
|
@@ -93,6 +100,8 @@ export interface ProjectEvidenceCapabilities {
|
|
|
93
100
|
export interface ProjectEvidenceSnapshot {
|
|
94
101
|
readonly version: 1
|
|
95
102
|
readonly routes: readonly ProjectEvidenceRoute[]
|
|
103
|
+
/** Mounted children whose routes are not in `routes`, present when there is at least one. */
|
|
104
|
+
readonly mounts?: readonly ReflectedMount[]
|
|
96
105
|
readonly assurance?: ProjectEvidenceAssurance
|
|
97
106
|
readonly capabilities?: ProjectEvidenceCapabilities
|
|
98
107
|
}
|
|
@@ -100,8 +109,23 @@ export interface ProjectEvidenceSnapshot {
|
|
|
100
109
|
export interface ProjectEvidenceOptions {
|
|
101
110
|
readonly assurance?: AssuranceReport
|
|
102
111
|
readonly capabilities?: CapabilityAssuranceReport
|
|
112
|
+
/**
|
|
113
|
+
* An existing reflection pass. Supplying it lets several offline projections share one pass
|
|
114
|
+
* without asking the runtime source for `.routes()` again. Validators are consumed only while
|
|
115
|
+
* building the token-only snapshot and never cross this seam.
|
|
116
|
+
*/
|
|
117
|
+
readonly routes?: readonly ReflectedRoute[]
|
|
103
118
|
/** Optional static source locations keyed by `${METHOD}\n${path}`. */
|
|
104
119
|
readonly sourceLocations?: ReadonlyMap<string, readonly ProjectEvidenceSourceLocation[]>
|
|
120
|
+
/** An existing mount reflection, like `routes`. Default: `reflectMounts(source)`. */
|
|
121
|
+
readonly mounts?: readonly ReflectedMount[]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** One token-only evidence snapshot in a composed application surface. */
|
|
125
|
+
export interface ProjectEvidenceCompositionPart {
|
|
126
|
+
readonly evidence: ProjectEvidenceSnapshot
|
|
127
|
+
/** Public pathname prefix to add when the child receives a stripped mount request. */
|
|
128
|
+
readonly pathPrefix?: string
|
|
105
129
|
}
|
|
106
130
|
|
|
107
131
|
const recordOf = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
|
|
@@ -114,6 +138,9 @@ function schemaPart(value: SchemaReflection | undefined): ProjectEvidenceSchemaP
|
|
|
114
138
|
return Object.freeze({
|
|
115
139
|
...(value.jsonSchema !== undefined ? { jsonSchema: value.jsonSchema } : {}),
|
|
116
140
|
...(value.fields !== undefined ? { fields: Object.freeze([...value.fields]) } : {}),
|
|
141
|
+
...(value.mediaTypes !== undefined
|
|
142
|
+
? { mediaTypes: Object.freeze([...value.mediaTypes].sort()) }
|
|
143
|
+
: {}),
|
|
117
144
|
})
|
|
118
145
|
}
|
|
119
146
|
|
|
@@ -121,6 +148,7 @@ function schemaOf(route: ReflectedRoute): ProjectEvidenceSchema | undefined {
|
|
|
121
148
|
const source = route.schema
|
|
122
149
|
if (source === undefined) return undefined
|
|
123
150
|
const headers = schemaPart(source.headers)
|
|
151
|
+
const cookies = schemaPart(source.cookies)
|
|
124
152
|
const body = schemaPart(source.body)
|
|
125
153
|
const query = schemaPart(source.query)
|
|
126
154
|
const params = schemaPart(source.params)
|
|
@@ -135,6 +163,7 @@ function schemaOf(route: ReflectedRoute): ProjectEvidenceSchema | undefined {
|
|
|
135
163
|
...(source.bodyLimit !== undefined ? { bodyLimit: source.bodyLimit } : {}),
|
|
136
164
|
...(source.bodyLimitReason !== undefined ? { bodyLimitReason: source.bodyLimitReason } : {}),
|
|
137
165
|
...(headers !== undefined ? { headers } : {}),
|
|
166
|
+
...(cookies !== undefined ? { cookies } : {}),
|
|
138
167
|
...(body !== undefined ? { body } : {}),
|
|
139
168
|
...(query !== undefined ? { query } : {}),
|
|
140
169
|
...(params !== undefined ? { params } : {}),
|
|
@@ -157,7 +186,7 @@ function assuranceEvidenceOf(values: readonly AssuranceEvidence[]): readonly Ass
|
|
|
157
186
|
provenance: evidenceProvenance(item),
|
|
158
187
|
}),
|
|
159
188
|
)
|
|
160
|
-
.sort((a, b) => a.id
|
|
189
|
+
.sort((a, b) => codeUnitOrder(a.id, b.id) || codeUnitOrder(a.source, b.source)),
|
|
161
190
|
)
|
|
162
191
|
}
|
|
163
192
|
|
|
@@ -169,9 +198,9 @@ function capabilityEvidenceOf(
|
|
|
169
198
|
.map((item) => Object.freeze({ id: item.id, kind: item.kind, source: item.source }))
|
|
170
199
|
.sort(
|
|
171
200
|
(a, b) =>
|
|
172
|
-
a.id
|
|
173
|
-
a.kind
|
|
174
|
-
a.source
|
|
201
|
+
codeUnitOrder(a.id, b.id) ||
|
|
202
|
+
codeUnitOrder(a.kind, b.kind) ||
|
|
203
|
+
codeUnitOrder(a.source, b.source),
|
|
175
204
|
),
|
|
176
205
|
)
|
|
177
206
|
}
|
|
@@ -180,7 +209,7 @@ const sortByRoute = <T extends { readonly method: string; readonly path: string
|
|
|
180
209
|
values: readonly T[],
|
|
181
210
|
): readonly T[] =>
|
|
182
211
|
Object.freeze(
|
|
183
|
-
[...values].sort((a, b) => a.path
|
|
212
|
+
[...values].sort((a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.method, b.method)),
|
|
184
213
|
)
|
|
185
214
|
|
|
186
215
|
function evidenceRouteOf(
|
|
@@ -192,6 +221,7 @@ function evidenceRouteOf(
|
|
|
192
221
|
method: route.method.toUpperCase(),
|
|
193
222
|
path: route.path,
|
|
194
223
|
...(schema !== undefined ? { schema } : {}),
|
|
224
|
+
...(route.responseContract === undefined ? {} : { responseContract: route.responseContract }),
|
|
195
225
|
...(route.assurance !== undefined ? { assurance: assuranceEvidenceOf(route.assurance) } : {}),
|
|
196
226
|
...(route.capabilities !== undefined
|
|
197
227
|
? { capabilities: Object.freeze([...route.capabilities].sort()) }
|
|
@@ -224,9 +254,9 @@ function assuranceOf(report: AssuranceReport | undefined): ProjectEvidenceAssura
|
|
|
224
254
|
findings: Object.freeze(
|
|
225
255
|
[...report.findings].sort(
|
|
226
256
|
(a, b) =>
|
|
227
|
-
a.path
|
|
228
|
-
a.method
|
|
229
|
-
a.code
|
|
257
|
+
codeUnitOrder(a.path, b.path) ||
|
|
258
|
+
codeUnitOrder(a.method, b.method) ||
|
|
259
|
+
codeUnitOrder(a.code, b.code),
|
|
230
260
|
),
|
|
231
261
|
),
|
|
232
262
|
})
|
|
@@ -254,14 +284,259 @@ function capabilitiesOf(
|
|
|
254
284
|
findings: Object.freeze(
|
|
255
285
|
[...report.findings].sort(
|
|
256
286
|
(a, b) =>
|
|
257
|
-
a.path
|
|
258
|
-
a.method
|
|
259
|
-
a.code
|
|
287
|
+
codeUnitOrder(a.path, b.path) ||
|
|
288
|
+
codeUnitOrder(a.method, b.method) ||
|
|
289
|
+
codeUnitOrder(a.code, b.code),
|
|
260
290
|
),
|
|
261
291
|
),
|
|
262
292
|
})
|
|
263
293
|
}
|
|
264
294
|
|
|
295
|
+
function trimTrailingSlashes(path: string): string {
|
|
296
|
+
// Index-scan instead of `/\/+$/`: path prefixes can be library input, and the
|
|
297
|
+
// unanchored-start trailing-run replacement can backtrack quadratically.
|
|
298
|
+
let end = path.length
|
|
299
|
+
while (end > 0 && path.charCodeAt(end - 1) === 47 /* '/' */) end--
|
|
300
|
+
return path.slice(0, end)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const composedPath = (prefix: string, path: string): string => {
|
|
304
|
+
if (path === "*") return path
|
|
305
|
+
if (!path.startsWith("/"))
|
|
306
|
+
throw new TypeError(`project evidence: route path must start with "/": ${path}`)
|
|
307
|
+
if (prefix === "") return path
|
|
308
|
+
if (!prefix.startsWith("/")) {
|
|
309
|
+
throw new TypeError(`project evidence: path prefix must start with "/": ${prefix}`)
|
|
310
|
+
}
|
|
311
|
+
const normalized = prefix === "/" ? "" : trimTrailingSlashes(prefix)
|
|
312
|
+
return normalized === "" ? path : path === "/" ? normalized : `${normalized}${path}`
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function remapEvidenceSnapshot(
|
|
316
|
+
evidence: ProjectEvidenceSnapshot,
|
|
317
|
+
pathPrefix: string,
|
|
318
|
+
): ProjectEvidenceSnapshot {
|
|
319
|
+
if (evidence.version !== 1) throw new Error("project evidence: unsupported snapshot version")
|
|
320
|
+
const route = (
|
|
321
|
+
method: string,
|
|
322
|
+
path: string,
|
|
323
|
+
): { readonly method: string; readonly path: string } => ({
|
|
324
|
+
method: method.toUpperCase(),
|
|
325
|
+
path: composedPath(pathPrefix, path),
|
|
326
|
+
})
|
|
327
|
+
const routes = evidence.routes.map((item) => {
|
|
328
|
+
const key = route(item.method, item.path)
|
|
329
|
+
return Object.freeze({ ...item, ...key })
|
|
330
|
+
})
|
|
331
|
+
const mounts = evidence.mounts?.map((item) =>
|
|
332
|
+
Object.freeze({ ...item, path: composedPath(pathPrefix, item.path) }),
|
|
333
|
+
)
|
|
334
|
+
const assurance =
|
|
335
|
+
evidence.assurance === undefined
|
|
336
|
+
? undefined
|
|
337
|
+
: Object.freeze({
|
|
338
|
+
ok: evidence.assurance.ok,
|
|
339
|
+
routes: Object.freeze(
|
|
340
|
+
evidence.assurance.routes.map((item) =>
|
|
341
|
+
Object.freeze({ ...item, ...route(item.method, item.path) }),
|
|
342
|
+
),
|
|
343
|
+
),
|
|
344
|
+
findings: Object.freeze(
|
|
345
|
+
evidence.assurance.findings.map((item) =>
|
|
346
|
+
Object.freeze({ ...item, ...route(item.method, item.path) }),
|
|
347
|
+
),
|
|
348
|
+
),
|
|
349
|
+
})
|
|
350
|
+
const capabilities =
|
|
351
|
+
evidence.capabilities === undefined
|
|
352
|
+
? undefined
|
|
353
|
+
: Object.freeze({
|
|
354
|
+
ok: evidence.capabilities.ok,
|
|
355
|
+
routes: Object.freeze(
|
|
356
|
+
evidence.capabilities.routes.map((item) =>
|
|
357
|
+
Object.freeze({ ...item, ...route(item.method, item.path) }),
|
|
358
|
+
),
|
|
359
|
+
),
|
|
360
|
+
findings: Object.freeze(
|
|
361
|
+
evidence.capabilities.findings.map((item) =>
|
|
362
|
+
Object.freeze({ ...item, ...route(item.method, item.path) }),
|
|
363
|
+
),
|
|
364
|
+
),
|
|
365
|
+
})
|
|
366
|
+
return Object.freeze({
|
|
367
|
+
version: 1,
|
|
368
|
+
routes: Object.freeze(routes),
|
|
369
|
+
...(mounts === undefined ? {} : { mounts: Object.freeze(mounts) }),
|
|
370
|
+
...(assurance === undefined ? {} : { assurance }),
|
|
371
|
+
...(capabilities === undefined ? {} : { capabilities }),
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function routeKeyForEvidence(method: string, path: string): string {
|
|
376
|
+
return `${method.toUpperCase()}\n${path}`
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function validateComposedReportRoutes(
|
|
380
|
+
report: ProjectEvidenceAssurance | ProjectEvidenceCapabilities,
|
|
381
|
+
routeKeys: ReadonlySet<string>,
|
|
382
|
+
kind: "assurance" | "capabilities",
|
|
383
|
+
): void {
|
|
384
|
+
for (const route of report.routes) {
|
|
385
|
+
if (!routeKeys.has(routeKeyForEvidence(route.method, route.path))) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`project evidence: ${kind} route ${route.method} ${route.path} is not present in the composed route set`,
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Compose page and mounted-app evidence into one deterministic snapshot.
|
|
395
|
+
*
|
|
396
|
+
* This is an offline operation. It carries route contracts, assurance/capability tokens, and source
|
|
397
|
+
* locations only; it never invokes handlers or copies request/business data. A duplicate public
|
|
398
|
+
* method+path or a report that no longer describes a composed route is rejected rather than silently
|
|
399
|
+
* dropping one side of the trust boundary.
|
|
400
|
+
*/
|
|
401
|
+
export function composeProjectEvidence(
|
|
402
|
+
parts: readonly ProjectEvidenceCompositionPart[],
|
|
403
|
+
): ProjectEvidenceSnapshot {
|
|
404
|
+
const routes: ProjectEvidenceRoute[] = []
|
|
405
|
+
const routeKeys = new Set<string>()
|
|
406
|
+
const mounts: ReflectedMount[] = []
|
|
407
|
+
const assuranceReports: ProjectEvidenceAssurance[] = []
|
|
408
|
+
const capabilityReports: ProjectEvidenceCapabilities[] = []
|
|
409
|
+
|
|
410
|
+
for (const part of parts) {
|
|
411
|
+
const prefix = part.pathPrefix ?? ""
|
|
412
|
+
const mapped = remapEvidenceSnapshot(part.evidence, prefix)
|
|
413
|
+
for (const route of mapped.routes) {
|
|
414
|
+
const key = routeKeyForEvidence(route.method, route.path)
|
|
415
|
+
if (routeKeys.has(key)) {
|
|
416
|
+
throw new Error(`project evidence: duplicate composed route ${route.method} ${route.path}`)
|
|
417
|
+
}
|
|
418
|
+
routeKeys.add(key)
|
|
419
|
+
routes.push(route)
|
|
420
|
+
}
|
|
421
|
+
mounts.push(...(mapped.mounts ?? []))
|
|
422
|
+
if (mapped.assurance !== undefined) assuranceReports.push(mapped.assurance)
|
|
423
|
+
if (mapped.capabilities !== undefined) capabilityReports.push(mapped.capabilities)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const assurance =
|
|
427
|
+
assuranceReports.length === 0
|
|
428
|
+
? undefined
|
|
429
|
+
: Object.freeze({
|
|
430
|
+
ok: assuranceReports.every((report) => report.ok),
|
|
431
|
+
routes: sortByRoute(assuranceReports.flatMap((report) => report.routes)),
|
|
432
|
+
findings: Object.freeze(
|
|
433
|
+
assuranceReports
|
|
434
|
+
.flatMap((report) => report.findings)
|
|
435
|
+
.sort(
|
|
436
|
+
(a, b) =>
|
|
437
|
+
codeUnitOrder(a.path, b.path) ||
|
|
438
|
+
codeUnitOrder(a.method, b.method) ||
|
|
439
|
+
codeUnitOrder(a.code, b.code),
|
|
440
|
+
),
|
|
441
|
+
),
|
|
442
|
+
})
|
|
443
|
+
const capabilities =
|
|
444
|
+
capabilityReports.length === 0
|
|
445
|
+
? undefined
|
|
446
|
+
: Object.freeze({
|
|
447
|
+
ok: capabilityReports.every((report) => report.ok),
|
|
448
|
+
routes: sortByRoute(capabilityReports.flatMap((report) => report.routes)),
|
|
449
|
+
findings: Object.freeze(
|
|
450
|
+
capabilityReports
|
|
451
|
+
.flatMap((report) => report.findings)
|
|
452
|
+
.sort(
|
|
453
|
+
(a, b) =>
|
|
454
|
+
codeUnitOrder(a.path, b.path) ||
|
|
455
|
+
codeUnitOrder(a.method, b.method) ||
|
|
456
|
+
codeUnitOrder(a.code, b.code),
|
|
457
|
+
),
|
|
458
|
+
),
|
|
459
|
+
})
|
|
460
|
+
if (assurance !== undefined) validateComposedReportRoutes(assurance, routeKeys, "assurance")
|
|
461
|
+
if (capabilities !== undefined)
|
|
462
|
+
validateComposedReportRoutes(capabilities, routeKeys, "capabilities")
|
|
463
|
+
|
|
464
|
+
return Object.freeze({
|
|
465
|
+
version: 1,
|
|
466
|
+
routes: sortByRoute(routes),
|
|
467
|
+
...mountsPart(mounts),
|
|
468
|
+
...(assurance === undefined ? {} : { assurance }),
|
|
469
|
+
...(capabilities === undefined ? {} : { capabilities }),
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function schemaPartToReflection(
|
|
474
|
+
value: ProjectEvidenceSchemaPart | undefined,
|
|
475
|
+
): SchemaReflection | undefined {
|
|
476
|
+
return value === undefined
|
|
477
|
+
? undefined
|
|
478
|
+
: {
|
|
479
|
+
standard: undefined,
|
|
480
|
+
jsonSchema: value.jsonSchema,
|
|
481
|
+
fields: value.fields,
|
|
482
|
+
...(value.mediaTypes !== undefined ? { mediaTypes: value.mediaTypes } : {}),
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function schemaFromEvidence(schema: ProjectEvidenceSchema | undefined): ReflectedRoute["schema"] {
|
|
487
|
+
if (schema === undefined) return undefined
|
|
488
|
+
const errors = Object.fromEntries(
|
|
489
|
+
Object.entries(schema.errors ?? {}).map(([status, value]) => [
|
|
490
|
+
status,
|
|
491
|
+
schemaPartToReflection(value) as SchemaReflection,
|
|
492
|
+
]),
|
|
493
|
+
)
|
|
494
|
+
const headers = schemaPartToReflection(schema.headers)
|
|
495
|
+
const cookies = schemaPartToReflection(schema.cookies)
|
|
496
|
+
const body = schemaPartToReflection(schema.body)
|
|
497
|
+
const query = schemaPartToReflection(schema.query)
|
|
498
|
+
const params = schemaPartToReflection(schema.params)
|
|
499
|
+
const response = schemaPartToReflection(schema.response)
|
|
500
|
+
const sse = schemaPartToReflection(schema.sse)
|
|
501
|
+
return {
|
|
502
|
+
...(schema.bodyLimit !== undefined ? { bodyLimit: schema.bodyLimit } : {}),
|
|
503
|
+
...(schema.bodyLimitReason !== undefined ? { bodyLimitReason: schema.bodyLimitReason } : {}),
|
|
504
|
+
...(headers !== undefined ? { headers } : {}),
|
|
505
|
+
...(cookies !== undefined ? { cookies } : {}),
|
|
506
|
+
...(body !== undefined ? { body } : {}),
|
|
507
|
+
...(query !== undefined ? { query } : {}),
|
|
508
|
+
...(params !== undefined ? { params } : {}),
|
|
509
|
+
...(response !== undefined ? { response } : {}),
|
|
510
|
+
...(Object.keys(errors).length > 0 ? { errors } : {}),
|
|
511
|
+
...(sse !== undefined ? { sse } : {}),
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Adapt the canonical token-only snapshot to the runtime-reflection shape used by projections.
|
|
517
|
+
* The returned schemas deliberately have no `standard` validator: this adapter is for offline
|
|
518
|
+
* contract views, not request validation or mock generation.
|
|
519
|
+
*/
|
|
520
|
+
export function reflectedRoutesFromEvidence(
|
|
521
|
+
evidence: ProjectEvidenceSnapshot,
|
|
522
|
+
): readonly ReflectedRoute[] {
|
|
523
|
+
if (evidence.version !== 1) throw new Error("project evidence: unsupported snapshot version")
|
|
524
|
+
return evidence.routes.map((route) => {
|
|
525
|
+
const schema = schemaFromEvidence(route.schema)
|
|
526
|
+
return {
|
|
527
|
+
method: route.method,
|
|
528
|
+
path: route.path,
|
|
529
|
+
...(schema !== undefined ? { schema } : {}),
|
|
530
|
+
...(route.responseContract === undefined ? {} : { responseContract: route.responseContract }),
|
|
531
|
+
...(route.assurance !== undefined ? { assurance: route.assurance } : {}),
|
|
532
|
+
...(route.capabilities !== undefined ? { capabilities: route.capabilities } : {}),
|
|
533
|
+
...(route.family === true ? { family: true } : {}),
|
|
534
|
+
...(route.classification !== undefined ? { classification: route.classification } : {}),
|
|
535
|
+
...(route.tool !== undefined ? { tool: route.tool } : {}),
|
|
536
|
+
}
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
|
|
265
540
|
/** Build the canonical snapshot from reflection and already-evaluated offline reports. */
|
|
266
541
|
export function snapshotProjectEvidence(
|
|
267
542
|
source: unknown,
|
|
@@ -270,21 +545,36 @@ export function snapshotProjectEvidence(
|
|
|
270
545
|
const locations = options.sourceLocations
|
|
271
546
|
const assurance = assuranceOf(options.assurance)
|
|
272
547
|
const capabilities = capabilitiesOf(options.capabilities)
|
|
548
|
+
const reflected = options.routes ?? reflectRoutes(source)
|
|
273
549
|
const routes = sortByRoute(
|
|
274
550
|
// Importantly, this is the only reflection pass for this snapshot. Every projection can consume
|
|
275
551
|
// the result instead of independently asking the app for `.routes()` and re-shaping schemas.
|
|
276
|
-
|
|
552
|
+
reflected.map((route) =>
|
|
277
553
|
evidenceRouteOf(route, locations?.get(keyOf(route.method, route.path))),
|
|
278
554
|
),
|
|
279
555
|
)
|
|
280
556
|
return Object.freeze({
|
|
281
557
|
version: 1,
|
|
282
558
|
routes,
|
|
559
|
+
...mountsPart(options.mounts ?? reflectMounts(source)),
|
|
283
560
|
...(assurance !== undefined ? { assurance } : {}),
|
|
284
561
|
...(capabilities !== undefined ? { capabilities } : {}),
|
|
285
562
|
})
|
|
286
563
|
}
|
|
287
564
|
|
|
565
|
+
/** The snapshot's `mounts` field, sorted, and absent when empty so a mount-free app's snapshot (and its
|
|
566
|
+
* manifest hash) is unchanged. */
|
|
567
|
+
const mountsPart = (
|
|
568
|
+
mounts: readonly ReflectedMount[],
|
|
569
|
+
): { readonly mounts?: readonly ReflectedMount[] } =>
|
|
570
|
+
mounts.length === 0
|
|
571
|
+
? {}
|
|
572
|
+
: {
|
|
573
|
+
mounts: Object.freeze(
|
|
574
|
+
[...mounts].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
|
575
|
+
),
|
|
576
|
+
}
|
|
577
|
+
|
|
288
578
|
function canonicalValue(value: unknown): string {
|
|
289
579
|
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
290
580
|
return JSON.stringify(value)
|
package/src/idempotency.ts
CHANGED
|
@@ -16,6 +16,9 @@ export type IdempotencyScope = "request" | "durable"
|
|
|
16
16
|
/** Default retention for a stored idempotent response: 24 hours. */
|
|
17
17
|
export const DEFAULT_IDEMPOTENCY_TTL_MS = 86_400_000
|
|
18
18
|
|
|
19
|
+
/** Default lease on a key whose handler is still running, with a store that can renew it: 60 seconds. */
|
|
20
|
+
export const DEFAULT_IDEMPOTENCY_PENDING_TTL_MS = 60_000
|
|
21
|
+
|
|
19
22
|
/** Canonical request header carrying the client-chosen idempotency key. */
|
|
20
23
|
export const DEFAULT_IDEMPOTENCY_HEADER = "idempotency-key"
|
|
21
24
|
|
|
@@ -80,7 +83,14 @@ export interface IdempotencyEntryKey {
|
|
|
80
83
|
|
|
81
84
|
export interface IdempotencyBeginInput extends IdempotencyEntryKey {
|
|
82
85
|
readonly fingerprint: string
|
|
86
|
+
/** Retention for the completed response. */
|
|
83
87
|
readonly ttlMs: number
|
|
88
|
+
/**
|
|
89
|
+
* Lease on a `new` reservation: it lapses after this long unless {@link IdempotencyStore.renew}
|
|
90
|
+
* extends it, and `complete` then keeps the response for `ttlMs`. Passed only to a store with
|
|
91
|
+
* `renew`; when absent, the pending reservation holds the key for `ttlMs`.
|
|
92
|
+
*/
|
|
93
|
+
readonly pendingTtlMs?: number | undefined
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
export interface IdempotencyCompletionInput extends IdempotencyEntryKey {
|
|
@@ -94,6 +104,13 @@ export interface IdempotencyAbandonInput extends IdempotencyEntryKey {
|
|
|
94
104
|
readonly reservation: string
|
|
95
105
|
}
|
|
96
106
|
|
|
107
|
+
export interface IdempotencyRenewInput extends IdempotencyEntryKey {
|
|
108
|
+
/** Opaque ownership token returned by `begin(state:"new")`. */
|
|
109
|
+
readonly reservation: string
|
|
110
|
+
/** The new lease, from now. */
|
|
111
|
+
readonly ttlMs: number
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
/**
|
|
98
115
|
* Storage seam for idempotent responses. `begin` MUST be atomic: for one key, exactly one concurrent
|
|
99
116
|
* caller sees `new`; the rest see `in-flight` (or `replay` once completed). The in-memory store gets
|
|
@@ -115,6 +132,12 @@ export interface IdempotencyStore {
|
|
|
115
132
|
complete(input: IdempotencyCompletionInput): boolean | Promise<boolean>
|
|
116
133
|
/** Release only the pending reservation owned by `reservation`. */
|
|
117
134
|
abandon(input: IdempotencyAbandonInput): boolean | Promise<boolean>
|
|
135
|
+
/**
|
|
136
|
+
* Extend the pending reservation owned by `reservation` to `ttlMs` from now. Returns false when it
|
|
137
|
+
* is gone, lapsed, or completed. The server calls it while the handler runs, so with it a key
|
|
138
|
+
* whose process died frees after `pendingTtlMs` instead of `ttlMs`.
|
|
139
|
+
*/
|
|
140
|
+
renew?(input: IdempotencyRenewInput): boolean | Promise<boolean>
|
|
118
141
|
}
|
|
119
142
|
|
|
120
143
|
/** A key must be a non-empty, bounded, control-char-free token. Fail closed on anything else. */
|
|
@@ -240,7 +263,14 @@ async function boundedResponseBytes(response: Response, maxBytes: number): Promi
|
|
|
240
263
|
if (done) break
|
|
241
264
|
total += value.byteLength
|
|
242
265
|
if (total > maxBytes) {
|
|
243
|
-
|
|
266
|
+
// `Response.clone()` tees the body. Cancelling only the clone branch leaves the live branch
|
|
267
|
+
// unread, so a streaming producer can keep the tee pending forever. This path stores no
|
|
268
|
+
// response and the caller will replace it with a terminal error, so cancel both branches.
|
|
269
|
+
const liveBody = response.body
|
|
270
|
+
await Promise.allSettled([
|
|
271
|
+
reader.cancel(),
|
|
272
|
+
liveBody === null ? Promise.resolve() : liveBody.cancel(),
|
|
273
|
+
])
|
|
244
274
|
throw new IdempotencyResponseTooLargeError(maxBytes)
|
|
245
275
|
}
|
|
246
276
|
chunks.push(value)
|
|
@@ -305,6 +335,7 @@ export function responseFromStored(
|
|
|
305
335
|
}
|
|
306
336
|
|
|
307
337
|
interface MemoryEntry {
|
|
338
|
+
readonly namespace: string
|
|
308
339
|
readonly fingerprint: string
|
|
309
340
|
readonly reservation: string
|
|
310
341
|
readonly ttlMs: number
|
|
@@ -317,6 +348,11 @@ export interface MemoryIdempotencyStoreOptions {
|
|
|
317
348
|
readonly now?: () => number
|
|
318
349
|
/** Hard memory bound. At capacity new keys fail closed; completed/pending entries are never evicted early. */
|
|
319
350
|
readonly maxEntries?: number
|
|
351
|
+
/**
|
|
352
|
+
* Bound for one namespace, so a single tenant or principal cannot use up `maxEntries` for the
|
|
353
|
+
* rest: past it, only that namespace's new keys fail closed. Default: no separate bound.
|
|
354
|
+
*/
|
|
355
|
+
readonly maxEntriesPerNamespace?: number
|
|
320
356
|
}
|
|
321
357
|
|
|
322
358
|
/**
|
|
@@ -327,8 +363,10 @@ export interface MemoryIdempotencyStoreOptions {
|
|
|
327
363
|
export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
328
364
|
readonly durability = "memory" as const
|
|
329
365
|
private readonly entries = new Map<string, MemoryEntry>()
|
|
366
|
+
private readonly perNamespace = new Map<string, number>()
|
|
330
367
|
private readonly now: () => number
|
|
331
368
|
private readonly maxEntries: number
|
|
369
|
+
private readonly maxPerNamespace: number
|
|
332
370
|
|
|
333
371
|
constructor(options: MemoryIdempotencyStoreOptions = {}) {
|
|
334
372
|
this.now = options.now ?? Date.now
|
|
@@ -336,6 +374,10 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
336
374
|
if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
|
|
337
375
|
throw new RangeError("idempotency: maxEntries must be a positive integer")
|
|
338
376
|
}
|
|
377
|
+
this.maxPerNamespace = options.maxEntriesPerNamespace ?? this.maxEntries
|
|
378
|
+
if (!Number.isInteger(this.maxPerNamespace) || this.maxPerNamespace < 1) {
|
|
379
|
+
throw new RangeError("idempotency: maxEntriesPerNamespace must be a positive integer")
|
|
380
|
+
}
|
|
339
381
|
}
|
|
340
382
|
|
|
341
383
|
begin(input: IdempotencyBeginInput): IdempotencyBeginResult {
|
|
@@ -346,17 +388,19 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
346
388
|
if (existing.response !== undefined) return { state: "replay", response: existing.response }
|
|
347
389
|
return { state: "in-flight" }
|
|
348
390
|
}
|
|
349
|
-
if (existing !== undefined) this.
|
|
350
|
-
if (this.
|
|
391
|
+
if (existing !== undefined) this.drop(storageKey, existing)
|
|
392
|
+
if (this.full(input.namespace)) {
|
|
351
393
|
this.sweep()
|
|
352
|
-
if (this.
|
|
394
|
+
if (this.full(input.namespace)) return { state: "capacity" }
|
|
353
395
|
}
|
|
354
396
|
const reservation = crypto.randomUUID()
|
|
397
|
+
this.perNamespace.set(input.namespace, (this.perNamespace.get(input.namespace) ?? 0) + 1)
|
|
355
398
|
this.entries.set(storageKey, {
|
|
399
|
+
namespace: input.namespace,
|
|
356
400
|
fingerprint: input.fingerprint,
|
|
357
401
|
reservation,
|
|
358
402
|
ttlMs: input.ttlMs,
|
|
359
|
-
expiresAt: this.now() + input.ttlMs,
|
|
403
|
+
expiresAt: this.now() + (input.pendingTtlMs ?? input.ttlMs),
|
|
360
404
|
response: undefined,
|
|
361
405
|
})
|
|
362
406
|
return { state: "new", reservation }
|
|
@@ -367,7 +411,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
367
411
|
const entry = this.entries.get(key)
|
|
368
412
|
if (entry === undefined || entry.reservation !== input.reservation) return false
|
|
369
413
|
if (entry.expiresAt <= this.now()) {
|
|
370
|
-
this.
|
|
414
|
+
this.drop(key, entry)
|
|
371
415
|
return false
|
|
372
416
|
}
|
|
373
417
|
entry.response = input.response
|
|
@@ -379,7 +423,7 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
379
423
|
const key = this.storageKey(input)
|
|
380
424
|
const entry = this.entries.get(key)
|
|
381
425
|
if (entry !== undefined && entry.expiresAt <= this.now()) {
|
|
382
|
-
this.
|
|
426
|
+
this.drop(key, entry)
|
|
383
427
|
return false
|
|
384
428
|
}
|
|
385
429
|
// Only drop a still-pending reservation; never evict a completed (replayable) response.
|
|
@@ -388,17 +432,30 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
388
432
|
entry.response === undefined &&
|
|
389
433
|
entry.reservation === input.reservation
|
|
390
434
|
) {
|
|
391
|
-
this.
|
|
435
|
+
this.drop(key, entry)
|
|
392
436
|
return true
|
|
393
437
|
}
|
|
394
438
|
return false
|
|
395
439
|
}
|
|
396
440
|
|
|
441
|
+
renew(input: IdempotencyRenewInput): boolean {
|
|
442
|
+
const entry = this.entries.get(this.storageKey(input))
|
|
443
|
+
if (
|
|
444
|
+
entry?.reservation !== input.reservation ||
|
|
445
|
+
entry.response !== undefined ||
|
|
446
|
+
entry.expiresAt <= this.now()
|
|
447
|
+
) {
|
|
448
|
+
return false
|
|
449
|
+
}
|
|
450
|
+
entry.expiresAt = this.now() + input.ttlMs
|
|
451
|
+
return true
|
|
452
|
+
}
|
|
453
|
+
|
|
397
454
|
/** Evict expired entries. Callers may run this on an interval; access-time eviction covers the rest. */
|
|
398
455
|
sweep(): void {
|
|
399
456
|
const now = this.now()
|
|
400
457
|
for (const [key, entry] of this.entries) {
|
|
401
|
-
if (entry.expiresAt <= now) this.
|
|
458
|
+
if (entry.expiresAt <= now) this.drop(key, entry)
|
|
402
459
|
}
|
|
403
460
|
}
|
|
404
461
|
|
|
@@ -410,6 +467,20 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
410
467
|
private storageKey(input: IdempotencyEntryKey): string {
|
|
411
468
|
return `${input.namespace.length}:${input.namespace}${input.key}`
|
|
412
469
|
}
|
|
470
|
+
|
|
471
|
+
private full(namespace: string): boolean {
|
|
472
|
+
return (
|
|
473
|
+
this.entries.size >= this.maxEntries ||
|
|
474
|
+
(this.perNamespace.get(namespace) ?? 0) >= this.maxPerNamespace
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private drop(key: string, entry: MemoryEntry): void {
|
|
479
|
+
this.entries.delete(key)
|
|
480
|
+
const left = (this.perNamespace.get(entry.namespace) ?? 1) - 1
|
|
481
|
+
if (left > 0) this.perNamespace.set(entry.namespace, left)
|
|
482
|
+
else this.perNamespace.delete(entry.namespace)
|
|
483
|
+
}
|
|
413
484
|
}
|
|
414
485
|
|
|
415
486
|
/** Convenience factory mirroring the other core primitives' `create*` style. */
|