@nifrajs/core 2.1.0 → 2.3.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 +2 -2
- package/dist/assurance.d.ts +48 -4
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +87 -9
- package/dist/assurance.js.map +1 -1
- package/dist/binary.d.ts +54 -0
- package/dist/binary.d.ts.map +1 -0
- package/dist/binary.js +90 -0
- package/dist/binary.js.map +1 -0
- package/dist/budget.d.ts +1 -1
- package/dist/budget.js +1 -1
- package/dist/capabilities.d.ts +19 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +60 -8
- package/dist/capabilities.js.map +1 -1
- package/dist/classification.d.ts +1 -1
- package/dist/classification.js +1 -1
- package/dist/diff.d.ts +6 -6
- package/dist/diff.js +6 -6
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +36 -70
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-execution.d.ts +2 -2
- package/dist/durable-execution.js +2 -2
- package/dist/idempotency.d.ts +3 -3
- package/dist/idempotency.js +4 -4
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/internal/brand.d.ts +1 -1
- package/dist/internal/brand.js +1 -1
- package/dist/internal/route-catalog.d.ts +58 -0
- package/dist/internal/route-catalog.d.ts.map +1 -0
- package/dist/internal/route-catalog.js +58 -0
- package/dist/internal/route-catalog.js.map +1 -0
- package/dist/internal/route-execution.d.ts +75 -0
- package/dist/internal/route-execution.d.ts.map +1 -0
- package/dist/internal/route-execution.js +2 -0
- package/dist/internal/route-execution.js.map +1 -0
- package/dist/internal/same-origin.d.ts +29 -0
- package/dist/internal/same-origin.d.ts.map +1 -0
- package/dist/internal/same-origin.js +47 -0
- package/dist/internal/same-origin.js.map +1 -0
- package/dist/internal/secret.d.ts +1 -1
- package/dist/internal/secret.js +1 -1
- package/dist/ledger.d.ts +9 -9
- package/dist/ledger.js +5 -5
- package/dist/reflection.d.ts +1 -1
- package/dist/router/pattern.d.ts +26 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +164 -8
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +52 -4
- package/dist/router/router.js.map +1 -1
- package/dist/schema/standard.d.ts +2 -2
- package/dist/schema/standard.js +2 -2
- package/dist/seo.d.ts +3 -3
- package/dist/seo.js +4 -4
- package/dist/server/body.d.ts +2 -2
- package/dist/server/body.js +2 -2
- package/dist/server/context.d.ts +18 -18
- package/dist/server/contract.d.ts +7 -7
- package/dist/server/contract.js +3 -3
- package/dist/server/cookies.d.ts +4 -4
- package/dist/server/cookies.js +8 -8
- package/dist/server/edge.d.ts +83 -0
- package/dist/server/edge.d.ts.map +1 -0
- package/dist/server/edge.js +71 -0
- package/dist/server/edge.js.map +1 -0
- package/dist/server/install.d.ts +2 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +2 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/logger.d.ts +7 -7
- package/dist/server/logger.js +4 -4
- package/dist/server/registry.d.ts +5 -5
- package/dist/server/response-contract-lane.d.ts +91 -0
- package/dist/server/response-contract-lane.d.ts.map +1 -0
- package/dist/server/response-contract-lane.js +81 -0
- package/dist/server/response-contract-lane.js.map +1 -0
- package/dist/server/server.d.ts +52 -91
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +130 -186
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +4 -4
- package/dist/server/sse.js +7 -7
- package/dist/server/webhook.d.ts +1 -1
- package/dist/server/webhook.js +5 -5
- package/dist/server/websocket.d.ts +19 -19
- package/dist/server/websocket.js +9 -9
- package/dist/server/ws-bun.d.ts +4 -4
- package/dist/server/ws-bun.js +3 -3
- package/dist/server.d.ts +3 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -1
- package/dist/server.js.map +1 -1
- package/dist/transport-codec.d.ts +13 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +48 -9
- package/dist/transport-codec.js.map +1 -1
- package/package.json +12 -2
- package/src/assurance.ts +154 -8
- package/src/binary.ts +128 -0
- package/src/budget.ts +1 -1
- package/src/capabilities.ts +71 -9
- package/src/classification.ts +1 -1
- package/src/diff.ts +8 -8
- package/src/durable-adapters.ts +40 -70
- package/src/durable-execution.ts +2 -2
- package/src/idempotency.ts +4 -4
- package/src/index.ts +3 -3
- package/src/internal/brand.ts +1 -1
- package/src/internal/route-catalog.ts +94 -0
- package/src/internal/route-execution.ts +106 -0
- package/src/internal/same-origin.ts +43 -0
- package/src/internal/secret.ts +1 -1
- package/src/ledger.ts +11 -11
- package/src/reflection.ts +1 -1
- package/src/router/pattern.ts +179 -8
- package/src/router/router.ts +80 -6
- package/src/schema/standard.ts +2 -2
- package/src/seo.ts +6 -6
- package/src/server/body.ts +2 -2
- package/src/server/context.ts +18 -18
- package/src/server/contract.ts +13 -13
- package/src/server/cookies.ts +8 -8
- package/src/server/edge.ts +150 -0
- package/src/server/install.ts +5 -0
- package/src/server/logger.ts +8 -8
- package/src/server/registry.ts +5 -5
- package/src/server/response-contract-lane.ts +164 -0
- package/src/server/server.ts +159 -366
- package/src/server/sse.ts +11 -11
- package/src/server/webhook.ts +5 -5
- package/src/server/websocket.ts +22 -22
- package/src/server/ws-bun.ts +4 -4
- package/src/server.ts +8 -5
- package/src/transport-codec.ts +63 -10
package/src/binary.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/core/binary` - a route that returns bytes, and says so in its type.
|
|
3
|
+
*
|
|
4
|
+
* A download route had no way to be part of the contract. Returning a raw `Response` is the only way
|
|
5
|
+
* to send bytes, and a raw `Response` is exactly what the typed client cannot describe - so the route
|
|
6
|
+
* needed a `// nifra-expect raw-response` pragma to quiet the advisory, and its caller got no type at
|
|
7
|
+
* all. One whole category of endpoint sat outside the thing the framework is otherwise strict about.
|
|
8
|
+
*
|
|
9
|
+
* import { bytes } from "@nifrajs/core/binary"
|
|
10
|
+
*
|
|
11
|
+
* app.get("/invoice.pdf", async (c) => bytes(await render(c.params.id), {
|
|
12
|
+
* type: "application/pdf",
|
|
13
|
+
* filename: "invoice.pdf",
|
|
14
|
+
* }))
|
|
15
|
+
*
|
|
16
|
+
* The client types `data` as `Blob` for that route, and gets the bytes intact.
|
|
17
|
+
*
|
|
18
|
+
* The brand is a phantom: `bytes()` returns a real `Response` and nothing is added to it at runtime.
|
|
19
|
+
* It exists so the type carries a fact the value cannot - that these bytes are the payload rather than
|
|
20
|
+
* a serialization accident - which is what lets `Jsonify` answer `Blob` instead of trying to describe
|
|
21
|
+
* a `Response`'s properties.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Marker on a binary response's TYPE. A unique symbol rather than a string key, so no ordinary object
|
|
26
|
+
* can satisfy it by accident, and declared required rather than optional - an optional brand is
|
|
27
|
+
* satisfied by every type that lacks it, which would make the check match `Response` itself.
|
|
28
|
+
*/
|
|
29
|
+
declare const NIFRA_BYTES: unique symbol
|
|
30
|
+
|
|
31
|
+
/** Runtime marker paired with the type-only brand so clients do not have to guess from media type. */
|
|
32
|
+
export const NIFRA_BINARY_HEADER = "x-nifra-binary"
|
|
33
|
+
|
|
34
|
+
/** A `Response` a route declared as binary. `Jsonify` maps this to `Blob`. */
|
|
35
|
+
export type BinaryResponse = Response & { readonly [NIFRA_BYTES]: true }
|
|
36
|
+
|
|
37
|
+
/** What can be sent as bytes without being re-encoded on the way out. */
|
|
38
|
+
export type BinaryBody = ArrayBuffer | ArrayBufferView | Blob | ReadableStream<Uint8Array>
|
|
39
|
+
|
|
40
|
+
export interface BytesOptions {
|
|
41
|
+
/** Media type. Defaults to `application/octet-stream`, the honest answer for unlabelled bytes. */
|
|
42
|
+
readonly type?: string
|
|
43
|
+
/**
|
|
44
|
+
* Offer the body as a download under this name.
|
|
45
|
+
*
|
|
46
|
+
* A filename derived from user data is the ordinary case - an upload's original name, a document
|
|
47
|
+
* title - so this has to survive anything a person can type. Two things follow, and both are tested:
|
|
48
|
+
* a `"` or a newline cannot end the value and write further parameters, and a non-Latin-1 name is
|
|
49
|
+
* encoded rather than thrown on.
|
|
50
|
+
*/
|
|
51
|
+
readonly filename?: string
|
|
52
|
+
readonly status?: number
|
|
53
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Everything a header value may not contain: quotes, backslashes, and the CR/LF that would split the
|
|
58
|
+
* header. Replaced rather than rejected - a filename is cosmetic, and failing a download because a
|
|
59
|
+
* document was titled `Q1 "final"` would be worse than sending it with the quotes dropped.
|
|
60
|
+
*/
|
|
61
|
+
const UNSAFE_FILENAME = /["\\\r\n]/g
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Printable ASCII only.
|
|
65
|
+
*
|
|
66
|
+
* A header value is nominally Latin-1, but runtimes do not agree: Bun rejects `é` in a header outright,
|
|
67
|
+
* so "Latin-1 is fine" would have been a portability bug dressed as a standards reading. Printable
|
|
68
|
+
* ASCII is what every runtime and client accepts, and `filename*` carries everything else.
|
|
69
|
+
*/
|
|
70
|
+
const NON_ASCII = /[^\x20-\x7e]/g
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `Content-Disposition` for a name that may be anything a user typed.
|
|
74
|
+
*
|
|
75
|
+
* Setting a header containing `报告.pdf` or an emoji THROWS - which on a download route is a 500 for
|
|
76
|
+
* the ordinary act of naming a file in a language ASCII does not cover, and the name usually came from
|
|
77
|
+
* the user in the first place. RFC 6266 exists for exactly this: an ASCII `filename` every client
|
|
78
|
+
* understands, plus `filename*` carrying the real name UTF-8 percent-encoded, which browsers prefer.
|
|
79
|
+
*
|
|
80
|
+
* The ASCII fallback keeps the characters it can rather than substituting a placeholder, so
|
|
81
|
+
* `report 报告.pdf` still arrives as `report .pdf` on a client that ignores `filename*`.
|
|
82
|
+
*
|
|
83
|
+
* `filename*` is built from the SANITIZED name, not the raw one. Percent-encoding would make a hostile
|
|
84
|
+
* name inert either way, but round-tripping `"; attachment; filename="evil.exe` into the header - even
|
|
85
|
+
* encoded - puts an attacker's string somewhere a future reader has to reason about, and the second
|
|
86
|
+
* parameter is only there to carry characters ASCII cannot.
|
|
87
|
+
*/
|
|
88
|
+
function contentDisposition(filename: string): string {
|
|
89
|
+
const safe = filename.toWellFormed().replace(UNSAFE_FILENAME, "")
|
|
90
|
+
const ascii = safe.replace(NON_ASCII, "")
|
|
91
|
+
const disposition = `attachment; filename="${ascii}"`
|
|
92
|
+
// Only when it adds something: a name ASCII already carries needs no second parameter.
|
|
93
|
+
if (ascii === safe) return disposition
|
|
94
|
+
// encodeURIComponent leaves `'()*` untouched, but RFC 5987's attr-char grammar does not.
|
|
95
|
+
const encoded = encodeURIComponent(safe).replace(
|
|
96
|
+
/['()*]/g,
|
|
97
|
+
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
98
|
+
)
|
|
99
|
+
return `${disposition}; filename*=UTF-8''${encoded}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function bytes(body: BinaryBody, options: BytesOptions = {}): BinaryResponse {
|
|
103
|
+
const headers = new Headers(options.headers as Record<string, string> | undefined)
|
|
104
|
+
headers.set("content-type", options.type ?? "application/octet-stream")
|
|
105
|
+
headers.set(NIFRA_BINARY_HEADER, "1")
|
|
106
|
+
// Response headers outside the CORS safelist are hidden from cross-origin clients unless exposed.
|
|
107
|
+
// Preserve an existing list and add the marker so the typed contract works cross-origin too.
|
|
108
|
+
const exposed = headers.get("access-control-expose-headers")
|
|
109
|
+
if (exposed !== "*") {
|
|
110
|
+
const names = exposed
|
|
111
|
+
?.split(",")
|
|
112
|
+
.map((name) => name.trim())
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
if (!names?.some((name) => name.toLowerCase() === NIFRA_BINARY_HEADER)) {
|
|
115
|
+
headers.set(
|
|
116
|
+
"access-control-expose-headers",
|
|
117
|
+
[...(names ?? []), NIFRA_BINARY_HEADER].join(", "),
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (options.filename !== undefined) {
|
|
122
|
+
headers.set("content-disposition", contentDisposition(options.filename))
|
|
123
|
+
}
|
|
124
|
+
return new Response(body as ConstructorParameters<typeof Response>[0], {
|
|
125
|
+
status: options.status ?? 200,
|
|
126
|
+
headers,
|
|
127
|
+
}) as BinaryResponse
|
|
128
|
+
}
|
package/src/budget.ts
CHANGED
|
@@ -154,7 +154,7 @@ class UnboundedRequestBudget implements RequestBudget {
|
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
156
|
* Create a budget from an admitted absolute deadline. Wall time is sampled once; every subsequent
|
|
157
|
-
* `remaining()` call is monotonic. This function does not arm a timer
|
|
157
|
+
* `remaining()` call is monotonic. This function does not arm a timer-the owner of `signal` does.
|
|
158
158
|
*/
|
|
159
159
|
export function createRequestBudget(options: CreateRequestBudgetOptions): RequestBudget {
|
|
160
160
|
assertDeadline(options.deadline)
|
package/src/capabilities.ts
CHANGED
|
@@ -44,6 +44,36 @@ export type {
|
|
|
44
44
|
EffectLifecycleObserver,
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const EXECUTION_JOURNAL = Symbol("nifra.capability.journal")
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Framework wiring: put a journal on a request context so every `executeCapability` on that request is
|
|
51
|
+
* journaled without each call site threading it. Not for application code - install the
|
|
52
|
+
* `durableCommand()` adapter, which calls this and declares the assurance evidence that goes with it.
|
|
53
|
+
*
|
|
54
|
+
* Symbol-keyed, mirroring `attachEffectLedger`: it cannot collide with an application field and does
|
|
55
|
+
* not show up in a context spread or a log of it.
|
|
56
|
+
*
|
|
57
|
+
* Deliberately here rather than beside the journal TYPE in `internal/capability-runtime.ts`. That
|
|
58
|
+
* module is reachable from a bare server - it holds the route-capability guard - so a seam living
|
|
59
|
+
* there ships to every app, while here it arrives only with `executeCapability`, the one thing that
|
|
60
|
+
* reads it. Measured rather than assumed: in the internal module this cost every bundle row in the
|
|
61
|
+
* size matrix 10-18 bytes gzip, enough to put `nifra-mcp` over its ceiling.
|
|
62
|
+
*/
|
|
63
|
+
export function attachCapabilityJournal(
|
|
64
|
+
context: object,
|
|
65
|
+
journal: CapabilityExecutionJournal,
|
|
66
|
+
): void {
|
|
67
|
+
;(context as Record<PropertyKey, unknown>)[EXECUTION_JOURNAL as unknown as string] = journal
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The journal installed for this request, if any. */
|
|
71
|
+
export function capabilityJournalOf(context: object): CapabilityExecutionJournal | undefined {
|
|
72
|
+
return (context as { readonly [EXECUTION_JOURNAL]?: CapabilityExecutionJournal })[
|
|
73
|
+
EXECUTION_JOURNAL
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
|
|
47
77
|
export type CapabilityZone = "domain" | "operational"
|
|
48
78
|
export type CapabilityAccess = "read" | "write"
|
|
49
79
|
export type CapabilityIdempotency = "none" | "request" | "durable"
|
|
@@ -121,9 +151,11 @@ export type CapabilityFindingCode =
|
|
|
121
151
|
| "provenance-uncovered"
|
|
122
152
|
| "undeclared-capability-evidence"
|
|
123
153
|
| "safe-method-domain-write"
|
|
154
|
+
| "unconfined-write-reach"
|
|
124
155
|
| "missing-request-idempotency"
|
|
125
156
|
| "missing-durable-idempotency"
|
|
126
157
|
| "forbidden-effect-import"
|
|
158
|
+
| "provenance-truncated"
|
|
127
159
|
|
|
128
160
|
export interface CapabilityFinding {
|
|
129
161
|
readonly code: CapabilityFindingCode
|
|
@@ -343,16 +375,38 @@ export function evaluateCapabilityAssurance(
|
|
|
343
375
|
message: `${route.method} ${route.path} has no static provenance coverage`,
|
|
344
376
|
})
|
|
345
377
|
}
|
|
378
|
+
// A safe-method route that REACHES a domain write without declaring it used to draw two findings
|
|
379
|
+
// giving opposite advice: declare it (evidence exceeds its declaration) and do not declare it (a
|
|
380
|
+
// safe method cannot carry a domain write). Both are right and together they are a dead end - the
|
|
381
|
+
// route cannot declare its way out, because the problem is not the declaration. Reach is computed
|
|
382
|
+
// from the module that registers the route, so a read endpoint sitting beside a write seam has
|
|
383
|
+
// write powers in scope, and the only fix is to confine them. Report that once, and say so.
|
|
384
|
+
const unconfined = new Set<string>()
|
|
346
385
|
for (const id of evidenceIds) {
|
|
347
|
-
if (
|
|
386
|
+
if (declared.includes(id)) continue
|
|
387
|
+
const definition = definitions.get(id)
|
|
388
|
+
if (
|
|
389
|
+
SAFE_METHODS.has(route.method) &&
|
|
390
|
+
definition?.zone === "domain" &&
|
|
391
|
+
definition.access === "write"
|
|
392
|
+
) {
|
|
393
|
+
unconfined.add(id)
|
|
348
394
|
findings.push({
|
|
349
|
-
code: "
|
|
395
|
+
code: "unconfined-write-reach",
|
|
350
396
|
method: route.method,
|
|
351
397
|
path: route.path,
|
|
352
398
|
capability: id,
|
|
353
|
-
message: `${route.method} ${route.path}
|
|
399
|
+
message: `${route.method} ${route.path} can reach domain write capability ${id} without declaring it, and a safe method may not declare one - move the route or the effect so the write is not in its module's reach`,
|
|
354
400
|
})
|
|
401
|
+
continue
|
|
355
402
|
}
|
|
403
|
+
findings.push({
|
|
404
|
+
code: "undeclared-capability-evidence",
|
|
405
|
+
method: route.method,
|
|
406
|
+
path: route.path,
|
|
407
|
+
capability: id,
|
|
408
|
+
message: `${route.method} ${route.path} evidence exceeds its declaration: ${id}`,
|
|
409
|
+
})
|
|
356
410
|
}
|
|
357
411
|
|
|
358
412
|
const effective = new Set([...declared, ...evidenceIds])
|
|
@@ -363,7 +417,10 @@ export function evaluateCapabilityAssurance(
|
|
|
363
417
|
if (
|
|
364
418
|
SAFE_METHODS.has(route.method) &&
|
|
365
419
|
definition.zone === "domain" &&
|
|
366
|
-
definition.access === "write"
|
|
420
|
+
definition.access === "write" &&
|
|
421
|
+
// Already reported as unconfined reach, with the fix that case actually has. Repeating "cannot
|
|
422
|
+
// carry" here is the half of the dead end that sends the reader back the way they came.
|
|
423
|
+
!unconfined.has(id)
|
|
367
424
|
) {
|
|
368
425
|
findings.push({
|
|
369
426
|
code: "safe-method-domain-write",
|
|
@@ -419,7 +476,7 @@ export function evaluateCapabilityAssurance(
|
|
|
419
476
|
|
|
420
477
|
/**
|
|
421
478
|
* Optional effect-ledger fields for one `useCapability` beacon. Token-only by design: an adapter
|
|
422
|
-
* names *what* it touched and *how much resource* it used
|
|
479
|
+
* names *what* it touched and *how much resource* it used - never the value it read or wrote.
|
|
423
480
|
*/
|
|
424
481
|
export interface UseCapabilityOptions {
|
|
425
482
|
/** Adapter/resource token recorded on the ledger entry (`repo:orders`). */
|
|
@@ -664,6 +721,11 @@ export async function executeCapability<T>(
|
|
|
664
721
|
}
|
|
665
722
|
const guard = guardFor(context, capability)
|
|
666
723
|
const ledger = effectLedgerOf(context)
|
|
724
|
+
// An explicitly passed journal wins, so every existing call site behaves exactly as it did. The
|
|
725
|
+
// context journal is what the `durableCommand()` adapter installs, so that declaring the adapter
|
|
726
|
+
// once journals every effect on the request rather than each call site remembering to thread it -
|
|
727
|
+
// and so the `nifra.durable-command` evidence it declares is backed by something.
|
|
728
|
+
const journal = options.journal ?? capabilityJournalOf(context)
|
|
667
729
|
const effectId = crypto.randomUUID()
|
|
668
730
|
const metadata = normalizeEffectMetadata(options)
|
|
669
731
|
const trace = effectTraceParentOf(context)
|
|
@@ -709,7 +771,7 @@ export async function executeCapability<T>(
|
|
|
709
771
|
transitions: {
|
|
710
772
|
async intent(execution) {
|
|
711
773
|
ledger?.append({ capability, effectId: execution.effectId, ...metadata, phase: "intent" })
|
|
712
|
-
await
|
|
774
|
+
await journal?.intent({
|
|
713
775
|
effectId: execution.effectId,
|
|
714
776
|
capability,
|
|
715
777
|
...(metadata.target === undefined ? {} : { target: metadata.target }),
|
|
@@ -720,18 +782,18 @@ export async function executeCapability<T>(
|
|
|
720
782
|
})
|
|
721
783
|
},
|
|
722
784
|
async executing(execution) {
|
|
723
|
-
await
|
|
785
|
+
await journal?.executing(execution.effectId)
|
|
724
786
|
},
|
|
725
787
|
async committed(execution) {
|
|
726
788
|
try {
|
|
727
|
-
await
|
|
789
|
+
await journal?.committed(execution.effectId)
|
|
728
790
|
} catch {
|
|
729
791
|
throw new CapabilityJournalTransitionError(capability, execution.effectId, "committed")
|
|
730
792
|
}
|
|
731
793
|
},
|
|
732
794
|
async failed(execution, failure) {
|
|
733
795
|
try {
|
|
734
|
-
await
|
|
796
|
+
await journal?.failed(execution.effectId, {
|
|
735
797
|
began: failure.began,
|
|
736
798
|
errorCode: failure.errorCode,
|
|
737
799
|
})
|
package/src/classification.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Response data-classification tags. A route can declare the highest sensitivity its response body
|
|
3
|
-
* carries (`schema.classification`). This is a declarative, compile-time + introspection fact
|
|
3
|
+
* carries (`schema.classification`). This is a declarative, compile-time + introspection fact - never
|
|
4
4
|
* read on the request hot path and never enforced at runtime here. Downstream consumers use it: a
|
|
5
5
|
* partner-API surface refuses to expose a route whose response is `pii`/`secret`, privacy tooling
|
|
6
6
|
* learns which routes emit regulated data, and the capability lockfile records it so a route that
|
package/src/diff.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API breaking-change detection over route reflection.
|
|
3
3
|
*
|
|
4
|
-
* `snapshotRoutes` turns an app (or reflected descriptors) into a plain-JSON snapshot
|
|
4
|
+
* `snapshotRoutes` turns an app (or reflected descriptors) into a plain-JSON snapshot - the
|
|
5
5
|
* `standard` validators are dropped, so a snapshot survives `JSON.stringify` and can be committed
|
|
6
6
|
* as a CI baseline. `diffRouteSnapshots` compares two snapshots and classifies every change by
|
|
7
7
|
* wire compatibility, direction-aware:
|
|
8
8
|
*
|
|
9
|
-
* request direction (body/query
|
|
9
|
+
* request direction (body/query - what clients SEND): a new required field, a removed field, or
|
|
10
10
|
* a narrowed type breaks existing callers; widening (new optional field, enum superset,
|
|
11
11
|
* required→optional) is compatible.
|
|
12
|
-
* response direction (response/sse/errors
|
|
12
|
+
* response direction (response/sse/errors - what clients RECEIVE): a removed field, a field made
|
|
13
13
|
* optional, or a widened type breaks existing readers; narrowing (new field, optional→required,
|
|
14
14
|
* enum subset) is compatible.
|
|
15
15
|
*
|
|
16
16
|
* The classifier FAILS CLOSED: a schema change it cannot prove compatible is reported as breaking.
|
|
17
17
|
* Schemas without JSON Schema metadata (validation-only Standard Schemas) cannot be compared and
|
|
18
|
-
* yield `info`
|
|
18
|
+
* yield `info` - never a silent pass presented as proof.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import {
|
|
@@ -39,7 +39,7 @@ export interface RouteSnapshotSchema {
|
|
|
39
39
|
readonly errors?: Readonly<Record<string, SchemaSnapshot>>
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/** One route in a snapshot
|
|
42
|
+
/** One route in a snapshot - plain JSON, safe to persist as a CI baseline. */
|
|
43
43
|
export interface RouteSnapshot {
|
|
44
44
|
readonly method: string
|
|
45
45
|
readonly path: string
|
|
@@ -61,7 +61,7 @@ export interface RouteChange {
|
|
|
61
61
|
|
|
62
62
|
export interface RoutesDiff {
|
|
63
63
|
readonly changes: readonly RouteChange[]
|
|
64
|
-
/** True when any change is `breaking`
|
|
64
|
+
/** True when any change is `breaking` - the CI-gate signal. */
|
|
65
65
|
readonly hasBreaking: boolean
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -269,9 +269,9 @@ const diffSchemaSection = (
|
|
|
269
269
|
return
|
|
270
270
|
}
|
|
271
271
|
if (before.jsonSchema === undefined || after.jsonSchema === undefined) {
|
|
272
|
-
// Validation-only schemas expose no JSON Schema metadata
|
|
272
|
+
// Validation-only schemas expose no JSON Schema metadata - nothing provable either way.
|
|
273
273
|
if (before.jsonSchema !== after.jsonSchema || before.fields !== after.fields) {
|
|
274
|
-
push(ctx, "info", `${ctx.section} schema is not introspectable
|
|
274
|
+
push(ctx, "info", `${ctx.section} schema is not introspectable - cannot verify compatibility`)
|
|
275
275
|
}
|
|
276
276
|
return
|
|
277
277
|
}
|
package/src/durable-adapters.ts
CHANGED
|
@@ -461,10 +461,26 @@ export interface PostgresClient {
|
|
|
461
461
|
}
|
|
462
462
|
|
|
463
463
|
function sqlIdentifier(value: string): string {
|
|
464
|
-
|
|
464
|
+
// PostgreSQL truncates identifiers to 63 bytes. Reserve `_records_reconcile` (18 bytes), the
|
|
465
|
+
// longest suffix this adapter appends, so two accepted prefixes can never collapse to one name.
|
|
466
|
+
if (!/^[a-z_][a-z0-9_]{0,44}$/u.test(value)) throw new TypeError("invalid SQL table prefix")
|
|
465
467
|
return value
|
|
466
468
|
}
|
|
467
469
|
|
|
470
|
+
/** Explicit static-analysis boundary: every substitution is a validated, internal SQL fragment. */
|
|
471
|
+
function sqlIdentifiers(strings: TemplateStringsArray, ...values: readonly string[]): string {
|
|
472
|
+
for (const value of values) {
|
|
473
|
+
if (!/^(?:[a-z_][a-z0-9_]{0,62}|\?(?:,\?)*)$/u.test(value)) {
|
|
474
|
+
throw new TypeError("invalid SQL identifier fragment")
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return strings.reduce((sql, part, index) => sql + part + (values[index] ?? ""), "")
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function sqlPlaceholders(count: number): string {
|
|
481
|
+
return Array.from({ length: count }, () => "?").join(",")
|
|
482
|
+
}
|
|
483
|
+
|
|
468
484
|
function recordFromRow<T>(row: Record<string, unknown> | undefined): T | undefined {
|
|
469
485
|
if (row === undefined) return undefined
|
|
470
486
|
const payload = row.payload
|
|
@@ -490,21 +506,13 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
490
506
|
|
|
491
507
|
async migrate(): Promise<void> {
|
|
492
508
|
await this.client.query(
|
|
493
|
-
|
|
494
|
-
this.recordsTable +
|
|
495
|
-
" (kind TEXT NOT NULL, id TEXT NOT NULL, state TEXT NOT NULL, updated_at BIGINT NOT NULL, version BIGINT NOT NULL, payload JSONB NOT NULL, PRIMARY KEY (kind, id))",
|
|
509
|
+
sqlIdentifiers`CREATE TABLE IF NOT EXISTS ${this.recordsTable} (kind TEXT NOT NULL, id TEXT NOT NULL, state TEXT NOT NULL, updated_at BIGINT NOT NULL, version BIGINT NOT NULL, payload JSONB NOT NULL, PRIMARY KEY (kind, id))`,
|
|
496
510
|
)
|
|
497
511
|
await this.client.query(
|
|
498
|
-
|
|
499
|
-
this.recordsTable +
|
|
500
|
-
"_reconcile ON " +
|
|
501
|
-
this.recordsTable +
|
|
502
|
-
" (kind, state, updated_at, id)",
|
|
512
|
+
sqlIdentifiers`CREATE INDEX IF NOT EXISTS ${this.recordsTable}_reconcile ON ${this.recordsTable} (kind, state, updated_at, id)`,
|
|
503
513
|
)
|
|
504
514
|
await this.client.query(
|
|
505
|
-
|
|
506
|
-
this.leasesTable +
|
|
507
|
-
" (name TEXT PRIMARY KEY, owner TEXT, token TEXT, expires_at BIGINT NOT NULL DEFAULT 0, cursor TEXT)",
|
|
515
|
+
sqlIdentifiers`CREATE TABLE IF NOT EXISTS ${this.leasesTable} (name TEXT PRIMARY KEY, owner TEXT, token TEXT, expires_at BIGINT NOT NULL DEFAULT 0, cursor TEXT)`,
|
|
508
516
|
)
|
|
509
517
|
}
|
|
510
518
|
|
|
@@ -514,9 +522,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
514
522
|
record: { readonly version: number; readonly state?: string; readonly updatedAt?: number },
|
|
515
523
|
): Promise<boolean> {
|
|
516
524
|
const result = await this.client.query(
|
|
517
|
-
|
|
518
|
-
this.recordsTable +
|
|
519
|
-
" (kind,id,state,updated_at,version,payload) VALUES ($1,$2,$3,$4,$5,$6::jsonb) ON CONFLICT DO NOTHING RETURNING id",
|
|
525
|
+
sqlIdentifiers`INSERT INTO ${this.recordsTable} (kind,id,state,updated_at,version,payload) VALUES ($1,$2,$3,$4,$5,$6::jsonb) ON CONFLICT DO NOTHING RETURNING id`,
|
|
520
526
|
[
|
|
521
527
|
kind,
|
|
522
528
|
id,
|
|
@@ -533,7 +539,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
533
539
|
id: string,
|
|
534
540
|
): Promise<T | undefined> {
|
|
535
541
|
const result = await this.client.query(
|
|
536
|
-
`SELECT payload FROM ${this.recordsTable} WHERE kind=$1 AND id=$2`,
|
|
542
|
+
sqlIdentifiers`SELECT payload FROM ${this.recordsTable} WHERE kind=$1 AND id=$2`,
|
|
537
543
|
[kind, id],
|
|
538
544
|
)
|
|
539
545
|
return recordFromRow<T>(result.rows[0])
|
|
@@ -546,9 +552,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
546
552
|
): Promise<boolean> {
|
|
547
553
|
if (record.version !== expectedVersion + 1) return false
|
|
548
554
|
const result = await this.client.query(
|
|
549
|
-
|
|
550
|
-
this.recordsTable +
|
|
551
|
-
" SET state=$4,updated_at=$5,version=$6,payload=$7::jsonb WHERE kind=$1 AND id=$2 AND version=$3 RETURNING id",
|
|
555
|
+
sqlIdentifiers`UPDATE ${this.recordsTable} SET state=$4,updated_at=$5,version=$6,payload=$7::jsonb WHERE kind=$1 AND id=$2 AND version=$3 RETURNING id`,
|
|
552
556
|
[
|
|
553
557
|
kind,
|
|
554
558
|
id,
|
|
@@ -566,9 +570,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
566
570
|
input: ReconciliationScanOptions<string>,
|
|
567
571
|
): Promise<ReconciliationScanPage<T>> {
|
|
568
572
|
const result = await this.client.query(
|
|
569
|
-
|
|
570
|
-
this.recordsTable +
|
|
571
|
-
" 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",
|
|
573
|
+
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`,
|
|
572
574
|
[kind, input.states, input.updatedBefore ?? null, input.cursor ?? null, input.limit + 1],
|
|
573
575
|
)
|
|
574
576
|
const hasMore = result.rows.length > input.limit
|
|
@@ -584,11 +586,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
584
586
|
const token = crypto.randomUUID()
|
|
585
587
|
const expiresAt = input.now + input.leaseMs
|
|
586
588
|
const result = await this.client.query(
|
|
587
|
-
|
|
588
|
-
this.leasesTable +
|
|
589
|
-
" (name,owner,token,expires_at) VALUES ($1,$2,$3,$4) ON CONFLICT (name) DO UPDATE SET owner=EXCLUDED.owner,token=EXCLUDED.token,expires_at=EXCLUDED.expires_at WHERE " +
|
|
590
|
-
this.leasesTable +
|
|
591
|
-
".expires_at <= $5 RETURNING name,owner,token,expires_at,cursor",
|
|
589
|
+
sqlIdentifiers`INSERT INTO ${this.leasesTable} (name,owner,token,expires_at) VALUES ($1,$2,$3,$4) ON CONFLICT (name) DO UPDATE SET owner=EXCLUDED.owner,token=EXCLUDED.token,expires_at=EXCLUDED.expires_at WHERE ${this.leasesTable}.expires_at <= $5 RETURNING name,owner,token,expires_at,cursor`,
|
|
592
590
|
[input.name, input.owner, token, expiresAt, input.now],
|
|
593
591
|
)
|
|
594
592
|
const row = result.rows[0]
|
|
@@ -603,9 +601,7 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
603
601
|
}
|
|
604
602
|
async renewLease(input: Parameters<DurableRecordBackend["renewLease"]>[0]): Promise<boolean> {
|
|
605
603
|
const result = await this.client.query(
|
|
606
|
-
|
|
607
|
-
this.leasesTable +
|
|
608
|
-
" SET expires_at=$4 WHERE name=$1 AND owner=$2 AND token=$3 AND expires_at>$5 RETURNING name",
|
|
604
|
+
sqlIdentifiers`UPDATE ${this.leasesTable} SET expires_at=$4 WHERE name=$1 AND owner=$2 AND token=$3 AND expires_at>$5 RETURNING name`,
|
|
609
605
|
[input.name, input.owner, input.token, input.now + input.leaseMs, input.now],
|
|
610
606
|
)
|
|
611
607
|
return result.rows.length === 1
|
|
@@ -614,18 +610,14 @@ export class PostgresDurableRecordBackend implements DurableRecordBackend {
|
|
|
614
610
|
input: Parameters<DurableRecordBackend["checkpointLease"]>[0],
|
|
615
611
|
): Promise<boolean> {
|
|
616
612
|
const result = await this.client.query(
|
|
617
|
-
|
|
618
|
-
this.leasesTable +
|
|
619
|
-
" SET cursor=$4 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name",
|
|
613
|
+
sqlIdentifiers`UPDATE ${this.leasesTable} SET cursor=$4 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name`,
|
|
620
614
|
[input.name, input.owner, input.token, input.cursor ?? null],
|
|
621
615
|
)
|
|
622
616
|
return result.rows.length === 1
|
|
623
617
|
}
|
|
624
618
|
async releaseLease(input: Parameters<DurableRecordBackend["releaseLease"]>[0]): Promise<boolean> {
|
|
625
619
|
const result = await this.client.query(
|
|
626
|
-
|
|
627
|
-
this.leasesTable +
|
|
628
|
-
" SET owner=NULL,token=NULL,expires_at=0 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name",
|
|
620
|
+
sqlIdentifiers`UPDATE ${this.leasesTable} SET owner=NULL,token=NULL,expires_at=0 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name`,
|
|
629
621
|
[input.name, input.owner, input.token],
|
|
630
622
|
)
|
|
631
623
|
return result.rows.length === 1
|
|
@@ -680,17 +672,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
680
672
|
}
|
|
681
673
|
migrate(): void {
|
|
682
674
|
this.client.exec(
|
|
683
|
-
|
|
684
|
-
this.recordsTable +
|
|
685
|
-
" (kind TEXT NOT NULL, id TEXT NOT NULL, state TEXT NOT NULL, updated_at INTEGER NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (kind,id));" +
|
|
686
|
-
"CREATE INDEX IF NOT EXISTS " +
|
|
687
|
-
this.recordsTable +
|
|
688
|
-
"_reconcile ON " +
|
|
689
|
-
this.recordsTable +
|
|
690
|
-
" (kind,state,updated_at,id);" +
|
|
691
|
-
"CREATE TABLE IF NOT EXISTS " +
|
|
692
|
-
this.leasesTable +
|
|
693
|
-
" (name TEXT PRIMARY KEY, owner TEXT, token TEXT, expires_at INTEGER NOT NULL DEFAULT 0, cursor TEXT);",
|
|
675
|
+
sqlIdentifiers`CREATE TABLE IF NOT EXISTS ${this.recordsTable} (kind TEXT NOT NULL, id TEXT NOT NULL, state TEXT NOT NULL, updated_at INTEGER NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (kind,id));CREATE INDEX IF NOT EXISTS ${this.recordsTable}_reconcile ON ${this.recordsTable} (kind,state,updated_at,id);CREATE TABLE IF NOT EXISTS ${this.leasesTable} (name TEXT PRIMARY KEY, owner TEXT, token TEXT, expires_at INTEGER NOT NULL DEFAULT 0, cursor TEXT);`,
|
|
694
676
|
)
|
|
695
677
|
}
|
|
696
678
|
async create(
|
|
@@ -700,9 +682,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
700
682
|
): Promise<boolean> {
|
|
701
683
|
const result = this.client
|
|
702
684
|
.query(
|
|
703
|
-
|
|
704
|
-
this.recordsTable +
|
|
705
|
-
" (kind,id,state,updated_at,version,payload) VALUES (?,?,?,?,?,?)",
|
|
685
|
+
sqlIdentifiers`INSERT OR IGNORE INTO ${this.recordsTable} (kind,id,state,updated_at,version,payload) VALUES (?,?,?,?,?,?)`,
|
|
706
686
|
)
|
|
707
687
|
.run(
|
|
708
688
|
kind,
|
|
@@ -719,7 +699,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
719
699
|
id: string,
|
|
720
700
|
): Promise<T | undefined> {
|
|
721
701
|
const row = this.client
|
|
722
|
-
.query(`SELECT payload FROM ${this.recordsTable} WHERE kind=? AND id=?`)
|
|
702
|
+
.query(sqlIdentifiers`SELECT payload FROM ${this.recordsTable} WHERE kind=? AND id=?`)
|
|
723
703
|
.get(kind, id)
|
|
724
704
|
return recordFromRow<T>(row ?? undefined)
|
|
725
705
|
}
|
|
@@ -732,9 +712,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
732
712
|
if (record.version !== expectedVersion + 1) return false
|
|
733
713
|
const result = this.client
|
|
734
714
|
.query(
|
|
735
|
-
|
|
736
|
-
this.recordsTable +
|
|
737
|
-
" SET state=?,updated_at=?,version=?,payload=? WHERE kind=? AND id=? AND version=?",
|
|
715
|
+
sqlIdentifiers`UPDATE ${this.recordsTable} SET state=?,updated_at=?,version=?,payload=? WHERE kind=? AND id=? AND version=?`,
|
|
738
716
|
)
|
|
739
717
|
.run(
|
|
740
718
|
record.state ?? "",
|
|
@@ -752,14 +730,10 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
752
730
|
input: ReconciliationScanOptions<string>,
|
|
753
731
|
): Promise<ReconciliationScanPage<T>> {
|
|
754
732
|
if (input.states.length === 0) return { records: [] }
|
|
755
|
-
const placeholders = input.states.
|
|
733
|
+
const placeholders = sqlPlaceholders(input.states.length)
|
|
756
734
|
const rows = this.client
|
|
757
735
|
.query(
|
|
758
|
-
|
|
759
|
-
this.recordsTable +
|
|
760
|
-
" WHERE kind=? AND state IN (" +
|
|
761
|
-
placeholders +
|
|
762
|
-
") AND (? IS NULL OR updated_at <= ?) AND (? IS NULL OR id > ?) ORDER BY id LIMIT ?",
|
|
736
|
+
sqlIdentifiers`SELECT id,payload FROM ${this.recordsTable} WHERE kind=? AND state IN (${placeholders}) AND (? IS NULL OR updated_at <= ?) AND (? IS NULL OR id > ?) ORDER BY id LIMIT ?`,
|
|
763
737
|
)
|
|
764
738
|
.all(
|
|
765
739
|
kind,
|
|
@@ -785,11 +759,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
785
759
|
const expiresAt = input.now + input.leaseMs
|
|
786
760
|
const row = this.client
|
|
787
761
|
.query(
|
|
788
|
-
|
|
789
|
-
this.leasesTable +
|
|
790
|
-
" (name,owner,token,expires_at) VALUES (?,?,?,?) ON CONFLICT(name) DO UPDATE SET owner=excluded.owner,token=excluded.token,expires_at=excluded.expires_at WHERE " +
|
|
791
|
-
this.leasesTable +
|
|
792
|
-
".expires_at <= ? RETURNING name,owner,token,expires_at,cursor",
|
|
762
|
+
sqlIdentifiers`INSERT INTO ${this.leasesTable} (name,owner,token,expires_at) VALUES (?,?,?,?) ON CONFLICT(name) DO UPDATE SET owner=excluded.owner,token=excluded.token,expires_at=excluded.expires_at WHERE ${this.leasesTable}.expires_at <= ? RETURNING name,owner,token,expires_at,cursor`,
|
|
793
763
|
)
|
|
794
764
|
.get(input.name, input.owner, token, expiresAt, input.now)
|
|
795
765
|
if (row === undefined || row === null) return undefined
|
|
@@ -805,7 +775,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
805
775
|
return (
|
|
806
776
|
this.client
|
|
807
777
|
.query(
|
|
808
|
-
`UPDATE ${this.leasesTable} SET expires_at=? WHERE name=? AND owner=? AND token=? AND expires_at>?`,
|
|
778
|
+
sqlIdentifiers`UPDATE ${this.leasesTable} SET expires_at=? WHERE name=? AND owner=? AND token=? AND expires_at>?`,
|
|
809
779
|
)
|
|
810
780
|
.run(input.now + input.leaseMs, input.name, input.owner, input.token, input.now).changes ===
|
|
811
781
|
1
|
|
@@ -816,7 +786,9 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
816
786
|
): Promise<boolean> {
|
|
817
787
|
return (
|
|
818
788
|
this.client
|
|
819
|
-
.query(
|
|
789
|
+
.query(
|
|
790
|
+
sqlIdentifiers`UPDATE ${this.leasesTable} SET cursor=? WHERE name=? AND owner=? AND token=?`,
|
|
791
|
+
)
|
|
820
792
|
.run(input.cursor ?? null, input.name, input.owner, input.token).changes === 1
|
|
821
793
|
)
|
|
822
794
|
}
|
|
@@ -824,9 +796,7 @@ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
|
|
|
824
796
|
return (
|
|
825
797
|
this.client
|
|
826
798
|
.query(
|
|
827
|
-
|
|
828
|
-
this.leasesTable +
|
|
829
|
-
" SET owner=NULL,token=NULL,expires_at=0 WHERE name=? AND owner=? AND token=?",
|
|
799
|
+
sqlIdentifiers`UPDATE ${this.leasesTable} SET owner=NULL,token=NULL,expires_at=0 WHERE name=? AND owner=? AND token=?`,
|
|
830
800
|
)
|
|
831
801
|
.run(input.name, input.owner, input.token).changes === 1
|
|
832
802
|
)
|
package/src/durable-execution.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Durable capability execution primitives. This subpath is opt-in so the bare HTTP runtime stays
|
|
3
3
|
* lean. Approval and journal records are token-only. Saga input, results, and compensation arguments
|
|
4
|
-
* intentionally live only in the caller-supplied durable saga store
|
|
5
|
-
* telemetry
|
|
4
|
+
* intentionally live only in the caller-supplied durable saga store - never in the effect ledger or
|
|
5
|
+
* telemetry - and production stores should encrypt them according to the application's data policy.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { type EffectLifecycleObserver, emitEffectLifecycle } from "./effect-lifecycle.ts"
|
package/src/idempotency.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Idempotency primitive. A mutating route can declare `schema.idempotency`; the server then dedupes
|
|
3
3
|
* on an `Idempotency-Key` header: the first request runs and its response is stored, and a retry with
|
|
4
4
|
* the same key replays the stored response byte-for-byte without re-running the handler. A retry that
|
|
5
|
-
* reuses a key with a *different* request body is rejected (409)
|
|
5
|
+
* reuses a key with a *different* request body is rejected (409) - a key binds to one request.
|
|
6
6
|
*
|
|
7
7
|
* This module is the runtime-neutral core: the store interface, an in-memory store, the request
|
|
8
8
|
* fingerprint, and response (de)serialization. The server owns the request-path lane that reads the
|
|
@@ -136,7 +136,7 @@ const encoder = new TextEncoder()
|
|
|
136
136
|
|
|
137
137
|
/**
|
|
138
138
|
* SHA-256 fingerprint binding a key to one request: method, path (+ query), and the raw body bytes.
|
|
139
|
-
* A collision-resistant hash matters
|
|
139
|
+
* A collision-resistant hash matters - a weak hash would let a crafted body replay another's response.
|
|
140
140
|
*/
|
|
141
141
|
export async function computeIdempotencyFingerprint(
|
|
142
142
|
method: string,
|
|
@@ -297,7 +297,7 @@ export function responseFromStored(
|
|
|
297
297
|
const headers = new Headers(replaySafeHeaders(stored.headers) as [string, string][])
|
|
298
298
|
headers.set(IDEMPOTENT_REPLAY_HEADER, "1")
|
|
299
299
|
// Reference the Response body type structurally: `BodyInit` isn't a named type under every lib config,
|
|
300
|
-
// but a fresh `Uint8Array` is a valid body
|
|
300
|
+
// but a fresh `Uint8Array` is a valid body - the cast bridges the `ArrayBufferLike` generic variance.
|
|
301
301
|
const body = (stored.body === "" ? null : base64ToBytes(stored.body)) as ConstructorParameters<
|
|
302
302
|
typeof Response
|
|
303
303
|
>[0]
|
|
@@ -320,7 +320,7 @@ export interface MemoryIdempotencyStoreOptions {
|
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
/**
|
|
323
|
-
* In-process idempotency store. Reservation is atomic by construction
|
|
323
|
+
* In-process idempotency store. Reservation is atomic by construction - `begin` never awaits, so the
|
|
324
324
|
* single-threaded event loop serializes concurrent callers for one key. Expired entries are treated
|
|
325
325
|
* as absent (lazy eviction on access); a periodic {@link MemoryIdempotencyStore.sweep} bounds memory.
|
|
326
326
|
*/
|