diff --git a/.drive/projects/forcing-function-apps/design-notes.md b/.drive/projects/forcing-function-apps/design-notes.md index 438cb3af..1ea4fbaf 100644 --- a/.drive/projects/forcing-function-apps/design-notes.md +++ b/.drive/projects/forcing-function-apps/design-notes.md @@ -102,3 +102,92 @@ cutover, streams integration shape, secrets backing, dev-loop architecture. - [prisma/datahub](https://github.com/prisma/datahub), [prisma/open-chat](https://github.com/prisma/open-chat), [prisma/streams](https://github.com/prisma/streams) + +## Streams consumer auth — settled by ADR-0030 (2026-07-15) + +The streams slice shipped with a root-bound `secret()` bearer key and a +`{ url }`-only binding: consumers declare their own secret slot and the root +binds both to one platform variable. Flagged in review as the first +authenticated module contract — the two slots are connected only by +convention (nothing checks they name the same variable; a mismatch deploys +green and 401s at runtime). + +PR #89 (ADR-0030, rpc-service-key project) settles the pattern framework-wide: +wired-peer auth uses a **framework-minted service key carried on the binding's +own config rail** (like the URL), kept in deploy state — explicitly not an +ADR-0029 secret, which is reserved for user-supplied external values. + +**Follow-up (blocked on #89 slice 2 — the ServiceKey resource + generic +per-edge value channel):** re-shape streams to match — drop the module's +`apiKey` secret slot, mint the key at deploy, binding becomes +`{ url, apiKey }`. Constraint: `@prisma/streams-server` auth is single-key +(`API_KEY`), so v1 is one minted key per module instance delivered on every +binding; distinct per-edge keys need an upstream accepted-key-set change +(mirroring what #89 slice 1 added to rpc's serve()) — candidate minimal +upstream PR. Do this before S7 (open-chat port) consumes the module. + +**ADR-0031 adopted (2026-07-16, corrected).** An earlier note here claimed +per-edge provisioning could not apply because `@prisma/streams-server` +authenticates a single `API_KEY`. That over-claimed: ADR-0031 leaves +cardinality to the provisioner, so a per-PROVIDER provisioner satisfies the +single-key server perfectly well. The design session settled on adopting +ADR-0031, and the deciding reason was **consistency**, not capability: a +module must build on the framework's general internals rather than stand a +second, module-shaped mechanism beside them — two ways to audit one concept +is one too many. Shipped: `durableStreams()`'s `apiKey` param declares a need +under a streams brand; the prisma-cloud target registers a per-provider +provisioner (same `ServiceKey` mint as RPC, keyed on the provider's address) +and lands the value on the streams service; the module owns no credential +resource, and `BearerKey` + the `streams` descriptor are deleted. Future +per-edge keys = flip the provisioner's cardinality and land an accepted set, +once upstream takes a key set (ADR-0031-provisioned-param-values-are-a-need- +resolved-through-a-target-registry.md). + +## Streams client lib — the home for compensatory machinery (2026-07-17, direction from Will; upstream check pending) + +The examples/streams walkthrough established that the example hand-rolls a +Durable Streams protocol client (URL layout, bearer scheme, JSON-array +appends, offset conventions, long-poll dance) — machinery every consumer +would re-write and that belongs in neither userspace nor the binding. +`@durable-streams/client` (npm, 0.2.x, matches server 0.1.11) is the +protocol's own client: supports per-poll `headers` (built for auth tokens), +`live: long-poll | sse`, pluggable fetch. + +Direction: wrap it in a Composer-shipped streams client lib. That lib — +platform-aware by definition — is the legitimate home for the compensatory +machinery that was rightly deleted from the example (D12): auth from the +`{ url, apiKey }` binding, live tail defaulting to long-poll while PRO-218 +stands, cold-start retry for idempotent calls only (never appends — no +idempotency key upstream; the D8-D12 evidence and reasoning carry over). +ADR-0015 intact: the binding stays config; the client lib is an app-side +dependency the app chooses, like aws-sdk for storage. + +Open until Will's upstream check returns: what @durable-streams/client +already covers vs what we compensate (it may retry / it may grow auth +natively); what belongs upstream (prisma/streams or the client) vs in our +wrapper; where ours ships (likely a composer-prisma-cloud subpath) and its +name; whether the example swap lands with the lib slice. Blocked on that +check; examples/streams stays plain-fetch in #92 meanwhile. + +## Streams binding redesign + provider params (2026-07-17, from Will's #92 review) + +Recorded exhaustively in [streams-binding-design.md](streams-binding-design.md), +per Will's direction, before any slice spec/plan. Two parts: (A) provider-side +minted values (rpc accepted keys, streams API key) become target-owned reserved +params — declared, schema-validated, carried by the normal serialize/stash +pipeline — deleting `restashAddressFree` and the raw env scrapes it fed; +(B) the streams contract names its streams (optional per-stream event schema, +untyped retained as the `postgres()` parity), `durableStreams(contract)` +hydrates to per-stream handles that own ensure-create and the proven-safe +404 heal, so no stream lifecycle code remains in userspace. + +## Halt + audit of the as-built streams binding (2026-07-17, Will's order) + +Will halted execution on discovering the `never`-typed provider contract. +Full as-built audit in [streams-binding-audit.md](streams-binding-audit.md): +the checking model moment by moment, the `never` hole and a probe-proven +honest replacement (wide `required`, `__cmp: {}`), the behavior changes that +shipped inside Part B's refactor (implicit create, read-creates, contentType +dropped), the complete unenforced-invariant inventory (T1–T9), deviations +from the design doc, and decisions R1–R6 awaiting his ruling. No further +dispatches until he rules. diff --git a/.drive/projects/forcing-function-apps/slices/streams-binding-redesign/plan.md b/.drive/projects/forcing-function-apps/slices/streams-binding-redesign/plan.md new file mode 100644 index 00000000..08626226 --- /dev/null +++ b/.drive/projects/forcing-function-apps/slices/streams-binding-redesign/plan.md @@ -0,0 +1,48 @@ +# Dispatch plan: streams-binding-redesign + +Contract sources: [spec.md](spec.md) + +[streams-binding-design.md](../../streams-binding-design.md). Three +dispatches, sequential (one branch, PR #92). Reviewer rounds after D1 and +D2, per this branch's established loop; docs edits stay with the +orchestrator, not the implementers (the delegated-docs failure mode is on +record twice). + +## D1 — Part A: provider params (target package) + +**Outcome:** provider-side minted values flow through the declared-param +pipeline end to end. The registration type replaces `ProvisionLanding` +(name + schema + value-from-refs; brand-blind registry in `control.ts`); +`descriptors/compute.ts` writes the rows through the serializer's normal +encode; `deserialize` gains the reserved provider params as a second +enumeration source and the stash carries them (excluded from user `config()` +typing); `restashAddressFree` deleted; `serve()` and the streams entrypoint +read validated stash rows (absent = never provisioned, semantics unchanged); +`serviceKeyEnvName`/`streamsApiKeyEnvName` subsumed by `configKey` +derivation. No coined vocabulary in names or comments — "landing" goes away. + +**Completed when:** the spec's invariant tests are green in their new homes; +the four restash tests are replaced by round-trip tests; grep shows no +`restashAddressFree` and no `Landing`; repo checks green; committed. + +## D2 — Part B: contract + handles (streams package + example) + +**Outcome:** `streamsContract({ jobs: streamDef(), … })` (untyped defs +only); `durableStreams(contract)` hydrates to per-stream handles; +bare `durableStreams()` hydrates to `stream(name)` dynamic handles; +`StreamsClient`/`StreamHandle` as classes; ensure-create + 404-heal +(retry-once) inside the handle; `isStreamNotFound` un-exported; append +no-retry/no-batch and `IDEMPOTENT_BACKOFF` untouched. The example app loses +`STREAM`/`ensureStream`/`withStream` and keeps routes + error mapping; the +heal test and wire-count tests move into the streams package. + +**Completed when:** streams package + example tests green; both mutation +checks re-verified red in their new homes; the example has zero lifecycle +or wire-client knowledge; repo checks green; committed. + +## D3 — Live re-proof, docs, thread closeout + +**Outcome:** deploy/conformance/smoke/canary/destroy per the spec's bar; +gotchas + design-notes touched only where the code moved under them +(orchestrator writes docs); every open #92 thread replied-to (commit or +design-doc section) and resolved; PR body refreshed; Will's re-review +requested. No auto-merge armed; merge only on Will's word. diff --git a/.drive/projects/forcing-function-apps/slices/streams-binding-redesign/spec.md b/.drive/projects/forcing-function-apps/slices/streams-binding-redesign/spec.md new file mode 100644 index 00000000..66700a58 --- /dev/null +++ b/.drive/projects/forcing-function-apps/slices/streams-binding-redesign/spec.md @@ -0,0 +1,56 @@ +# Slice: streams binding redesign — provider params + contract-owned lifecycle + +## At a glance + +Close out Will's 2026-07-17 review of PR #92 by implementing the two settled +designs recorded in +[streams-binding-design.md](../../streams-binding-design.md) (the binding +contract for this slice — read it in full before implementing): + +- **Part A**: provider-side minted values (rpc accepted keys, streams API + key) become target-owned **reserved params** — declared, schema-validated, + carried by the normal serialize → deserialize → stash pipeline. + `restashAddressFree` and the raw `process.env` scrapes it fed are deleted. +- **Part B (untyped defs only)**: the streams contract **names its + streams**; `durableStreams(contract)` hydrates to one handle per stream; + handles own ensure-create and the proven-safe 404 heal; the client + becomes a class. `streamDef({ event })` typed validation is the recorded + follow-up and does NOT ship here — no schema parameter that does nothing. + +This lands on PR #92's branch (`claude/streams-minted-key`); the slice is +done when every open review thread is answered with either the fix or the +recorded rationale, and Will's re-review is requested. + +## Scope + +In: everything under "The design" in Parts A and B of the design doc, at the +#92 scope split; moving (not weakening) the existing invariant tests; the +live re-proof; replying on and resolving all 11 open review threads. + +Out: `streamDef({ event })` / both-edge validation (follow-up slice); RPC +cold-start handling and its canary (separate task, operator direction); +required-checks branch protection (Will's manual step). + +## Verification bar (inherited from this branch's history — do not lower) + +- **Invariant tests move, not die**: zero-consumer deny-all `"[]"`; + no-expose-no-rows; a third brand touches only `control.ts`; the boot + round-trip of provider params through deserialize/stash (replaces the four + restash tests). +- **Mutation checks keep their teeth**: the wire-counted append tests (one + POST per append, zero retries — 503 + concurrency) and the heal test + (delete the stream out from under the handle; red without the heal) are + re-verified red after moving into the streams package. +- **Live re-proof** on real Prisma Cloud: deploy `examples/streams`, + conformance (215/239, the 24 = PRO-218 SSE), smoke (401 unauth, authed + append/read/tail), the PRO-217 canary still runs and classifies, destroy + clean. +- Repo checks green throughout: typecheck, biome, lint, depcruise, casts, + `pnpm test:scripts`. + +## Review-thread closeout (the slice's exit) + +Every open thread on #92 gets a reply naming the commit that resolves it or +the recorded rationale (design doc section), then resolved; the three stale +pre-2026-07-17 threads likewise. Re-request Will's review. No merge without +his word. diff --git a/.drive/projects/forcing-function-apps/slices/streams-minted-key/plan.md b/.drive/projects/forcing-function-apps/slices/streams-minted-key/plan.md new file mode 100644 index 00000000..89ed9291 --- /dev/null +++ b/.drive/projects/forcing-function-apps/slices/streams-minted-key/plan.md @@ -0,0 +1,53 @@ +# Dispatch plan: streams-minted-key + +Two dispatches, sequential. Contract source: [spec.md](spec.md). + +## D1 — The key moves to the binding rail (framework + module + tests) + +**Outcome:** on a fresh branch off origin/main, the streams binding is +`{ url, apiKey }` end to end locally: minted-credential resource + `streams` +descriptor in `@internal/prisma-cloud`, module/service/entrypoint rewired +(no secret slot anywhere in the package), contract + tests + example root +updated, everything green (package tests incl. the entrypoint integration +test, local conformance, repo lint/typecheck/depcruise/casts). + +**Builds on:** merged streams-composed-module (main) + the templates named in +the spec. +**Hands to:** a locally-proven branch ready for the live re-proof. + +**Completed when:** `bun test src` green with the integration test driving +`COMPOSER_CREDENTIALS_APIKEY`; `pnpm test:conformance:local` 239/239; grep +shows no `secret(` in packages/1-prisma-cloud/2-shared-modules/streams; +repo checks green; committed (DCO dual sign-off). + +## D2 — Live re-proof + docs + PR + +**Outcome:** the re-shaped module proven on real Prisma Cloud (deploy, +deployed conformance green modulo PRO-218 SSE failures, smoke: 401 unauth / +authed append + read from offset + long-poll — harness key read from deploy +state), deployment destroyed, README + SCOPE updated, PR open with the +ADR-0030 alignment narrative. + +**Builds on:** D1's branch. +**Hands to:** review URL; slice enters review. + +**Completed when:** deploy + proofs + destroy recorded with counts; docs +updated; PR open (no auto-merge armed). + +## D4 — Rework onto ADR-0031 (added 2026-07-16, spec amendment) + +**Outcome:** the key is provisioned through ADR-0031's registry — need on the +`apiKey` param, per-provider provisioner in the target, provider landing for +`API_KEY` — with BearerKey + the streams descriptor deleted, the example +carrying a consumer service, and all local verification green again. + +**Builds on:** D1-D3's rebased branch; #93's service-keys machinery as +template. +**Hands to:** an ADR-0031-native branch for the final live re-proof + PR +refresh. + +## D5 — Re-proof + PR refresh (after D4) + +**Outcome:** live deploy re-proven (consumer service exercises the binding +in-deployment; conformance + smoke as before; destroy), PR body rewritten for +the new mechanism, reviewer round green. diff --git a/.drive/projects/forcing-function-apps/slices/streams-minted-key/spec.md b/.drive/projects/forcing-function-apps/slices/streams-minted-key/spec.md new file mode 100644 index 00000000..91701a4f --- /dev/null +++ b/.drive/projects/forcing-function-apps/slices/streams-minted-key/spec.md @@ -0,0 +1,170 @@ +# Slice: Streams bearer key becomes a minted binding credential + +## At a glance + +Re-shape the streams module's consumer auth onto the ADR-0030 pattern: the +bearer key is **minted at deploy** and delivered **through the binding**, not +root-bound as an ADR-0029 secret. The binding becomes `{ url, apiKey }`; the +module's `apiKey` secret slot and the consumer-side "bind the same platform +variable twice" convention disappear. Settled in the 2026-07-15 design session +(see design-notes.md § "Streams consumer auth — settled by ADR-0030"); +predecessor slice: streams-composed-module (merged, PR #84). + +## Chosen design + +> **Amended 2026-07-16 (design session with Will, post-#93/ADR-0031).** The +> original design below (BearerKey resource + `streams` descriptor + outputs +> rail) was built before ADR-0031 landed and is superseded. +> +> **Deciding rationale: consistency.** A module must build on the framework's +> general internals, not invent per-module ones. ADR-0031 is the framework's +> answer for a framework-minted param value; a second, streams-shaped +> mechanism beside it means two ways to audit one concept and one more thing +> a maintainer must learn per module. The narrower technical comparison +> (below) only ever showed the resource route was *possible*, never that it +> was *right*. ADR-0031's +> provisioner explicitly owns per-edge vs per-provider cardinality, and the +> zero-consumer case that seemed to require a module-owned resource is a +> configuration error, not a scenario (a streams module with no consumers +> serves nobody — the key exists only in deploy state). Settled design: +> +> - `durableStreams()`'s `apiKey` connection param carries a **ProvisionNeed** +> (streams-owned brand, mirroring `RPC_PEER_KEY`). +> - The prisma-cloud target registers a **per-provider provisioner**: keyed on +> `providerAddress`, mint-once 48-hex, stable in deploy state (ADR-0031 +> blesses per-provider as provisioner policy; `service-keys.ts` + +> `serviceKeyProvisioner` are the templates). +> - The **provider landing** writes the minted value where the streams +> entrypoint reads its key (target-owned per ADR-0019); the server still +> receives `API_KEY`. +> - **Zero consumers = no key = the server cannot boot**; prefer a loud +> deploy-time error over a boot loop if cheaply expressible. +> - `BearerKey` resource + the `streams` descriptor are **deleted**; +> `streamsCompute` reverts to plain `compute()` if no extended outputs +> remain (url alone needs no override). +> - `examples/streams` gains a small **consumer service** that exercises the +> binding in-deployment (append + read via `load()`'s `{ url, apiKey }`), +> which also removes the zero-consumer shape from the example. +> - Future per-edge migration = provisioner-internal cardinality flip + the +> accepted-set landing, once upstream supports key sets. +> +> **Amendment 2 (2026-07-16, Will): compute() must be brand-blind.** The first +> cut of this rework landed streams' provider-side key by adding a second +> hardcoded block (and a second import) to `descriptors/compute.ts`, mirroring +> the one #93 added for RPC. That is a per-brand accretion in the general +> compute descriptor — the same anti-pattern ADR-0031 exists to prevent, moved +> from core into the target. ADR-0031 already assigns the fix: *"The +> provisioner owns mint, size, **aggregation**, stability, and rotation"* — so +> the provider-side landing belongs with the provisioner, not in compute. +> +> Settled: `compute.ts`'s serialize does ONE brand-blind loop over inbound +> provisioned edges grouped by `provision.brand`, handing each brand's refs to +> that brand's registered landing; the two hand-written edge scanners collapse +> into one generic `provisionedEdges(graph)`. `compute.ts` imports neither +> `service-keys.ts` nor `streams-keys.ts`. RPC's accepted-set landing migrates +> onto the same seam (in scope — the leak is not deleted while its first +> instance remains). Prefer a target-local seam over changing core, per +> ADR-0031's "landing stays the target's"; changing core's +> `ProvisionerDescriptor` is acceptable only if the target genuinely cannot +> own it, and must be reported. + +Original (superseded) design, kept for the record — mirror storage's +minted-credential machinery end to end (`s3Credentials` + `s3StoreDescriptor` +are the templates): + +- **Contract** (`@internal/streams/contract.ts`): `StreamsConfig` gains + `readonly apiKey: string`; `durableStreams()` connection params become + `{ url, apiKey }`; `__cmp` updated. README example's consumer fetch snippet + switches from `service.secrets()` to the binding. +- **Minted resource** (`@internal/prisma-cloud`, mirror + `s3-credentials-resource.ts`/`s3-credentials.ts`): a bearer-key credential + — random 48-hex, minted once at deploy, stable in deploy state — kind + distinct from slice 2's planned per-edge `ServiceKey` (that name is + reserved by the rpc-service-key project). Provides `{ apiKey }`. Server + minimum is 10 chars; 48 hex clears it. +- **Descriptor** (`target/descriptors`, mirror `s3-store.ts`): a `streams` + node kind = compute's descriptor with extended outputs — `apiKey` surfaced + from the wired credentials resource in `serialize`/`deploy` outputs, so a + consumer's `durableStreams()` binding resolves both fields by name. + `streamsService()` routes to it the way `s3StoreService` routes to + `s3-store` (type override on `compute()`). +- **Module** (`streams-module.ts`): drops the `apiKey` secret slot from its + boundary; provisions the credentials resource internally (as storage owns + `credentials`); boundary keeps `store: s3()`. Service deps become + `{ store, credentials }`, secrets: none. +- **Entrypoint**: `API_KEY` comes from `load().credentials.apiKey` instead of + `secrets()`. +- **Single key per module instance** — the upstream server's auth is one + `API_KEY`. Distinct per-edge keys (full ADR-0030 slice-2 alignment) need an + upstream accepted-key-set change; recorded as future work, out of scope. + +## Coherence rationale + +One auth model swapped whole: contract, mint, descriptor, module wiring, +entrypoint, docs, and the live re-proof must move together — a partial land +would ship a binding whose `apiKey` never resolves or a server whose key +nobody holds. One reviewer holds "did the key move from secret slot to +binding rail correctly?" in one sitting; rolls back as one unit. + +## Scope + +**In:** contract change; the minted-key resource + `streams` descriptor in +`@internal/prisma-cloud`; module/service/entrypoint rewiring; tests +(module graph, integration, type tests) updated; `examples/streams` root +drops `envSecret` (fresh branch off main — the repo renamed to Composer); +README + SCOPE.md auth sections; live re-proof (deploy, deployed conformance, +smoke, destroy). + +**Deliberately out:** +- Per-edge keys / accepted-key sets (needs upstream server change + #89 + slice 2; recorded in design-notes.md). +- External (out-of-deployment) key access — same recorded platform ask as + storage's minted credentials. Test harnesses may read the key from deploy + state; document that as the harness route. +- Touching rpc-service-key's surfaces or claiming the `ServiceKey` name. + +## Pre-investigated edge cases + +| Case | Handling | +| --- | --- | +| Deployed conformance/smoke harnesses need the minted key externally | Read it from deploy state (it is stable there), as the harness-only route; not a consumer pattern. | +| `configKey` collision: credentials dep param `apiKey` vs old secret slot `APIKEY` | Old slot is deleted in the same change; integration test env moves from `COMPOSER_APIKEY` (secret pointer) to `COMPOSER_CREDENTIALS_APIKEY` (dep param). | +| A stale consumer built against `{ url }`-only binding | Contract `satisfies` compares kind only; the new param resolves by name from producer outputs — old consumers rebuild against the new types on upgrade (pre-1.0, no compat shim). | + +## Done conditions (slice-specific) + +- Module tests + entrypoint integration test green with the key wired as a + dep, not a secret; no `secret()` remains in the streams package. +- Live re-proof: deploy `examples/streams`, deployed conformance green + modulo the known SSE-ingress failures (PRO-218), consumer smoke green + (401 unauthenticated / authed append + read + long-poll), then destroyed. +- README/SCOPE reflect the minted model and the future per-edge note. + +## References + +- design-notes.md § Streams consumer auth (the settled decision) +- ADR-0030 (merged, #89); `.drive/projects/rpc-service-key/plan.md` on main + (slice 2's reserved shape) +- Templates: `s3-credentials-resource.ts`, `s3-credentials.ts`, + `descriptors/s3-store.ts`, `s3-store.ts` (service factory) + +> **Amendment 3 (2026-07-17, Will): protocol logic ships in OUR client lib.** +> Requirement, verbatim in effect: the PR will not be accepted with Durable +> Streams protocol logic (URL layout, bearer scheme, JSON-array appends, +> offset conventions, long-poll dance) hand-rolled in a user application. It +> must live in the client library Composer offers users — the same way RPC +> users don't do their own request encoding (`rpc()` hydrates to a client via +> `makeClient`). +> +> Shape (pending the upstream check's findings): wrap +> `@durable-streams/client` (ElectricSQL's canonical protocol client; +> supports per-poll headers for auth, live long-poll/sse, pluggable fetch) in +> a streams client shipped by `@internal/streams`. `durableStreams()` +> hydrates to that client (RPC parity); the factory is also exported +> standalone so local dev/tests can wrap the stand-in URL without load(). +> The wrapper is the home for platform compensations, each annotated with its +> ticket: auth from the binding, live tail defaults to long-poll while +> PRO-218 stands, cold-start retry for idempotent calls only while PRO-219 +> stands (appends never retried — no idempotency key upstream). The example +> reduces to app logic over the client. Conformance harnesses stay raw-fetch +> (they test the server, not our client). diff --git a/.drive/projects/forcing-function-apps/streams-binding-audit.md b/.drive/projects/forcing-function-apps/streams-binding-audit.md new file mode 100644 index 00000000..d76bd6d1 --- /dev/null +++ b/.drive/projects/forcing-function-apps/streams-binding-audit.md @@ -0,0 +1,241 @@ +# Audit: the streams binding as built on `claude/streams-minted-key` + +Status: written 2026-07-17 on Will's halt order, for his audit. All dispatch +work is stopped; nothing further executes until he rules on the decisions at +the end. Everything below was read from the code at `655bda1` (and verified +by running probes where stated), not taken from any implementer's report. + +Scope: the streams module end to end as it exists on the branch — the +contract model and its type-level hole (the trigger for this audit), the +client and handle behavior, the provider-param provisioning pipeline (Part +A), and a complete inventory of every point where correctness rests on an +unenforced invariant. Deviations from the recorded design doc +([streams-binding-design.md](streams-binding-design.md)) are listed +explicitly. + +--- + +## 1. What is actually checked, where + +Core's contract model ([contract.ts](../../../packages/0-framework/1-core/core/src/contract.ts)) +is two mechanisms: + +- **Authoring time (compile):** wiring compatibility is plain TypeScript + assignability on `Contract`, checked where a provider's + ref-port is passed into a consumer slot at `provision()`. Core never + inspects `Cmp`; the *kind's builder* is responsible for shaping `Cmp` so + that assignability means something. RPC does this properly: its `Cmp` is + the concrete function map, so assignability applies real function + variance. +- **Load time (runtime):** `ref.satisfies(required)` as a backstop + ([load-module.ts:85](../../../packages/0-framework/1-core/core/src/load-module.ts)). + +For streams, as built: + +| Moment | What it verifies for a streams edge | +|---|---| +| Compile, wiring | **Nothing beyond `kind: 'streams'`.** The provider port is `Contract<'streams', never>`; `never` is assignable to every consumer's `Cmp`, so the check passes vacuously for any def map. | +| Load, `satisfies` | `required.kind === 'streams'`. Kind only. | +| Deploy | Part A's provider params (§4): the minted key row is written, schema-known. | +| Boot | The key row is schema-validated (`string`) before the service can use it. | +| Runtime | Nothing validates stream names or events. Untyped by design in this slice. | + +The semantic claim behind the vacuous pass is true: the Durable Streams +server is schema-agnostic — it will serve any stream name — so *any* streams +provider genuinely does satisfy *any* streams consumer. The defect is not +the absence of checking (nothing checkable exists yet); it is that the +absence was encoded as a type-level lie instead of stated honestly (§2). + +## 2. The `never` provider contract — the hole, precisely + +**As built** (`packages/1-prisma-cloud/2-shared-modules/streams/src/contract.ts`): + +```ts +export const streamsProviderContract: Contract<'streams', never> = Object.freeze({ + kind: 'streams', + __cmp: blindCast(undefined), + satisfies: (required) => required.kind === 'streams', +}); +``` + +Why it exists: the `streams()` module's exposed port must satisfy every +consumer's `streamsContract(defs)`, whose `Cmp` is a *literal* record type +(`{ jobs: StreamDef }`). A wide `Record` is **not** +assignable to a literal record (an index signature does not supply a +required literal property — verified). `never` is assignable to everything, +so it passes. D2 chose it and manufactured the impossible value with +`blindCast(undefined)`. + +What is wrong with it, concretely: + +1. **The runtime value lies about its type.** `__cmp` is declared `never` — + "no value can exist here" — and holds `undefined`. Everything downstream + that could ever touch a provider's `__cmp` is now trusting the comment + "nothing reads this," which no test or type enforces. If any future code + path reads a provider's `__cmp` (e.g. a diagnostic, a future typed + `satisfies`), it receives `undefined` where the type system promises it + cannot even be asked, and the failure will not point back here. +2. **It cost a real dependency.** `@internal/streams` now depends on + `@internal/foundation` solely to manufacture the impossible value. +3. **It normalizes the wrong idiom.** The next kind with a schema-agnostic + provider will copy it; `never`-plus-cast becomes the house pattern for + "provider can't know," when an honest encoding exists. + +**The honest encoding exists and is proven.** I verified this with a +typecheck probe against the real core types before writing it here (probe +run, passed, deleted — both its `@ts-expect-error` checks consumed, meaning +literal-key handle typing and wrong-kind rejection both still hold): + +- The consumer's **binding type** keeps the literal defs: + `durableStreams(contract: Contract<'streams', D>)` still hydrates to + `StreamHandles` with exact keys. +- The consumer's **required type** (what wiring compatibility is checked + against) is typed wide: `DependencyEnd, Contract<'streams', StreamDefs>>`. + Widening is plain safe assignability (`D extends StreamDefs`), not a cast. +- The provider port becomes `Contract<'streams', StreamDefs>` with + `__cmp: {}` — **an empty record is a legitimate `StreamDefs` value.** No + `never`, no `blindCast`, no foundation dependency, no unenforced + invariant. A postgres-like contract still fails the wiring check. + +This encodes the truth directly: "what a streams consumer requires of its +provider is *that it is a streams provider*" — which is exactly what the +kind-only `satisfies` already says at runtime. Type and runtime then make +the same claim. Recommendation R1. + +What this does **not** fix, because it is not fixable at wiring time: two +consumers of one module naming the same stream with different (future +typed) defs. The server cannot attest to event shapes; the recorded +follow-up slice puts validation at the reading edge, which is the same trust +model RPC uses for outputs. That part of the design stands. + +## 3. Behavior changes that shipped inside the "refactor" (Part B) + +The design doc authorized moving the lifecycle into handles. The following +went further than the doc's words, and each is a real behavior decision: + +| # | Was | Now | Risk / note | +|---|---|---|---| +| B1 | `client.create(name)` public, ensure-style | **No public create at all.** First `append`/`read`/`tail` creates, memoized per handle (`ensureCreate` private). | Doc said "no variant *requires* the app to create"; D2 made deliberate creation *impossible*. Defensible reading, but it is a removal, not a move. | +| B2 | Reading a never-created stream → 404 error | **Reading it creates it** and returns an empty page. | The old test pinning the 404 was deleted as "obsolete by design." For an app author, "read errors" vs "read silently manufactures an empty stream" is a real difference — a typo'd dynamic name now yields plausible-looking empty data instead of a failure. Contract-declared names are reviewed identifiers, so the primary path is fine; the dynamic `stream(name)` path carries the risk. | +| B3 | `create(name, { contentType })` — caller could choose | `JSON_CONTENT_TYPE` hardcoded internally. | Capability silently dropped. Nothing on the branch needs it; the module is JSON-events by contract. Should be a recorded decision, not an accident. | +| B4 | `isStreamNotFound` exported | Module-private again | Correct; its only consumer was the app-side heal, which no longer exists. | + +The heal and append safety are **unchanged in substance** and their tests +moved with teeth re-verified (no-retry mutant fails the 503 wire-count +test; no-batch mutant yields 2-not-5 POSTs; gutting the heal fails the heal +test). `IDEMPOTENT_BACKOFF` untouched — mandatory, since PRO-217 was +reproduced live today (three closes; see the canary section of gotchas.md). + +## 4. Part A as built (provider params) — audit summary + +Reviewed SATISFIED by an independent pass that re-derived every claim; the +shape on the branch after D1c: + +- Provider-side minted values (`RPC_ACCEPTED_KEYS`, `STREAMS_API_KEY`) are + **declared reserved params**: name + arktype schema in per-brand modules + (`service-keys.ts`, `streams-keys.ts`, each carrying its `brand`); + the boot list `RESERVED_PROVIDER_PARAMS` in `provider-params.ts` (runtime- + safe, imports no deploy machinery); `control.ts` holds only the deploy-side + `value(refs)` functions and **derives** its registry from the boot list, + throwing at module load on a missing value — deploy cannot write a row + boot never stashes. A further test pins `PROVISIONERS` ↔ `PROVIDER_PARAMS` + brand coverage (the fail-open path where keys are minted but no provider + row is written). +- `restashAddressFree` (the raw whole-namespace env sweep) is deleted; + boot is deserialize → typed stash → provider-param stash → secret + pointers → `PORT`. A present-but-invalid row fails loudly; an absent row + stashes nothing ("never provisioned" semantics preserved — `serve()` + pass-through, streams entrypoint refuses to boot). +- Provider params are excluded from `config()`'s `Values

` (verified at + type level during review with consumed `@ts-expect-error` probes). +- Zero-consumer rpc provider stores byte-exact `"[]"` (deny-all); traced + through deploy encode → stash → `serve()`'s parser during review. + +Two facts about the boot side an auditor should know: + +- The boot list is a **static two-entry array in the runtime bundle**. It + cannot be fed from `control.ts`'s registry because `run(address, boot)`'s + signature is fixed by target-agnostic lowering and a schema is code, not + storable data. A new brand edits its own keys module + `control.ts`'s + value map; the derivation makes forgetting one a load-time throw, not a + silent fail-open. +- `descriptors/compute.ts`'s `Output.isOutput(raw)` true-branch — the branch + **every real deploy takes** — has no test. The test suite mocks Alchemy's + `Output` process-wide, so tests only ever exercise the plain-value branch. + A correctness failure there is bounded (boot's schema check rejects the + stringified expression object; loud, not fail-open) and the D3 live deploy + exercises it for real, but it is an untested production conditional and + two honest attempts to test it produced only order-dependent flakes. + Recommendation R5. + +## 5. Trust inventory — every unenforced invariant on the branch + +Every point where correctness rests on something no type or test enforces, +in one place. "Enforced by" names the strongest existing guard. + +| # | Location | The claim being trusted | Enforced by | If false | +|---|---|---|---|---| +| T1 | `contract.ts` `streamsProviderContract.__cmp` | No code ever reads a provider contract's `__cmp` | Comment only | Reader receives `undefined` typed as `never`; failure surfaces far from the lie. **Fix available: R1 removes the lie entirely.** | +| T2 | `control.ts` provisioned-ref cast (`Output`) | Refs keyed by a brand's edges were produced by that brand's sole registered provisioner | Registration locality (one file) + review | Wrong ref shape flows into an env row; boot's schema check catches non-strings loudly | +| T3 | `serve.ts:65` reads `COMPOSER_RPC_ACCEPTED_KEYS` | Every target hosting an rpc provider stashes that exact slot, decoded shape `string[]` | Part A tests for this target; the slot name is a cross-package string constant in two packages | A new target that forgets → `serve()` pass-through = **fail-open**. Cross-package contract has no shared constant; rpc's `RPC_ACCEPTED_KEYS_ENV` and the target's param name coincide by spelling | +| T4 | streams entrypoint reads `COMPOSER_STREAMS_API_KEY` | Same slot contract, target-side | Entrypoint integration test (spawns real entrypoint; JSON.parse removal verified to fail it) | Refuses to boot (fail-closed) — safe direction | +| T5 | Heal predicate (`instanceof FetchError/DurableStreamError` + `status === 404`) | Electric's error classes are the same module instance in app and lib; a 404 is generated instead of a write at every layer | Review round 11's protocol trace; heal test | If class identity split (dual bundling), heal never fires — fail-safe (no retry). If a 404-after-apply existed, duplicate append — protocol-traced as impossible | +| T6 | `satisfies` kind-only (streams, both sides) | The server really serves any stream name; def conflicts between consumers are acceptable until typed defs land | Design decision, recorded | Two consumers' conflicting future-typed defs are undetected at wiring; caught at reading edge only | +| T7 | `Output.isOutput` true-branch (§4) | Real deploys produce `Output`s that the branch maps correctly | Live deploy only | Env row holds a stringified expression object; boot rejects loudly | +| T8 | Boot list ↔ deploy registry (Part A) | The two-entry list covers every brand that mints | Load-time throw + coverage tests (D1c) | Was the fail-open drift path; now structurally closed | +| T9 | Canary's cross-clock margin (2s) | Runner and VM clocks agree within 2s | NTP assumption, measured ≈0 skew once | A touch near the margin is classed `unknown` (inconclusive), not guessed — degrades safe | + +Cast movement on the branch vs main: **+2 / −1** (`control.ts` gained the +relocated provisioned-ref cast when the old one left `descriptors/compute.ts`; +`contract.ts` gained the `never` cast). The ratchet reads delta 0 against +its merge-base; the net-new cast of this slice is T1, and R1 deletes it. + +## 6. Deviations from the recorded design doc + +| Doc said | Branch does | Verdict | +|---|---|---| +| "Ensure-create on first use" | Also: no explicit create exists (B1), read-creates (B2), contentType dropped (B3) | Went further than authorized; needs Will's ruling | +| Silent on provider port typing | `Contract<'streams', never>` + cast (T1) | Unauthorized encoding decision; R1 replaces it | +| Silent on def value shape | `StreamDef = { kind: 'stream-def' }` marker | Good: an empty `{}` would type-accept anything (`streamsContract({ jobs: 123 })` would have compiled). Keep; back-record in doc | +| `streamDef({ event })` deferred, nothing half-lands | Honored — no schema parameter exists | Matches | +| Handles own name/lifecycle; example = routes + error mapping | Matches; example has zero lifecycle/wire imports | Matches | +| Part A design | Matches after D1b/D1c, one addition: registry **derivation** (stronger than the doc's two-lists-plus-test) | Matches, improved; back-record | + +## 7. Decisions — Will rules, nothing proceeds until then + +- **R1 (the trigger): replace the `never` encoding with the wide-required + encoding.** Provider port `Contract<'streams', StreamDefs>` with honest + `__cmp: {}`; consumer `required` typed `Contract<'streams', StreamDefs>` + while the binding keeps literal `StreamHandles`. Proven by probe + against real core types: literal handle keys preserved, wrong-kind still + rejected, `never`/`blindCast`/foundation-dep all deleted. My + recommendation: do it; it is a small, mechanical change confined to + `contract.ts` + the module's expose type. +- **R2 (B1/B2): decide the missing-stream read semantics.** Options: + (a) keep read-creates (as built); (b) restore a 404 on read of a + never-created stream while keeping append/tail auto-create; (c) restore an + explicit ensure-`create()` on the handle and make it required before + reads of dynamic names. My recommendation: (a) for contract-declared + names, and I lean (a) overall for symmetry — but this is an app-facing + semantic you should choose knowingly, since B2 turns a typo into empty + data instead of an error on the dynamic path. +- **R3 (B3): contentType.** Record its removal as deliberate (my + recommendation — the module is JSON-events by contract and the typed + follow-up would own any future negotiation), or restore the option. +- **R4: back-record §6's items in the design doc** once ruled (my job, not + an implementer's). +- **R5 (T7): accept the untested `isOutput` branch with the D3 live deploy + as its proof, or require a test.** An honest test needs the Output mocking + de-globalized in `control-lowering.test.ts` (per-file mock hygiene) — + roughly a half-day of test refactoring. My recommendation: accept for this + slice, file it as debt with the follow-up. +- **R6 (T3): the rpc accepted-keys slot name is spelled independently in two + packages.** Worth a shared constant or a cross-package test so a rename + cannot fail open. Small; could ride R1's commit or the follow-up. + +Nothing in this audit found a fail-open defect live on the branch. The two +fail-open *classes* found during the work (deploy-writes-boot-never-stashes; +minted-keys-without-provider-row) were both closed structurally in D1c and +have biting tests. The `never` contract is not a runtime hazard today — it +is a type-system lie with an available honest replacement, and it is the +kind of lie that becomes a runtime hazard the day someone believes it. diff --git a/.drive/projects/forcing-function-apps/streams-binding-design.md b/.drive/projects/forcing-function-apps/streams-binding-design.md new file mode 100644 index 00000000..f3c5c31e --- /dev/null +++ b/.drive/projects/forcing-function-apps/streams-binding-design.md @@ -0,0 +1,301 @@ +# Design: the streams binding — typed contract, framework-owned lifecycle, and provider params + +Status: recorded for operator review (2026-07-17). Source: Will's review of +PR #92 (2026-07-17 09:16 round) and the design discussion that followed. +Supersedes the ad-hoc shapes those comments flagged. Once approved, the slice +spec/plan for the implementation derives from this document; decisions that +outlive the project get promoted to ADRs at close-out. + +Two parts. Part A fixes how provider-side minted values reach a running +service (deletes `restashAddressFree`). Part B redesigns the streams contract +and client surface (deletes the app-side stream lifecycle code). They are +independent designs that happen to be exposed by the same review. + +--- + +## Part A — provider-side minted values are reserved params + +### The rule this restores + +The address-namespaced platform vars (`COMPOSER_

_*`) are the +target's **private storage medium** (ADR-0019). Exactly one reader is +sanctioned: `run(address)`'s `deserialize`, which validates each var against +a declared schema and re-emits the typed values into the process-local stash +(`COMPOSER_`, address-free) that `load()`/`config()` read. Application +code never reads `process.env`; nothing downstream of `run()` knows the +address. Writer and reader share one definition (the serializer), so they +cannot drift. + +### The defect in the current branch + +ADR-0031's provisioning gave the *consumer* side this treatment properly: a +consumer's minted value (e.g. rpc's `serviceKey`) is a declared connection +param, filled at provision, carried through the typed pipeline. + +The *provider* side did not get it. A provider's minted values — the rpc +accepted-keys set, the streams service's API key — are written at deploy as +**undeclared** vars by per-brand hooks (`ProvisionLanding`). Because they are +undeclared, `deserialize` cannot carry them, so two runtime readers scrape +`process.env` directly (`serve()` for `COMPOSER_RPC_ACCEPTED_KEYS`, the +streams entrypoint for `COMPOSER_STREAMS_API_KEY`), and `restashAddressFree` +exists solely to make those scrapes work: at boot it copies the service's +**entire raw namespace** to address-free names, unvalidated, around the typed +pipeline. That is the design violation the review flagged ("Woah what? +why???"), and it is a symptom: the cause is the undeclared values. + +### The design + +**Every provider-side minted value is a reserved param**: a named, schema- +carrying declaration owned by the target, exactly like `port`. One +registration per brand, kept where brands are already named — the target's +provisioning registry in `control.ts` — so `compute()` (factory and +descriptor) stays brand-blind. + +A registration replaces today's `ProvisionLanding` and carries: + +- `name` — the reserved param's name. The stored var name is derived through + `configKey(address, { owner: 'service', name })` at deploy and + `configKey('', …)` at boot, so writer and reader cannot drift (this is + already how `serviceKeyEnvName`/`streamsApiKeyEnvName` are built). +- `schema` — a Standard Schema for the decoded value (e.g. `string[]` for + accepted keys, `string` for the streams API key). Boot validates against + it like any param. +- `value(refs)` — deploy-side: given the provider's inbound minted refs for + this brand (possibly empty), the typed value to store, or `undefined` to + store no row. Encoded by the serializer as a service-own literal + (JSON), like every other param — no more brand-hook-invented encodings. + +**Deploy side** (`descriptors/compute.ts`): unchanged in structure — for each +exposing service, group inbound provisioned edges' refs by brand, ask each +registered entry for its value, write the env row through the serializer's +normal encode. The provider-driven iteration is kept deliberately: a deployed +provider with zero wired consumers must still be able to emit a +deny-everything value (rpc stores `[]`), because an absent var means "never +provisioned" (local dev, tests), not "no consumers". + +**Boot side** (`compute.ts` `run()`): `deserialize` gains the reserved +provider params as a second enumeration source alongside `paramEntries` — +same validation, same stash emission. If the row is absent, nothing is +stashed (absence keeps meaning "never provisioned"). `restashAddressFree` is +**deleted**; `run()` returns to deserialize + stash + stashSecrets + `PORT`, +as on main. + +**Runtime readers**: the address-free stash row is the documented +process-local channel for framework runtime code, and it now holds a +validated value or nothing. + +- `serve()` keeps its documented slot (`COMPOSER_RPC_ACCEPTED_KEYS`), now fed + by the typed stash rather than a raw sweep. Absent → pass-through mode, + unchanged semantics. This stays an env-shaped contract because `serve()` + is framework-layer (target-agnostic) code that cannot call a target + factory's `config()`; the slot's name and decoded shape are part of the + rpc kind's runtime contract, and any target that hosts an rpc provider + must fill it. +- The streams entrypoint reads its slot (`COMPOSER_STREAMS_API_KEY`) the same + way, same justification. + +These values do **not** appear in user-facing `config()`: they are the +framework's plumbing, not the service's settings. Reserved provider params +are validated and stashed but excluded from `Values

`. + +### What this deletes or renames + +- `restashAddressFree` — deleted, with its tests replaced by tests that the + reserved provider params round-trip through deserialize/stash. +- `ProvisionLanding` / "landing" — the name goes away. The concept is renamed + to what it now literally is: a **provider param** registration (working + names: `ProviderParam`, registry `providerParams`; final naming at + implementation, but no coined vocabulary — the doc comments say "the + provider-side reserved param for this brand's minted values"). +- `serviceKeyEnvName` / `streamsApiKeyEnvName` — subsumed by deriving the + name from the registration through `configKey`. + +### Invariants preserved (pinned by existing tests, which move, not die) + +- Zero-consumer rpc provider stores exactly `"[]"` (deny-all, fail-closed). +- A pure consumer (no `expose`) gets no provider rows. +- A third brand touches only `control.ts` (its registration) — no compute + file, no descriptor. + +--- + +## Part B — the streams contract carries the streams + +### The defect + +`streamsContract` is kind-only: `satisfies` is `kind === 'streams'`, and the +binding is transport config (`{ url, apiKey }`) hydrated to a raw protocol +client. The contract carries no stream names and no event definitions, so +everything above the transport fell to the app: the `STREAM` constant, the +memoized ensure-create, the `withStream` heal wrapper on every operation. +That is platform-level handling in user code, which the framework exists to +prevent. + +### The contract + +A streams contract **names the streams it transports**, each with an +**optional event definition**. Untyped streams are retained deliberately — +the parity is `postgres()`, which binds a real resource without a schema +contract — but "untyped" only drops the event type, never the lifecycle: +no variant of the API requires the app to name streams in call sites, create +them, or heal them. + +Authoring (names indicative; final spelling at implementation): + +```ts +// Typed: the event definition is a Standard Schema (arktype canonical). +const jobLog = streamsContract({ + jobs: streamDef({ event: JobEvent }), // typed stream + audit: streamDef(), // untyped stream: events are `unknown` +}); +``` + +- `streamDef({ event })` — a typed stream: events validate against the + schema. +- `streamDef()` — an untyped stream: same lifecycle, no validation, events + type as `unknown`. +- The contract value carries the def map as its `__cmp` (the rpc pattern); + stream names are protocol data (URL path segments), not config keys, so no + `configKey` interaction. + +### The consumer surface + +```ts +// deps: { events: durableStreams(jobLog) } +const { events } = service.load(); + +await events.jobs.append({ id, state: 'queued' }); // validated, typed +const { events: page, nextOffset } = await events.audit.read(); +const delivery = await events.jobs.tail({ offset: nextOffset }); +``` + +- `durableStreams(contract)` hydrates to **one handle per declared stream**, + keyed by name. The handle owns the name; no `STREAM` constant. +- Bare `durableStreams()` (no contract) is retained for dynamic stream names + (e.g. per-tenant streams, and the raw parity with `postgres()`): it + hydrates to a client whose surface is `stream(name)` returning an untyped + handle. Same lifecycle ownership; the name is data, not app-side protocol + handling. + +### Handle semantics (the lifecycle moves inside the framework) + +Each handle owns, internally: + +- **Ensure-create**: the first operation on a handle creates the stream + (memoized per handle; create is already ensure-style upstream, so racing + instances are harmless). Consequence, accepted: using a stream is + sufficient to create it, so a mistyped dynamic name creates an empty + stream rather than erroring. Contract-declared streams make the name a + reviewed, typo-checked identifier, which is the primary path. +- **Heal on stream-not-found**: if an operation fails with the wire client's + 404, drop the memo, re-create, retry the operation **once**. The safety + argument, proven in PR #92's review round 11 and unchanged by the move: a + 404 is generated instead of a write at every layer, so it proves nothing + was applied — retrying once cannot duplicate an event, even an append. + Ambiguous failures (socket close, 502/504) carry no 404 status, never + match, and surface raw. +- **Append safety, unchanged**: appends are never retried (beyond the heal's + proven-safe case) and never batched; the wire-counting mutation tests move + from the example into the client package and keep their teeth. +- **Cold-start compensation, unchanged**: `IDEMPOTENT_BACKOFF` on + create/read/tail, nothing on append, PRO-219 annotation and the PRO-217 + canary's removal trigger all stay as shipped. + +`isStreamNotFound` stops being exported: its only consumer was the app-side +heal, which no longer exists. The example app after this design is routes and +error mapping only. + +### Validation semantics (typed streams) + +- `append` validates the event against the def **before** sending — a bad + event fails locally, no wire traffic. +- `read`/`tail` validate each received event **after** receiving — the same + trust model as rpc's `makeClient` output validation: a provider can be + type-compatible and still lie at runtime. +- Untyped streams skip both; values pass through as `unknown`. + +### What `satisfies` checks — and deliberately does not + +`satisfies` stays **kind-level** (`kind === 'streams'`). The streams server +is schema-agnostic — the Durable Streams protocol carries bytes, not types — +so a provider cannot attest to event shapes and pretending otherwise would +be a guess (the principles forbid guessing). The event definitions are a +**client-side compact**: enforcement is the consumer's validation at the +edges, exactly like rpc's output validation. Two consumers declaring +conflicting defs for the same stream name is therefore expressible and not +detected at wiring time; the runtime validation is what catches the lie, +on the reader's side, per reader. + +### Client construction + +`createStreamsClient`'s object-of-closures becomes a **class** (review +direction, no repo rule to the contrary): `StreamsClient` class holding the +transport (URL, auth, writer map), `StreamHandle` class holding per-stream +state (name, def, ensure memo). `isAlreadyExists` and the 404 predicate stay +module-private functions. + +### Example after the design + +`examples/streams/src/jobs/app.ts` keeps: routes, HTTP mapping of +`offset`/`timeout` query params, the 502-with-cause error mapping. It loses: +`STREAM`, `ensureStream`, `withStream`, the `isStreamNotFound` import. The +heal test (delete the stream out from under the app, prove recovery) moves +to the streams package as a handle test; the example's integration tests +exercise the typed handle surface. + +--- + +## Scope split + +- **PR #92** (this branch): Part A in full. Part B with **untyped defs + only** — contract carries names, handles own lifecycle, `streamDef()` + without an event parameter, bare `durableStreams()` retained. This is + everything the review findings require; no half-implemented schema + parameter ships. +- **Follow-up slice** (recorded, not scheduled): `streamDef({ event })` — + the schema parameter, both-edge validation, typed handle generics. Purely + additive on the #92 shape; nothing lands in #92 that the typed version + throws away. +- **Set aside separately** (operator direction, 2026-07-17): RPC cold-start + handling with its own canary — its own task, not part of this design. + +## Review threads this resolves + +| Thread | Resolution | +|---|---| +| `STREAM` / `withStream` in app code | Part B: handles own name + lifecycle | +| `restashAddressFree` ("Woah what? why???") | Part A: deleted; values become declared reserved params | +| `ProvisionLanding` "deeply suspicious" / registry map "looks like a hack" | Part A: reframed and renamed as provider param registrations; same brand-blind seam, now inside the param system | +| `createStreamsClient` "convert to a class" | Part B: class-based client and handles | +| app.ts/server.ts split | Kept: the handler stays a pure `Request → Response` function testable without a server; reply on thread | +| descriptors/compute.ts "just indentation?" | Substantive (per-brand block → generic loop); reply on thread; Part A reshapes it again | + +--- + +## Amendment 1 (2026-07-17, post-audit rulings) + +From [streams-binding-audit.md](streams-binding-audit.md), after Will's +review of the as-built branch: + +- **The provider contract is the postgres pattern** (`7b07aa0`): the + module's port is `Contract<'streams', StreamDefs>` with an honest empty + def map as its unread placeholder — the same encoding `postgresContract` + uses. The consumer's required type is equally wide (kind is the whole + wiring requirement); literal handle typing comes from the generic + parameter. The `never`-typed `__cmp` and its cast are deleted. +- **Creation is implicit and only implicit** — no public `create()` on + handles; the first operation creates, memoized. Recorded as deliberate. +- **Reading a never-created stream creates it** and returns an empty page, + uniformly on both the contract and dynamic paths. Deliberate: symmetry + beats a special case, and contract-declared names are reviewed + identifiers. Known cost, accepted: a typo'd *dynamic* name yields empty + data rather than an error. +- **`contentType` is gone from the public surface**; the module is + JSON-events by contract. Any future negotiation belongs to the typed + `streamDef({ event })` follow-up. +- **`StreamDef` carries `{ kind: 'stream-def' }`** so the def map rejects + arbitrary values and the typed follow-up has a shape to extend. +- **Part A improvement over this doc**: `control.ts` *derives* its deploy + registry from the boot list (`provider-params.ts`) and throws at module + load on a missing value — stronger than the two-lists-plus-drift-test + this doc described. diff --git a/.github/workflows/e2e-deploy.yml b/.github/workflows/e2e-deploy.yml index 0645c65a..37447fc8 100644 --- a/.github/workflows/e2e-deploy.yml +++ b/.github/workflows/e2e-deploy.yml @@ -91,3 +91,45 @@ jobs: - uses: ./.github/actions/setup - run: pnpm install --frozen-lockfile - run: bun scripts/cold-connect-canary.ts + + cold-start-canary: + name: Cold-start canary (PRO-217) + runs-on: ubuntu-latest + # After the FT-5226 canary for the same quota-serialization reason as the + # deploys. Fails when the ingress no longer closes first-touch connections + # to a booting instance — the signal to remove the streams client's + # IDEMPOTENT_BACKOFF (the PRO-219 compensation) and this canary. The bug + # being PRESENT is today's normal and passes. + # + # Runtime: the script spaces its samples 60s apart and stops at the first + # close (see cold-start-canary.ts), so a normal run — the bug is live + # today — finishes quickly: four live runs of this version of the script + # all closed on the very first sample, one measured (via `time`) at + # 96.86s end to end. The worst case is a run that never sees a close and + # has to collect MIN_HELD_SAMPLES_FOR_BUG_GONE (14) confirmed cold-start + # holds to justify a bug-gone verdict: at roughly 85s per sample (60s + # spacing plus ~21-25s of create/upload/start/promote/touch/log-read, per + # those same live runs), 14 samples is about 20 minutes, plus this job's + # own install/build/deploy/destroy/sweep steps. cold-start-canary.ts's own + # MAX_RUN_MS (20 minutes) stops the sampling loop and reports whatever it + # has collected before that point, so the 30-minute timeout here is + # headroom for the script to reach that self-imposed stop and still exit + # cleanly — not the primary mechanism. + needs: cold-connect-canary + timeout-minutes: 30 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + env: + PRISMA_SERVICE_TOKEN: ${{ secrets.PRISMA_SERVICE_TOKEN }} + PRISMA_WORKSPACE_ID: ${{ vars.PRISMA_WORKSPACE_ID }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: ./.github/actions/deploy-verify-destroy + with: + working-directory: examples/streams + build-filter: '@prisma/example-streams...' + stack-name: streams-canary-ci-${{ github.run_id }} + verify-command: bun "${{ github.workspace }}/scripts/cold-start-canary.ts" + destroy-label: 'streams-canary ' + sweep-prefixes: storefront-auth pn-widgets hello canary streams-canary diff --git a/architecture.config.json b/architecture.config.json index 09e61244..281f5d7e 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -156,6 +156,30 @@ "layer": "extensions", "plane": "shared" }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/streams-keys.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/provider-params.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/streams/src/client.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/provisioned-edges.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "shared" + }, { "glob": "packages/1-prisma-cloud/1-extensions/target/src/prisma-next-migrate.ts", "domain": "prisma-cloud", diff --git a/docs/design/90-decisions/ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md b/docs/design/90-decisions/ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md index 92185b5d..cadbbe8a 100644 --- a/docs/design/90-decisions/ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md +++ b/docs/design/90-decisions/ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md @@ -44,7 +44,7 @@ A provisioning need is those two moves applied to a param value. From secrets it **The what/how boundary that keeps it at one field.** A param declaration owns the *what* — the semantic contract the declarer needs ("a shared unguessable value, distinct per binding"). The provisioner owns the *how* — mint mechanics, byte length, storage medium, stability across redeploys, rotation schedule. The test for whether something belongs on the param is mechanical: *does core need to read it to do its job?* `optional` and `default` pass — `coerce()` reads them at boot. A provisioning strategy fails — core only forwards it. So "minted on odd days," "64 bytes," "HSM-backed" are never new param fields; they are provisioner policy, invisible to core. This is the rule that answers "how many facets will this accrue?" with *none more*. -**Resolution is the framework's; landing is the target's.** Core enumerates the needs in the graph, resolves each against the **consumer's** extension registry (the consumer is the node that declares the need and must ultimately hydrate the value), invokes the resolved provisioner to mint each edge's value, and threads results into the wiring — the consumer's param is filled like any other input (which removes the need for the value to be an unfilled optional), and the provider is handed its inbound edges' values. Only the physical landing — which env var, what encoding — stays the target's, exactly as [ADR-0019](ADR-0019-the-target-owns-config-serialization.md) already assigns it. +**The framework decides what the value is; the target decides where it is stored.** Core enumerates the needs in the graph, resolves each against the **consumer's** extension registry (the consumer is the node that declares the need and must ultimately hydrate the value), invokes the resolved provisioner to mint each edge's value, and threads results into the wiring — the consumer's param is filled like any other input (which removes the need for the value to be an unfilled optional), and the provider is handed its inbound edges' values. Only the storage itself — which environment variable holds the value, and in what encoding — stays the target's, exactly as [ADR-0019](ADR-0019-the-target-owns-config-serialization.md) already assigns it. **Cross-extension edges fail closed.** A need lives on an edge whose two nodes may belong to two different extensions. Resolving against the consumer's extension is deterministic, but a value minted by one target and validated by another is a cross-target contract with no second target yet to prove it against. Rather than guess that shape, core rejects a provisioned edge that spans two extensions with a clear "not supported yet" error. The registry and the resolution rule are designed so that lifting this restriction later adds a code path, not a redesign. @@ -68,5 +68,5 @@ A provisioning need is those two moves applied to a param value. From secrets it - [ADR-0029](ADR-0029-secrets-are-a-forwardable-slot.md) — the opaque branded slot this reuses; a secret's value is sourced out-of-band, a provisioned value is minted by the framework. - [ADR-0017](ADR-0017-control-plane-loads-through-the-app-config.md) — the statically-listed registry with loud resolution this extends from nodes to param provisioners. - [ADR-0018](ADR-0018-config-params-carry-a-caller-owned-schema.md) — the no-enum-in-core principle applied to provisioning strategies. -- [ADR-0019](ADR-0019-the-target-owns-config-serialization.md) — the physical landing (env var, encoding) that stays the target's. +- [ADR-0019](ADR-0019-the-target-owns-config-serialization.md) — where a value is stored (which environment variable, in what encoding), which stays the target's. - [ADR-0030](ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md) — the first need: RPC's per-binding service key. diff --git a/examples/streams/module.ts b/examples/streams/module.ts index b09ec45f..5eaf2fdd 100644 --- a/examples/streams/module.ts +++ b/examples/streams/module.ts @@ -1,22 +1,23 @@ import { module } from '@prisma/composer'; -import { envSecret } from '@prisma/composer-prisma-cloud'; import { storage } from '@prisma/composer-prisma-cloud/storage'; import { streams } from '@prisma/composer-prisma-cloud/streams'; +import jobsService from './src/jobs/service.ts'; /** - * The streams example: durable event streams backed by the `storage()` - * module as its durable tier. The root wires the storage module's `store` - * port into the streams module's `store` dependency, and binds the streams - * module's `apiKey` secret slot to a platform variable — secret values never - * travel through framework config (ADR-0029), so the value is bound here by - * name only. + * The streams example: durable event streams backed by the `storage()` module + * as its durable tier, plus a `jobs` app that consumes them. The root wires + * the storage module's `store` port into the streams module's `store` + * dependency, and the streams module's `streams` port into the jobs service's + * `events` slot. + * + * Nothing here mentions the bearer key: the `events` binding declares it as a + * provisioning need, so the deploy mints one key for the streams module and + * writes it to both ends (ADR-0031). * * A closed root: no boundary argument, no return — it only provisions. */ export default module('streams-example', ({ provision }) => { const store = provision(storage()); - provision(streams(), { - deps: { store: store.store }, - secrets: { apiKey: envSecret('STREAMS_API_KEY') }, - }); + const events = provision(streams(), { deps: { store: store.store } }); + provision(jobsService, { deps: { events: events.streams } }); }); diff --git a/examples/streams/package.json b/examples/streams/package.json index 5ece75b0..6d4f10cf 100644 --- a/examples/streams/package.json +++ b/examples/streams/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "echo 'nothing to build for this package itself — @prisma/composer-prisma-cloud builds via turbo ^build'", + "build": "tsdown", "typecheck": "tsc --noEmit", "test": "bun test tests", "smoke:deployed": "bun scripts/smoke.ts", @@ -19,6 +19,7 @@ "@prisma/composer": "workspace:0.1.0", "@prisma/composer-prisma-cloud": "workspace:0.1.0", "@types/bun": "^1.3.13", + "tsdown": "^0.22.3", "typescript": "^6.0.3" } } diff --git a/examples/streams/scripts/smoke.ts b/examples/streams/scripts/smoke.ts index 1dedde60..3f96dee0 100644 --- a/examples/streams/scripts/smoke.ts +++ b/examples/streams/scripts/smoke.ts @@ -6,6 +6,10 @@ * and confirm an unauthenticated request is rejected. * * STREAMS_URL=https://… STREAMS_API_KEY=… bun scripts/smoke.ts + * + * STREAMS_API_KEY is the bare bearer key — if you copied it out of the + * Compute console's `COMPOSER__STREAMS_API_KEY` var, strip the + * surrounding quotes first: the stored row is JSON-encoded (ADR-0031). */ import { blindCast } from '@prisma/composer/casts'; diff --git a/examples/streams/src/jobs/app.ts b/examples/streams/src/jobs/app.ts new file mode 100644 index 00000000..2e6328e1 --- /dev/null +++ b/examples/streams/src/jobs/app.ts @@ -0,0 +1,68 @@ +/** + * A tiny job-log app that uses the streams module as its event log. The + * `StreamHandle` arrives hydrated from the `durableStreams(jobLog)` binding — + * URL, bearer auth, append framing, offsets, the long-poll dance, the + * stream's name, its create-on-first-use, and its 404 heal are all the + * handle's business (like RPC's generated client), so what remains here is + * app logic: routes and error mapping. + * + * POST /jobs append one event; body is the event JSON + * GET /jobs read the whole log back (optionally from ?offset=…) + * GET /jobs/tail wait for the next event after the current head + * + * `createJobsApp` returns a plain `Request → Response` handler so the same app + * runs behind `Bun.serve` in the deployed service and inside the integration + * test with no server. + */ +import type { StreamHandle } from '@prisma/composer-prisma-cloud/streams'; + +export function createJobsApp(jobs: StreamHandle): (req: Request) => Promise { + const append = async (req: Request): Promise => { + const event = await req.json(); + // The handle never retries an append beyond its own proven-safe 404 heal + // (no idempotency key upstream — a failed request is indistinguishable + // from one that applied). The caller retries, because only it knows + // whether a duplicate is acceptable. + await jobs.append(event); + return Response.json({ appended: event }, { status: 201 }); + }; + + const read = async (url: URL): Promise => { + const offset = url.searchParams.get('offset') ?? undefined; + const result = await jobs.read(offset !== undefined ? { offset } : undefined); + return Response.json({ events: result.events, nextOffset: result.nextOffset }); + }; + + const tail = async (url: URL): Promise => { + const timeout = url.searchParams.get('timeout'); + const result = await jobs.tail({ + ...(timeout !== null ? { timeoutMs: Number(timeout) * 1000 } : {}), + }); + return Response.json({ events: result.events, timedOut: result.timedOut }); + }; + + const route = async (req: Request, url: URL): Promise => { + if (url.pathname === '/' || url.pathname === '/health') { + return new Response('jobs — POST /jobs, GET /jobs, GET /jobs/tail\n'); + } + if (url.pathname === '/jobs/tail' && req.method === 'GET') return tail(url); + if (url.pathname === '/jobs') { + if (req.method === 'POST') return append(req); + if (req.method === 'GET') return read(url); + return new Response('method not allowed', { status: 405 }); + } + return new Response('not found', { status: 404 }); + }; + + return async (req: Request): Promise => { + try { + return await route(req, new URL(req.url)); + } catch (error) { + // An unreachable dependency is this app's upstream problem, not the + // caller's mistake: say so as 502, naming the cause, rather than letting + // the throw become an opaque 500. + console.error('jobs: request failed', error); + return new Response(`streams unreachable: ${String(error)}`, { status: 502 }); + } + }; +} diff --git a/examples/streams/src/jobs/server.ts b/examples/streams/src/jobs/server.ts new file mode 100644 index 00000000..6533c602 --- /dev/null +++ b/examples/streams/src/jobs/server.ts @@ -0,0 +1,14 @@ +// The jobs service's entrypoint (the build adapter's `entry`). After +// main.run(address, boot) re-keys the environment, service.load() hands the +// hydrated handle directly — no URL, no key, no protocol, no stream name +// here. Bind all interfaces — Compute routes external HTTP to the VM. +import { createJobsApp } from './app.ts'; +import service from './service.ts'; + +const { events } = service.load(); // { jobs: StreamHandle }, ready to call +const { port } = service.config(); + +process.on('uncaughtException', (err) => console.error('uncaughtException', err)); +process.on('unhandledRejection', (err) => console.error('unhandledRejection', err)); + +Bun.serve({ port, hostname: '0.0.0.0', fetch: createJobsApp(events.jobs) }); diff --git a/examples/streams/src/jobs/service.ts b/examples/streams/src/jobs/service.ts new file mode 100644 index 00000000..0f6ccbec --- /dev/null +++ b/examples/streams/src/jobs/service.ts @@ -0,0 +1,22 @@ +import node from '@prisma/composer/node'; +import { compute } from '@prisma/composer-prisma-cloud'; +import { durableStreams, streamDef, streamsContract } from '@prisma/composer-prisma-cloud/streams'; + +/** This service's one stream: the job event log. Untyped in this slice — events type as `unknown`. */ +export const jobLog = streamsContract({ jobs: streamDef() }); + +/** + * The jobs service: a plain HTTP app that appends and reads events, backed by + * the streams module. Its `events` slot is a `durableStreams(jobLog)` + * dependency, so `load()` hands it one handle per declared stream — here, + * `events.jobs` — already owning the name, the create-on-first-use, and the + * 404 heal. The wire binding underneath carries the endpoint URL and the + * bearer key the deploy minted for it (ADR-0031); no secret slot, nothing to + * bind at the root — declaring the dependency IS what causes the key to + * exist. + */ +export default compute({ + name: 'jobs', + deps: { events: durableStreams(jobLog) }, + build: node({ module: import.meta.url, entry: '../../dist/jobs/server.mjs' }), +}); diff --git a/examples/streams/tests/jobs.integration.test.ts b/examples/streams/tests/jobs.integration.test.ts new file mode 100644 index 00000000..ff12fb93 --- /dev/null +++ b/examples/streams/tests/jobs.integration.test.ts @@ -0,0 +1,138 @@ +/** + * The jobs app's integration test: the app driven through the hydrated-style + * handle (`StreamsClient` pointed at the local stand-in, `.stream('jobs')` — + * exactly what `load()` hands the deployed service) — append → read-back, a + * live long-poll tail, and error mapping. The stand-in needs no auth, so the + * key is a placeholder. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { StreamsClient } from '@prisma/composer-prisma-cloud/streams'; +import { + type LocalStreamsServer, + startLocalStreamsServer, +} from '@prisma/composer-prisma-cloud/streams/testing'; +import { createJobsApp } from '../src/jobs/app.ts'; + +let server: LocalStreamsServer; +let app: (req: Request) => Promise; +let dataRoot: string; +let prevDataRoot: string | undefined; + +const post = (event: unknown): Request => + new Request('http://app/jobs', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(event), + }); + +beforeAll(async () => { + dataRoot = mkdtempSync(join(tmpdir(), 'jobs-example-test-')); + prevDataRoot = process.env['DS_LOCAL_DATA_ROOT']; + process.env['DS_LOCAL_DATA_ROOT'] = dataRoot; + server = await startLocalStreamsServer({ name: 'jobs-example-test', port: 0 }); + const client = new StreamsClient({ + url: server.exports.http.url, + apiKey: 'local-stand-in-needs-no-auth', + }); + app = createJobsApp(client.stream('jobs')); +}); + +afterAll(async () => { + await server?.close(); + if (prevDataRoot === undefined) delete process.env['DS_LOCAL_DATA_ROOT']; + else process.env['DS_LOCAL_DATA_ROOT'] = prevDataRoot; + rmSync(dataRoot, { recursive: true, force: true }); +}); + +describe('jobs app vs an upstream that says no', () => { + let coldServer: LocalStreamsServer; + let coldRoot: string; + + beforeAll(async () => { + // Its own stand-in: this suite appends, and the suite below asserts on an + // exact log. + coldRoot = mkdtempSync(join(tmpdir(), 'jobs-cold-test-')); + process.env['DS_LOCAL_DATA_ROOT'] = coldRoot; + coldServer = await startLocalStreamsServer({ name: 'jobs-cold-test', port: 0 }); + process.env['DS_LOCAL_DATA_ROOT'] = dataRoot; + }); + + afterAll(async () => { + await coldServer?.close(); + rmSync(coldRoot, { recursive: true, force: true }); + }); + + // A stub in front of the real stand-in, so the app's own error handling is + // exercised end to end rather than asserted against a mock of itself. + const startFlakyProxy = (target: string, fail: () => Response | undefined) => { + const server = Bun.serve({ + port: 0, + fetch: async (req) => { + const refused = fail(); + if (refused !== undefined) return refused; + const url = new URL(req.url); + return fetch(`${target}${url.pathname}${url.search}`, { + method: req.method, + headers: req.headers, + ...(req.method === 'POST' || req.method === 'PUT' ? { body: await req.text() } : {}), + }); + }, + }); + return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop(true) }; + }; + + test('an upstream 401 surfaces as a 502 naming the cause, not an opaque 500', async () => { + let calls = 0; + const proxy = startFlakyProxy(coldServer.exports.http.url, () => { + calls++; + return new Response('nope', { status: 401 }); + }); + try { + const client = new StreamsClient({ url: proxy.url, apiKey: 'wrong-key' }); + const app = createJobsApp(client.stream('jobs')); + const res = await app(new Request('http://app/jobs')); + expect(res.status).toBe(502); // this app's "my upstream said no", not a 500 + expect(calls).toBe(1); // called once — a real protocol error is never retried + } finally { + proxy.stop(); + } + }, 15_000); +}); + +describe('jobs app (against the local streams stand-in)', () => { + test('POST /jobs appends and GET /jobs reads the log back', async () => { + const first = await app(post({ kind: 'created', id: 1 })); + expect(first.status).toBe(201); + const second = await app(post({ kind: 'started', id: 1 })); + expect(second.status).toBe(201); + + const read = await app(new Request('http://app/jobs')); + expect(read.status).toBe(200); + const body = (await read.json()) as { events: unknown[]; nextOffset: string | null }; + expect(body.events).toEqual([ + { kind: 'created', id: 1 }, + { kind: 'started', id: 1 }, + ]); + expect(body.nextOffset).not.toBeNull(); + }); + + test('GET /jobs/tail long-polls and delivers an event appended after it opened', async () => { + const tail = app(new Request('http://app/jobs/tail?timeout=10')); + await new Promise((r) => setTimeout(r, 300)); + await app(post({ kind: 'finished', id: 1 })); + + const res = await tail; + expect(res.status).toBe(200); + const body = (await res.json()) as { events: unknown[]; timedOut: boolean }; + expect(body.timedOut).toBe(false); + expect(body.events).toEqual([{ kind: 'finished', id: 1 }]); + }, 15_000); + + test('an unknown route is 404 and /health is served', async () => { + expect((await app(new Request('http://app/nope'))).status).toBe(404); + expect((await app(new Request('http://app/health'))).status).toBe(200); + }); +}); diff --git a/examples/streams/tsconfig.json b/examples/streams/tsconfig.json index 581f8dcb..7b83852e 100644 --- a/examples/streams/tsconfig.json +++ b/examples/streams/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["bun"] }, - "include": ["module.ts", "prisma-composer.config.ts", "tests", "scripts"] + "include": ["module.ts", "prisma-composer.config.ts", "src", "tests", "scripts"] } diff --git a/examples/streams/tsdown.config.ts b/examples/streams/tsdown.config.ts new file mode 100644 index 00000000..0567ca45 --- /dev/null +++ b/examples/streams/tsdown.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'tsdown'; + +// The app's own build (ADR-0005): the jobs service's runnable, built to a +// SINGLE self-contained dist/jobs/server.mjs. @prisma/composer/node's +// assemble() copies only the entry file into the deployed bundle (ADR-0004), +// so no sibling chunks are allowed — outputOptions.inlineDynamicImports +// collapses shared helpers into the one file. Everything is inlined +// (node_modules isn't shipped to Compute — PRO-213) EXCEPT `bun` (a Compute +// runtime built-in) and `node:` builtins (provided by the runtime). +export default defineConfig({ + entry: { server: 'src/jobs/server.ts' }, + outDir: 'dist/jobs', + format: 'esm', + platform: 'node', + external: [/^bun$/, /^bun:/, /^node:/], + noExternal: [/.*/], + outputOptions: { inlineDynamicImports: true }, + dts: false, + sourcemap: false, + clean: true, +}); diff --git a/gotchas.md b/gotchas.md index a4a22e49..70921c56 100644 --- a/gotchas.md +++ b/gotchas.md @@ -331,7 +331,7 @@ await withConnectionRetry(() => client.dbInit(...), { attempts: 12, delayMs: 500 - Upstream: [FT-5226](https://linear.app/prisma-company/issue/FT-5226/first-connection-to-a-freshly-provisioned-postgres-is-rejected-while) - Workaround source: [`packages/app-cloud/src/prisma-next-migrate.ts`](packages/app-cloud/src/prisma-next-migrate.ts) (`withConnectionRetry`) -- Removal guard: the CI canary (`scripts/cold-connect-canary.ts`, "Cold-connect canary" E2E job) passes only while the rejection exists — when the platform fixes FT-5226 it goes red, forcing removal of `withConnectionRetry` and itself +- Removal guard: the CI canary (`scripts/cold-connect-canary.ts`, "Cold-connect canary" E2E job) fails only when every cold connect succeeds — when the platform fixes FT-5226 it goes red, forcing removal of `withConnectionRetry` and itself (an inconclusive run passes with a warning annotation instead of blocking) - Related: [FT-5219](https://linear.app/prisma-company/issue/FT-5219) (idle-close, runtime), [PRO-212](https://linear.app/prisma-company/issue/PRO-212) (nested endpoint DSNs) --- @@ -372,21 +372,29 @@ pool.on("error", (err) => console.error("pg pool idle client error", err)); **Filed upstream:** [PRO-217](https://linear.app/prisma-company/issue/PRO-217/service-to-service-http-gets-econnreset-while-the-target-cold-starts) — _"Service-to-service HTTP gets ECONNRESET while the target cold-starts from scale-to-zero"_ **Product:** Prisma Compute (ingress / scale-to-zero cold start) -**Version:** Prisma Compute, Bun `fetch` from a Next.js standalone SSR render; observed 2026-07-13 -**First hit:** `examples/store` — the storefront's SSR calls to the catalog and orders services' `*.ewr.prisma.build` endpoints -**Cost:** folded into the idle-500 diagnosis above; intermittent enough to first read as "the in-app browser is flaky" +**Version:** Prisma Compute, Bun `fetch` — from a Next.js standalone SSR render (observed 2026-07-13) and from a plain Bun service (observed 2026-07-16) +**First hit:** `examples/store` — the storefront's SSR calls to the catalog and orders services' `*.ewr.prisma.build` endpoints. Hit again in `examples/streams`, where the `jobs` service calls the streams module. +**Cost:** folded into the idle-500 diagnosis above; intermittent enough to first read as "the in-app browser is flaky". Later cost a round of misdiagnosis in `examples/streams`: a bare status code cannot tell an app's own 502 from the edge's, so the reset was mistaken for a too-short retry budget until response bodies were captured. **Symptom.** An HTTP request from one Compute service to another intermittently fails with `ECONNRESET` — Bun reports `The socket connection was closed unexpectedly` with the target service's URL as `path`. It happens on the first request(s) after the target has been idle; a retry moments later succeeds. When the caller is an SSR page fanning out to several services, one reset is enough to 500 the whole page render. +The failure is a **thrown socket error, and only that**: it surfaces fast (~400 ms on the first touch after an idle spell — far quicker than the target's own boot), and across every cold hit captured in `examples/streams` the edge never once answered `502`/`503`/`504`. Code written to retry a cold-start *status* is therefore guarding a face of this bug that has not been observed; the reset is the whole of it. It lands on whatever call happens to be first, idempotent or not — in `examples/streams` on both the stream-creating `PUT` and, on a later request whose `PUT` was already memoized, the non-idempotent append. + **Cause (observed, mechanism presumed).** The target service had scaled to zero. Instead of the edge holding the connection until the VM finishes booting (which it does do on most cold hits — those requests just take seconds), the connection is sometimes closed mid-establishment during the cold-start window, surfacing as a socket reset to the caller. Warm targets never reset. +The window is measurable, and much wider than first recorded. In `examples/streams`' Compute logs, `spark: starting bun with entrypoint: bootstrap.js` → the server's own "listening" line was first measured at **~3.5 s** for a service restoring little state and **~8 s** for one restoring more from its object store. Later sampling (2026-07-17, two independent operators) measured boots of **3.3 s, 10.4 s, 11.6 s, 12.8 s, 21.9 s** — so treat **~3 s to ~22 s** as the real range, not 3.5–8 s. That is the window a first request falls into. Usually the request simply blocks for it and succeeds — a first request against a deliberately fresh instance returned `201` in 3.7 s with no error. The bug is not the wait; it is that the same first request sometimes gets the ~400 ms socket close instead, and nothing on the caller's side predicts which. + +**Boot length tracks how long the service was left alone, and the close only shows up in the long boots.** Promoting fresh versions back-to-back (samples ~4 s apart) yields atypically short **~1 s** boots, and across 20 such touches the edge held every time. Spacing the same trigger 60 s apart yields the 3–22 s boots above, and the close reproduces readily (3 of 5 touches in one probe; 4 of 4 first touches in another). A test that redeploys in a tight loop is therefore measuring a window the bug does not live in — and will conclude, wrongly, that the bug is gone. This cost the cold-start canary two rounds; see its removal guard below. + **Workaround.** No principled client-side fix for non-idempotent calls (blind retry could double-execute a write). Mitigations: - retry only requests that never reached the server / are idempotent reads; - keep chatty targets warm — a scheduled ping (the `cron` shared module's 30 s trigger) masks the window for whatever it touches; - warm the whole app with one request before a demo. -An always-on / min-instances option on Compute services would remove the window; none exists today. +**But do not push this into application code.** Hand-rolling it per app costs every consumer a platform-specific backoff, cannot cover the non-idempotent calls (where this was actually observed to land), and hides the defect from the people who would fix it. The compensation lives ONCE, as policy in the streams client Composer ships (the `IDEMPOTENT_BACKOFF` policy in `client.ts`'s `StreamsClient`): idempotent operations — create, read, tail — are retried with a bounded backoff; **appends are not retried** (no idempotency key upstream, so a failed append is indistinguishable from an applied one) and surface as a 502 naming the cause, keeping the platform behaviour visible where it cannot be safely absorbed. An app's first append after an idle spell may therefore still fail intermittently — the honest state of the platform. The cost this pushes onto tooling and users is filed as [PRO-219](https://linear.app/prisma-company/issue/PRO-219/scale-to-zero-cold-starts-force-platform-specific-retry-boilerplate); an always-on / min-instances option, or making the first-request behaviour consistent, would remove the window and the compensation with it. Neither exists today. + +**Removal guard.** The CI "Cold-start canary (PRO-217)" job (`scripts/cold-start-canary.ts`, in the E2E deploy workflow) touches freshly promoted instances each run, 60 s apart, and confirms from the deployment's own boot log that each touch was sent before the server's "listening" line — a touch that cannot be placed on one side of the boot counts for nothing rather than being guessed. It fails only when enough touches reached a genuine cold start **and** every one of them held; because this bug is intermittent, "every touch held" in a small run is the expected outcome of a run that is too small, so the job requires 14 confirmed cold-start holds before it will claim the bug is gone (at a conservative 20% close rate, 0.8^14 ≈ 4.4% — the chance of being fooled by luck). An inconclusive run passes with a warning annotation. That failure is the signal to remove the streams client's `IDEMPOTENT_BACKOFF` (the PRO-219 compensation) and the canary itself, the same contract as FT-5226's cold-connect canary. **Reproduction.** @@ -394,10 +402,18 @@ An always-on / min-instances option on Compute services would remove the window; 2. Let B idle to scale-to-zero. 3. Hit A repeatedly right as B cold-starts → occasional `ECONNRESET` from A's fetch to B; warm B never resets. +Idling is an unreliable trigger — a service left alone for 6 minutes (and another for ~30) still answered warm in under 700 ms, so the scale-to-zero threshold is longer than a convenient wait. Stopping the deployment does not work either: `POST /v1/deployments/{id}/stop` returns 204, the app then serves a plain `404` and stays down until something explicitly calls `start` — it never revives on a request, so it cannot produce a cold start at all. + +What does work: promote a fresh version of B (create → upload → start → **race the promote call itself**, retrying immediately on its "not running yet" 409) and touch A the instant promote succeeds. Do **not** wait for the version to report `running` first — `running` flips within ~1 s of `start`, well before the app is listening, so by the time a poll loop and a promote call have run, the boot is already over and the touch lands on a warm instance. That mistake is what made the canary report the bug fixed while it was live. + +Two more requirements for a trustworthy probe. **Leave ~60 s between samples** — back-to-back promotions produce ~1 s boots the close does not appear in (see above). And **confirm coldness from the deployment's own log** (`/v1/deployments/{id}/logs?from_start=true`, read from the start) rather than inferring it from latency: a touch counts only if it was sent before the server's own "listening" line. Note that comparison crosses clocks — the touch is timestamped on the runner, "listening" by the app on the VM — so require a margin (the canary uses 2 s) comfortably larger than plausible skew, and treat anything closer as unknown. Capture the response **body**, not just the status, or A's own 502 is indistinguishable from the edge's. + **References.** - Observed in `storefront` runtime logs (`app logs --project store --app storefront`): `code: 'ECONNRESET', path: 'https://….ewr.prisma.build/rpc/listProducts'` -- Related: [FT-5219](https://linear.app/prisma-company/issue/FT-5219) / FT-5226 (the DB faces of the same scale-to-zero/cold family) +- Observed again from `examples/streams`' `jobs` service: `streams unreachable: Error: The socket connection was closed unexpectedly`, returned in 404 ms +- Product ask: [PRO-219](https://linear.app/prisma-company/issue/PRO-219/scale-to-zero-cold-starts-force-platform-specific-retry-boilerplate) — the userspace retry boilerplate this forces on every consumer +- Related: [FT-5219](https://linear.app/prisma-company/issue/FT-5219) / FT-5226 (the DB faces of the same scale-to-zero/cold family), [PRO-218](https://linear.app/prisma-company/issue/PRO-218/compute-ingress-buffers-streaming-responses-sse-cannot-deliver-edge) (the same ingress, streaming-response face) --- diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index 3247fa5c..93f3d596 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts @@ -1,5 +1,6 @@ import { describe, expect, mock, test } from 'bun:test'; import type { Contract } from '@internal/core'; +import type { NodeDescriptor } from '@internal/core/config'; import type { LowerContext, LoweredResult, Outputs } from '@internal/core/deploy'; // Import the REAL modules the mocks below stub, so each mock can spread them. // This matters beyond convenience: `bun test` runs every test file in ONE @@ -11,13 +12,20 @@ import type { LowerContext, LoweredResult, Outputs } from '@internal/core/deploy // mode regardless of the (filesystem-dependent) test-file order. import * as RealPrismaAlchemy from '@internal/lowering'; import * as RealOutput from 'alchemy/Output'; +import { type } from 'arktype'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; import type { ComputeProvisioned, ComputeSerialized } from '../descriptors/compute.ts'; +import { computeDescriptor } from '../descriptors/compute.ts'; import type { S3StoreSerialized } from '../descriptors/s3-store.ts'; // shared.ts's only @internal/lowering import is type-only, so pulling // projectIdOf in statically doesn't drag the mocked runtime module in early. -import { type CloudApplication, projectIdOf } from '../descriptors/shared.ts'; +import { + type CloudApplication, + type ProviderParam, + projectIdOf, + type ResolvedCloudOptions, +} from '../descriptors/shared.ts'; import * as RealPgWarm from '../pg-warm-resource.ts'; import * as RealS3Credentials from '../s3-credentials-resource.ts'; @@ -127,6 +135,7 @@ const { compute, envParam, envSecret, postgres, postgresContract, s3StoreService const { dependency, module, provisionNeed, secret, string } = await import('@internal/core'); const { lowering } = await import('@internal/core/deploy'); const { RPC_PEER_KEY } = await import('@internal/rpc'); +const { STREAMS_API_KEY } = await import('../streams-keys.ts'); // The node registry erases each descriptor's P/S to `unknown`, so every hook // hands back Effect. `A` is the caller's claim about what the hook @@ -1267,6 +1276,259 @@ describe('ADR-0030: per-binding RPC service keys — mint (control.ts) + wire (d }); }); +describe("streams' provisioned bearer key — one value per PROVIDER, stored on the provider", () => { + const build = { + extension: '@prisma/composer/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', + }; + // A fake streams-shaped Contract + dependency: the target reacts to the + // `apiKey` param's need brand alone, never to "streams" by name. + const fakeStreamsContract: Contract<'streams', Record> = { + kind: 'streams', + __cmp: {}, + satisfies: () => true, + }; + const streamsLikeDep = () => + dependency({ + type: 'streams', + connection: { + params: { url: string(), apiKey: string({ provision: provisionNeed(STREAMS_API_KEY) }) }, + hydrate: (v) => v, + }, + }); + + test('two consumers of one streams module share ONE key; the provider stores that same key', async () => { + await withEnv( + { PRISMA_PROJECT_ID: 'shop-project#cloud-id', PRISMA_BRANCH_ID: undefined }, + () => { + const target = prismaCloud({ workspaceId: 'ws_1' }); + const root = module('shop', {}, ({ provision }) => { + const events = provision( + compute({ name: 'events', deps: {}, build, expose: { streams: fakeStreamsContract } }), + { id: 'events' }, + ); + provision(compute({ name: 'reader', deps: { events: streamsLikeDep() }, build }), { + id: 'reader', + deps: { events: events.streams }, + }); + provision(compute({ name: 'writer', deps: { events: streamsLikeDep() }, build }), { + id: 'writer', + deps: { events: events.streams }, + }); + return {}; + }); + const before = { envVar: recorded.envVar.length, serviceKey: recorded.serviceKey.length }; + + run( + lowering(root, configFor(target), { + name: 'shop', + bundles: { + events: { dir: 'modules/events/dist/bundle', entry: 'server.js' }, + reader: { dir: 'modules/reader/dist/bundle', entry: 'server.js' }, + writer: { dir: 'modules/writer/dist/bundle', entry: 'server.js' }, + }, + }), + ); + + // Both edges resolve to ONE resource id — the PROVIDER's address, not + // the edge's — so the mint is shared (upstream auths a single API_KEY). + expect([ + ...new Set(recorded.serviceKey.slice(before.serviceKey).map(([id]) => id)), + ]).toEqual(['streamskey-events']); + + const writes = recorded.envVar.slice(before.envVar).map(([, props]) => props); + const writtenValue = (envName: string): unknown => + ( + writes.find((w) => (w as { key: string }).key === envName) as + | { value?: unknown } + | undefined + )?.value; + expect(writtenValue('COMPOSER_READER_EVENTS_APIKEY')).toBe('key-for-streamskey-events'); + expect(writtenValue('COMPOSER_WRITER_EVENTS_APIKEY')).toBe('key-for-streamskey-events'); + + // The provider's own reserved provider param: the same key, under the + // name the streams entrypoint reads (address-scoped; compute's run + // validates and re-stashes it), JSON-encoded like any service-own + // literal param. + expect(writes).toContainEqual({ + projectId: 'shop-project#cloud-id', + key: 'COMPOSER_EVENTS_STREAMS_API_KEY', + value: '"key-for-streamskey-events"', + class: 'production', + }); + }, + ); + }); + + test('the reserved provider param refuses two disagreeing keys for one provider (a per-edge flip would be loud)', () => { + // The provider param writes ONE key, which is only correct while the + // provisioner mints per provider. Drive serialize directly with two + // inbound edges whose refs disagree — the shape a per-edge flip would + // produce without a paired accepted-set provider param. + const target = prismaCloud({ workspaceId: 'ws_1' }); + const node = compute({ + name: 'events', + deps: {}, + build, + expose: { streams: fakeStreamsContract }, + }); + const consumerNode = compute({ name: 'reader', deps: { events: streamsLikeDep() }, build }); + const graph = { + nodes: [ + { id: 'events', node }, + { id: 'reader', node: consumerNode }, + { id: 'writer', node: consumerNode }, + ], + edges: [ + { kind: 'dependency', from: 'events', to: 'reader', input: 'events' }, + { kind: 'dependency', from: 'events', to: 'writer', input: 'events' }, + ], + secrets: [], + }; + const ctx = { + address: 'events', + node, + graph, + provisioned: new Map([ + ['reader.events', 'key-one'], + ['writer.events', 'key-two'], + ]), + } as unknown as LowerContext; + + expect(() => + run( + serviceDescriptorOf(target, 'compute').serialize( + ctx, + { outputs: { projectId: 'shop-project#cloud-id' } }, + { service: { port: 3000 }, inputs: {} } as Parameters< + ReturnType['serialize'] + >[2], + ), + ), + ).toThrow(/provisioned 2 distinct keys/); + }); + + test('a streams provider with no consumers mints nothing and stores no key', async () => { + await withEnv( + { PRISMA_PROJECT_ID: 'shop-project#cloud-id', PRISMA_BRANCH_ID: undefined }, + () => { + const target = prismaCloud({ workspaceId: 'ws_1' }); + const root = module('shop', {}, ({ provision }) => { + provision( + compute({ name: 'lonely', deps: {}, build, expose: { streams: fakeStreamsContract } }), + { id: 'lonely' }, + ); + return {}; + }); + const before = { envVar: recorded.envVar.length, serviceKey: recorded.serviceKey.length }; + + run( + lowering(root, configFor(target), { + name: 'shop', + bundles: { lonely: { dir: 'modules/lonely/dist/bundle', entry: 'server.js' } }, + }), + ); + + expect(recorded.serviceKey.slice(before.serviceKey)).toEqual([]); + const keys = recorded.envVar + .slice(before.envVar) + .map(([, props]) => (props as { key: string }).key); + expect(keys).not.toContain('COMPOSER_LONELY_STREAMS_API_KEY'); + }, + ); + }); +}); + +describe("descriptors/compute.ts's provider-param loop is generic over the registry it is handed — adding a brand is control.ts's edit alone", () => { + const build = { + extension: '@prisma/composer/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', + }; + const anyContract: Contract<'rpc', Record> = { + kind: 'rpc', + __cmp: {}, + satisfies: () => true, + }; + + test('three independently-registered provider params — including a brand this test invents — each write their own row through the same unmodified serialize()', async () => { + await withEnv({ PRISMA_BRANCH_ID: undefined }, () => { + // Neither of these two symbols is RPC_PEER_KEY or STREAMS_API_KEY — + // `computeDescriptor` is handed this registry as plain data and never + // imports a brand's module, so it cannot tell a real brand from a made- + // up one. That is the property this test pins: the loop in + // descriptors/compute.ts needs no edit to support a new registrant. + const brandOne = Symbol('provider-param-test/one'); + const brandTwo = Symbol('provider-param-test/two'); + const brandThree = Symbol('provider-param-test/three'); + const providerParams: ReadonlyMap = new Map([ + [ + brandOne, + { name: 'PARAM_ONE', schema: type('string'), brand: brandOne, value: () => 'value-one' }, + ], + [ + brandTwo, + { name: 'PARAM_TWO', schema: type('string'), brand: brandTwo, value: () => 'value-two' }, + ], + // A third registrant may also decline to write a row at all. + [ + brandThree, + { + name: 'PARAM_THREE', + schema: type('string'), + brand: brandThree, + value: () => undefined, + }, + ], + ]); + const o: ResolvedCloudOptions = { + workspaceId: 'ws_1', + projectId: 'shop-project#cloud-id', + branchId: undefined, + providerParams, + }; + const node = compute({ name: 'multi', deps: {}, build, expose: { any: anyContract } }); + const ctx = { + address: 'multi', + node, + graph: { secrets: [], edges: [] }, + application: { outputs: {} }, + provisioned: new Map(), + } as unknown as LowerContext; + const provisioned = { serviceId: 'multi-svc#cloud-id', projectId: 'shop-project#cloud-id' }; + const config = { service: { port: 3000 }, inputs: {} }; + const before = recorded.envVar.length; + + // The three-brand options can't ride the shared registry, so erase the + // precise descriptor to the registry's own type — the same assignment + // control.ts makes when it registers the real one. + const descriptor: NodeDescriptor = computeDescriptor(o); + if (descriptor.kind !== 'service') throw new Error('expected a service descriptor'); + run(descriptor.serialize(ctx, provisioned, config)); + + const writes = recorded.envVar.slice(before).map(([, props]) => props); + expect(writes).toContainEqual({ + projectId: 'shop-project#cloud-id', + key: 'COMPOSER_MULTI_PARAM_ONE', + value: '"value-one"', + class: 'production', + }); + expect(writes).toContainEqual({ + projectId: 'shop-project#cloud-id', + key: 'COMPOSER_MULTI_PARAM_TWO', + value: '"value-two"', + class: 'production', + }); + expect(writes.map((w) => (w as { key: string }).key)).not.toContain( + 'COMPOSER_MULTI_PARAM_THREE', + ); + }); + }); +}); + describe('name validation — fail fast on Prisma name constraints, before creating anything', () => { const build = { extension: '@prisma/composer/node', diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts index ee701eec..ac273108 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/extension.test.ts @@ -14,9 +14,14 @@ import { RPC_ACCEPTED_KEYS_ENV } from '@internal/rpc'; import { type } from 'arktype'; import { compute, postgres, postgresContract } from '../index.ts'; import { configKey, deserialize, deserializeSecrets, encode, secretKey } from '../serializer.ts'; -import { serviceKeyEnvName } from '../service-keys.ts'; +import { RPC_ACCEPTED_KEYS_PARAM } from '../service-keys.ts'; +import { STREAMS_API_KEY_ENV, STREAMS_API_KEY_PARAM } from '../streams-keys.ts'; import { bootstrapService } from '../testing.ts'; +/** The deploy-side, address-scoped row for a reserved provider param. */ +const providerParamKey = (address: string, name: string): string => + configKey(address, { owner: 'service', name }); + function scalarDeclaration( owner: ConfigDeclaration['owner'], name: string, @@ -39,6 +44,8 @@ const build = { }; /** Sets env vars for the duration of `fn`, restoring whatever was there before. */ +const COMPOSER_RETRIES_KEY = configKey('', { owner: 'service', name: 'retries' }); + async function withEnv(values: Record, fn: () => Promise | T): Promise { const previous = new Map(Object.keys(values).map((k) => [k, process.env[k]])); for (const [k, v] of Object.entries(values)) process.env[k] = v; @@ -412,12 +419,20 @@ describe('compute().run(address, boot) → load() — the round trip', () => { expect(portAtBoot).toBe('3000'); }); - test('run() re-stashes the address-scoped RPC accepted-keys var address-free (ADR-0030)', async () => { + // Reserved provider params (ADR-0031) are the provider-side counterpart of + // a declared param: `run()` validates each one's address-scoped row against + // its own schema — the same `coerce` a declared param takes — and re-stashes + // it address-free, so `serve()`'s accepted keys and the streams entrypoint's + // API_KEY read a checked value, never raw copied bytes. It cannot be + // replaced by writing address-free at deploy: one project is one env + // namespace, so two services would collide on COMPOSER_PORT. Only boot + // knows which address it is. + test("run() validates a reserved provider param's address-scoped row and re-stashes it address-free (RPC's accepted-keys set)", async () => { const app = compute({ name: 'auth', deps: {}, build }); let seenAtBoot: string | undefined; await withEnv( { - [serviceKeyEnvName('auth')]: '["key-a","key-b"]', + [providerParamKey('auth', RPC_ACCEPTED_KEYS_PARAM.name)]: '["key-a","key-b"]', COMPOSER_AUTH_PORT: '', COMPOSER_PORT: '', [RPC_ACCEPTED_KEYS_ENV]: '', @@ -430,7 +445,43 @@ describe('compute().run(address, boot) → load() — the round trip', () => { expect(seenAtBoot).toBe('["key-a","key-b"]'); }); - test('run() re-stashes nothing when no accepted-keys var was written for this address', async () => { + test("run() validates a reserved provider param's own schema, not another's (streams' single-value key)", async () => { + const app = compute({ name: 'events', deps: {}, build }); + let seenAtBoot: string | undefined; + await withEnv( + { + [providerParamKey('events', STREAMS_API_KEY_PARAM.name)]: '"minted-key-abc"', + COMPOSER_EVENTS_PORT: '', + COMPOSER_PORT: '', + [STREAMS_API_KEY_ENV]: '', + }, + () => + app.run('events', async () => { + seenAtBoot = process.env[STREAMS_API_KEY_ENV]; + }), + ); + expect(seenAtBoot).toBe('"minted-key-abc"'); + }); + + test("run() validates and re-stashes a reserved provider param at a nested address — the streams module's real deployed shape (streams.service)", async () => { + const app = compute({ name: 'service', deps: {}, build }); + let seenAtBoot: string | undefined; + await withEnv( + { + [providerParamKey('streams.service', STREAMS_API_KEY_PARAM.name)]: '"minted-key-abc"', + COMPOSER_STREAMS_SERVICE_PORT: '', + COMPOSER_PORT: '', + [STREAMS_API_KEY_ENV]: '', + }, + () => + app.run('streams.service', async () => { + seenAtBoot = process.env[STREAMS_API_KEY_ENV]; + }), + ); + expect(seenAtBoot).toBe('"minted-key-abc"'); + }); + + test('run() stashes nothing when a reserved provider param row is absent — absence stays "never provisioned"', async () => { const app = compute({ name: 'plain', deps: {}, build }); let seenAtBoot: string | undefined; await withEnv({ COMPOSER_PLAIN_PORT: '', COMPOSER_PORT: '', [RPC_ACCEPTED_KEYS_ENV]: '' }, () => @@ -441,8 +492,87 @@ describe('compute().run(address, boot) → load() — the round trip', () => { expect(seenAtBoot).toBe(''); }); - test("serviceKeyEnvName('') is @internal/rpc's RPC_ACCEPTED_KEYS_ENV — writer and reader cannot drift", () => { - expect(serviceKeyEnvName('')).toBe(RPC_ACCEPTED_KEYS_ENV); + test('run() fails loudly on a reserved provider param row that fails its schema, rather than passing raw bytes through', async () => { + const app = compute({ name: 'events', deps: {}, build }); + await withEnv( + { + // Not JSON — the shape a raw, unvalidated copy would have let through. + [providerParamKey('events', STREAMS_API_KEY_PARAM.name)]: 'not-json-at-all', + COMPOSER_EVENTS_PORT: '', + COMPOSER_PORT: '', + }, + () => expect(app.run('events', async () => {})).rejects.toThrow(/invalid value/), + ); + }); + + test("run() does not alias another service's reserved provider param — only this address's own row is read", async () => { + const app = compute({ name: 'a', deps: {}, build }); + let seenAtBoot: string | undefined; + await withEnv( + { + // Compute injects EVERY project var into EVERY service, so a sibling's + // reserved-param row is always in this process's env — the normal case. + [providerParamKey('other.service', STREAMS_API_KEY_PARAM.name)]: '"not-mine"', + // And the sharp one: a service nested UNDER my address. Its row shares + // no key with mine — `configKey` addresses each service exactly, no + // prefix matching — so it must not surface here either. + [providerParamKey('a.b', STREAMS_API_KEY_PARAM.name)]: '"nested-not-mine"', + COMPOSER_A_PORT: '', + COMPOSER_PORT: '', + [STREAMS_API_KEY_ENV]: '', + }, + () => + app.run('a', async () => { + seenAtBoot = process.env[STREAMS_API_KEY_ENV]; + }), + ); + expect(seenAtBoot).toBe(''); + }); + + test('stash() writes the re-encoded typed value for a declared param, not a raw copy', async () => { + const app = compute({ + name: 'web', + deps: {}, + params: { retries: number({ default: 1 }) }, + build, + }); + let seenAtBoot: string | undefined; + // The address-scoped row is textually different from what encode() emits + // (JSON tolerates the padding); stash() decodes then re-encodes the typed + // value, so the address-free row is always the canonical form. + await withEnv( + { + COMPOSER_WEB_RETRIES: ' 5 ', + COMPOSER_RETRIES: '', + COMPOSER_WEB_PORT: '', + COMPOSER_PORT: '', + }, + () => + app.run('web', async () => { + seenAtBoot = process.env[COMPOSER_RETRIES_KEY]; + }), + ); + expect(seenAtBoot).toBe('5'); + }); + + test('a lone-service deploy (address "") reads and re-writes the same address-free key for a declared param', async () => { + const app = compute({ + name: 'plain', + deps: {}, + params: { retries: number({ default: 1 }) }, + build, + }); + let seenAtBoot: string | undefined; + await withEnv({ COMPOSER_RETRIES: '7', COMPOSER_PORT: '' }, () => + app.run('', async () => { + seenAtBoot = process.env[COMPOSER_RETRIES_KEY]; + }), + ); + expect(seenAtBoot).toBe('7'); + }); + + test("the RPC reserved provider param's address-free key is @internal/rpc's RPC_ACCEPTED_KEYS_ENV — writer and reader cannot drift", () => { + expect(providerParamKey('', RPC_ACCEPTED_KEYS_PARAM.name)).toBe(RPC_ACCEPTED_KEYS_ENV); }); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index 8b0e783d..5e864b52 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts @@ -91,7 +91,7 @@ describe('invariant 2: authoring imports stay lean (core + pack)', () => { }); describe('invariant 4: environment touches are confined to the config serializer and the control factory', () => { - test("the process-env token appears only in serializer.ts (param read+stash, secret double-lookup+stash, env-sourced param double-lookup), control.ts's prismaCloud(), preflight.ts (shell token + fill-missing lookup), teardown.ts (shell token), and compute.ts (boot exposes the resolved port as PORT; ADR-0030's accepted-keys re-stash) (the extension factory's env read, ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; ADR-0019 — PRISMA_PROJECT_ID + optional PRISMA_BRANCH_ID)", () => { + test("the process-env token appears only in serializer.ts (param read+stash, reserved-provider-param read+stash, secret double-lookup+stash, env-sourced param double-lookup), control.ts's prismaCloud(), preflight.ts (shell token + fill-missing lookup), teardown.ts (shell token), and compute.ts (exposes the resolved port as PORT) (the extension factory's env read, ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; ADR-0019 — PRISMA_PROJECT_ID + optional PRISMA_BRANCH_ID)", () => { const sources = shippedSources(); expect(sources.length).toBeGreaterThan(0); @@ -102,10 +102,10 @@ describe('invariant 4: environment touches are confined to the config serializer }); expect(hits.sort((a, b) => a.file.localeCompare(b.file))).toEqual([ - { file: 'compute.ts', count: 3 }, + { file: 'compute.ts', count: 1 }, { file: 'control.ts', count: 4 }, { file: 'preflight.ts', count: 2 }, - { file: 'serializer.ts', count: 7 }, + { file: 'serializer.ts', count: 9 }, { file: 'teardown.ts', count: 1 }, ]); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/provider-params.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/provider-params.test.ts new file mode 100644 index 00000000..7625e838 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/provider-params.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test'; +import { type } from 'arktype'; +import { buildProviderParams, PROVIDER_PARAMS, prismaCloud } from '../control.ts'; +import { RESERVED_PROVIDER_PARAMS } from '../provider-params.ts'; +import type { ProviderParamEntry } from '../serializer.ts'; + +const FAKE_BRAND_A: unique symbol = Symbol('provider-params.test.ts/fake-brand-a'); +const FAKE_BRAND_B: unique symbol = Symbol('provider-params.test.ts/fake-brand-b'); + +describe('control.ts derives PROVIDER_PARAMS from provider-params.ts, not the other way around', () => { + test("buildProviderParams throws when a boot-side entry's brand has no registered deploy-side value() — the drift Finding B closes", () => { + const entries: readonly ProviderParamEntry[] = [ + { name: 'FAKE_A', schema: type('string'), brand: FAKE_BRAND_A }, + { name: 'FAKE_B', schema: type('string'), brand: FAKE_BRAND_B }, + ]; + // Only FAKE_BRAND_A has a registered value() — the shape deploy would be + // in if a brand were added to RESERVED_PROVIDER_PARAMS (the boot-side + // list) without a matching registration in control.ts. + const values = new Map([[FAKE_BRAND_A, () => 'a-value']]); + + expect(() => buildProviderParams(entries, values)).toThrow(/FAKE_B/); + expect(() => buildProviderParams(entries, values)).toThrow( + /has no registered deploy-side value/, + ); + }); + + test('buildProviderParams succeeds and carries every entry through when every brand has a registered value()', () => { + const entries: readonly ProviderParamEntry[] = [ + { name: 'FAKE_A', schema: type('string'), brand: FAKE_BRAND_A }, + { name: 'FAKE_B', schema: type('string'), brand: FAKE_BRAND_B }, + ]; + const values = new Map([ + [FAKE_BRAND_A, () => 'a-value'], + [FAKE_BRAND_B, () => 'b-value'], + ]); + + const built = buildProviderParams(entries, values); + expect([...built.keys()]).toEqual([FAKE_BRAND_A, FAKE_BRAND_B]); + expect(built.get(FAKE_BRAND_A)?.name).toBe('FAKE_A'); + expect(built.get(FAKE_BRAND_A)?.value([])).toBe('a-value'); + expect(built.get(FAKE_BRAND_B)?.value([])).toBe('b-value'); + }); + + test('the real registry: every param control.ts writes for deploy is exactly a param provider-params.ts lists for boot', () => { + // Now a structural guarantee (PROVIDER_PARAMS is built BY mapping over + // RESERVED_PROVIDER_PARAMS), not a coincidence two independent lists + // happen to agree — this test exists as a sensor should that construction + // ever get bypassed (e.g. a future edit that hand-writes an extra entry + // into PROVIDER_PARAMS alongside the derived ones). + const deploySide = [...PROVIDER_PARAMS.values()].map((entry) => entry.name).sort(); + const bootSide = RESERVED_PROVIDER_PARAMS.map((entry) => entry.name).sort(); + expect(deploySide).toEqual(bootSide); + }); +}); + +describe("control.ts's PROVISIONERS and PROVIDER_PARAMS cover the same brands", () => { + // Nothing else compared these two maps before this test. A brand present in + // PROVISIONERS (core mints a value for it) but absent from PROVIDER_PARAMS + // (no provider ever stores the accepted-keys/API-key row) mints keys for + // consumers while the row the runtime reader checks (serve()'s accepted + // keys, the streams entrypoint's API_KEY) never gets written — an ABSENT + // var reads as "never provisioned", so serve() would pass through and + // accept every caller. This test is where a future brand that legitimately + // needs no provider-side param would record that decision (e.g. by scoping + // the assertion to an explicit allowlist) — it is not a law, just currently + // true for every brand this target registers. + test('every brand in provisions (the minting registry) has a matching reserved provider param, and vice versa', () => { + const target = prismaCloud({ workspaceId: 'ws_1' }); + const provisionerBrands = [...(target.provisions?.keys() ?? [])]; + const paramBrands = [...PROVIDER_PARAMS.keys()]; + + expect(new Set(provisionerBrands)).toEqual(new Set(paramBrands)); + expect(provisionerBrands.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/compute.ts b/packages/1-prisma-cloud/1-extensions/target/src/compute.ts index 99c2e95b..84765f28 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/compute.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/compute.ts @@ -12,8 +12,14 @@ import type { } from '@internal/core'; import { hydrateSecrets, hydrateSync, number, service } from '@internal/core'; import { blindCast } from '@internal/foundation/casts'; -import { deserialize, deserializeSecrets, stash, stashSecrets } from './serializer.ts'; -import { serviceKeyEnvName } from './service-keys.ts'; +import { RESERVED_PROVIDER_PARAMS } from './provider-params.ts'; +import { + deserialize, + deserializeSecrets, + stash, + stashProviderParams, + stashSecrets, +} from './serializer.ts'; const reservedParams = { port: number({ default: 3000 }) } as const; type ReservedParams = typeof reservedParams; @@ -98,14 +104,14 @@ export const compute = < async run(address: string, boot: () => Promise) { const config = deserialize(node, address); stash(node, config); + // ADR-0031's provider-side sibling of the param re-stash above — the + // reader is serve()'s accepted keys or the streams entrypoint's + // API_KEY. An absent row stays absent (never provisioned); a present + // one is schema-checked exactly like a declared param before it moves. + stashProviderParams(RESERVED_PROVIDER_PARAMS, address); // Re-emit the secret POINTERS address-free too, so secrets() double-looks-up // the same way with no address (the value stays only in its platform var). stashSecrets(node, address); - // ADR-0030: re-stash the address-scoped accepted-keys var address-free — - // serve() (RPC_ACCEPTED_KEYS_ENV) reads it with no address, one service - // per running instance, same as config/secrets above. - const accepted = process.env[serviceKeyEnvName(address)]; - if (accepted !== undefined) process.env[serviceKeyEnvName('')] = accepted; // Expose the resolved service port under the near-universal PORT convention, // so a framework-unaware server (Next.js's standalone server.js binds the // PORT env var) listens on the port Compute routes to — not its own default. diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control.ts b/packages/1-prisma-cloud/1-extensions/target/src/control.ts index 23c7947e..3f48e97a 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/control.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/control.ts @@ -5,10 +5,12 @@ import type { ExtensionDescriptor } from '@internal/core/config'; import type { ProvisionerDescriptor } from '@internal/core/deploy'; +import { blindCast } from '@internal/foundation/casts'; import * as Prisma from '@internal/lowering'; /** The Prisma Cloud–hosted deploy state store; its implementation lives in @internal/lowering. */ import { prismaState } from '@internal/lowering/state'; import { RPC_PEER_KEY } from '@internal/rpc'; +import * as Output from 'alchemy/Output'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import { computeDescriptor } from './descriptors/compute.ts'; @@ -16,11 +18,18 @@ import { postgresDescriptor } from './descriptors/postgres.ts'; import { prismaNextDescriptor } from './descriptors/prisma-next.ts'; import { s3CredentialsDescriptor } from './descriptors/s3-credentials.ts'; import { s3StoreDescriptor } from './descriptors/s3-store.ts'; -import type { CloudApplication, ResolvedCloudOptions } from './descriptors/shared.ts'; +import type { + CloudApplication, + ProviderParam, + ResolvedCloudOptions, +} from './descriptors/shared.ts'; import { PgWarmProvider } from './pg-warm-resource.ts'; import { PnMigrationProvider } from './pn-migration-resource.ts'; import { runPreflight } from './preflight.ts'; +import { RESERVED_PROVIDER_PARAMS } from './provider-params.ts'; import { S3CredentialsProvider } from './s3-credentials-resource.ts'; +import type { ProviderParamEntry } from './serializer.ts'; +import { STREAMS_API_KEY } from './streams-keys.ts'; import { runTeardown } from './teardown.ts'; /** @@ -40,6 +49,86 @@ const serviceKeyProvisioner: ProvisionerDescriptor = { }), }; +/** + * `ctx.provisioned`'s refs are typed `unknown` — core forwards a provisioner's + * ref without inspecting it. Each provider param below is the sole reader of + * its own provisioner's output, so the shape is asserted here, once, rather + * than checked. + */ +const asKeyOutputs = (refs: readonly unknown[]): Output.Output[] => + refs.map((ref) => + blindCast< + Output.Output, + "the ref is keyed by an edge provisionedEdges matched on this param's own brand, and the provisioner registered beside it is that brand's sole registrant — it returns a ServiceKey resource's `value`, an Output" + >(ref), + ); + +/** + * RPC's deploy-side `value(refs)` (ADR-0030): the provider stores a SET — one + * key per inbound edge — into the accepted-keys var `serve()` reads. Paired + * with the provisioner above: mint per edge, aggregate every edge. + * + * Zero consumers still emits, and that is the whole point of #100: an ABSENT + * var means "never provisioned" and passes every caller through, so a deployed + * provider nobody wired must say "[]" — deny everything — rather than say + * nothing. Written as a literal because `Output.all()` with no arguments has + * nothing to resolve. + */ +const rpcAcceptedKeysValue: ProviderParam['value'] = (refs) => + refs.length > 0 ? Output.all(...asKeyOutputs(refs)) : []; + +/** + * ADR-0031's registered provisioner for STREAMS_API_KEY — the same `ServiceKey` + * mint, keyed PER PROVIDER instead of per edge: the resource id is the + * provider's address, so every consumer edge of one streams module resolves to + * the same resource and therefore the same stable value. That is what + * `@prisma/streams-server` requires (it authenticates a single `API_KEY`), and + * cardinality is exactly what ADR-0031 leaves to the provisioner. Making it + * per-edge later is this id's shape plus an accepted-set provider param — no + * new resource, no core change. + */ +const streamsApiKeyProvisioner: ProvisionerDescriptor = { + provision: (edge) => + Effect.gen(function* () { + const key = yield* Prisma.ServiceKey(`streamskey-${edge.providerAddress}`, {}); + return key.value; + }), +}; + +/** + * Streams' deploy-side `value(refs)`: ONE value, not a set — + * `@prisma/streams-server` authenticates a single `API_KEY`, which is why the + * provisioner above mints per provider. That pairing is the invariant this + * param depends on, so it asserts rather than trusts it: a future per-edge + * flip without a paired accepted-set param here would otherwise ship + * whichever key came first and leave every other consumer 401ing, silently. + * The refs are lazy Outputs (not comparable at serialize time); inside + * `Output.map` they are resolved strings, the same seam RPC's set aggregates + * on. + * + * Zero consumers emits NOTHING — the streams counterpart to RPC's "[]". + * `@prisma/streams-server` has no deny-all mode: it either authenticates a key + * or runs --no-auth, so there is no value here that means "refuse everyone". + * Writing no key is what fails closed — the entrypoint refuses to boot with + * a named error rather than serve unauthenticated. + */ +const streamsApiKeyValue: ProviderParam['value'] = (refs) => { + if (refs.length === 0) return undefined; + return Output.map(Output.all(...asKeyOutputs(refs)), (vals) => { + const distinct = [...new Set(vals)]; + if (distinct.length > 1) { + throw new Error( + `a streams provider was provisioned ${distinct.length} distinct keys across its ` + + `${refs.length} inbound bindings, but it can only be given one ` + + '(@prisma/streams-server authenticates a single API_KEY). Its provisioner must mint ' + + 'per provider, not per edge — or this param must store an accepted-key set, once ' + + 'the server accepts one.', + ); + } + return distinct[0] ?? ''; + }); +}; + export { prismaState }; export interface PrismaCloudOptions { @@ -63,6 +152,72 @@ function asProvidersLayer(layer: Layer.Layer): Layer.Layer; } +/** + * This extension's brands, each with the two halves ADR-0031 splits: the + * PROVISIONER core resolves a mint through, and the reserved PROVIDER PARAM + * that stores the minted values on the provider. So this file stays the only + * place a brand is named — `descriptors/compute.ts` just looks a provider + * param up by brand. + * + * `__tests__/provider-params.test.ts` asserts this map's brands are exactly + * `PROVIDER_PARAMS`'s brands: a brand minted here with no provider param + * below would leave the value it mints written to consumers while no + * provider ever stores an accepted-keys row for it — `serve()` (or the + * equivalent runtime reader) then sees an absent var and passes every caller + * through. + */ +const PROVISIONERS: ReadonlyMap = new Map([ + [RPC_PEER_KEY, serviceKeyProvisioner], + [STREAMS_API_KEY, streamsApiKeyProvisioner], +]); + +/** + * Every brand's deploy-side `value(refs)` — the only per-brand thing this + * file still holds directly. `PROVIDER_PARAMS` below is built by mapping + * `RESERVED_PROVIDER_PARAMS` (`provider-params.ts`, the boot-side list) onto + * this map, so a param can exist on the deploy side only if it already + * exists on the boot side: `RESERVED_PROVIDER_PARAMS` is the single source of + * which reserved provider params exist at all, closing the drift the old + * name-comparison test only detected after the fact. + */ +const PROVIDER_PARAM_VALUES: ReadonlyMap = new Map([ + [RPC_PEER_KEY, rpcAcceptedKeysValue], + [STREAMS_API_KEY, streamsApiKeyValue], +]); + +/** + * Builds the deploy-side registry from the boot-side list, keyed by brand — + * throws if a boot-side entry has no registered deploy-side `value(refs)`, so + * `RESERVED_PROVIDER_PARAMS` stays the single source of which reserved + * provider params exist: deploy can no longer write a row boot never + * stashes. Exported (rather than inlined into `PROVIDER_PARAMS` below) so + * `__tests__/provider-params.test.ts` can drive it directly with a + * deliberately incomplete value map and watch it throw. + */ +export function buildProviderParams( + entries: readonly ProviderParamEntry[], + values: ReadonlyMap, +): ReadonlyMap { + return new Map( + entries.map((entry): [symbol, ProviderParam] => { + const value = values.get(entry.brand); + if (value === undefined) { + throw new Error( + `prisma-cloud: reserved provider param "${entry.name}" (provider-params.ts) has no ` + + "registered deploy-side value() in control.ts's PROVIDER_PARAM_VALUES — every param " + + 'in RESERVED_PROVIDER_PARAMS must have one.', + ); + } + return [entry.brand, { ...entry, value }]; + }), + ); +} + +export const PROVIDER_PARAMS: ReadonlyMap = buildProviderParams( + RESERVED_PROVIDER_PARAMS, + PROVIDER_PARAM_VALUES, +); + /** * Resolves the factory's env-or-option inputs, failing fast with the exact * variable name. `projectId`/`branchId` aren't required here — `prismaCloud()` @@ -77,11 +232,19 @@ function resolveOptions(opts: PrismaCloudOptions): ResolvedCloudOptions { const projectId = process.env['PRISMA_PROJECT_ID'] || undefined; const branchId = process.env['PRISMA_BRANCH_ID'] || undefined; - if (opts.region !== undefined) return { workspaceId, region: opts.region, projectId, branchId }; + if (opts.region !== undefined) { + return { + workspaceId, + region: opts.region, + projectId, + branchId, + providerParams: PROVIDER_PARAMS, + }; + } const region = process.env['PRISMA_REGION']; if (region === undefined || region.length === 0) { - return { workspaceId, projectId, branchId }; + return { workspaceId, projectId, branchId, providerParams: PROVIDER_PARAMS }; } if (!isComputeRegion(region)) { throw new Error( @@ -89,7 +252,7 @@ function resolveOptions(opts: PrismaCloudOptions): ResolvedCloudOptions { `(expected one of: ${Prisma.COMPUTE_REGIONS.join(', ')}).`, ); } - return { workspaceId, region, projectId, branchId }; + return { workspaceId, region, projectId, branchId, providerParams: PROVIDER_PARAMS }; } /** The Prisma Cloud extension descriptor — `prisma-composer.config.ts` lists it under `extensions`. */ @@ -154,7 +317,7 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // ADR-0031: this extension's param provisioners, keyed by need brand. // Core resolves a provisioned param's `need.brand` against the CONSUMER // node's extension — the same registry this one is. - provisions: new Map([[RPC_PEER_KEY, serviceKeyProvisioner]]), + provisions: PROVISIONERS, nodes: { postgres: postgresDescriptor(o), diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts index 3be4242e..efc3f065 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts @@ -2,11 +2,11 @@ import { isParamSource, type ServiceNode } from '@internal/core'; import type { ServiceLowering } from '@internal/core/deploy'; -import { blindCast } from '@internal/foundation/casts'; import * as Prisma from '@internal/lowering'; import * as Output from 'alchemy/Output'; import * as Effect from 'effect/Effect'; import { paramBindingFor, paramName } from '../param.ts'; +import { provisionedEdges } from '../provisioned-edges.ts'; import { configKey, encode, @@ -14,7 +14,6 @@ import { paramEntries, secretPointerRows, } from '../serializer.ts'; -import { serviceKeyEdges, serviceKeyEnvName } from '../service-keys.ts'; import { DEFAULT_REGION, projectIdOf, type ResolvedCloudOptions, validateName } from './shared.ts'; /** @@ -121,41 +120,50 @@ export function computeDescriptor( // fills a provisioned param like any other, so there is no // consumer-side special case left to write here. - // Provider side: a service that serves anything gets an accepted-keys - // var, even with zero wired consumers — otherwise an unprovisioned - // var reads as "no enforcement" (serve.ts's pass-through state) and a - // zero-consumer RPC provider would accept every caller. A pure - // consumer (no `expose`) never serves, so it gets no such var. + // Provider side (ADR-0031). Driven by the PROVIDER, not by its edges: + // every registered reserved provider param is asked, even when this + // service has no inbound edge for that brand, because "no edges" and + // "no var" are not the same thing — an absent var reads as "never + // provisioned" (local dev, tests), so a deployed provider with zero + // wired consumers must still be able to emit a deny-everything value. + // Whether an empty set means deny-all or emit-nothing is that param's + // own call, so it decides and may return undefined to write no row at + // all. Compute never names a brand — it looks one up. + // + // The check is main's and stays: a service that exposes nothing can + // never be any binding's provider, so it gets no provider param rows. if (svc.expose !== undefined && Object.keys(svc.expose).length > 0) { - const inbound = serviceKeyEdges(graph).filter((e) => e.providerAddress === address); - // `ctx.provisioned` is typed `unknown` — core forwards a provisioner's - // ref without inspecting it. The filter only drops absent edges; the - // shape of what survives is asserted, not checked. - const keyOuts = inbound - .map((e) => ctx.provisioned.get(e.edgeId)) - .filter((value) => value !== undefined) - .map((value) => - blindCast< - Output.Output, - "these refs are keyed by an edge serviceKeyEdges matched on RPC_PEER_KEY, and control.ts's serviceKeyProvisioner is the sole registrant of that brand — it returns a ServiceKey resource's `value`, an Output" - >(value), + const refsByBrand = new Map(); + for (const edge of provisionedEdges(graph)) { + if (edge.providerAddress !== address) continue; + const ref = ctx.provisioned.get(edge.edgeId); + if (ref === undefined) continue; + const refs = refsByBrand.get(edge.brand) ?? []; + refs.push(ref); + refsByBrand.set(edge.brand, refs); + } + for (const [brand, entry] of o.providerParams) { + const raw = entry.value(refsByBrand.get(brand) ?? []); + if (raw === undefined) continue; + const key = configKey(address, { owner: 'service', name: entry.name }); + // The value may still be an unresolved deploy-time Output (a + // minted key isn't known until Alchemy applies it) or already a + // plain value (e.g. a zero-refs deny-all literal) — either way it + // is JSON-encoded through the same `encode` a declared param's + // own literal takes, never a brand-invented format. + const value = Output.isOutput(raw) + ? Output.map(raw, (v) => encode('service', v)) + : encode('service', raw); + records.push( + yield* Prisma.EnvironmentVariable(`${key}-var`, { + projectId, + key, + value, + class: cls, + ...branch, + }), ); - // Zero consumers: no refs to resolve, so write the literal "[]" - // directly rather than calling Output.all() with no arguments. - const acceptedJson = - keyOuts.length > 0 - ? Output.map(Output.all(...keyOuts), (vals) => JSON.stringify(vals)) - : JSON.stringify([]); - const key = serviceKeyEnvName(address); - records.push( - yield* Prisma.EnvironmentVariable(`${key}-var`, { - projectId, - key, - value: acceptedJson, - class: cls, - ...branch, - }), - ); + } } // Carries the resolved port to deploy(); falls back to 3000 if unset. diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts index 64a68e09..da9183a2 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts @@ -1,6 +1,34 @@ /** Helpers shared by the per-node-kind descriptors under `src/descriptors/` and the extension factory in `control.ts`. */ import type * as Prisma from '@internal/lowering'; +import type * as Output from 'alchemy/Output'; +import type { ProviderParamEntry } from '../serializer.ts'; + +/** + * The provider-side reserved param for one brand's minted values (ADR-0031: + * "the provisioner owns mint, size, **aggregation**, stability, and + * rotation", and ADR-0019: the physical encoding is the target's). `value` + * is deploy-side: given every inbound edge's minted ref for one provider + * (possibly empty), it returns the typed value to store, or `undefined` to + * write no row. The returned value is encoded through the serializer's + * normal service-own literal path (JSON) — the same path any declared param + * takes — never a brand-invented wire format. + * + * This is the seam that keeps `descriptors/compute.ts` brand-blind: a + * `ProviderParam` is registered beside its brand's provisioner in + * `control.ts`; the descriptor asks every registered entry about every + * exposing service and writes whatever comes back. + */ +export interface ProviderParam extends ProviderParamEntry { + /** + * Every inbound edge's minted ref for this provider — POSSIBLY EMPTY. A + * provider with no wired consumers is still asked, because "no edges" and + * "no var" mean different things at boot: an absent var reads as "never + * provisioned" (local dev, tests). What an empty set means is this param's + * own call — deny everything, or emit nothing and let its reader fail closed. + */ + readonly value: (refs: readonly unknown[]) => Output.Output | unknown | undefined; +} /** * The factory's resolved options each node descriptor closes over. `projectId` @@ -12,6 +40,14 @@ export interface ResolvedCloudOptions { readonly region?: Prisma.ComputeRegion; readonly projectId: string | undefined; readonly branchId: string | undefined; + /** + * This extension's reserved provider params, keyed by need brand — the + * mirror of the `provisions` registry core resolves mints through. Passed + * as data so the descriptors never import a brand's module (and so + * control.ts, which owns both registries, stays the only place a brand is + * named). + */ + readonly providerParams: ReadonlyMap; } /** Where a resource lands when the deploy names no region. */ diff --git a/packages/1-prisma-cloud/1-extensions/target/src/index.ts b/packages/1-prisma-cloud/1-extensions/target/src/index.ts index 84f0bd59..b393929a 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/index.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/index.ts @@ -11,8 +11,11 @@ export { http } from './http.ts'; export { envParam, paramName } from './param.ts'; export type { PostgresConfig } from './postgres.ts'; export { postgres, postgresContract } from './postgres.ts'; +export type { ProvisionedEdge } from './provisioned-edges.ts'; +export { provisionedEdges } from './provisioned-edges.ts'; export type { CredentialsConfig, CredentialsContract } from './s3-credentials.ts'; export { credentialsContract, s3Credentials } from './s3-credentials.ts'; export { s3StoreService } from './s3-store.ts'; export { envSecret, secretName } from './secret.ts'; export { configKey } from './serializer.ts'; +export { STREAMS_API_KEY, STREAMS_API_KEY_ENV, streamsApiKeyNeed } from './streams-keys.ts'; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/provider-params.ts b/packages/1-prisma-cloud/1-extensions/target/src/provider-params.ts new file mode 100644 index 00000000..9c3fd514 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/provider-params.ts @@ -0,0 +1,29 @@ +/** + * The list of provider-side reserved params the boot path validates and + * stashes (ADR-0031): every brand's `{name, schema, brand}` declaration, + * collected from that brand's own module (`service-keys.ts`, + * `streams-keys.ts`) so `compute.ts` names no brand itself. + * + * This list exists separately from `control.ts`'s deploy-side registry + * (`PROVIDER_PARAMS`) because `control.ts` is deploy-only code — it imports + * `@internal/lowering` and `effect` to mint values — and a booted service + * must never import it. This module is reachable from a user service's + * bundle through `compute.ts`, so it must never import `@internal/lowering`, + * `effect`, `alchemy`, or `control.ts`. + * + * This is the single source of which reserved provider params exist: + * control.ts builds `PROVIDER_PARAMS` by mapping over this list and looking + * up each entry's `value(refs)` by its `brand`, throwing at module load if + * one is missing. Adding a brand means adding its entry here, plus its + * deploy-side `value(refs)` in control.ts — a brand registered for deploy + * but absent here is no longer expressible, because deploy no longer names + * its own param set independently. + */ +import type { ProviderParamEntry } from './serializer.ts'; +import { RPC_ACCEPTED_KEYS_PARAM } from './service-keys.ts'; +import { STREAMS_API_KEY_PARAM } from './streams-keys.ts'; + +export const RESERVED_PROVIDER_PARAMS: readonly ProviderParamEntry[] = [ + RPC_ACCEPTED_KEYS_PARAM, + STREAMS_API_KEY_PARAM, +]; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/provisioned-edges.ts b/packages/1-prisma-cloud/1-extensions/target/src/provisioned-edges.ts new file mode 100644 index 00000000..d6224eac --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/provisioned-edges.ts @@ -0,0 +1,61 @@ +/** + * The ONE scan for ADR-0031 provisioned edges: every dependency edge whose + * consumer-side input declares a param with a `provision` need, carrying that + * need's brand as DATA. No brand is named here — a brand is a value the + * declaring package owns and the target registers under, so recognising one + * is a map lookup, never a branch (the anti-pattern ADR-0031 exists to + * prevent). + * + * Consumed by `descriptors/compute.ts` (which groups a provider's inbound + * edges by brand and hands each group to that brand's registered provider + * param) and by tests. Nothing here knows what any brand means. + * + * This module is reachable from the RUNTIME/authoring side (re-exported + * through index.ts) — it must never import `@internal/lowering` or `effect`. + */ +import type { Graph } from '@internal/core'; + +/** One faceted dependency edge: a consumer input whose param carries a provisioning need. */ +export interface ProvisionedEdge { + /** `${consumerAddress}.${input}` — `ctx.provisioned`'s key. */ + readonly edgeId: string; + readonly consumerAddress: string; + readonly input: string; + readonly providerAddress: string; + /** The need's brand — opaque here; the target's registry gives it meaning. */ + readonly brand: symbol; +} + +/** + * Every provisioned edge in the graph. Core resolves and mints these (one + * value per edge, keyed by `edgeId`); this scan is how the target finds them + * again when it gathers a provider's inbound values. + */ +export function provisionedEdges(graph: Graph): readonly ProvisionedEdge[] { + const edges: ProvisionedEdge[] = []; + + for (const edge of graph.edges) { + if (edge.kind !== 'dependency') continue; + const consumer = graph.nodes.find((n) => n.id === edge.to)?.node; + if (consumer === undefined || consumer.kind !== 'service') continue; + const slot = consumer.inputs[edge.input]; + if (slot === undefined) continue; + + for (const param of Object.values(slot.connection.params)) { + const brand = param.provision?.brand; + if (brand === undefined) continue; + edges.push({ + edgeId: `${edge.to}.${edge.input}`, + consumerAddress: edge.to, + input: edge.input, + providerAddress: edge.from, + brand, + }); + // Core rejects a connection with more than one provisioned param, so the + // first is the edge's only one. + break; + } + } + + return edges; +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts index b417523c..58fef14c 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts @@ -294,6 +294,58 @@ export const stashSecrets = (node: ServiceNode, address: string): void => { } }; +// ——— Reserved provider params (ADR-0031): a provider-side minted value — +// the rpc accepted-key set, the streams API key — is a named, schema-carrying +// declaration owned by the target, exactly like a service's own param, but +// never part of `node.params`: nothing here reaches `config()`. Deploy writes +// its row the same way a service-own literal param does (JSON, through +// `encode`); boot validates and re-stashes it the same way `stash` does for a +// declared param. The difference from a declared param is only where the +// declaration comes from — the target's own registrations +// (`descriptors/shared.ts`'s `ProviderParam`), not the node the app authored. + +/** + * One reserved provider param's declaration: the boot-relevant half (name + + * schema) of a `ProviderParam` — the half the target's runtime side needs, + * without the deploy-only `value(refs)` function control.ts adds on top. + * + * `brand` is the ADR-0031 need brand this param answers for (e.g. + * `RPC_PEER_KEY`, `STREAMS_API_KEY`). control.ts's `PROVIDER_PARAMS` is built + * by mapping over the boot-side list of these entries (`provider-params.ts`'s + * `RESERVED_PROVIDER_PARAMS`) and looking up each brand's `value(refs)` by + * this field — so a param can exist on the deploy side only if it already + * exists here. + */ +export interface ProviderParamEntry { + readonly name: string; + readonly schema: StandardSchemaV1; + readonly brand: symbol; +} + +/** + * Boot: for each reserved provider param, read its address-scoped row through + * the same `coerce` a declared param uses (JSON-decode, schema-validate), and + * re-emit it address-free — `stash`'s counterpart for this separate + * declaration space. A param is declared optional here unconditionally: an + * absent row means "never provisioned" (local dev, tests, a provider with no + * registered value for this deploy), never a boot failure, so nothing is + * stashed and the runtime reader that owns this slot falls back to its own + * pass-through behavior. + */ +export function stashProviderParams(entries: readonly ProviderParamEntry[], address: string): void { + for (const entry of entries) { + const d: ParamEntry = { + owner: 'service', + name: entry.name, + param: { schema: entry.schema, optional: true }, + }; + const key = configKey(address, d); + const value = coerce(process.env[key], d, key); + if (value === undefined) continue; + process.env[configKey('', d)] = encode('service', value); + } +} + /** Synchronous Standard Schema validation — see the matching note in core's `config.ts`. */ function standardValidateSync( schema: S, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/service-keys.ts b/packages/1-prisma-cloud/1-extensions/target/src/service-keys.ts index 62e2f5e7..3c3ed040 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/service-keys.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/service-keys.ts @@ -1,53 +1,31 @@ /** - * ADR-0030's per-binding service keys: the ONE enumeration of faceted RPC - * edges and the ONE accepted-keys env-var name — shared by control.ts (which - * registers the provisioner that mints them; see its `serviceKeyProvisioner`) - * and descriptors/compute.ts (serializes the provider's accepted set, in - * `serialize`) so minting and wiring can never drift apart. Reacts only to - * the `serviceKey` connection param's `provision.brand` — never to "rpc" by - * name, keeping the target's not-RPC-special-cased promise. + * RPC's reserved provider param (ADR-0030/ADR-0031): the declaration — + * name + schema + brand — for the accepted-keys set a provider stores, shared + * by `control.ts` (which registers the deploy-side `value(refs)` that mints + * and aggregates it — see its `rpcAcceptedKeysValue`) and `compute.ts` (which + * validates and stashes it at boot), so writer and reader cannot drift. + * Finding the edges themselves is `provisioned-edges.ts`'s generic, + * brand-blind scan — RPC is not special-cased anywhere in this target. * - * This module is also reachable from the RUNTIME/authoring side (compute.ts, - * re-exported through index.ts) — it must never import `@internal/lowering` - * or `effect`, or those tokens leak into a user service's bundle (the - * provisioner itself lives in control.ts, the control-plane-only entry). + * This module is reachable from the RUNTIME/authoring side — it must never + * import `@internal/lowering` or `effect`, or those tokens leak into a user + * service's bundle (the deploy-side `value(refs)` lives in control.ts, the + * control-plane-only entry). */ -import type { Graph } from '@internal/core'; import { RPC_PEER_KEY } from '@internal/rpc'; -import { configKey } from './serializer.ts'; +import { type } from 'arktype'; +import type { ProviderParamEntry } from './serializer.ts'; -/** One faceted dependency edge: a consumer's input whose `serviceKey` param carries RPC's provisioning need. */ -export interface ServiceKeyEdge { - /** `${consumerAddress}.${input}` — the mint id and `ctx.provisioned`'s key. */ - readonly edgeId: string; - readonly consumerAddress: string; - readonly input: string; - readonly providerAddress: string; -} - -/** Every faceted RPC edge in the graph — scans each dependency edge's consumer-side input for the need. */ -export function serviceKeyEdges(graph: Graph): readonly ServiceKeyEdge[] { - const edges: ServiceKeyEdge[] = []; - - for (const edge of graph.edges) { - if (edge.kind !== 'dependency') continue; - const consumer = graph.nodes.find((n) => n.id === edge.to)?.node; - if (consumer === undefined || consumer.kind !== 'service') continue; - const slot = consumer.inputs[edge.input]; - if (slot === undefined) continue; - if (slot.connection.params['serviceKey']?.provision?.brand !== RPC_PEER_KEY) continue; - - edges.push({ - edgeId: `${edge.to}.${edge.input}`, - consumerAddress: edge.to, - input: edge.input, - providerAddress: edge.from, - }); - } - - return edges; -} - -/** The reserved accepted-keys env var: COMPOSER__RPC_ACCEPTED_KEYS ("" ↦ @internal/rpc's RPC_ACCEPTED_KEYS_ENV). */ -export const serviceKeyEnvName = (address: string): string => - configKey(address, { owner: 'service', name: 'RPC_ACCEPTED_KEYS' }); +/** + * The reserved provider param for RPC's accepted-keys set: the var name is + * `RPC_ACCEPTED_KEYS`, derived through `configKey` at both ends + * (`configKey(address, …)` at deploy, `configKey('', …)` at boot — the + * address-free form is `@internal/rpc`'s `RPC_ACCEPTED_KEYS_ENV`). `brand` is + * `RPC_PEER_KEY`, the same brand `perBindingToken()`'s need carries — control.ts + * looks its `value(refs)` up by this field. + */ +export const RPC_ACCEPTED_KEYS_PARAM: ProviderParamEntry = { + name: 'RPC_ACCEPTED_KEYS', + schema: type('string[]'), + brand: RPC_PEER_KEY, +}; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/streams-keys.ts b/packages/1-prisma-cloud/1-extensions/target/src/streams-keys.ts new file mode 100644 index 00000000..b91973f5 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/streams-keys.ts @@ -0,0 +1,58 @@ +/** + * The streams module's bearer key as an ADR-0031 provisioning need: the ONE + * brand and the ONE reserved provider param — shared by control.ts (which + * registers the deploy-side `value(refs)` that mints and stores it — see its + * `streamsApiKeyProvisioner`/`streamsApiKeyValue`) and compute.ts (which + * validates and stashes it at boot), so minting and wiring can never drift + * apart. Finding the edges themselves is `provisioned-edges.ts`'s generic, + * brand-blind scan. Mirrors `service-keys.ts` exactly. + * + * **Why the brand lives here, not in the declaring package.** ADR-0031's + * discipline is that the declarer owns the brand and the target imports it — + * which is what `@internal/rpc` does, sitting BELOW the target. `@internal/streams` + * sits ABOVE it (prisma-cloud's layer order is lowering → extensions → + * modules), so a target import of the module would invert the layering. The + * brand therefore lives in the target and the module imports it downward; the + * writer/reader-share-one-key discipline is unchanged. + * + * This module is also reachable from the RUNTIME/authoring side (compute.ts, + * re-exported through index.ts) — it must never import `@internal/lowering` + * or `effect`, or those tokens leak into a user service's bundle (the + * deploy-side `value(refs)` lives in control.ts, the control-plane-only + * entry). + */ +import type { ProvisionNeed } from '@internal/core'; +import { provisionNeed } from '@internal/core'; +import { type } from 'arktype'; +import type { ProviderParamEntry } from './serializer.ts'; +import { configKey } from './serializer.ts'; + +/** ADR-0031's need brand for the streams module's bearer key — control.ts registers the provisioner under this. */ +export const STREAMS_API_KEY: unique symbol = Symbol.for('prisma:streams/api-key'); + +/** + * The provisioning need `durableStreams()`'s `apiKey` param declares: an + * unguessable value the target mints ONCE PER PROVIDER (not per edge) — + * `@prisma/streams-server` authenticates a single `API_KEY`, so every + * consumer of one streams module must present the same value. Per-provider + * cardinality is provisioner policy (ADR-0031), invisible to core. + */ +export const streamsApiKeyNeed = (): ProvisionNeed => provisionNeed(STREAMS_API_KEY); + +/** + * The reserved provider param for the streams bearer key: the var name is + * `STREAMS_API_KEY`. `brand` is `STREAMS_API_KEY` itself (the same symbol + * `streamsApiKeyNeed()`'s need carries) — control.ts looks its `value(refs)` + * up by this field. + */ +export const STREAMS_API_KEY_PARAM: ProviderParamEntry = { + name: 'STREAMS_API_KEY', + schema: type('string'), + brand: STREAMS_API_KEY, +}; + +/** The address-free name compute.ts re-stashes to and the streams entrypoint reads. */ +export const STREAMS_API_KEY_ENV = configKey('', { + owner: 'service', + name: STREAMS_API_KEY_PARAM.name, +}); diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/README.md b/packages/1-prisma-cloud/2-shared-modules/streams/README.md index 9c52fd2a..a93ff2d4 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/README.md +++ b/packages/1-prisma-cloud/2-shared-modules/streams/README.md @@ -3,24 +3,60 @@ Durable append-only event streams as a Prisma Composer module. It wraps the production `@prisma/streams-server` runtime (npm, unmodified) as a Compute service behind a typed boundary: the module's `store` dependency takes a -`storage()` module's port as its durable tier, its `apiKey` secret slot holds -the bearer key, and it exposes a single `streams` port. Consumers get a -`{ url }` binding and speak the **Durable Streams HTTP protocol** directly. +`storage()` module's port as its durable tier, and it exposes a single +`streams` port. A consumer names the streams it uses with +`streamsContract(defs)`, and its `durableStreams(contract)` dependency +hydrates to one ready **`StreamHandle`** per declared name — like RPC's +generated client, no app hand-rolls the protocol, and no app carries a +stream-lifecycle constant: the handle owns creating the stream on first use +and healing a 404 by re-creating and retrying once. The wire binding +underneath is `{ url, apiKey }`, the key minted by the deploy. Ships as the `@prisma/composer-prisma-cloud/streams` subpath (like `/storage`). ## Contract scope -The binding is the endpoint URL only: +A contract names its streams: ```ts -interface StreamsConfig { - readonly url: string; +const jobLog = streamsContract({ + jobs: streamDef(), // untyped in this slice — events type as `unknown` + audit: streamDef(), +}); +``` + +Hydration hands a consumer one handle per declared name: + +```ts +interface StreamHandle { + append(event): Promise; // one JSON event; NEVER retried beyond the 404 heal below + read(opts?): Promise<{ events: T[]; nextOffset: string }>; + tail(opts?): Promise<{ events: T[]; nextOffset: string; timedOut: boolean }>; } ``` -Consumers build their own HTTP client (ADR-0015) against the Durable Streams -surface: +No `create` — a handle creates its stream on first use, memoized, and heals a +404 (the stream vanished from the durable tier) by dropping that memo, +re-creating, and retrying the failed operation once. That heal is safe even +for an append: a 404 is generated INSTEAD OF a write at every layer, so it +proves nothing was applied. + +For dynamic stream names (e.g. per-tenant streams), call `durableStreams()` +with no contract — the `postgres()` parity, same lifecycle ownership, the +name is data rather than a wiring-time declaration: + +```ts +interface StreamsClient { + stream(name: string): StreamHandle; +} +``` + +The wire binding underneath is the typed connection config (ADR-0015) — +`{ url: string, apiKey: string }` — and the client wraps +[`@durable-streams/client`](https://www.npmjs.com/package/@durable-streams/client) +(ElectricSQL's canonical protocol client, pinned to the version +`@prisma/streams-server` 0.1.11's own repo pairs with). The full protocol +surface it drives: | Op | Notes | | --- | --- | @@ -30,85 +66,91 @@ surface: | `GET …&live=long-poll&timeout=…` | held read — returns when fresh events arrive or timeout | | `GET …&live=sse` | SSE tail (see the deployed live path note below) | -Offsets are **opaque cursors**, not numeric indices: take them from the -`stream-next-offset` response header and pass them back verbatim. - -**Auth is not in the binding.** The bearer key is an ADR-0029 secret — its -value never travels through framework config, so `{ url }` cannot carry it. A -consumer that calls the service declares its **own** `secret()` slot, and the -root binds both slots to the same platform variable. Every endpoint, including -`/health`, requires `Authorization: Bearer `. +Offsets are **opaque cursors**, not numeric indices: take them from a read's +`nextOffset` and pass them back verbatim. + +**Auth rides the binding.** The bearer key is not an ADR-0029 secret (there +is no external value to bind) and not a producer output. The `apiKey` +connection param declares an [ADR-0031](../../../../docs/design/90-decisions/ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md) +**provisioning need**: the deploy target mints the value, keeps it stable in +deploy state, and fills the param like any other input. Declaring a +`durableStreams()` dependency is the whole of the wiring — there is no secret +slot and nothing to bind at the root. Every endpoint, including `/health`, +requires `Authorization: Bearer `. + +Two consequences worth knowing: + +- **One key per streams module.** The provisioner mints per provider, not per + edge, because `@prisma/streams-server` authenticates a single `API_KEY` — + every consumer of one `streams()` instance holds the same value. Cardinality + is provisioner policy (ADR-0031), so per-edge keys are later a change of + that policy plus an accepted-set provider param once the upstream server + takes a key set: no resource to add, no core change, nothing here to delete. +- **No consumers, no key.** The need lives on the consumer's edge, so a + `streams()` module nothing depends on never gets a key minted, and its + server refuses to boot rather than serve unauthenticated. Wire a consumer, + or drop the module. ## Wiring -The root provisions `storage()` as the durable tier, wires its `store` port -into `streams()`, and binds the bearer key by name: +The root provisions `storage()` as the durable tier and wires its `store` +port into `streams()` — the key needs no wiring at all: ```ts // module.ts — the deploy root import { module } from '@prisma/composer'; -import { envSecret } from '@prisma/composer-prisma-cloud'; import { storage } from '@prisma/composer-prisma-cloud/storage'; import { streams } from '@prisma/composer-prisma-cloud/streams'; import worker from './src/worker/service.ts'; export default module('my-app', ({ provision }) => { const store = provision(storage()); - const events = provision(streams(), { - deps: { store: store.store }, - secrets: { apiKey: envSecret('STREAMS_API_KEY') }, - }); - provision(worker, { - deps: { streams: events.streams }, - // The consumer's own secret slot, bound to the SAME platform variable — - // this is how the key reaches a caller without entering the binding. - secrets: { streamsKey: envSecret('STREAMS_API_KEY') }, - }); + const events = provision(streams(), { deps: { store: store.store } }); + provision(worker, { deps: { streams: events.streams } }); }); ``` ```ts -// src/worker/service.ts — the consumer +// src/worker/service.ts — the consumer. Declaring the dependency is what +// causes the key to be minted; nothing names it a second time. import node from '@prisma/composer/node'; -import { secret } from '@prisma/composer'; import { compute } from '@prisma/composer-prisma-cloud'; -import { durableStreams } from '@prisma/composer-prisma-cloud/streams'; +import { durableStreams, streamDef, streamsContract } from '@prisma/composer-prisma-cloud/streams'; + +const jobLog = streamsContract({ jobs: streamDef() }); export default compute({ name: 'worker', - deps: { streams: durableStreams() }, - secrets: { streamsKey: secret() }, + deps: { streams: durableStreams(jobLog) }, build: node({ module: import.meta.url, entry: '../../dist/worker/server.mjs' }), }); ``` ```ts -// src/worker/server.ts — append, then long-poll for what follows +// src/worker/server.ts — append, then wait for what follows import service from './service.ts'; -const { streams } = service.load(); // StreamsConfig: { url } -const { streamsKey } = service.secrets(); -const authed = { authorization: `Bearer ${streamsKey.expose()}` }; +const { streams } = service.load(); // { jobs: StreamHandle }, ready to call -await fetch(`${streams.url}/v1/stream/jobs`, { - method: 'POST', - headers: { ...authed, 'content-type': 'application/json' }, - body: JSON.stringify([{ kind: 'created' }]), -}); +await streams.jobs.append({ kind: 'created' }); +const { events, nextOffset } = await streams.jobs.read(); +const next = await streams.jobs.tail(); // resolves on the next event (or timedOut) +``` -const head = await fetch(`${streams.url}/v1/stream/jobs?offset=-1&format=json`, { - headers: authed, -}); -const offset = head.headers.get('stream-next-offset'); -const next = await fetch( - `${streams.url}/v1/stream/jobs?offset=${offset}&format=json&live=long-poll&timeout=20s`, - { headers: authed }, -); // resolves when a fresh append lands (or 204 on timeout) +For local development and tests, build the same client against the stand-in +(no deployed binding, no auth): + +```ts +import { StreamsClient } from '@prisma/composer-prisma-cloud/streams'; + +const client = new StreamsClient({ url: standIn.url, apiKey: 'unused' }); +const jobs = client.stream('jobs'); // a StreamHandle, same surface as the hydrated binding ``` [`examples/streams`](../../../../examples/streams) is the worked example — the -module deployed to Prisma Cloud with `storage()` as its tier, plus a local -integration test and a deployed consumer smoke script. +module deployed to Prisma Cloud with `storage()` as its tier and a `jobs` +service consuming the binding, plus local integration tests and a deployed +consumer smoke script. ## Local development @@ -136,6 +178,6 @@ response completes. An open `?live=sse` tail therefore never delivers through a deployment's public URL — the client sees zero bytes and the edge returns a 504 after ~60s — while the same request works locally and against the stand-in. `?live=long-poll` completes per response and delivers live events -end to end through the ingress; use it for deployed live tailing. The deployed -conformance harness keeps the SSE tests, so they flip green when the platform -supports streaming responses. +end to end through the ingress, so `StreamHandle.tail` long-polls. The +deployed conformance harness keeps the SSE tests, so they flip green when the +platform supports streaming responses. diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/SCOPE.md b/packages/1-prisma-cloud/2-shared-modules/streams/SCOPE.md index 376d5115..8eac50d9 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/SCOPE.md +++ b/packages/1-prisma-cloud/2-shared-modules/streams/SCOPE.md @@ -26,29 +26,47 @@ root module ```ts interface StreamsConfig { readonly url: string; + readonly apiKey: string; } ``` -`streamsContract` (`kind: 'streams'`) with `durableStreams()` as the consumer -dependency factory. The binding is the endpoint URL only; consumers build -their own HTTP client against the Durable Streams protocol (ADR-0015): +`streamsContract(defs)` (`kind: 'streams'`) names the streams a consumer +transports — `streamsContract({ jobs: streamDef() })` — with +`durableStreams(contract)` as the consumer dependency factory; bare +`durableStreams()` (no contract) is retained for dynamic stream names. The +wire binding is the endpoint URL plus the minted bearer key (ADR-0015); +hydration hands a `durableStreams(contract)` consumer one `StreamHandle` per +declared name (append/read/tail — wrapping `@durable-streams/client`), and +hands a bare `durableStreams()` consumer a `StreamsClient` whose surface is +`stream(name)`, returning the same kind of handle. The handle owns the +stream's lifecycle: it creates the stream on first use (memoized) and heals +a 404 by re-creating and retrying the failed operation once — no app code +names a stream lifecycle event. Protocol surface: `PUT/POST/GET/HEAD/DELETE /v1/stream/{name}`, reads from an `offset`, live tail via `?live=sse` and `?live=long-poll`. No websockets — the server has none and the module adds none. -**Auth is not in the binding.** The bearer key is a `secret()` slot on the -module boundary; a consumer that calls the service declares its own secret -slot and the root binds both to the same platform variable. Secret values -never travel through framework config (ADR-0029), so the binding cannot -carry the key. +**Auth rides the binding.** The bearer key is neither an ADR-0029 secret nor +a producer output: the `apiKey` connection param declares an ADR-0031 +**provisioning need** (brand + provisioner live in `@internal/prisma-cloud`'s +`streams-keys.ts` — the target sits below this module, so the brand is +imported downward). The target mints one value PER PROVIDER (the upstream +server authenticates a single `API_KEY`), keeps it stable in deploy state, +fills every consumer's param with it, and stores the same value on the streams +service itself as a reserved provider param. Per-edge keys are later a +provisioner-cardinality change plus an accepted-set provider param once +upstream accepts a key set — no resource to add, no core change. A module +with no consumers gets no key and refuses to boot. ## Config surface - **Typed params: none** (v1). The service keeps only the reserved `port`. -- **Secrets: `apiKey`** — forwarded to the service, exported to the runtime - as `API_KEY` with `--auth-strategy api-key`. All endpoints including - `/health` require `Authorization: Bearer ` (verified acceptable on - Compute by open-chat's production deploy). +- **Secrets: none.** The bearer key is provisioned by the target for the + `durableStreams()` binding and written to this service under a reserved + config key; the entrypoint reads it there and exports it to the runtime as + `API_KEY` with `--auth-strategy api-key`. All endpoints including `/health` + require `Authorization: Bearer ` (verified acceptable on Compute by + open-chat's production deploy). - **Deps: `store: s3()`** on the module boundary — the storage module's port. The entrypoint maps the `S3Config` binding onto the server's `DURABLE_STREAMS_R2_{BUCKET,ENDPOINT,ACCESS_KEY_ID,SECRET_ACCESS_KEY}` env diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/conformance/deployed.vitest.ts b/packages/1-prisma-cloud/2-shared-modules/streams/conformance/deployed.vitest.ts index aa13d1d4..1060d35d 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/conformance/deployed.vitest.ts +++ b/packages/1-prisma-cloud/2-shared-modules/streams/conformance/deployed.vitest.ts @@ -5,6 +5,16 @@ * * CONFORMANCE_TEST_URL=https://… STREAMS_API_KEY=… \ * pnpm vitest run -c vitest.conformance.deployed.config.ts + * + * STREAMS_API_KEY carries the deploy-minted bearer key — the harness-only + * route is reading it from deploy state, where it is stable. Pass the bare + * key: if you copied it out of the Compute console's + * `COMPOSER__STREAMS_API_KEY` var, strip the surrounding quotes first — + * the stored row is JSON-encoded (ADR-0031). + * + * The suite is pinned to exact 0.2.3: later versions (0.3.x) test features + * @prisma/streams-server 0.1.11 does not ship, so a floating range fails + * conformance for reasons unrelated to this module. */ import { runConformanceTests } from '@durable-streams/server-conformance-tests'; diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/conformance/local.vitest.ts b/packages/1-prisma-cloud/2-shared-modules/streams/conformance/local.vitest.ts index 5b89e22d..6408dfcd 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/conformance/local.vitest.ts +++ b/packages/1-prisma-cloud/2-shared-modules/streams/conformance/local.vitest.ts @@ -2,6 +2,10 @@ * The Durable Streams conformance suite against the local stand-in — proves * the module's local-dev path speaks the same protocol as the deployed * server. Mirrors the server repo's conformance.local.vitest.ts. + * + * The suite is pinned to exact 0.2.3: later versions (0.3.x) test features + * @prisma/streams-server 0.1.11 does not ship, so a floating range fails + * conformance for reasons unrelated to this module. */ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/package.json b/packages/1-prisma-cloud/2-shared-modules/streams/package.json index e9848d2d..d1d5452f 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/package.json +++ b/packages/1-prisma-cloud/2-shared-modules/streams/package.json @@ -32,6 +32,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@durable-streams/client": "0.2.1", "@internal/core": "workspace:0.1.0", "@internal/node": "workspace:0.1.0", "@internal/prisma-cloud": "workspace:0.1.0", @@ -40,7 +41,7 @@ "@prisma/streams-server": "0.1.11" }, "devDependencies": { - "@durable-streams/server-conformance-tests": "^0.3.5", + "@durable-streams/server-conformance-tests": "0.2.3", "@internal/tsdown-config": "workspace:0.1.0", "@types/bun": "^1.3.13", "tsdown": "^0.22.4", diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/client.test.ts b/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/client.test.ts new file mode 100644 index 00000000..8a0a92a6 --- /dev/null +++ b/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/client.test.ts @@ -0,0 +1,205 @@ +/** + * The streams client against the local stand-in: every operation a + * consumer's hydrated handle exposes, driven over the real protocol — + * ensure-create memoized across repeated operations, JSON append framing, + * read from the beginning and from an opaque mid-stream cursor, a long-poll + * tail that delivers an event appended after it opened (and times out + * cleanly when nothing arrives), and the 404 heal that re-creates a stream + * deleted out from under a handle. The stand-in has no auth, so the bearer + * header the client always sends is simply ignored. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { StreamsClient } from '../client.ts'; +import { type LocalStreamsServer, startLocalStreamsServer } from '../testing.ts'; + +let server: LocalStreamsServer; +let client: StreamsClient; +let dataRoot: string; +let prevDataRoot: string | undefined; + +beforeAll(async () => { + dataRoot = mkdtempSync(join(tmpdir(), 'streams-client-test-')); + prevDataRoot = process.env['DS_LOCAL_DATA_ROOT']; + process.env['DS_LOCAL_DATA_ROOT'] = dataRoot; + server = await startLocalStreamsServer({ name: 'streams-client-test', port: 0 }); + client = new StreamsClient({ + url: server.exports.http.url, + apiKey: 'local-stand-in-needs-no-auth', + }); +}); + +afterAll(async () => { + await server?.close(); + if (prevDataRoot === undefined) delete process.env['DS_LOCAL_DATA_ROOT']; + else process.env['DS_LOCAL_DATA_ROOT'] = prevDataRoot; + rmSync(dataRoot, { recursive: true, force: true }); +}); + +/** + * A proxy in front of the stand-in that counts requests of one HTTP method, + * optionally intercepting the Nth match — used to pin behavior a mock of + * the wire client itself couldn't prove (what actually reached the wire). + */ +const startCountingProxy = ( + target: string, + opts: { method?: string; intercept?: (n: number) => Response | undefined } = {}, +) => { + const method = opts.method ?? 'POST'; + let count = 0; + const server = Bun.serve({ + port: 0, + fetch: async (req) => { + if (req.method === method) { + count++; + const intercepted = opts.intercept?.(count); + if (intercepted !== undefined) return intercepted; + // A little latency so concurrent requests overlap in flight — the + // window Electric's batching coalesces in. + await new Promise((resolve) => setTimeout(resolve, 40)); + } + const url = new URL(req.url); + return fetch(`${target}${url.pathname}${url.search}`, { + method: req.method, + headers: req.headers, + ...(req.method === 'POST' || req.method === 'PUT' ? { body: await req.text() } : {}), + }); + }, + }); + return { + url: `http://127.0.0.1:${server.port}`, + count: () => count, + stop: () => server.stop(true), + }; +}; + +describe("the append contract's sharp edges (a counting proxy in front of the stand-in)", () => { + // The wire client's DEFAULT behavior is the hazard these tests pin: its + // shared backoff retries any non-4xx failure indefinitely — on every + // method, appends included — and its default batching coalesces concurrent + // appends into shared POSTs. A 4xx-based test cannot pin the first + // property (Electric throws 4xx before the retry branch at ANY + // maxRetries), so these drive a 503 and concurrency through a proxy that + // counts what actually reached the wire. + test('a 503 on an append REJECTS after exactly ONE POST — appends enter no retry branch', async () => { + // 503 is IN Electric's HTTP_RETRY_STATUS_CODES: with its default + // maxRetries (Infinity) this append would be silently re-POSTed until + // the proxy stopped failing. NO_RETRY_BACKOFF is what makes it throw + // instead — remove it and this test goes red (the append resolves on + // the proxy's second POST, and two POSTs arrive). A 503 is not the + // handle's 404 heal target either, so no retry comes from that path. + const proxy = startCountingProxy(server.exports.http.url, { + intercept: (n) => (n === 1 ? new Response('cold', { status: 503 }) : undefined), + }); + try { + const flaky = new StreamsClient({ url: proxy.url, apiKey: 'unused' }); + expect(flaky.stream('retry-pin').append({ n: 1 })).rejects.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 300)); // a retry would land here + expect(proxy.count()).toBe(1); + } finally { + proxy.stop(); + } + }, 15_000); + + test('N concurrent appends are N POSTs — never coalesced into shared requests', async () => { + // With Electric's default batching, appends 2..5 would buffer behind the + // in-flight first and drain as ONE shared POST (2 total): a failure + // would then be ambiguous across several callers' events. batching: + // false is what makes one append one POST — remove it and this goes red. + const proxy = startCountingProxy(server.exports.http.url); + try { + const counted = new StreamsClient({ url: proxy.url, apiKey: 'unused' }); + const handle = counted.stream('batch-pin'); + await Promise.all(Array.from({ length: 5 }, (_, i) => handle.append({ n: i }))); + expect(proxy.count()).toBe(5); + const readBack = await handle.read(); + expect(readBack.events).toHaveLength(5); + } finally { + proxy.stop(); + } + }, 15_000); +}); + +describe('StreamHandle ensure-create (against a counting proxy)', () => { + test('repeated operations on the same handle issue exactly one create', async () => { + // Every operation calls the handle's ensure-create first; the memo is + // what collapses three calls into one PUT — remove it and this goes red + // (three PUTs, one per operation). + const proxy = startCountingProxy(server.exports.http.url, { method: 'PUT' }); + try { + const client = new StreamsClient({ url: proxy.url, apiKey: 'unused' }); + const handle = client.stream('memo-pin'); + await handle.append({ n: 1 }); + await handle.append({ n: 2 }); + await handle.read(); + expect(proxy.count()).toBe(1); + } finally { + proxy.stop(); + } + }, 15_000); +}); + +describe('StreamHandle (against the local stand-in)', () => { + test('using a handle is sufficient to create its stream — no explicit create call', async () => { + // The accepted consequence of ensure-create: a handle nothing ever + // explicitly created still works, reading back an empty log rather than + // 404ing. + const result = await client.stream('never-explicitly-created').read(); + expect(result.events).toEqual([]); + }); + + test('append then read round-trips events, and a mid-stream cursor resumes correctly', async () => { + const log = client.stream('log'); + await log.append({ n: 1 }); + + const first = await log.read(); + expect(first.events).toEqual([{ n: 1 }]); + expect(first.nextOffset).not.toBe(''); + + await log.append({ n: 2 }); + await log.append({ n: 3 }); + + const all = await log.read(); + expect(all.events).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]); + + const rest = await log.read({ offset: first.nextOffset }); + expect(rest.events).toEqual([{ n: 2 }, { n: 3 }]); + }); + + test('tail delivers an event appended after it opened', async () => { + const live = client.stream('live'); + const tail = live.tail({ timeoutMs: 10_000 }); + await new Promise((resolve) => setTimeout(resolve, 300)); + await live.append({ kind: 'ping' }); + + const result = await tail; + expect(result.timedOut).toBe(false); + expect(result.events).toEqual([{ kind: 'ping' }]); + }, 15_000); + + test('tail times out cleanly when nothing arrives', async () => { + const quiet = client.stream('quiet'); + const result = await quiet.tail({ timeoutMs: 1_000 }); + expect(result.timedOut).toBe(true); + expect(result.events).toEqual([]); + }, 10_000); + + test('a stream lost from the durable tier heals: the handle re-creates and the append lands', async () => { + const handle = client.stream('heals'); + await handle.append({ kind: 'before-loss' }); + // Delete the stream out from under the handle's memoized create (the + // stand-in needs no auth). A fresh streams instance restoring an older + // store is the deployed shape of the same loss. + const del = await fetch(`${server.exports.http.url}/v1/stream/heals`, { method: 'DELETE' }); + expect(del.ok).toBe(true); + + // The append 404s (the stream is gone), which the handle heals by + // dropping its memo, re-creating, and retrying this append once — remove + // the heal body and this test goes red (the append rejects). + await handle.append({ kind: 'after-loss' }); + const read = await handle.read<{ kind: string }>(); + expect(read.events.map((e) => e.kind)).toEqual(['after-loss']); + }); +}); diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/contract.test-d.ts b/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/contract.test-d.ts index a7e2c812..8bd60492 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/contract.test-d.ts +++ b/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/contract.test-d.ts @@ -1,16 +1,32 @@ -import type { DependencyEnd } from '@internal/core'; +import type { Contract, DependencyEnd } from '@internal/core'; import { describe, expectTypeOf, test } from 'vitest'; -import type { StreamsConfig, streamsContract } from '../contract.ts'; -import { durableStreams } from '../contract.ts'; +import type { StreamHandle, StreamsClient } from '../client.ts'; +import type { StreamDefs, StreamsConfig } from '../contract.ts'; +import { durableStreams, streamDef, streamsContract } from '../contract.ts'; -describe('durableStreams()', () => { - test('is a DependencyEnd binding StreamsConfig against streamsContract', () => { +describe('durableStreams(contract)', () => { + test('hydrates to one handle per declared stream name', () => { + const jobLog = streamsContract({ jobs: streamDef(), audit: streamDef() }); + expectTypeOf(durableStreams(jobLog)).toEqualTypeOf< + DependencyEnd< + { readonly jobs: StreamHandle; readonly audit: StreamHandle }, + Contract<'streams', StreamDefs> + > + >(); + }); +}); + +describe('durableStreams() (bare)', () => { + test('is a DependencyEnd hydrating to a StreamsClient for dynamic stream names', () => { expectTypeOf(durableStreams()).toEqualTypeOf< - DependencyEnd + DependencyEnd> >(); }); - test('the binding carries only the endpoint url', () => { - expectTypeOf().toEqualTypeOf<{ readonly url: string }>(); + test('the wire binding carries the endpoint url and the minted bearer key', () => { + expectTypeOf().toEqualTypeOf<{ + readonly url: string; + readonly apiKey: string; + }>(); }); }); diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/contract.test.ts b/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/contract.test.ts index 93832127..21dd6711 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/contract.test.ts +++ b/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/contract.test.ts @@ -1,19 +1,32 @@ import { describe, expect, test } from 'bun:test'; -import { streamsContract } from '../contract.ts'; +import { streamDef, streamsContract } from '../contract.ts'; -describe('streamsContract.satisfies', () => { - test('accepts a contract of the same kind', () => { - const other = { kind: 'streams' as const, __cmp: undefined, satisfies: () => true }; - expect(streamsContract.satisfies(other)).toBe(true); +describe('streamsContract(defs).satisfies', () => { + test('accepts a contract of the same kind, regardless of its def map', () => { + const jobs = streamsContract({ jobs: streamDef() }); + const other = { + kind: 'streams' as const, + __cmp: { audit: streamDef() }, + satisfies: () => true, + }; + expect(jobs.satisfies(other)).toBe(true); }); test('rejects a contract of a different kind', () => { + const jobs = streamsContract({ jobs: streamDef() }); // Cast is test-only: the mismatched kind literal is the point of the test. const other = { kind: 's3', __cmp: undefined, satisfies: () => true, - } as unknown as typeof streamsContract; - expect(streamsContract.satisfies(other)).toBe(false); + } as unknown as Parameters[0]; + expect(jobs.satisfies(other)).toBe(false); + }); + + test('carries the declared def map as __cmp', () => { + const jobDef = streamDef(); + const auditDef = streamDef(); + const contract = streamsContract({ jobs: jobDef, audit: auditDef }); + expect(contract.__cmp).toEqual({ jobs: jobDef, audit: auditDef }); }); }); diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/entrypoint.integration.test.ts b/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/entrypoint.integration.test.ts index 3dbf46fb..f17d961d 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/entrypoint.integration.test.ts +++ b/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/entrypoint.integration.test.ts @@ -35,8 +35,12 @@ function childEnv(): NodeJS.ProcessEnv { COMPOSER_STORE_ACCESSKEYID: 'local', COMPOSER_STORE_SECRETACCESSKEY: 'local-secret', COMPOSER_PORT: JSON.stringify(port), - COMPOSER_APIKEY: 'STREAMS_TEST_API_KEY', - STREAMS_TEST_API_KEY: API_KEY, + // The target stores the provisioned key address-scoped and compute's `run` + // validates and re-stashes it address-free (JSON-encoded, the same wire + // format any service-own literal param takes); this child boots the + // entrypoint directly, so it sets the address-free name the entrypoint + // reads, already in that encoding. + COMPOSER_STREAMS_API_KEY: JSON.stringify(API_KEY), DS_ROOT: dsRoot, DS_HOST: '127.0.0.1', // Seal + upload fast so durability is observable within the test. diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/module.test.ts b/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/module.test.ts index f421feb5..ee2d0380 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/module.test.ts +++ b/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/module.test.ts @@ -1,13 +1,13 @@ /** * The `streams()` module Loads into a wired graph: its `store` boundary dep - * forwards into the compute service, its `apiKey` secret slot binds through, - * and a consumer's `durableStreams()` slot resolves to the service's - * `streams` port. Mirrors storage's module.test.ts. + * forwards into the compute service, and a consumer's `durableStreams()` slot + * resolves to the service's `streams` port carrying the provisioning need for + * the bearer key. Mirrors storage's module.test.ts. */ import { describe, expect, test } from 'bun:test'; -import { Load, module, secretSource } from '@internal/core'; +import { Load, module } from '@internal/core'; import node from '@internal/node'; -import { compute } from '@internal/prisma-cloud'; +import { compute, STREAMS_API_KEY } from '@internal/prisma-cloud'; import { storage } from '@internal/storage'; import { durableStreams } from '../contract.ts'; import { streams } from '../streams-module.ts'; @@ -21,7 +21,6 @@ const root = () => const events = provision(streams(), { id: 'streams', deps: { store: store.store }, - secrets: { apiKey: secretSource('STREAMS_API_KEY') }, }); provision(consumer(), { id: 'consumer', deps: { events: events.streams } }); return {}; @@ -45,6 +44,18 @@ describe('streams()', () => { }); }); + test("the consumer's apiKey param carries the streams provisioning need — nothing is wired for it", () => { + const graph = Load(root()); + const consumerNode = graph.nodes.find((n) => n.id === 'consumer')?.node; + if (consumerNode === undefined || consumerNode.kind !== 'service') { + throw new Error('expected the consumer service'); + } + const apiKey = consumerNode.inputs['events']?.connection.params['apiKey']; + expect(apiKey?.provision?.brand).toBe(STREAMS_API_KEY); + // The module owns no credentials resource — the key is the target's to mint. + expect(graph.nodes.map((n) => n.id)).not.toContain('streams.credentials'); + }); + test("a consumer's durableStreams() slot resolves to the module's streams port (the service)", () => { const graph = Load(root()); expect(graph.edges).toContainEqual({ @@ -55,11 +66,9 @@ describe('streams()', () => { }); }); - test('the apiKey secret binds to the service at its full address', () => { + test('no secret slot remains anywhere in the graph', () => { const graph = Load(root()); - expect(graph.secrets).toContainEqual( - expect.objectContaining({ serviceAddress: 'streams.service', slot: 'apiKey' }), - ); + expect(graph.secrets).toEqual([]); }); test('opts.name customizes the module', () => { @@ -68,7 +77,6 @@ describe('streams()', () => { provision(streams({ name: 'events' }), { id: 'events', deps: { store: store.store }, - secrets: { apiKey: secretSource('STREAMS_API_KEY') }, }); return {}; }); diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/src/client.ts b/packages/1-prisma-cloud/2-shared-modules/streams/src/client.ts new file mode 100644 index 00000000..57bd6ee4 --- /dev/null +++ b/packages/1-prisma-cloud/2-shared-modules/streams/src/client.ts @@ -0,0 +1,318 @@ +/** + * The streams client a consumer's `durableStreams()` binding hydrates to — + * the RPC parity: RPC users don't hand-roll request encoding (`rpc()` hydrates + * through `makeClient`), and streams users don't hand-roll the Durable Streams + * protocol. All protocol knowledge lives here: the URL layout, the bearer + * scheme, JSON-array append framing, opaque offsets, and the long-poll dance + * — plus the stream lifecycle (ensure-create, the proven-safe 404 heal) that + * used to live in application code. The wire client is + * `@durable-streams/client` (ElectricSQL's canonical protocol client, + * Apache-2.0); this wrapper narrows it to what the module contract promises + * and adds the platform compensations, each annotated with the ticket it + * stands in for. + * + * Two classes: `StreamsClient` holds the transport (base URL, bearer header, + * the per-stream write handles a batched append needs) and hands out one + * `StreamHandle` per stream name, memoized so its ensure-create state + * survives repeat calls. `StreamHandle` holds one stream's name and + * ensure-create memo, and is what a consumer actually calls `append`/`read`/ + * `tail` on — no call site names a stream twice. + * + * Exported standalone (and via the umbrella) so local dev and tests can wrap + * the stand-in's URL without a deployed binding: + * + * const client = new StreamsClient({ url: standIn.url, apiKey: 'unused' }); + * await client.stream('log').append({ n: 1 }); + */ +import { + BackoffDefaults, + DurableStream, + DurableStreamError, + FetchError, + stream, +} from '@durable-streams/client'; +import type { StreamsConfig } from './contract.ts'; + +/** A catch-up read: the events from the requested offset, and the cursor to resume from. */ +export interface StreamsReadResult { + readonly events: readonly T[]; + /** Opaque resume cursor (the protocol's `stream-next-offset`); feed it back as `offset`. */ + readonly nextOffset: string; +} + +/** One live long-poll delivery, or a timeout with nothing new. */ +export interface StreamsTailResult { + readonly events: readonly T[]; + readonly nextOffset: string; + readonly timedOut: boolean; +} + +const JSON_CONTENT_TYPE = 'application/json'; + +/** + * PRO-219: a scale-to-zero streams service can reset the first connection + * while its instance boots (~3.5–8s observed), so IDEMPOTENT operations ride + * it out with a bounded backoff. The wire client retries any failure except + * a 4xx other than 429 — thrown network errors and 5xx statuses included — + * so a real protocol error (401, 404, 409) surfaces on the first try. The + * bound is ATTEMPTS, not wall-clock: each wait is jittered up to the current + * delay, and a server Retry-After acts as a per-wait floor (capped upstream + * at 1h). Appends never get any of this (see `StreamsClient.append`). Remove + * when CI's "Cold-start canary (PRO-217)" goes clean — it exists to flag + * exactly that. + */ +const IDEMPOTENT_BACKOFF = { + ...BackoffDefaults, + initialDelay: 250, + maxDelay: 5_000, + multiplier: 2, + maxRetries: 5, +}; + +/** The wire client retries network errors by default — appends must not be (no idempotency key). */ +const NO_RETRY_BACKOFF = { ...BackoffDefaults, maxRetries: 0 }; + +const DEFAULT_TAIL_TIMEOUT_MS = 20_000; + +function isAlreadyExists(error: unknown): boolean { + return error instanceof DurableStreamError && error.status === 409; +} + +/** + * Whether a client operation failed because the stream does not exist — the + * one failure that provably applied NOTHING, so re-creating the stream and + * re-running the operation is safe even for an append. Deliberately exactly + * that: ambiguous failures (socket closes, 502/504) never match. Not + * exported — its only consumer is `StreamHandle`'s own heal, so no app code + * needs the wire client's error shape. + */ +function isStreamNotFound(error: unknown): boolean { + return ( + (error instanceof FetchError || error instanceof DurableStreamError) && error.status === 404 + ); +} + +function streamUrl(base: string, name: string): string { + return `${base}/v1/stream/${encodeURIComponent(name)}`; +} + +/** + * The transport a consumer's `durableStreams()` binding hydrates to (bare + * form) — holds the base URL, the bearer header, and the per-stream write + * handles a batched append needs. `stream(name)` is the client's whole + * public surface: a dynamic streams consumer names a stream by calling it, + * never by any other method here. + */ +export class StreamsClient { + private readonly base: string; + private readonly headers: Record; + // One write handle per stream: batching off so one append() is one POST + // (the wire client's default coalescing would make a failure ambiguous + // across several callers' events), retries off per the append contract. + private readonly writers = new Map(); + private readonly handles = new Map(); + + constructor(config: StreamsConfig) { + this.base = config.url.replace(/\/$/, ''); + this.headers = { authorization: `Bearer ${config.apiKey}` }; + } + + /** One handle per stream name, memoized so its ensure-create state survives repeat calls. */ + stream(name: string): StreamHandle { + let handle = this.handles.get(name); + if (handle === undefined) { + handle = new StreamHandle(name, this); + this.handles.set(name, handle); + } + return handle; + } + + private writer(name: string): DurableStream { + let handle = this.writers.get(name); + if (handle === undefined) { + handle = new DurableStream({ + url: streamUrl(this.base, name), + headers: this.headers, + contentType: JSON_CONTENT_TYPE, + batching: false, + backoffOptions: NO_RETRY_BACKOFF, + }); + this.writers.set(name, handle); + } + return handle; + } + + /** Creates the stream (idempotent: an existing stream of any content type is success). Used by `StreamHandle`'s ensure-create. */ + async create(name: string): Promise { + const handle = new DurableStream({ + url: streamUrl(this.base, name), + headers: this.headers, + contentType: JSON_CONTENT_TYPE, + backoffOptions: IDEMPOTENT_BACKOFF, + }); + try { + await handle.create(); + } catch (error) { + // PUT-create is create-only upstream; a handle's ensure-create is + // ensure-style, so an existing stream is success. + if (!isAlreadyExists(error)) throw error; + } + } + + /** + * Appends one JSON event. NEVER retried beyond `StreamHandle`'s one-shot + * 404 heal: the protocol has no idempotency key, so a failed request is + * indistinguishable from one that applied — the caller retries, because + * only it knows whether a duplicate is acceptable. + */ + async append(name: string, event: unknown): Promise { + await this.writer(name).append(JSON.stringify(event)); + } + + /** Reads the stream from `offset` (default: the beginning) to the current head. */ + async read(name: string, opts?: { offset?: string }): Promise> { + const res = await stream({ + url: streamUrl(this.base, name), + headers: this.headers, + offset: opts?.offset ?? '-1', + live: false, + json: true, // JSON by contract; don't depend on a content-type header + backoffOptions: IDEMPOTENT_BACKOFF, + }); + const events = await res.json(); + return { events, nextOffset: res.offset }; + } + + /** + * Waits for the next live delivery after `offset` (default: the current + * head), via long-poll — SSE cannot traverse the Compute ingress (PRO-218). + * Resolves with the delivered events, or `timedOut: true` after `timeoutMs` + * (default 20s) with nothing new. + */ + async tail( + name: string, + opts?: { offset?: string; timeoutMs?: number; signal?: AbortSignal }, + ): Promise> { + // `offset: 'now'` is the protocol's own "from the current head" — no + // client-side head dance needed. + const abort = new AbortController(); + const onCallerAbort = (): void => abort.abort(); + opts?.signal?.addEventListener('abort', onCallerAbort, { once: true }); + const timer = setTimeout(() => abort.abort(), opts?.timeoutMs ?? DEFAULT_TAIL_TIMEOUT_MS); + + try { + const res = await stream({ + url: streamUrl(this.base, name), + headers: this.headers, + offset: opts?.offset ?? 'now', + live: 'long-poll', + // The deployed server's `offset=now` long-poll answers 204 with no + // content-type, which would defeat the client's JSON-mode + // detection; this module's streams are JSON by contract. + json: true, + backoffOptions: IDEMPOTENT_BACKOFF, + signal: abort.signal, + }); + + return await new Promise>((resolve, reject) => { + abort.signal.addEventListener( + 'abort', + () => resolve({ events: [], nextOffset: res.offset, timedOut: true }), + { once: true }, + ); + try { + res.subscribeJson((batch) => { + if (batch.items.length === 0) return; // control-only delivery, keep waiting + // Resolve BEFORE aborting: the abort listener above also + // resolves (as a timeout), and a settled promise ignores it. + resolve({ events: batch.items, nextOffset: batch.offset, timedOut: false }); + abort.abort(); // stop the session's follow loop + }); + } catch (error) { + reject(error); + } + }); + } catch (error) { + // Aborted before the first response: a timeout, not a failure. + if (abort.signal.aborted) + return { events: [], nextOffset: opts?.offset ?? 'now', timedOut: true }; + throw error; + } finally { + clearTimeout(timer); + opts?.signal?.removeEventListener('abort', onCallerAbort); + } + } +} + +/** + * One stream's handle — the name and the ensure-create memo. Everything a + * `durableStreams(contract)` handle or a `durableStreams()` client's + * `stream(name)` result exposes; no call site passes a name again. + * + * Owns the lifecycle the app used to hand-roll: the first operation creates + * the stream (memoized here; upstream create is already ensure-style, so a + * racing second instance is harmless — using a stream is sufficient to + * create it), and a 404 on any operation heals by dropping the memo, + * re-creating, and retrying that operation once. A 404 is generated INSTEAD + * OF a write at every layer, so it proves nothing was applied — retrying + * once cannot duplicate an event, even an append. Ambiguous failures (socket + * closes, 502/504) never match `isStreamNotFound` and surface raw. + */ +export class StreamHandle { + private ensured: Promise | undefined; + + constructor( + private readonly name: string, + private readonly transport: StreamsClient, + ) {} + + private ensureCreate(): Promise { + if (this.ensured === undefined) { + this.ensured = this.transport.create(this.name).catch((error: unknown) => { + this.ensured = undefined; + throw error; + }); + } + return this.ensured; + } + + private async withHeal(op: () => Promise): Promise { + await this.ensureCreate(); + try { + return await op(); + } catch (error) { + if (!isStreamNotFound(error)) throw error; + this.ensured = undefined; + await this.ensureCreate(); + return op(); + } + } + + /** + * Appends one JSON event. NEVER retried beyond the one-shot 404 heal above: + * the protocol has no idempotency key, so a failed request is + * indistinguishable from one that applied — the caller retries, because + * only it knows whether a duplicate is acceptable. + */ + append(event: unknown): Promise { + return this.withHeal(() => this.transport.append(this.name, event)); + } + + /** Reads the stream from `offset` (default: the beginning) to the current head. */ + read(opts?: { offset?: string }): Promise> { + return this.withHeal(() => this.transport.read(this.name, opts)); + } + + /** + * Waits for the next live delivery after `offset` (default: the current + * head), via long-poll. Resolves with the delivered events, or + * `timedOut: true` after `timeoutMs` (default 20s) with nothing new. + */ + tail(opts?: { + offset?: string; + timeoutMs?: number; + signal?: AbortSignal; + }): Promise> { + return this.withHeal(() => this.transport.tail(this.name, opts)); + } +} diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/src/contract.ts b/packages/1-prisma-cloud/2-shared-modules/streams/src/contract.ts index 14d51f98..417c9982 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/src/contract.ts +++ b/packages/1-prisma-cloud/2-shared-modules/streams/src/contract.ts @@ -1,36 +1,127 @@ /** - * The durable-streams contract: the binding a consumer's `durableStreams()` - * dependency requires, and the streams service's `streams` port provides. - * Mirrors `s3Contract`/`s3()`: `satisfies` compares kind only, and the binding - * IS the typed connection config (ADR-0015) — the app builds its own HTTP - * client against the Durable Streams protocol. The bearer key is NOT in the - * binding: secret values never travel through framework config (ADR-0029), so - * a consumer declares its own `secret()` slot bound to the same platform - * variable as the module's `apiKey`. + * The durable-streams contract: it names the streams a consumer's + * `durableStreams()` dependency binds to. `streamsContract(defs)` is the + * authoring surface — one entry per stream, each an optional `streamDef()` + * (untyped only in this slice: no event schema yet, that is a follow-up + * slice). The def map is the contract's `__cmp`, the same place rpc's + * `contract()` puts its function map. + * + * `satisfies` stays kind-only: the Durable Streams server is schema-agnostic + * (it carries bytes, not types), so a provider cannot attest to a consumer's + * chosen stream names or event shapes, and checking `__cmp` at wiring time + * would be a guess. Two consumers naming the same stream with different defs + * is therefore expressible and unchecked at Load; each reader's own + * (currently no-op, future typed) validation is what would catch a lie. + * + * The wire binding underneath is still the typed connection config + * (ADR-0015) — `{ url, apiKey }` — unchanged by which streams a contract + * names, since stream names are protocol data (URL path segments), never + * config keys. + * + * The bearer key rides the binding as an ADR-0031 provisioning need: the + * framework mints it at deploy and fills the param like any other input, so + * it is neither an ADR-0029 secret (no name to bind, no out-of-band value) + * nor a producer output. The need's brand and the provisioner that resolves + * it live in `@internal/prisma-cloud` — the target sits BELOW this module, so + * the brand is imported downward (see its `streams-keys.ts` for why). */ import type { Contract, DependencyEnd } from '@internal/core'; import { dependency, string } from '@internal/core'; +import { streamsApiKeyNeed } from '@internal/prisma-cloud'; +import type { StreamHandle } from './client.ts'; +import { StreamsClient } from './client.ts'; export interface StreamsConfig { readonly url: string; + readonly apiKey: string; } -export const streamsContract: Contract<'streams', StreamsConfig> = Object.freeze({ +/** + * One stream's declaration in a `streamsContract` def map. Untyped only in + * this slice — carries no event schema. Typed validation + * (`streamDef({ event })`) is a recorded follow-up slice; this shape is + * deliberately empty rather than half-carrying a schema parameter that does + * nothing. + */ +export interface StreamDef { + readonly kind: 'stream-def'; +} + +/** Declares an untyped stream in a `streamsContract` def map. */ +export function streamDef(): StreamDef { + return Object.freeze({ kind: 'stream-def' as const }); +} + +export type StreamDefs = Record; + +/** + * Names the streams a contract transports, each with an optional def: + * `streamsContract({ jobs: streamDef(), audit: streamDef() })`. The + * `durableStreams(contract)` dependency built from it hydrates to one handle + * per declared name. + */ +export function streamsContract(defs: D): Contract<'streams', D> { + return Object.freeze({ + kind: 'streams', + __cmp: defs, + satisfies: (required: Contract<'streams', unknown>) => required.kind === 'streams', + }); +} + +/** The type of a `streamsContract(defs)` value. */ +export type StreamsContract = Contract<'streams', D>; + +/** + * The `streams()` module's own exposed port: a general streams provider, + * satisfied by kind alone — the `postgresContract` pattern. The module + * cannot know its eventual consumers' stream names (different consumers of + * one module each name their own), and the server genuinely serves any + * stream, so what a consumer requires of its provider is only "is a streams + * provider". That is exactly what this wide type says, and the empty def + * map is a legitimate `StreamDefs` value — a placeholder nobody reads, like + * postgres's `{ url: '' }`. Consumers keep their literal handle typing from + * `durableStreams(contract)`'s generic parameter, which is independent of + * the wiring-compatibility type here. + */ +export const streamsProviderContract: Contract<'streams', StreamDefs> = Object.freeze({ kind: 'streams', - __cmp: { url: '' }, + __cmp: {}, satisfies: (required: Contract<'streams', unknown>) => required.kind === 'streams', }); -export type StreamsContract = typeof streamsContract; +/** The handles a `durableStreams(contract)` dependency hydrates to: one per declared stream name. */ +export type StreamHandles = { readonly [K in keyof D]: StreamHandle }; + +const connectionParams = { + url: string(), + apiKey: string({ provision: streamsApiKeyNeed() }), +}; -/** A consumer's dependency on a durable-streams server. */ -export function durableStreams(): DependencyEnd { +/** + * A consumer's dependency on a durable-streams server. Given a + * `streamsContract(defs)`, hydrates to one handle per declared stream name — + * the handle owns the name, so no call site names it again. Called with no + * argument, hydrates to a `StreamsClient` for dynamic stream names (e.g. + * per-tenant streams) — the `postgres()` parity: the same lifecycle + * ownership, the name is data rather than a wiring-time declaration. + */ +export function durableStreams( + contract: Contract<'streams', D>, +): DependencyEnd, Contract<'streams', StreamDefs>>; +export function durableStreams(): DependencyEnd>; +export function durableStreams(contract?: Contract<'streams', StreamDefs>): unknown { return dependency({ type: 'streams', connection: { - params: { url: string() }, - hydrate: (v): StreamsConfig => v, + params: connectionParams, + hydrate: (v: StreamsConfig) => { + const client = new StreamsClient(v); + if (contract === undefined) return client; + const handles: Record = {}; + for (const name of Object.keys(contract.__cmp)) handles[name] = client.stream(name); + return handles; + }, }, - required: streamsContract, + required: contract ?? streamsProviderContract, }); } diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/src/index.ts b/packages/1-prisma-cloud/2-shared-modules/streams/src/index.ts index eba2f055..8dad25c9 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/src/index.ts +++ b/packages/1-prisma-cloud/2-shared-modules/streams/src/index.ts @@ -5,7 +5,15 @@ * barrel, so a consumer graph that imports this module never bundles a * `bun`/`node:` token or the server runtime. */ -export type { StreamsConfig, StreamsContract } from './contract.ts'; -export { durableStreams, streamsContract } from './contract.ts'; +export type { StreamsReadResult, StreamsTailResult } from './client.ts'; +export { StreamHandle, StreamsClient } from './client.ts'; +export type { + StreamDef, + StreamDefs, + StreamHandles, + StreamsConfig, + StreamsContract, +} from './contract.ts'; +export { durableStreams, streamDef, streamsContract } from './contract.ts'; export { streams } from './streams-module.ts'; export { streamsService } from './streams-service.ts'; diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/src/streams-entrypoint.ts b/packages/1-prisma-cloud/2-shared-modules/streams/src/streams-entrypoint.ts index 2b2f842d..6839a89b 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/src/streams-entrypoint.ts +++ b/packages/1-prisma-cloud/2-shared-modules/streams/src/streams-entrypoint.ts @@ -4,15 +4,49 @@ // recovery, bearer-key auth) is the production server, unmodified. Deployment // defaults follow open-chat's production streams app. import { existsSync } from 'node:fs'; +import { STREAMS_API_KEY_ENV } from '@internal/prisma-cloud'; import { streamsService } from './streams-service.ts'; const service = streamsService(); const { store } = service.load(); -const { apiKey } = service.secrets(); const { port } = service.config(); -process.env['API_KEY'] = apiKey.expose(); +// The bearer key is minted per streams module by the target's registered +// provisioner and stashed here as a reserved provider param (ADR-0031/ +// ADR-0019); compute's `run` validates and re-stashes it address-free. It +// exists only if at least one consumer declared a `durableStreams()` +// dependency — the need lives on that edge. No consumers means no key, and +// the only thing this server could do without one is serve every endpoint +// unauthenticated. Refuse to boot instead, naming the cause. +const raw = process.env[STREAMS_API_KEY_ENV]; +if (raw === undefined || raw === '') { + throw new Error( + 'streams: no bearer key was provisioned for this module — nothing declares a ' + + 'durableStreams() dependency on it, so the key that authenticates its API was never ' + + "minted. Wire a consumer to the module's `streams` port, or remove the module.", + ); +} +// The reserved provider param is JSON-encoded, the same wire format any +// service-own literal param takes (ADR-0031) — decode it back to the bearer +// string. Re-checks the decoded shape after parsing, the same way rpc's +// serve() does after its own JSON.parse (serve.ts's acceptedKeys()): a +// malformed or wrongly-shaped stored value fails with the friendly message +// above, not a bare SyntaxError. +let parsed: unknown; +try { + parsed = JSON.parse(raw); +} catch { + parsed = undefined; +} +if (typeof parsed !== 'string' || parsed.length === 0) { + throw new Error( + 'streams: the provisioned bearer key is not a valid JSON-encoded string — the deploy wrote ' + + 'something this entrypoint cannot read back. Redeploy to re-mint the key.', + ); +} +const apiKey = parsed; +process.env['API_KEY'] = apiKey; process.env['PORT'] = String(port); // Bind beyond loopback so the Compute router can reach the server. process.env['DS_HOST'] ??= '0.0.0.0'; diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/src/streams-module.ts b/packages/1-prisma-cloud/2-shared-modules/streams/src/streams-module.ts index 32618356..b22d4b94 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/src/streams-module.ts +++ b/packages/1-prisma-cloud/2-shared-modules/streams/src/streams-module.ts @@ -3,35 +3,36 @@ * a Compute service behind a typed boundary. The durable tier arrives as a * dependency — wire a `storage()` module's `store` port into the `store` slot * (the first module-depends-on-module consumer of storage). The bearer key is - * a forwardable `secret()` slot the root binds (ADR-0029). Exposes a single - * `streams` port (`streamsContract`). + * neither wired nor owned by the module: a consumer's `durableStreams()` + * binding declares it as a provisioning need, and the target mints one value + * per streams module (ADR-0031) and stores it on this service. Exposes a + * single `streams` port (`streamsContract`). */ -import type { DependencyEnd, ModuleNode, SecretNeed } from '@internal/core'; -import { module, secret } from '@internal/core'; +import type { Contract, DependencyEnd, ModuleNode } from '@internal/core'; +import { module } from '@internal/core'; import type { S3Config, S3Contract } from '@internal/storage'; import { s3 } from '@internal/storage'; -import { streamsContract } from './contract.ts'; +import type { StreamDefs } from './contract.ts'; +import { streamsProviderContract } from './contract.ts'; import { streamsService } from './streams-service.ts'; export function streams(opts?: { name?: string; }): ModuleNode< { store: DependencyEnd }, - { streams: typeof streamsContract }, - { apiKey: SecretNeed } + { streams: Contract<'streams', StreamDefs> }, + Record > { return module( opts?.name ?? 'streams', { deps: { store: s3() }, - secrets: { apiKey: secret() }, - expose: { streams: streamsContract }, + expose: { streams: streamsProviderContract }, }, - ({ inputs, secrets, provision }) => { + ({ inputs, provision }) => { const service = provision(streamsService(), { id: 'service', deps: { store: inputs.store }, - secrets: { apiKey: secrets.apiKey }, }); return { streams: service.streams }; }, diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/src/streams-service.ts b/packages/1-prisma-cloud/2-shared-modules/streams/src/streams-service.ts index 720b021d..a175d937 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/src/streams-service.ts +++ b/packages/1-prisma-cloud/2-shared-modules/streams/src/streams-service.ts @@ -1,27 +1,28 @@ /** - * The streams service node: a plain `compute` service (no lowering extension — - * the contract binding is `{ url }`, which compute's deploy outputs already - * carry). It declares the `store` dependency (`s3()`, the storage module's - * port), the `apiKey` secret slot, and the `streams` expose. The deploy - * bootstrap runs the default-exported bare node; the real wiring arrives - * through serialized config at runtime — exactly like `storage-service.ts`. + * The streams service node: a plain `compute` service — the contract binding's + * `url` is a producer output compute's deploy already carries, and its + * `apiKey` is minted by the target's registered provisioner (ADR-0031), so + * nothing is left for a bespoke lowering to extend. It declares the `store` + * dependency (`s3()`, the storage module's port) and the `streams` expose; the + * bearer key reaches this service through the target's reserved provider + * param, not through a dependency. The deploy bootstrap runs the + * default-exported bare node; the real wiring arrives through serialized + * config at runtime — exactly like `storage-service.ts`. */ -import { secret } from '@internal/core'; import node from '@internal/node'; import { compute } from '@internal/prisma-cloud'; import { s3 } from '@internal/storage'; -import { streamsContract } from './contract.ts'; +import { streamsProviderContract } from './contract.ts'; export function streamsService() { return compute({ name: 'streams', deps: { store: s3() }, - secrets: { apiKey: secret() }, build: node({ module: new URL('./streams-service.mjs', import.meta.url).href, entry: './streams-entrypoint.mjs', }), - expose: { streams: streamsContract }, + expose: { streams: streamsProviderContract }, }); } diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/tsdown.config.ts b/packages/1-prisma-cloud/2-shared-modules/streams/tsdown.config.ts index 51a042f3..2cdeacfd 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/tsdown.config.ts +++ b/packages/1-prisma-cloud/2-shared-modules/streams/tsdown.config.ts @@ -14,6 +14,13 @@ export default defineConfig([ entry: { index: 'src/index.ts', 'streams-service': 'src/streams-service.ts' }, exports: false, clean: true, + // The wire client (@durable-streams/client, pinned 0.2.1 — the version + // @prisma/streams-server 0.1.11's own repo pairs with) is inlined so + // neither this dist nor the umbrella grows a runtime dependency; it is + // pure fetch-based JS (~90 kB + fastq/fetch-event-source), no node:/bun + // tokens, so the authoring barrel stays pure. + skipNodeModulesBundle: false, + noExternal: [/^@durable-streams\//, /^@microsoft\//, /^fastq/, /^reusify/], }, { ...baseConfig, @@ -22,7 +29,16 @@ export default defineConfig([ clean: false, skipNodeModulesBundle: false, external: [/^bun$/, /^bun:/], - noExternal: [/^@internal\//, /^@prisma\//, /^arktype/, /^@standard-schema\//], + noExternal: [ + /^@internal\//, + /^@prisma\//, + /^arktype/, + /^@standard-schema\//, + /^@durable-streams\//, + /^@microsoft\//, + /^fastq/, + /^reusify/, + ], // assemble() copies the entrypoint out with no siblings; the server's // dynamic-import chain must not split into chunks. outputOptions: { inlineDynamicImports: true }, @@ -34,6 +50,6 @@ export default defineConfig([ clean: false, skipNodeModulesBundle: false, external: [/^bun$/, /^bun:/], - noExternal: [/^@internal\//, /^@prisma\//], + noExternal: [/^@internal\//, /^@prisma\//, /^fastq/, /^reusify/], }, ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa51fbe5..e6fa7339 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -405,6 +405,9 @@ importers: '@types/bun': specifier: ^1.3.13 version: 1.3.14 + tsdown: + specifier: ^0.22.3 + version: 0.22.4(typescript@6.0.3) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -787,6 +790,9 @@ importers: packages/1-prisma-cloud/2-shared-modules/streams: dependencies: + '@durable-streams/client': + specifier: 0.2.1 + version: 0.2.1 '@internal/core': specifier: workspace:0.1.0 version: link:../../../0-framework/1-core/core @@ -807,8 +813,8 @@ importers: version: 0.1.11 devDependencies: '@durable-streams/server-conformance-tests': - specifier: ^0.3.5 - version: 0.3.5(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)) + specifier: 0.2.3 + version: 0.2.3(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)) '@internal/tsdown-config': specifier: workspace:0.1.0 version: link:../../../0-framework/0-foundation/tsdown-config @@ -1377,13 +1383,17 @@ packages: peerDependencies: effect: '>=4.0.0-beta.66 || >=4.0.0' - '@durable-streams/client@0.2.6': - resolution: {integrity: sha512-uHKKbWpsKLhFMeGjG0PgM6LXE3oEIi7FHKlJZkmYGxcqd4Yjjd/QEvnQnDzteRP4Av1uJVM8qjTL7kfKsgeS/w==} + '@durable-streams/client@0.2.1': + resolution: {integrity: sha512-+mGdK6TuDR9fJPo8jw6DufPfoUv6g+27xoPES76GXQc6y3val9Oe/SK2o2FV9sqqLSE19HEUSxTp0D6CZebfZw==} + engines: {node: '>=18.0.0'} + + '@durable-streams/client@0.2.3': + resolution: {integrity: sha512-609hWTqe8/OXzIFnv+oDdlT57QsCAc3F2c/nAQBcYhSLmmbXk5rHx7rnQSmk9MeGGQ8dsg9UCZf47dTJG3q3ig==} engines: {node: '>=18.0.0'} hasBin: true - '@durable-streams/server-conformance-tests@0.3.5': - resolution: {integrity: sha512-sXl2S562yVa+gO9ypZMJ3rC9lMpsUoc7XIBw24iY/k8g0RczgKdK3jYpgc10bttZLQLLGQMo3QxZ+pbGgIO5dw==} + '@durable-streams/server-conformance-tests@0.2.3': + resolution: {integrity: sha512-qJmrlD4QUon0TExZ72Rf9l99Rq6gCmD3nKapI+OIhDDUGc72V8hy90yFUGGwleV7tPtFPmXU5bj4KJSAjLiOWQ==} engines: {node: '>=18.0.0'} hasBin: true @@ -4923,14 +4933,19 @@ snapshots: '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.93) effect: 4.0.0-beta.93 - '@durable-streams/client@0.2.6': + '@durable-streams/client@0.2.1': + dependencies: + '@microsoft/fetch-event-source': 2.0.1 + fastq: 1.20.1 + + '@durable-streams/client@0.2.3': dependencies: '@microsoft/fetch-event-source': 2.0.1 fastq: 1.20.1 - '@durable-streams/server-conformance-tests@0.3.5(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))': + '@durable-streams/server-conformance-tests@0.2.3(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))': dependencies: - '@durable-streams/client': 0.2.6 + '@durable-streams/client': 0.2.3 fast-check: 4.9.0 vitest: 4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)) transitivePeerDependencies: @@ -5836,7 +5851,7 @@ snapshots: '@prisma/streams-server@0.1.11': dependencies: - '@durable-streams/client': 0.2.6 + '@durable-streams/client': 0.2.1 ajv: 8.20.0 better-result: 2.9.2 env-paths: 3.0.0 diff --git a/scripts/cold-connect-canary-classify.test.ts b/scripts/cold-connect-canary-classify.test.ts index 2db1e110..65fda15d 100644 --- a/scripts/cold-connect-canary-classify.test.ts +++ b/scripts/cold-connect-canary-classify.test.ts @@ -61,32 +61,36 @@ describe('classifyColdConnectSample', () => { }); }); -describe('classifyColdConnectRun (unanimity)', () => { +describe("classifyColdConnectRun (unanimity, with a REQUIRED check's three exits)", () => { const run = (...s: ColdConnectSample[]) => classifyColdConnectRun(s); - it('ANY rejection → PASS, even amid successes (a single rejection proves the bug)', () => { + it('ANY rejection → bug-present (exit 0), even amid successes (a single rejection proves the bug)', () => { const result = run('success', 'success', 'rejected', 'success', 'success'); - assert.equal(result.pass, true); + assert.equal(result.verdict, 'bug-present'); assert.match(result.message, /still present \(1\/5 rejected\)/); }); - it('ALL successes → FAIL with the remove-the-workaround signal', () => { + it('ALL successes → bug-gone (exit 1 — the forcing signal), actionable for a cold reader', () => { const result = run('success', 'success', 'success', 'success', 'success'); - assert.equal(result.pass, false); - assert.match(result.message, /FT-5226 fixed/); + assert.equal(result.verdict, 'bug-gone'); + assert.match(result.message, /not because of your change/); + assert.match(result.message, /withConnectionRetry/); + assert.match(result.message, /pg-connection\.ts/); + assert.match(result.message, /cold-connect-canary\.ts/); + assert.match(result.message, /e2e-deploy\.yml/); }); - it('no rejections but not all-success (timeouts) → FAIL inconclusive, not "fixed"', () => { + it('no rejections but not all-success (timeouts) → inconclusive (exit 0 + warning), not "fixed"', () => { const result = run('success', 'timeout', 'success', 'timeout', 'success'); - assert.equal(result.pass, false); - assert.match(result.message, /Inconclusive/); + assert.equal(result.verdict, 'inconclusive'); + assert.match(result.message, /not blocking/); }); it('a lone success does not flip a rejecting run to "fixed"', () => { - assert.equal(run('rejected', 'rejected', 'success').pass, true); + assert.equal(run('rejected', 'rejected', 'success').verdict, 'bug-present'); }); - it('zero samples → FAIL (broken canary)', () => { - assert.equal(classifyColdConnectRun([]).pass, false); + it('zero samples → inconclusive (broken canary; warn, do not block)', () => { + assert.equal(classifyColdConnectRun([]).verdict, 'inconclusive'); }); }); diff --git a/scripts/cold-connect-canary-classify.ts b/scripts/cold-connect-canary-classify.ts index 19b0a487..318d2b47 100644 --- a/scripts/cold-connect-canary-classify.ts +++ b/scripts/cold-connect-canary-classify.ts @@ -59,40 +59,54 @@ export function classifyColdConnectSample(error: unknown): ColdConnectSample { return 'other'; } +/** + * The three exits a REQUIRED check needs (the job fails only on the + * conclusive forcing signal): `bug-present` → exit 0; `bug-gone` → exit 1 + * (all clean — remove the workaround); `inconclusive` → exit 0 plus a CI + * warning annotation. + */ +export type ColdConnectVerdict = 'bug-present' | 'bug-gone' | 'inconclusive'; + export interface ColdConnectResult { - readonly pass: boolean; + readonly verdict: ColdConnectVerdict; readonly message: string; } /** * Aggregates N cold-connect samples with UNANIMITY, so one flaky connect can't - * flip the verdict: - * - any active rejection → PASS (bug present — a single rejection proves it), - * - all N succeeded → FAIL (FT-5226 looks gone; remove the workaround), - * - otherwise (timeouts / odd errors, no rejection but not all-success) → FAIL - * inconclusive (slow cold start, or a broken canary — a human should look). + * flip the verdict; see {@link ColdConnectVerdict} for what each verdict makes + * the job do. */ export function classifyColdConnectRun(samples: readonly ColdConnectSample[]): ColdConnectResult { const n = samples.length; - if (n === 0) return { pass: false, message: 'Canary took no samples — broken.' }; + if (n === 0) return { verdict: 'inconclusive', message: 'Canary took no samples — broken.' }; const count = (s: ColdConnectSample) => samples.filter((x) => x === s).length; const rejected = count('rejected'); const success = count('success'); if (rejected > 0) { return { - pass: true, + verdict: 'bug-present', message: `Cold-connect rejection still present (${rejected}/${n} rejected) — FT-5226 not fixed; keep withConnectionRetry.`, }; } if (success === n) { return { - pass: false, - message: `All ${n} cold connects succeeded — PPG cold-connect rejection is gone (FT-5226 fixed?). Remove withConnectionRetry and this canary.`, + verdict: 'bug-gone', + message: + `All ${n} cold connects succeeded — PPg no longer rejects a fresh database's first ` + + 'connect, so the workaround exists with no problem. To fix this build (you are seeing ' + + 'it because the cleanup is now due, not because of your change): 1) remove ' + + 'withConnectionRetry and its uses ' + + '(packages/1-prisma-cloud/1-extensions/target/src/pg-connection.ts); 2) remove ' + + 'scripts/cold-connect-canary.ts, scripts/cold-connect-canary-classify.ts (+ its test) ' + + 'and the "Cold-connect canary (FT-5226)" job in .github/workflows/e2e-deploy.yml; ' + + "3) drop the removal-guard line from gotchas.md's FT-5226 entry; 4) close FT-5226's " + + 'follow-up if one is open.', }; } return { - pass: false, - message: `Inconclusive across ${n} samples (${success} ok, ${count('timeout')} timeout, ${count('other')} other, 0 active rejections) — FT-5226 may be fixed via a slow cold start, or the canary/credentials are broken. Investigate before removing the workaround.`, + verdict: 'inconclusive', + message: `Inconclusive across ${n} samples (${success} ok, ${count('timeout')} timeout, ${count('other')} other, 0 active rejections) — FT-5226 may be fixed via a slow cold start, or the canary/credentials are broken. A human should look; not blocking.`, }; } diff --git a/scripts/cold-connect-canary.ts b/scripts/cold-connect-canary.ts index 83b9e31e..305b17c2 100644 --- a/scripts/cold-connect-canary.ts +++ b/scripts/cold-connect-canary.ts @@ -5,10 +5,12 @@ * with no retry. FT-5226 is intermittent (the edge proxy rejects a cold DB's * first connect while its upstream warms, but a fast connect occasionally slips * through), so a single connect can't tell "fixed" from "got lucky once". The - * run is judged unanimously (see classifyColdConnectRun): any active rejection - * → PASS (bug still present); ALL samples succeeding → FAIL, the signal to - * remove `withConnectionRetry` (packages/compose-cloud/src/pg-connection.ts) and - * this canary. + * run is judged unanimously (see classifyColdConnectRun) with a REQUIRED + * check's exits: any active rejection → exit 0 (bug still present); ALL + * samples succeeding → exit 1, the forcing signal to remove + * `withConnectionRetry` (packages/1-prisma-cloud/1-extensions/target/src/ + * pg-connection.ts) and this canary; inconclusive → exit 0 with a CI warning + * annotation, so a flake never blocks unrelated PRs. */ import pg from 'pg'; import { deleteProjectDeep, type HttpCall, type ProjectRef } from './ci-cleanup-utils.ts'; @@ -139,7 +141,13 @@ try { const result = classifyColdConnectRun(samples); console.log(result.message); - process.exitCode = result.pass ? 0 : 1; + if (result.verdict === 'inconclusive') { + const detail = samples.map((sample, i) => `sample #${i}: ${sample}`).join('; '); + console.log( + `::warning title=Cold-connect canary (FT-5226) inconclusive::${result.message} [${detail}]`, + ); + } + process.exitCode = result.verdict === 'bug-gone' ? 1 : 0; } finally { if (project) { console.log(`Deleting project "${project.name}" (${project.id})…`); diff --git a/scripts/cold-start-canary-classify.test.ts b/scripts/cold-start-canary-classify.test.ts new file mode 100644 index 00000000..59ce9052 --- /dev/null +++ b/scripts/cold-start-canary-classify.test.ts @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + CLOCK_SKEW_MARGIN_MS, + type ColdStartTouch, + classifyBootEvidence, + classifyColdStartRun, + classifyColdStartTouch, + findListeningTimestamp, + MAX_FALSE_CLEAN_PROBABILITY, + MIN_HELD_SAMPLES_FOR_BUG_GONE, + stripAnsiCodes, + TARGET_CLOSE_RATE, +} from './cold-start-canary-classify.ts'; + +describe('classifyColdStartTouch', () => { + it('a 201 confirmed cold → held (the edge carried the request through a real boot)', () => { + assert.equal(classifyColdStartTouch(201, '{"appended":{"n":1}}', 'confirmed-cold'), 'held'); + }); + + it('a 201 confirmed warm → no-cold-start (it proves nothing about PRO-217)', () => { + assert.equal( + classifyColdStartTouch(201, '{"appended":{"n":1}}', 'confirmed-warm'), + 'no-cold-start', + ); + }); + + it('a 201 with unknown boot evidence → other (log read failed or too close to call — not a guess)', () => { + assert.equal(classifyColdStartTouch(201, '{"appended":{"n":1}}', 'unknown'), 'other'); + }); + + it("the jobs service's surfaced close → closed (the PRO-217 signal), regardless of boot evidence", () => { + const body = + 'streams unreachable: Error: The socket connection was closed unexpectedly. For more information, pass `verbose: true`'; + assert.equal(classifyColdStartTouch(502, body, 'confirmed-cold'), 'closed'); + assert.equal(classifyColdStartTouch(502, body, 'unknown'), 'closed'); + }); + + it('reset/refused faces of the same close → closed', () => { + for (const body of ['ECONNRESET while fetching', 'connect ECONNREFUSED', 'socket hang up']) { + assert.equal(classifyColdStartTouch(502, body, 'unknown'), 'closed', body); + } + }); + + it('a 502 whose cause is something else → other (inconclusive, not a close)', () => { + assert.equal(classifyColdStartTouch(502, 'append failed: 500', 'unknown'), 'other'); + }); + + it('any other status → other, regardless of boot evidence', () => { + assert.equal(classifyColdStartTouch(500, 'boom', 'confirmed-cold'), 'other'); + assert.equal(classifyColdStartTouch(404, 'not found', 'unknown'), 'other'); + assert.equal(classifyColdStartTouch(200, 'ok but not an append', 'confirmed-cold'), 'other'); + }); +}); + +describe('stripAnsiCodes', () => { + it('removes SGR escape sequences from spark boot log lines', () => { + const colorized = `${String.fromCharCode(27)}[90m[${String.fromCharCode(27)}[0m2026-07-17T12:04:08Z ${String.fromCharCode(27)}[32mINFO ${String.fromCharCode(27)}[0m spark::app_source${String.fromCharCode(27)}[90m]${String.fromCharCode(27)}[0m compute.manifest.json not found`; + assert.equal( + stripAnsiCodes(colorized), + '[2026-07-17T12:04:08Z INFO spark::app_source] compute.manifest.json not found', + ); + }); + + it('leaves plain text untouched', () => { + assert.equal(stripAnsiCodes('[INFO] plain line, no escapes'), '[INFO] plain line, no escapes'); + }); +}); + +describe('findListeningTimestamp', () => { + it("reads the streams server's own listening line", () => { + const log = + 'streams: bootstrapping local state from the object store\r\n' + + '[2026-07-17T12:04:10.313Z] [INFO] prisma-streams server listening on 0.0.0.0:3000\r\n'; + const found = findListeningTimestamp(log); + assert.ok(found); + assert.equal(found?.toISOString(), '2026-07-17T12:04:10.313Z'); + }); + + it('returns undefined when the log never reached a listening line (e.g. read cut off mid-boot)', () => { + const log = + 'spark: starting bun with entrypoint: bootstrap.js\r\n' + + 'streams: bootstrapping local state from the object store\r\n'; + assert.equal(findListeningTimestamp(log), undefined); + }); + + it('returns undefined for an empty or unrelated log', () => { + assert.equal(findListeningTimestamp(''), undefined); + assert.equal(findListeningTimestamp('some other server started fine'), undefined); + }); +}); + +describe('classifyBootEvidence (margin-aware, cross-clock comparison)', () => { + const listeningAt = new Date('2026-07-17T12:04:10.000Z'); + + it('touch sent comfortably before listening (beyond the skew margin) → confirmed-cold', () => { + const touchSentAt = new Date(listeningAt.getTime() - CLOCK_SKEW_MARGIN_MS - 1); + assert.equal(classifyBootEvidence(touchSentAt, listeningAt), 'confirmed-cold'); + }); + + it('touch sent exactly at the margin boundary before listening → confirmed-cold (>=)', () => { + const touchSentAt = new Date(listeningAt.getTime() - CLOCK_SKEW_MARGIN_MS); + assert.equal(classifyBootEvidence(touchSentAt, listeningAt), 'confirmed-cold'); + }); + + it('touch sent comfortably after listening (beyond the skew margin) → confirmed-warm', () => { + const touchSentAt = new Date(listeningAt.getTime() + CLOCK_SKEW_MARGIN_MS + 1); + assert.equal(classifyBootEvidence(touchSentAt, listeningAt), 'confirmed-warm'); + }); + + it('touch sent within the skew margin on either side of listening → unknown (could be skew, not order)', () => { + const justBefore = new Date(listeningAt.getTime() - CLOCK_SKEW_MARGIN_MS + 1); + const justAfter = new Date(listeningAt.getTime() + CLOCK_SKEW_MARGIN_MS - 1); + assert.equal(classifyBootEvidence(justBefore, listeningAt), 'unknown'); + assert.equal(classifyBootEvidence(justAfter, listeningAt), 'unknown'); + }); + + it('no listening timestamp at all → unknown, not a guess', () => { + assert.equal(classifyBootEvidence(new Date(), undefined), 'unknown'); + }); +}); + +describe('the sample-budget arithmetic', () => { + it('MIN_HELD_SAMPLES_FOR_BUG_GONE is the smallest N keeping an all-held run at or under 5% chance at a 20% close rate', () => { + assert.equal(TARGET_CLOSE_RATE, 0.2); + assert.equal(MAX_FALSE_CLEAN_PROBABILITY, 0.05); + assert.equal(MIN_HELD_SAMPLES_FOR_BUG_GONE, 14); + const chance = (n: number) => (1 - TARGET_CLOSE_RATE) ** n; + assert.ok(chance(MIN_HELD_SAMPLES_FOR_BUG_GONE) <= MAX_FALSE_CLEAN_PROBABILITY); + assert.ok(chance(MIN_HELD_SAMPLES_FOR_BUG_GONE - 1) > MAX_FALSE_CLEAN_PROBABILITY); + }); +}); + +describe('classifyColdStartRun (the three-exit mapping of a REQUIRED check)', () => { + const run = (...touches: ColdStartTouch[]) => classifyColdStartRun(touches); + const heldTimes = (n: number): ColdStartTouch[] => Array.from({ length: n }, () => 'held'); + + it('no touches → inconclusive (broken canary; warn, do not block)', () => { + assert.equal(run().verdict, 'inconclusive'); + }); + + it("one close among holds → bug-present (exit 0; today's normal)", () => { + const result = run('held', 'closed', 'held', 'held'); + assert.equal(result.verdict, 'bug-present'); + assert.match(result.message, /1\/4 first touches closed/); + assert.match(result.message, /PRO-217 not fixed/); + }); + + it('a close is decisive even alongside touches that never went cold', () => { + const result = run('closed', 'no-cold-start', 'no-cold-start', 'no-cold-start'); + assert.equal(result.verdict, 'bug-present'); + }); + + it('a close is decisive even in a run large enough to otherwise reach the bug-gone budget', () => { + const result = classifyColdStartRun([...heldTimes(MIN_HELD_SAMPLES_FOR_BUG_GONE), 'closed']); + assert.equal(result.verdict, 'bug-present'); + }); + + it('all held but fewer than MIN_HELD_SAMPLES_FOR_BUG_GONE → inconclusive, not bug-gone (an all-held run this small is the expected outcome of an intermittent bug)', () => { + const result = classifyColdStartRun(heldTimes(4)); + assert.equal(result.verdict, 'inconclusive'); + assert.match(result.message, /All 4 confirmed cold-start touches held/); + assert.match(result.message, /41\.0%/); // 0.8^4 + assert.match(result.message, new RegExp(String(MIN_HELD_SAMPLES_FOR_BUG_GONE))); + assert.match(result.message, /not blocking/i); + }); + + it('all held at exactly MIN_HELD_SAMPLES_FOR_BUG_GONE → bug-gone (exit 1 — the forcing signal), actionable for a cold reader', () => { + const result = classifyColdStartRun(heldTimes(MIN_HELD_SAMPLES_FOR_BUG_GONE)); + assert.equal(result.verdict, 'bug-gone'); + assert.match(result.message, /4\.4%/); // 0.8^14 + assert.match(result.message, /not because of your change/); + assert.match(result.message, /IDEMPOTENT_BACKOFF/); + assert.match(result.message, /streams\/src\/client\.ts/); + assert.match(result.message, /cold-start-canary\.ts/); + assert.match(result.message, /e2e-deploy\.yml/); + assert.match(result.message, /gotchas\.md/); + assert.match(result.message, /PRO-219/); + }); + + it('one held short of the budget → inconclusive, not bug-gone', () => { + const result = classifyColdStartRun(heldTimes(MIN_HELD_SAMPLES_FOR_BUG_GONE - 1)); + assert.equal(result.verdict, 'inconclusive'); + }); + + it('any touch that never went cold makes the whole run inconclusive, even with no closes and plenty of holds', () => { + const result = classifyColdStartRun([ + ...heldTimes(MIN_HELD_SAMPLES_FOR_BUG_GONE), + 'no-cold-start', + ]); + assert.equal(result.verdict, 'inconclusive'); + assert.match(result.message, /failed to force a cold start/); + assert.match(result.message, /not blocking/); + }); + + it('all touches never going cold → inconclusive, not a clean bill of health', () => { + const result = run('no-cold-start', 'no-cold-start', 'no-cold-start', 'no-cold-start'); + assert.equal(result.verdict, 'inconclusive'); + assert.match(result.message, /4\/4 touches/); + }); + + it('an "other" (broken/ambiguous) touch also blocks a bug-gone verdict', () => { + const result = classifyColdStartRun([...heldTimes(MIN_HELD_SAMPLES_FOR_BUG_GONE), 'other']); + assert.equal(result.verdict, 'inconclusive'); + }); +}); diff --git a/scripts/cold-start-canary-classify.ts b/scripts/cold-start-canary-classify.ts new file mode 100644 index 00000000..d64f4129 --- /dev/null +++ b/scripts/cold-start-canary-classify.ts @@ -0,0 +1,302 @@ +/** + * Pass/fail logic for cold-start-canary.ts (PRO-217), split out for offline + * unit testing — the cold-connect-canary-classify.ts pattern applied to the + * Compute face of the cold-start family. + * + * PRO-217 (the ingress closes a first-touch connection while a scale-to-zero + * service boots) is INTERMITTENT: on most cold hits the edge holds the + * connection and the request just takes seconds, and only sometimes closes it + * mid-establishment (~400 ms fast-fail, observed via examples/streams). One + * touch against a freshly promoted instance can therefore land on one of + * three outcomes, not two: + * + * - a 502 whose body names a socket close, arriving fast — the bug + * reproduced. A close only happens during the boot window, so this alone + * proves the touch reached a cold start; no further evidence is needed. + * - a 201 that independent evidence (the deployment's own boot logs) confirms + * was sent before the app finished booting — the edge held the connection + * through a real cold start. Genuine evidence toward "fixed". + * - a 201 that arrived before there was anything left to boot through — no + * cold start happened, so the touch says nothing about the bug either way. + * + * A canary that folds the second and third cases together (as this file once + * did, mapping every 201 straight to "held") can report "fixed" from touches + * that never went near a cold instance — see gotchas.md's PRO-217 entry for + * the run that did exactly that. `classifyColdStartTouch` refuses to guess + * the cold/warm distinction itself: the caller must resolve it (from + * `classifyBootEvidence`, below) and pass the answer in. + * + * A second, separate defect survives even once the touch itself is + * genuinely cold: PRO-217 being intermittent means "every touch this run + * happened to hold" is the EXPECTED result of a run that is too small, not + * proof the bug is gone. `classifyColdStartRun`'s bug-gone branch therefore + * requires a minimum number of confirmed cold-start holds — see + * `MIN_HELD_SAMPLES_FOR_BUG_GONE` below for the arithmetic. + */ + +/** + * The caller (the jobs service's 502-with-cause guard) surfaces the close as + * `streams unreachable: … socket connection was closed …`; a direct Bun/node + * caller shows the same message or an ECONNRESET/ECONNREFUSED code. Keep in + * sync with gotchas.md's PRO-217 entry. + */ +const CLOSE_FRAGMENTS = [ + 'socket connection was closed', + 'econnreset', + 'econnrefused', + 'socket hang up', +]; + +/** One first-touch outcome against a freshly promoted instance. */ +export type ColdStartTouch = 'held' | 'closed' | 'no-cold-start' | 'other'; + +/** + * The caller's answer to "did this touch actually race a boot?", decided by + * `classifyBootEvidence` from the deployment's own logs before + * `classifyColdStartTouch` is called: + * + * - `confirmed-cold`: the touch was sent clearly before the app's own + * `listening` line, by more than the clock-skew margin. + * - `confirmed-warm`: the touch was sent clearly after `listening`, by more + * than the clock-skew margin — it landed on an instance that was already + * up. + * - `unknown`: the deployment's logs never showed a `listening` line within + * the read window, or the touch landed within the margin of it, so which + * side of the boot it fell on cannot be said. This is a real "we don't + * know", not a guess dressed up as one of the other two. + */ +export type BootEvidence = 'confirmed-cold' | 'confirmed-warm' | 'unknown'; + +/** + * Classifies one first-touch response from the CALLER's seat. + * + * A 201 only becomes `held` when `bootEvidence` is `confirmed-cold`; + * `confirmed-warm` makes it `no-cold-start` (a successful append that proves + * nothing, because nothing was booting when it landed), and `unknown` makes + * it `other` — the log evidence could not place the touch on either side of + * the boot, so the touch is inconclusive rather than assumed either way. A + * 502 naming the close is `closed` regardless of `bootEvidence`: the close + * itself only happens mid-boot, so it is its own proof. + */ +export function classifyColdStartTouch( + status: number, + body: string, + bootEvidence: BootEvidence, +): ColdStartTouch { + const lower = body.toLowerCase(); + if (status === 502 && CLOSE_FRAGMENTS.some((fragment) => lower.includes(fragment))) { + return 'closed'; + } + if (status === 201) { + if (bootEvidence === 'confirmed-cold') return 'held'; + if (bootEvidence === 'confirmed-warm') return 'no-cold-start'; + return 'other'; + } + return 'other'; +} + +/** + * Strips ANSI SGR color codes from spark's boot log lines (the platform's + * own log lines are colorized; the app's own log lines observed so far are + * not, but stripping first makes the timestamp regex robust either way). + * Built from String.fromCharCode rather than a regex literal containing the + * raw ESC byte, which Biome's noControlCharactersInRegex rule (rightly) + * rejects. + */ +export function stripAnsiCodes(text: string): string { + const ESC = String.fromCharCode(27); + return text.split(new RegExp(`${ESC}\\[[0-9;]*m`, 'g')).join(''); +} + +/** + * The streams server's own boot line — e.g. "[2026-07-17T12:04:10.313Z] + * [INFO] prisma-streams server listening on 0.0.0.0:3000" — read from a + * deployment's log history (`?from_start=true`). Returns the timestamp it + * logged, or undefined if the boot never reached it (or the log read didn't + * cover it). + */ +export function findListeningTimestamp(logText: string): Date | undefined { + const match = stripAnsiCodes(logText).match( + /\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)]\s*\[INFO]\s*prisma-streams server listening/, + ); + const timestamp = match?.[1]; + return timestamp !== undefined ? new Date(timestamp) : undefined; +} + +/** + * `touchSentAt` is `new Date()` on the CI runner; `listeningAt` is parsed out + * of a timestamp the streams server wrote with its own `new Date()` on a + * Prisma Compute VM. Nothing keeps those two clocks in lockstep, so a + * touch/listening gap of a few tens or hundreds of milliseconds is not by + * itself proof of ordering — it could be clock skew rather than a genuine + * race. Two well-behaved NTP-synced hosts (a GitHub Actions runner and a + * cloud VM) are not expected to disagree by more than a few hundred + * milliseconds; 2 seconds is a comfortable multiple of that, while still + * being small next to the 3.5-22s boot windows this canary now samples (see + * cold-start-canary.ts's SAMPLE_INTERVAL_MS comment), so it does not eat + * into the touches it can confidently call one way or the other. + */ +export const CLOCK_SKEW_MARGIN_MS = 2_000; + +/** + * Decides, from the deployment's own boot log, which side of the boot a + * touch landed on. Returns `unknown` — not a guess — when the log never + * showed a `listening` line, or when the touch and `listening` are within + * CLOCK_SKEW_MARGIN_MS of each other and cross-clock skew could plausibly + * explain the gap either way. + */ +export function classifyBootEvidence( + touchSentAt: Date, + listeningAt: Date | undefined, +): BootEvidence { + if (listeningAt === undefined) return 'unknown'; + const touchBeforeListeningByMs = listeningAt.getTime() - touchSentAt.getTime(); + if (touchBeforeListeningByMs >= CLOCK_SKEW_MARGIN_MS) return 'confirmed-cold'; + if (touchBeforeListeningByMs <= -CLOCK_SKEW_MARGIN_MS) return 'confirmed-warm'; + return 'unknown'; +} + +/** + * The three exits a REQUIRED check needs (the job fails only on the + * conclusive forcing signal): + * - `bug-present` → exit 0 (a close occurred; today's normal), + * - `bug-gone` → exit 1 (enough touches reached a genuinely fresh, booting + * instance and every one of them held that an all-held result is strong + * evidence, not luck — the actionable removal message is the point of the + * failure), + * - `inconclusive` → exit 0 plus a CI warning annotation (loud, not blocking + * every PR on a deploy flake, a run that never managed to force a cold + * start, or a run too small to trust; a human should look). + */ +export type ColdStartVerdict = 'bug-present' | 'bug-gone' | 'inconclusive'; + +export interface ColdStartResult { + readonly verdict: ColdStartVerdict; + readonly message: string; +} + +/** + * The close rate `classifyColdStartRun` powers the bug-gone verdict against. + * PRO-217 is intermittent, and the rates actually observed against this + * stack run well above this number: a 60-second-spaced manual probe saw 3 + * closes out of 5 confirmed cold starts (60%), and an earlier round saw 3 + * closes out of 3 (100%). TARGET_CLOSE_RATE is deliberately set far below + * both, at 20%, so the sample budget below stays conservative even if the + * platform's real defect rate is much lower than anything observed so far — + * the canary should not need the bug to be as reproducible as it is today in + * order to still catch it. + */ +export const TARGET_CLOSE_RATE = 0.2; + +/** + * The most a bug-gone verdict is allowed to be "all held by luck": if the + * true close rate were TARGET_CLOSE_RATE and the bug were still present, the + * chance of seeing every one of MIN_HELD_SAMPLES_FOR_BUG_GONE independent + * confirmed cold starts hold is at most this. + */ +export const MAX_FALSE_CLEAN_PROBABILITY = 0.05; + +/** + * The number of confirmed cold-start holds classifyColdStartRun requires + * before it will say bug-gone. At a true close rate of TARGET_CLOSE_RATE, + * the chance that N independent cold starts all happen to hold is + * (1 - TARGET_CLOSE_RATE)^N; this is the smallest N for which that chance is + * at or below MAX_FALSE_CLEAN_PROBABILITY: + * + * 0.8^13 ≈ 5.50% (not low enough) + * 0.8^14 ≈ 4.40% (first N at or below 5%) + * + * so N = 14. Below that count, an all-held run is not evidence the bug is + * gone — it is the outcome intermittency predicts most of the time from too + * few samples. + */ +export const MIN_HELD_SAMPLES_FOR_BUG_GONE = Math.ceil( + Math.log(MAX_FALSE_CLEAN_PROBABILITY) / Math.log(1 - TARGET_CLOSE_RATE), +); + +function chanceAllHoldByLuck(heldCount: number): number { + return (1 - TARGET_CLOSE_RATE) ** heldCount; +} + +function asPercent(probability: number): string { + return `${(probability * 100).toFixed(1)}%`; +} + +/** + * Aggregates N first touches. A close anywhere is decisive on its own (rule: + * a close only happens mid-boot, so it needs no corroboration). Short of + * that, a touch that landed on an already-warm instance (`no-cold-start`) or + * came back some other inconclusive way (`other`) means the run never earned + * an opinion from that touch, so ANY of those makes the whole run + * `inconclusive` rather than mixing an uninformative touch into a "clean" + * verdict. And even a run where every touch held is only allowed to say + * "fixed" once it has collected MIN_HELD_SAMPLES_FOR_BUG_GONE confirmed + * cold-start holds — see that constant's comment for why a smaller all-held + * run is not evidence. + */ +export function classifyColdStartRun(touches: readonly ColdStartTouch[]): ColdStartResult { + const n = touches.length; + if (n === 0) return { verdict: 'inconclusive', message: 'Canary made no touches — broken.' }; + const count = (t: ColdStartTouch) => touches.filter((x) => x === t).length; + const closed = count('closed'); + const held = count('held'); + const noColdStart = count('no-cold-start'); + const other = count('other'); + + if (closed > 0) { + return { + verdict: 'bug-present', + message: + `Cold-start close still present (${closed}/${n} first touches closed, ${held} held, ` + + `${noColdStart} never went cold) — PRO-217 not fixed; keep the PRO-219 backoff in ` + + 'the streams client class (client.ts).', + }; + } + + if (noColdStart > 0 || other > 0) { + return { + verdict: 'inconclusive', + message: + `The canary failed to force a cold start on ${noColdStart + other}/${n} touches ` + + `(${noColdStart} landed on an already-warm instance, ${other} were otherwise ` + + 'inconclusive) — a run that never reaches a cold instance has no opinion to report on ' + + 'PRO-217. A human should look; not blocking.', + }; + } + + // Every touch reached a fresh, booting instance and held (noColdStart === 0, + // other === 0, closed === 0), so held === n here. Whether that is enough + // still depends on how many holds it actually is. + if (held < MIN_HELD_SAMPLES_FOR_BUG_GONE) { + return { + verdict: 'inconclusive', + message: + `All ${held} confirmed cold-start touches held, but PRO-217 is intermittent, so that is ` + + 'the outcome a too-small sample is expected to produce even with the bug fully present: ' + + 'even at a conservative 20% close rate (well below the 60-100% close rates seen in manual ' + + `reproduction against this stack), the chance that ${held} independent cold starts would ` + + `all happen to hold is ${asPercent(chanceAllHoldByLuck(held))}. This run needs at least ` + + `${MIN_HELD_SAMPLES_FOR_BUG_GONE} confirmed cold-start holds before an all-held result ` + + `drops that chance to ${asPercent(MAX_FALSE_CLEAN_PROBABILITY)} or below (0.8^` + + `${MIN_HELD_SAMPLES_FOR_BUG_GONE} ≈ ` + + `${asPercent(chanceAllHoldByLuck(MIN_HELD_SAMPLES_FOR_BUG_GONE))}). Not blocking.`, + }; + } + + return { + verdict: 'bug-gone', + message: + `All ${n} first touches against genuinely fresh, still-booting instances were held to ` + + `success — ${held} confirmed cold-start holds with zero closes. Even at a conservative 20% ` + + 'close rate (well below the 60-100% close rates seen in manual reproduction against this ' + + 'stack), the chance of that happening by luck alone is only ' + + `${asPercent(chanceAllHoldByLuck(held))}, so this counts as real evidence: the platform no ` + + 'longer shows the PRO-217 close, and the workaround exists with no problem. To fix this ' + + 'build (you are seeing it because the cleanup is now due, not because of your change): ' + + '1) delete IDEMPOTENT_BACKOFF and its uses in the streams client class ' + + '(packages/1-prisma-cloud/2-shared-modules/streams/src/client.ts); ' + + '2) remove scripts/cold-start-canary.ts, scripts/cold-start-canary-classify.ts (+ its ' + + 'test) and the "Cold-start canary (PRO-217)" job in .github/workflows/e2e-deploy.yml; ' + + "3) drop the removal-guard paragraph from gotchas.md's PRO-217 entry; 4) close PRO-219.", + }; +} diff --git a/scripts/cold-start-canary.ts b/scripts/cold-start-canary.ts new file mode 100644 index 00000000..d01d23ff --- /dev/null +++ b/scripts/cold-start-canary.ts @@ -0,0 +1,438 @@ +#!/usr/bin/env bun +/** + * Canary for PRO-217 (the Compute ingress closing a first-touch connection + * while a scale-to-zero service boots) — the Compute sibling of + * cold-connect-canary.ts, run as the VERIFY step of a deploy-verify-destroy + * round over examples/streams (the deploy and teardown are the action's; this + * script only samples). + * + * Shape: A fetches B — the deployed `jobs` service appends to the streams + * service on every POST /jobs, un-retried (no idempotency key). Each sample + * forces a genuinely fresh streams instance (create a deployment, upload the + * artifact, start it, promote it to the app's stable endpoint), fires ONE + * first-touch POST /jobs the instant the promote call succeeds, then reads + * the deployment's own boot logs to confirm the touch actually raced the + * boot — not just that a fresh instance existed somewhere. + * + * That log check exists because two earlier designs both produced false + * signals without it: + * + * 1. Waiting for the promoted version to report `running` before touching it + * (the original design) gives the boot window time to close: `running` + * can flip within ~1s of `start`, well before the app itself is listening + * (observed boot time end-to-end: ~2-10s depending on how much state the + * streams module restores from the object store), so every touch after + * that wait lands on an already-warm process. A follow-up that added + * three probes at 0/2.5/5s after promote didn't fix this either — it just + * added more delay on top of a promote call that had already let the + * window close. + * 2. Stopping the promoted deployment and touching it — a Management API + * `/deployments/{id}/stop` looked like a cleaner trigger than promoting a + * new version each sample. Verified live and it doesn't work: a stopped + * deployment does not revive on the next request. The app's stable + * endpoint just 404s (a plain HTML "Not Found", not the PRO-217 close) + * and stays down until something explicitly calls `start` again — so + * "stop, then touch" cannot trigger a cold start at all; it's a dead end, + * not a shortcut. + * + * What does work: create a new deployment, start it, and — instead of + * waiting for `running` — race the promote call itself (retrying immediately + * on the 409 "not running yet" it returns before the VM is up), then fire the + * touch the instant promote succeeds. That still doesn't, by itself, prove + * the touch beat the boot — so every touch's evidence is checked against the + * deployment's own logs (`/deployments/{id}/logs`, read from the start): + * spark's `starting bun with entrypoint: bootstrap.js` line marks the boot + * beginning, and the streams server's own `listening on 0.0.0.0:…` line + * marks the moment it can answer anything. A touch sent before that + * `listening` line is a genuine cold-start observation; a touch sent after + * it landed on an already-up process and carries no information about + * PRO-217 either way (see cold-start-canary-classify.ts's `ColdStartTouch` + * for the exact three-way split, and gotchas.md's PRO-217 entry for the run + * that skipped this check and reported "fixed" from four warm hits). + * + * A REQUIRED check: any close → exit 0, bug still present (today's normal); + * enough touches reaching a genuine cold start AND holding → exit 1, the + * forcing signal to remove the streams client's IDEMPOTENT_BACKOFF + * (PRO-219) and this canary; a run that never manages to force a cold start, + * or one whose log evidence can't place a touch on either side of the boot, + * or one too small to trust an all-held result from → exit 0 with a CI + * warning annotation (a broken/inconclusive/underpowered canary run, not a + * clean bill of health), so a deploy flake never blocks unrelated PRs. + * + * Two more defects, found by a second review round on top of the log check + * above, are fixed here: + * + * 3. Sampling back-to-back (as fast as create/upload/start/promote allow) + * produced atypically short boots — around 1s end to end — because + * consecutive deployments land on some kind of already-warm host + * resource; the mechanism isn't established, but the effect is. A manual + * probe that instead spaced touches 60s apart against the same stack saw + * boots of 3.3s, 10.4s, 11.6s, 12.8s, and 21.9s, and reproduced the close + * on 3 of 5 touches. SAMPLE_INTERVAL_MS below reproduces that spacing, and + * — per live evidence gathered while building this fix — is applied + * before sample #0 too: even with DURABILITY_WAIT_MS already elapsed, + * sample #0 landed in the same short, ambiguous window when it fired + * right after the deploy step's own start of this service. + * 4. PRO-217 is intermittent, so a run where every touch happened to hold is + * the outcome intermittency predicts most of the time from a small + * sample — it is not evidence the bug is gone. `classifyColdStartRun`'s + * bug-gone branch now requires a sample size, derived from a target close + * rate, that makes an all-held run genuinely improbable if the bug is + * still present; see cold-start-canary-classify.ts's + * MIN_HELD_SAMPLES_FOR_BUG_GONE for the arithmetic. + * + * A third fix, also from that round: the boot-log timestamp comes from the + * streams server's own clock on the Compute VM, and `touchSentAt` comes from + * `new Date()` on the CI runner — two different clocks with no guaranteed + * sync between them. `classifyBootEvidence` in cold-start-canary-classify.ts + * only calls a touch confirmed-cold or confirmed-warm when it is on the far + * side of a margin comfortably larger than plausible clock skew; anything + * closer than that is `unknown`, not a guess. The latency-based fallback + * this file used to fall back to when the log read failed is gone outright — + * real samples have been observed running 860-1598ms, straddling any fixed + * latency threshold, so a latency alone cannot stand in for reading the log. + */ +import { execSync } from 'node:child_process'; +import * as os from 'node:os'; +import { + type ColdStartTouch, + classifyBootEvidence, + classifyColdStartRun, + classifyColdStartTouch, + findListeningTimestamp, + MIN_HELD_SAMPLES_FOR_BUG_GONE, +} from './cold-start-canary-classify.ts'; + +const API = 'https://api.prisma.io/v1'; +/** + * MIN_HELD_SAMPLES_FOR_BUG_GONE confirmed cold-start holds are what a + * bug-gone verdict needs; sampling fewer than that can never produce one + * (classifyColdStartRun reports inconclusive instead), so that count is the + * default budget. A close is decisive the moment it happens (see the + * early-exit in the sampling loop below), so a run against a stack where the + * bug is present typically finishes in far fewer samples than this. + */ +const SAMPLES = Number(process.env['COLD_START_SAMPLES'] ?? String(MIN_HELD_SAMPLES_FOR_BUG_GONE)); +/** + * The streams module seals segments every 5s and uploads them to the store; a + * fresh instance bootstraps from what the store holds. Sample too soon after + * the warmup and the fresh instance restores a world without the canary's + * stream — every touch 404s (observed on this canary's first live round). + */ +const DURABILITY_WAIT_MS = Number(process.env['COLD_START_DURABILITY_WAIT_MS'] ?? '10000'); +/** + * The gap enforced before every sample, including the first — reproduces the + * 60s-spaced manual probe that actually lands touches in the long boot + * window; see fix 3 in the module comment above. This is applied before + * sample #0 too, on top of DURABILITY_WAIT_MS: live runs during this fix + * showed sample #0 landing in the same ~1-1.3s ambiguous window that + * back-to-back sampling produces, even with DURABILITY_WAIT_MS already + * elapsed, most likely because it follows so soon after the deploy step's + * own start of this same service. Since any touch this run cannot place on + * either side of the boot blocks a bug-gone verdict for the whole run (see + * cold-start-canary-classify.ts), a sample #0 that is reliably ambiguous + * would make bug-gone unreachable no matter how many samples follow it. + */ +const SAMPLE_INTERVAL_MS = Number(process.env['COLD_START_SAMPLE_INTERVAL_MS'] ?? '60000'); +/** + * How long to read a fresh deployment's boot logs before giving up on + * finding the `listening` line. Manual probing has observed start→listening + * as long as 21.9s; this sits comfortably above that so a genuinely slow + * (but real) boot still gets read to completion rather than timing out into + * an `unknown` boot-evidence verdict. + */ +const LOG_READ_TIMEOUT_MS = Number(process.env['COLD_START_LOG_READ_TIMEOUT_MS'] ?? '30000'); +/** + * The run's own wall-clock budget. If sampling is still going once this + * elapses, the loop stops taking new samples and reports whatever it has + * collected so far instead of running toward the CI job's own + * timeout-minutes kill: a job killed by that external timeout never reaches + * classifyColdStartRun at all, so it can't emit the inconclusive exit and + * warning annotation this script is supposed to use for a run that can't + * finish — it just shows up as a bare failed step. 20 minutes matches the + * worst-case budget (MIN_HELD_SAMPLES_FOR_BUG_GONE samples at roughly 85s + * each: 60s of spacing plus ~25s of create/upload/start/promote/touch/log- + * read, measured live) with the surrounding job's install/build/deploy/ + * destroy/sweep steps still fitting under its 30-minute timeout. + */ +const MAX_RUN_MS = Number(process.env['COLD_START_MAX_RUN_MS'] ?? '1200000'); + +const token = process.env['PRISMA_SERVICE_TOKEN']; +const stackName = process.env['STACK_NAME']; +if (!token || !stackName) { + console.error('PRISMA_SERVICE_TOKEN and STACK_NAME are required'); + process.exit(1); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +interface ApiResponse { + readonly status: number; + readonly data: unknown; +} + +/** POSTs/GETs the Management API, returning the status alongside the parsed `data` field — never throws on a non-2xx status. */ +async function apiCall(method: string, path: string, body?: unknown): Promise { + const init: RequestInit = { + method, + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + }; + if (body !== undefined) init.body = JSON.stringify(body); + const res = await fetch(`${API}${path}`, init); + const text = await res.text(); + let json: unknown; + try { + json = text ? JSON.parse(text) : undefined; + } catch { + json = text; + } + return { status: res.status, data: isRecord(json) ? json['data'] : json }; +} + +/** Same as apiCall, but throws on a non-2xx status — for calls this script cannot proceed without. */ +async function apiData(method: string, path: string, body?: unknown): Promise { + const res = await apiCall(method, path, body); + if (res.status < 200 || res.status >= 300) { + throw new Error(`${method} ${path} failed: ${res.status} ${JSON.stringify(res.data)}`); + } + return res.data; +} + +function requireString(record: unknown, key: string): string { + if (!isRecord(record) || typeof record[key] !== 'string') { + throw new Error(`expected "${key}" to be a string`); + } + return record[key]; +} + +/** The per-run project shares the stack's name (`prisma-composer deploy --name`). */ +async function findProjectId(): Promise { + const projects = await apiData('GET', '/projects?limit=100'); + const list = Array.isArray(projects) ? projects : []; + const match = list.find((p) => isRecord(p) && p['name'] === stackName); + if (match === undefined) throw new Error(`no project named "${stackName}" — did the deploy run?`); + return requireString(match, 'id'); +} + +interface Apps { + readonly jobsUrl: string; + readonly streamsAppId: string; +} + +/** `/v1/apps` is the current Management API surface for what used to be `/v1/compute-services` (same underlying resources, verified live — see gotchas.md's PRO-217 entry). */ +async function findApps(projectId: string): Promise { + const apps = await apiData('GET', `/apps?projectId=${projectId}&limit=100`); + const list = Array.isArray(apps) ? apps : []; + let jobsUrl: string | undefined; + let streamsAppId: string | undefined; + for (const app of list) { + if (!isRecord(app)) continue; + if (app['name'] === 'jobs') jobsUrl = requireString(app, 'appEndpointDomain'); + if (app['name'] === 'streams.service') streamsAppId = requireString(app, 'id'); + } + if (!jobsUrl || !streamsAppId) { + throw new Error(`stack "${stackName}" is missing the jobs/streams apps`); + } + return { jobsUrl, streamsAppId }; +} + +/** + * The deploy that just ran left the content-addressed streams artifact in the + * runner's temp dir (packageComputeArtifact) — reuse it so every promoted + * deployment is byte-identical to the deployed one. + */ +function findStreamsArtifact(): string { + const dir = `${os.tmpdir()}/prisma-composer-compute-${os.userInfo().uid}`; + const found = execSync(`ls -t ${dir}/*/streams.service.tar.gz 2>/dev/null | head -1`, { + encoding: 'utf8', + }).trim(); + if (!found) throw new Error(`no streams.service.tar.gz under ${dir} — did the deploy build?`); + return found; +} + +/** + * Reads a deployment's boot log from the start, stopping as soon as the + * app's own `listening` line has been seen (or LOG_READ_TIMEOUT_MS elapses, + * or the socket errors/closes). Returns the concatenated log text collected + * so far — `findListeningTimestamp` on the result may still be undefined if + * the line was never seen. + */ +function readDeploymentBootLog(deploymentId: string): Promise { + return new Promise((resolve) => { + const chunks: string[] = []; + let settled = false; + const ws = new WebSocket( + `wss://api.prisma.io/v1/deployments/${deploymentId}/logs?from_start=true`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + ws.close(); + resolve(chunks.join('')); + }; + const timer = setTimeout(finish, LOG_READ_TIMEOUT_MS); + ws.addEventListener('message', (event) => { + let parsed: unknown; + try { + parsed = JSON.parse(String(event.data)); + } catch { + return; + } + if (isRecord(parsed) && parsed['type'] === 'log' && typeof parsed['text'] === 'string') { + chunks.push(parsed['text']); + if (findListeningTimestamp(chunks.join('')) !== undefined) finish(); + } + }); + ws.addEventListener('error', finish); + ws.addEventListener('close', finish); + }); +} + +/** + * One fresh streams deployment, touched once: create → upload → start → race + * the promote call (retrying immediately on the "not running yet" 409 — NOT + * polling for `running` and then promoting, which is what let the boot + * window close in the original design; see the module doc comment) → fire + * the touch the instant promote succeeds → confirm from the deployment's own + * boot log whether the touch actually landed before the app was listening. + */ +async function sampleFreshStart( + jobsUrl: string, + streamsAppId: string, + artifactPath: string, + index: number, +): Promise { + const created = await apiData('POST', `/apps/${streamsAppId}/deployments`, { + portMapping: { http: 3000 }, + }); + const deploymentId = requireString(created, 'id'); + const uploadUrl = requireString(created, 'uploadUrl'); + const artifact = await Bun.file(artifactPath).arrayBuffer(); + const uploaded = await fetch(uploadUrl, { method: 'PUT', body: artifact }); + if (!uploaded.ok) throw new Error(`artifact upload failed: ${uploaded.status}`); + + await apiData('POST', `/deployments/${deploymentId}/start`); + + const promoteDeadline = Date.now() + 30_000; + for (;;) { + const res = await apiCall('POST', `/apps/${streamsAppId}/promote`, { deploymentId }); + if (res.status === 200) break; + if (res.status !== 409 || Date.now() > promoteDeadline) { + throw new Error( + `promote never succeeded for deployment ${deploymentId}: ${res.status} ` + + JSON.stringify(res.data), + ); + } + // A short, deliberate courtesy delay — not a "wait for running" poll. + // Each retry is still racing to promote at the earliest legal moment; + // this just keeps a slow boot from hammering the API every few ms. + await sleep(200); + } + + const touchSentAt = new Date(); + const started = Date.now(); + const res = await fetch(`${jobsUrl}/jobs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ kind: 'canary', touch: `${index}` }), + signal: AbortSignal.timeout(60_000), + }); + const body = await res.text(); + const latencyMs = Date.now() - started; + + const logText = await readDeploymentBootLog(deploymentId); + const listeningAt = findListeningTimestamp(logText); + const bootEvidence = classifyBootEvidence(touchSentAt, listeningAt); + const evidence = + listeningAt !== undefined + ? `logs: listening ${listeningAt.toISOString()}, touch sent ${touchSentAt.toISOString()} (${bootEvidence})` + : `no listening line read within ${LOG_READ_TIMEOUT_MS}ms — boot evidence unknown, not guessed`; + + const touch = classifyColdStartTouch(res.status, body, bootEvidence); + const detail = touch === 'other' ? ` — ${body.slice(0, 160)}` : ''; + console.log( + ` sample #${index}: ${touch} (${res.status}, ${latencyMs}ms) [${evidence}]${detail}`, + ); + return touch; +} + +const projectId = await findProjectId(); +const { jobsUrl, streamsAppId } = await findApps(projectId); +const artifactPath = findStreamsArtifact(); +console.log(`Stack "${stackName}" (${projectId}); jobs at ${jobsUrl}`); + +// Warm the CALLER and create the stream, so every sample's failure can only +// come from the fresh streams instance — not from jobs' own cold start or the +// retried (idempotent) create path. A few attempts: the very first CI touch +// can meet BOTH services cold at once, which is not what this canary samples. +let warmed = false; +for (let attempt = 1; attempt <= 3 && !warmed; attempt++) { + const warm = await fetch(`${jobsUrl}/jobs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ kind: 'canary', touch: `warmup-${attempt}` }), + signal: AbortSignal.timeout(90_000), + }); + if (warm.status === 201) warmed = true; + else { + console.error( + ` warmup attempt ${attempt}: ${warm.status} ${(await warm.text()).slice(0, 160)}`, + ); + await sleep(5_000); + } +} +if (!warmed) { + console.error('warmup never succeeded — the stack is unhealthy; not a PRO-217 verdict.'); + process.exit(1); +} +console.log( + `Warmed up; waiting ${DURABILITY_WAIT_MS}ms for the stream to reach the store, ` + + `then sampling ${SAMPLES} fresh streams instances…`, +); +await sleep(DURABILITY_WAIT_MS); + +const runStartedAt = Date.now(); +const touches: ColdStartTouch[] = []; +for (let i = 0; i < SAMPLES; i++) { + if (Date.now() - runStartedAt > MAX_RUN_MS) { + console.log( + ` stopping after ${i} sample(s): the run's own ${MAX_RUN_MS}ms wall-clock budget is used ` + + 'up — reporting the touches collected so far rather than risking a CI timeout kill.', + ); + break; + } + console.log(` waiting ${SAMPLE_INTERVAL_MS}ms before sample #${i}…`); + await sleep(SAMPLE_INTERVAL_MS); + const touch = await sampleFreshStart(jobsUrl, streamsAppId, artifactPath, i); + touches.push(touch); + // A close is decisive on its own (classifyColdStartRun's rule) — the + // verdict is already bug-present, and running the rest of the budget only + // spends CI minutes without changing the answer. + if (touch === 'closed') { + console.log( + ` close observed on sample #${i}; bug-present is already decided — skipping the ` + + `remaining ${SAMPLES - i - 1} samples.`, + ); + break; + } +} + +const result = classifyColdStartRun(touches); +console.log(result.message); +if (result.verdict === 'inconclusive') { + // A GitHub Actions warning annotation: loud on the run page without + // failing a required check over a deploy flake. Newlines must be %0A. + const detail = touches.map((touch, i) => `sample #${i}: ${touch}`).join('; '); + console.log( + `::warning title=Cold-start canary (PRO-217) inconclusive::${result.message} [${detail}]`, + ); +} +process.exitCode = result.verdict === 'bug-gone' ? 1 : 0;