feat(streams)!: the bearer key is an ADR-0031 provisioning need, minted per streams module#92
feat(streams)!: the bearer key is an ADR-0031 provisioning need, minted per streams module#92wmadden-electric wants to merge 28 commits into
Conversation
commit: |
b29db7d to
96b3f90
Compare
| // Provider side, streams' need (ADR-0031): the upstream server | ||
| // authenticates a single API_KEY, so its provisioner mints ONE value | ||
| // per provider and every inbound edge's ref must be that same key. | ||
| // This landing writes one value, which is only correct while that | ||
| // holds — so assert it rather than trust it: a future per-edge flip | ||
| // without the paired accepted-set landing would otherwise ship the | ||
| // wrong key silently. The refs are lazy Outputs here (not comparable | ||
| // at serialize time), but inside Output.map they are resolved | ||
| // strings, which is the same seam RPC's accepted set aggregates on. | ||
| const inboundStreams = streamsApiKeyEdges(graph).filter( | ||
| (e) => e.providerAddress === address, | ||
| ); |
There was a problem hiding this comment.
What is this? Why does a particular node need magic logic in the compute() base?
|
Answering the review comment on You were right, and it was worse than a streams problem. compute imported two brand modules and carried two hardcoded provider-landing blocks — RPC's (from #93) and streams' (ours). Every future provisioned-param brand meant another import and another block in the general descriptor: the accretion ADR-0031 exists to prevent, relocated from core into the target. And ADR-0031 already assigned the fix — "the provisioner owns mint, size, aggregation, stability, and rotation" — so the landing never belonged in compute at all. We inherited the shape from #93 and doubled it.
Grep on the current head: zero occurrences of Now rebasing onto main, which moved under this: #100 made RPC's landing provider-driven (zero consumers → literal 🤖 Generated with Claude Code |
6294c1b to
73039df
Compare
…inding credential (ADR-0030)
The streams binding becomes { url, apiKey }: the key is minted once at
deploy and delivered to consumers on the same rail as the URL, mirroring
storage's minted-credential machinery end to end. The module's apiKey
secret slot — and the "consumer binds the same platform variable twice"
convention it forced — disappear.
New in @internal/prisma-cloud, each the s3-credentials/s3-store template
applied:
- BearerKey Alchemy resource (PrismaCloud.BearerKey): a 48-hex key minted
on first create, returned unchanged on every later apply, so redeploys
no-op. Named distinctly from the rpc-service-key project's reserved
per-edge ServiceKey — this is a module-level, single-key credential
(the upstream server auths one API_KEY).
- bearerKey(): the dual-form authoring factory (resource with { name } /
dependency without), contract kind "bearer-key", binding { apiKey }.
- streamsDescriptor: compute's service lowering with extended outputs —
serialize/deploy surface apiKey from the wired credentials resource and
fail closed when it is unwired.
- streamsCompute(): compute with the routing type overridden to "streams",
exactly s3StoreService / "s3-store".
The streams package rewires onto it: the module provisions its own
bearerKey({ name: "credentials" }) and wires it into the service (deps
{ store, credentials }, secrets: none); the entrypoint reads API_KEY from
load().credentials.apiKey; the contract carries { url, apiKey }; the
integration test drives COMPOSER_CREDENTIALS_APIKEY (a dep param) instead
of the old secret pointer. examples/streams drops envSecret — nothing to
bind at the root anymore.
Also pins @durable-streams/server-conformance-tests to 0.2.3: dependabot's
0.3.5 bump (#86) tests fork/TTL/sub-offset features the wrapped
@prisma/streams-server 0.1.11 does not implement, failing 60/332 locally
on main before this change; the suite must track the pinned server
version, not float.
BREAKING CHANGE: streams() no longer has an apiKey secret slot; consumers
read the key from the durableStreams() binding.
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… rationale
README and SCOPE now describe the ADR-0030 model: the binding is
{ url, apiKey }, the key is minted at deploy and stable in deploy state,
and the wiring example loses the consumer secret slot and the
bind-the-same-variable-twice convention — the consumer reads the key from
load(). Both keep the single-key-per-module-instance note: per-edge keys
(rpc-service-key slice 2) need an upstream accepted-key-set change and are
recorded as future work. The "Deployed live path: use long-poll" section
stays as is (PRO-218).
Both conformance harness headers now record why the suite is pinned to
exact 0.2.3: later 0.3.x versions test features @prisma/streams-server
0.1.11 does not ship, so a floating range fails conformance for reasons
unrelated to this module.
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…ped mint #93 landed the generic per-edge provisioning rail (ProvisionNeed + the target's provisions registry) while this branch was in review. Compared and kept the provider-scoped BearerKey design: core's provision semantics mint one value per consumer→provider EDGE, and @prisma/streams-server authenticates a single API_KEY — per-edge values cannot apply until the upstream server accepts a key set. The rebase resolution keeps #93's provisions registry and ServiceKeyProvider intact alongside this branch's bearer-key/streams registrations. Folded in: - streams-descriptor tests pass graph.edges (compute's serialize now scans RPC edges via serviceKeyEdges — #93's ctx shape). - architecture.config.json entries for bearer-key.ts / streams-compute.ts (shared plane) and bearer-key-resource.ts (control plane) — matching how #93 registered service-keys.ts, so the plane rules actually see them. - README/SCOPE future-work note now records the concrete migration path: upstream accepted-key-set PR, swap durableStreams()'s apiKey param to a ProvisionNeed with a registered streams provisioner, delete the module-level mint (ADR-0031). - design-notes.md § "Streams consumer auth — settled by ADR-0030" (stranded on the merged predecessor branch, re-landed here) extended with the ADR-0031 comparison outcome. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…esign session) Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…R-0031 rework Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…not a module resource Reverses this branch's own mechanism. The BearerKey resource + `streams` descriptor were a second, streams-shaped way to mint a param value beside ADR-0031's general one. A module must build on the framework's internals rather than invent its own: two ways to audit one concept is one too many. Consistency is the whole reason — the resource route was never wrong, just redundant. - `durableStreams()`'s `apiKey` param now declares a ProvisionNeed under STREAMS_API_KEY. The brand, the edge scan and the reserved env name live in the target's `streams-keys.ts` (mirroring `service-keys.ts`), NOT in the declaring package as RPC does: prisma-cloud's layer order is lowering → extensions → modules, so a target import of @internal/streams would invert it. The module imports the brand downward instead. - The target registers `streamsApiKeyProvisioner` under that brand: the same `ServiceKey` mint as RPC, keyed on the PROVIDER's address rather than the edge id, so every consumer of one streams module resolves to one resource and one stable value — what @prisma/streams-server's single API_KEY needs. ADR-0031 leaves cardinality to the provisioner, so this is policy, not a workaround. - Provider landing (ADR-0019): compute's serialize writes the minted value to COMPOSER_<addr>_STREAMS_API_KEY and its `run` re-stashes it address-free, exactly like RPC's accepted set. The entrypoint reads it there instead of from a `credentials` dep, and refuses to boot with a named error when it is absent — no consumers means no minted key, and the only alternative would be serving every endpoint unauthenticated. - Deleted: bearer-key.ts, bearer-key-resource.ts, descriptors/bearer-key.ts, descriptors/streams.ts, streams-compute.ts, their registrations, exports, arch-config entries and tests. streams-service.ts is a plain compute() again; the module provisions no credentials resource. - examples/streams gains a `jobs` consumer service (append / read / long-poll tail over the binding), which exercises the key in-deployment and removes the zero-consumer shape from the example. Verified: target 161/161, streams 13/13 (incl. the entrypoint integration test over the landed key), local conformance 239/239, example 5/5, build/typecheck/test:types green, lint:deps clean, cast ratchet 0. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
The landing writes ONE key, which is only correct while the provisioner mints per provider. Taking the first inbound ref trusted that silently: a future per-edge cardinality flip without the paired accepted-set landing would have shipped whichever key came first and left every other consumer 401ing, with nothing failing. The refs are lazy Alchemy Outputs at serialize time, so they cannot be compared there — but inside Output.map they are resolved strings, which is the same seam RPC aggregates its accepted set on. Assert distinctness there and throw a named error that says which invariant broke and the two ways to fix it (mint per provider, or land an accepted set once the server takes one). Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…ongs to the provisioner Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
… through its brand's registered landing compute had one hardcoded provider-landing block per brand, plus an import of each brand's module. Every future provisioned param would have added another of each, in the one descriptor that must stay general — the exact per-brand accretion ADR-0031 exists to prevent, relocated out of core into the target. #93 established the shape for RPC and this branch doubled it; mirroring it was never a justification. ADR-0031 already assigns the fix — "the provisioner owns mint, size, AGGREGATION, stability, and rotation" — so aggregation moves out of compute and next to each provisioner: - `ProvisionLanding` (descriptors/shared.ts): given a provider's inbound refs for one brand, return the reserved env name and the aggregated value. Registered in control.ts beside that brand's provisioner, and handed to the descriptors as data on ResolvedCloudOptions, so control.ts stays the only file that names a brand and no import cycle forms. - `provisionedEdges(graph)` (new): the ONE scan, carrying each edge's brand as data. Replaces the two hand-written per-brand scanners — recognising a brand is a map lookup, never a branch. - compute's serialize: group this address's inbound provisioned edges by brand, hand each group to its landing, write what comes back. It imports neither brand module and names no brand. A brand with no landing needs nothing provider-side (core fills its consumers' params), so it is skipped. - RPC's landing migrates onto the seam too — the leak is not deleted while its first instance remains. Its JSON accepted-set aggregation and RPC_ACCEPTED_KEYS name move to `serviceKeyLanding`; streams' single-value landing and its keys-must-agree assertion move to `streamsApiKeyLanding`. Same env keys, same encodings — #93's tests are the regression net. The runtime half had the same accretion (a re-stash per brand): boot now re-keys this address's whole reserved namespace address-free by prefix, derived through configKey so it cannot drift from what deploy wrote, before the typed stashes that stay authoritative for params and secret pointers. That drops the last two brand imports, from src/compute.ts. No deploy-observable change: same env var names, same values, same encodings — only where the code that computes them lives. Verified: target 162/162 (incl. #93's RPC provisioning tests untouched), streams 13/13, local conformance 239/239, example 5/5, build/typecheck/ test:types green, lint:deps clean, cast ratchet 0. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
The sweep is what makes every address-free reader work, and nothing exercised it through run(): the entrypoint integration test sets the address-free key directly and never boots through compute. Four tests, each verified against a planted defect: - a LANDING var at this address IS aliased address-free, with compute naming no brand (fails when the sweep is removed, and when it is made address-blind). - a sibling's var is NOT aliased — including the sharp case of a service nested UNDER this address, whose var starts with this sweep prefix. Anchoring the prefix is what makes a foreign var structurally unable to produce this address's key. - stash() wins over the sweep's raw copy for a declared param (fails when the sweep is moved after the typed stashes — the ordering the comment claims, now pinned). - an empty address is a no-op sweep; the address-free row stands. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
The live re-proof's first POST after a redeploy returned 500: ensureStream()'s PUT hit the streams service mid-cold-start, the fetch rejected, and the throw became an opaque 500. As a teaching artifact the example was demonstrating the wrong lesson — a consumer of a scale-to-zero service WILL meet it cold (PRO-217, already in gotchas.md). Did NOT reuse @internal/prisma-cloud/connection's retryTransientConnect: it is not a public export, and examples may import only the 9-public packages (ADR-0028, `examples-import-public-only`) precisely so they stay honest demos of what a user can write — a user writing this app could not import it either. Its predicate is also Postgres vocabulary, and the HTTP case needs transient STATUSES (502/503/504), which a predicate over thrown errors cannot express. So: a small, obvious retry local to the example, not an HTTP retry library. - `fetchIdempotent`: bounded backoff (4 attempts, 250ms→1s) over the cold-start class only — socket failures and 502/503/504. Used for the PUT and the reads. - Appends stay un-retried, deliberately: a write that may have reached the server cannot be blindly repeated (gotchas.md's own guidance). ensureStream() runs first on every path, so it is the first touch that absorbs the cold instance and the append meets a warm one. - A dependency still unreachable past the retries now answers 502 with the cause, instead of an opaque 500. Both new tests were checked against planted defects: dropping the retry fails the cold-start test, and treating any non-ok as transient fails the 401 test. A proxy in front of the real stand-in injects the failure, so the retry is exercised through the app rather than against a mock of itself. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
The comment claimed ensureStream() had already waited out a cold instance so the append meets a warm one. That is only true for the FIRST append — `created` is memoized, so a later one can still meet a cold service and surface 502. Narrow, and it fails closed, but in an example the comment IS the lesson, so it has to be true. Now states the real reason appends are not retried (no idempotency key, so a failure is indistinguishable from an applied write) and what actually follows: the first append is shielded by the retried PUT, a later one can surface 502, and the caller retries because only it knows whether a duplicate is acceptable. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
The old ~1.75s budget was a plausible-looking constant, and it was under the real thing: the streams entrypoint takes ~3.5s from VM start to listening on a small object store and ~8s on a larger one (measured from Compute version logs, spark start -> "prisma-streams server listening"). So the example demonstrated the opposite of its lesson — a cold dependency took three manual attempts. Now a 30s budget with exponential backoff (250ms doubling to an 8s cap), expressed as a deadline so the number IS the budget: covers the observed worst case with room, still gives up rather than hanging on a dependency that is genuinely down, and is well inside the framework's own 60s cold-start retry for the same class of problem on Postgres (FT-5226). The comment cites the measurement rather than folklore. Proven live: against a genuinely fresh streams instance (a new version promoted the moment it reported running), the FIRST POST /jobs returns 201 in 3.69s — riding out the boot the old budget would have thrown on. The non-retried append and its comment are unchanged. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
73039df to
53bb085
Compare
Reverses D8/D11. The retry was mine, not the evidence's, and interrogating it did not survive contact: - The budget was sized against the boot time (3.5-8s), but no fetch here is time-bounded — the deadline bounds the SLEEPS. If the edge holds the connection through a cold start, which PRO-217 says it does on most hits, a plain fetch blocks and returns 201 and the budget never applies. - The 201-in-3.69s proof cannot distinguish one held request from four retries; I did not capture the service's logs and cannot say which. Either way the old budget would have passed the same test, so the tune was justified by a number that was not measuring what I claimed. - The observation that motivated the tune — "three attempts after a redeploy" — was a misdiagnosis: those 502s were the deliberately non-retried append, which no budget governs. - The retried cold-start STATUS (502/503/504) was never observed from the platform at all. It came from my own proxy stub, written to match my assumption. What is left is real but not the app's job: an intermittent thrown socket close (PRO-217), already filed. Absorbing it here costs ~45 lines of platform-specific boilerplate in a teaching artifact, cannot cover the non-idempotent append where it was actually observed, and hides the defect from the people who would fix it. So: plain fetch. The top-level guard stays (a 502 naming the cause beats an opaque 500 — ordinary hygiene, not Prisma-specific), and the example's first request after an idle spell may now intermittently fail. That is the honest state of the platform. The synthetic cold-503 test goes with it — it asserted my assumption, not the platform. The 401 test stays and still catches removal of the guard. gotchas.md's PRO-217 entry is extended with what this slice learned: the thrown-close face and its ~400ms fast-fail, the measured ~3.5-8s boot window from the version logs, that no edge 502/503/504 face was ever seen, that it landed on both an idempotent PUT and a non-idempotent append, that idling is an unreliable trigger and bodies must be captured to tell whose 502 it is, and PRO-219 for the userspace-boilerplate cost. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…ot edge intent "How long the edge must hold" asserted a platform contract nobody made; the entry now states the two observed outcomes and that the caller cannot predict which. Matches the PRO-219 rewrite. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
| /** Edge statuses that mean "the service isn't up yet", not "your request was wrong". */ | ||
| const COLD_START_STATUS = new Set([502, 503, 504]); | ||
|
|
||
| /** | ||
| * The streams service scales to zero, so the first call after an idle spell | ||
| * meets an instance that is still booting: the connection can reset | ||
| * mid-establish, or the edge answers 502 while it comes up (Prisma Compute, | ||
| * PRO-217 in gotchas.md). | ||
| * | ||
| * The 30s budget is measured, not folklore: the streams entrypoint takes | ||
| * ~3.5s from VM start to listening on a small object store and ~8s on a | ||
| * larger one (Compute version logs), so this covers the observed worst case | ||
| * with room and still gives up rather than hanging on a dependency that is | ||
| * genuinely down. The framework's own cold-start retry allows 60s for the | ||
| * same class of problem on Postgres (FT-5226). | ||
| */ | ||
| const COLD_START_BUDGET_MS = 30_000; | ||
| const FIRST_BACKOFF_MS = 250; | ||
| const MAX_BACKOFF_MS = 8_000; |
There was a problem hiding this comment.
This doesn't belong in userspace
| // The boot sweep (compute.ts's `restashAddressFree`) is what makes every | ||
| // address-free reader work — config, secrets, serve()'s accepted keys, and | ||
| // any landing's reserved var (the streams entrypoint's API_KEY is the second | ||
| // one). 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() aliases a LANDING's var at this address address-free, with no knowledge of the brand", async () => { |
There was a problem hiding this comment.
What does any of this jargon mean
…am check) Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…ol logic leaves userspace
An app consuming streams was hand-rolling the Durable Streams protocol: URL
layout, bearer scheme, JSON-array append framing, offset conventions, the
long-poll dance. RPC users don't do their own request encoding (rpc()
hydrates through makeClient); streams users shouldn't either.
New `createStreamsClient({ url, apiKey })` in @internal/streams wraps
@durable-streams/client (ElectricSQL's canonical protocol client, Apache-2.0,
pinned 0.2.1 — the version @prisma/streams-server 0.1.11's own repo pairs
with; pure fetch-based JS, no node:/bun tokens, inlined by tsdown so no
package grows a runtime dependency). The surface is what the module contract
promises — create / append / read / tail — not everything Electric ships.
Platform compensations live here, each with its ticket:
- Auth from the binding: the bearer header rides every request kind (the wire
client resolves headers per request, including the polls).
- tail() long-polls — SSE cannot traverse the Compute ingress (PRO-218) — and
uses the protocol's own offset=now, so there is no client-side head dance.
- Idempotent operations (create/read/tail) ride out a cold start with a
bounded backoff (PRO-219). Appends NEVER retry: the wire client's default
retries network errors indefinitely on every method including POST, so the
writer handle pins maxRetries: 0 and batching: false (no idempotency key
upstream — a failed append is indistinguishable from an applied one).
durableStreams() now hydrates to the client (the wire binding stays
{ url, apiKey }; hydration returns StreamsClient). The factory is exported —
and via the umbrella — so local dev wraps the stand-in URL without load().
The example collapses to app logic: routes, the stream name, error mapping,
and calls on the hydrated client. No URL building, no bearer header, no
offset names, no long-poll dance in userspace. Its integration test drives
the app through the client against the stand-in; a new client test covers
create-ensure semantics, append/read round-trips, opaque-cursor resume, live
tail delivery and clean timeout, and that a real protocol error surfaces
unretried. Conformance harnesses stay raw fetch — they test the server, not
our client.
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…without a content-type Found live, invisible against the stand-in: the deployed server's offset=now long-poll answers 204 with no content-type, so the wire client's JSON-mode detection failed and tail() threw "JSON methods are only valid for JSON-mode streams". This module's streams are JSON by contract, so read() and tail() pass the wire client's `json: true` hint instead of depending on a header. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
The 401 no-retry test exercises a 4xx, which the wire client throws BEFORE its retry branch at any maxRetries — so deleting NO_RETRY_BACKOFF (or batching: false) regressed silently to infinitely-retried, batch-coalesced appends with every test green. Two tests through a counting proxy close that: - A 503 on an append (503 IS in the wire client's retry set) rejects after exactly ONE POST. Removing NO_RETRY_BACKOFF makes the append resolve on the proxy's second POST instead — verified red. - Five concurrent appends are five POSTs. Removing batching: false lets the wire client coalesce the in-flight window into shared POSTs (2 arrive, and a failure would be ambiguous across callers' events) — verified red. Also corrects the IDEMPOTENT_BACKOFF comment to what the wire client actually does: it retries any failure except a 4xx other than 429 — thrown network errors AND 5xx statuses — and the bound is attempts, not wall-clock (each wait jittered, a server Retry-After acting as a per-wait floor, capped upstream at 1h). Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…ms backoff The FT-5226 cold-connect canary's contract, applied to the Compute face of the same disease: a CI job that passes only while the bug is still present, and goes red as the signal to remove the workaround it guards — createStreamsClient's IDEMPOTENT_BACKOFF (the PRO-219 compensation). Shape: the deploy-verify-destroy action over examples/streams, with the canary as its verify step — A (jobs) appends to B (streams) un-retried, so what the caller sees on B's first touch is the raw platform behavior. Each sample forces a FRESH B by promoting a new version of the deploy's own content-addressed artifact (idling is an unreliable trigger, per the gotcha), then probes across the switchover window (0/2.5/5s — routing to the new instance is not instant, so a single immediate touch can land on the old warm one and read as a hold it never earned). Judged with the FT-5226 unanimity rule: any close proves the bug; all-held is evidence, not proof, and says so. Two flaws the first live rounds caught, fixed: - fresh instances bootstrap from the object store, and a sample taken before the warmup's stream had uploaded (5s seal interval) restored a world without it — every touch 404'd. The canary now waits out the durability window after warmup. - the 404s exposed a real app bug: jobs' memoized create meant a stream lost from the durable tier bricked the instance permanently. The app now heals — a 404'd operation provably applied nothing, so re-create and retry once is safe even for the append. Covered by a test that deletes the stream out from under the app (verified red without the heal). Observed today, fresh workspace: 0 closes across 9 fresh starts (17 probes) — historically the close was frequent (D7/D10/D13 all hit it). Either the platform improved or the trigger differs; exactly the question the canary now keeps asking on every run. gotchas.md's PRO-217 entry gains the removal-guard pointer, and IDEMPOTENT_BACKOFF's comment names the canary as its removal trigger. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…b reality
The entry still described the pre-client-lib example ("no retry, plain
fetch") two lines above the removal guard for exactly that retry. It now
says where the compensation actually lives (createStreamsClient, idempotent
operations only), why appends stay un-retried, and what the canary guards.
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…nt lib The jobs example healed a vanished stream by duck-typing status === 404 on the wire client's undocumented error shape — the last trace of wire-client knowledge in userspace. `isStreamNotFound(error)` now lives in the client module, where that shape is already a known dependency (an instanceof check against the wire client's own error classes, exported beside createStreamsClient and via the umbrella), and the example uses it. Semantics unchanged, deliberately: exactly the applied-nothing 404 — ambiguous failures (socket closes, 502/504) never match, which is what the heal's retry-once safety proof rests on. The healing test still passes and still goes red without the heal (re-checked). Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…signal The canaries are becoming REQUIRED checks: the build must fail when a workaround exists with no problem, and must not fail for any other reason. Both canaries move from pass/fail to a three-state verdict with the exits a required check needs: - bug-present → exit 0 (today's normal; the job stays green), - bug-gone (conclusive all-clean) → exit 1 — the forcing signal. The message is written for whoever meets it cold on an unrelated PR: it says the failure is not caused by their change, and lists the full cleanup — the workaround (createStreamsClient's IDEMPOTENT_BACKOFF / pg-connection.ts's withConnectionRetry), the canary files and workflow job, the gotchas.md removal-guard text, and the ticket to close (PRO-219 / FT-5226). - inconclusive (mixed, timeouts, 404s, broken canary) → exit 0 plus a ::warning:: annotation carrying the verdict and per-sample detail — loud on the run page without blocking every PR on a deploy flake. Previously inconclusive exited 1, which a required check cannot afford. Both classifier test suites pin the three-exit mapping, including that the bug-gone message names every artifact the cleanup touches. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…ontract Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
| const append = async (req: Request): Promise<Response> => { | ||
| const event = await req.json(); | ||
| // The client never retries appends (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 withStream(() => events.append(STREAM, event)); | ||
| return Response.json({ appended: event }, { status: 201 }); | ||
| }; |
There was a problem hiding this comment.
Why is the user's application doing this? I would have expected STREAM was a property of the durable-stream connection DI'ed into the service.
And why is await withStream() necessary in applciation code, instead of in the hydrated stream client?
There was a problem hiding this comment.
Is there value in separating this app.ts from server.ts?
There was a problem hiding this comment.
I don't understand, what changed in this file? Just indentation?
| * every registered landing about every exposing service and writes whatever | ||
| * comes back — returning `undefined` writes no row. | ||
| */ | ||
| export type ProvisionLanding = (input: { |
There was a problem hiding this comment.
I'm deeply suspicious of this - what is it?
| * 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 provisionLandings: ReadonlyMap<symbol, ProvisionLanding>; |
| // Re-key THIS service's whole reserved namespace address-free, before | ||
| // the typed re-stashes below: every reader downstream (config, secrets, | ||
| // serve()'s accepted keys, the streams entrypoint's API_KEY) looks its | ||
| // var up with no address, because one instance runs one service. Doing | ||
| // it by prefix keeps this brand-blind — a landing's reserved name is the | ||
| // registered landing's business (control.ts), never something compute | ||
| // has to know (ADR-0031). Only this address's own vars move; the typed | ||
| // stashes that follow overwrite anything they own, so they stay | ||
| // authoritative for params and secret pointers. | ||
| restashAddressFree(address); |
There was a problem hiding this comment.
What does this mean? What does "restash address free" mean? why would we want this? Why is this permitted in compute code?
| * Copies `COMPOSER_<address>_<rest>` to `COMPOSER_<rest>` for every var of this | ||
| * service's address. Derives both names through `configKey`, so it cannot drift | ||
| * from what deploy wrote. |
| ); | ||
| } | ||
|
|
||
| export function createStreamsClient(config: StreamsConfig): StreamsClient { |
There was a problem hiding this comment.
Convert to a class. It's an object with a bunch of methods, stop forcing factory functions in the wrong paradigm
Two settled designs, recorded before any slice spec/plan per operator direction: provider-side minted values become declared reserved params (deleting restashAddressFree), and the streams contract carries named streams whose handles own the lifecycle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
What an app author writes
Nothing anywhere authors the key, a URL, a header, or a protocol detail: no secret slot, no
envSecret, no bearer scheme, no offset names, no long-poll dance. Declaring the dependency is the wiring, and hydration hands back a typed client — the RPC parity (rpc()hydrates throughmakeClient; streams hydrates throughcreateStreamsClient). Previously the root bound one platform variable into the module'sapiKeysecret slot and into every consumer's own slot, connected only by convention, and the app hand-rolled every request.Decision
The bearer key is a value the framework mints, which is exactly what ADR-0031 is for:
durableStreams()'sapiKeyparam declares a provisioning need under a streams brand, and the prisma-cloud target registers a provisioner for that brand. It is not an ADR-0029 secret (no external value, nothing to name) and not a producer output (no producer hands it over).The provisioner is per-provider: it reuses #93's
ServiceKeyresource with the provider's address as the resource id, so every consumer edge of onestreams()module resolves to one resource and one stable value — which is what@prisma/streams-serverrequires, since it authenticates a singleAPI_KEY. ADR-0031 explicitly leaves cardinality to the provisioner, so this is policy, not a workaround.Consistency is the reason this shape won. An earlier revision of this PR minted the key with its own
BearerKeyresource + astreamsnode descriptor. That worked, but it stood a second, streams-shaped mechanism next to the framework's general one — two ways to audit one concept, and one more thing a maintainer must learn per module. A module should build on the framework's internals rather than invent its own.What's in the diff
streams-keys.ts(new, target): the brandSTREAMS_API_KEY, the need constructor, the faceted-edge scan, and the reserved env name — mirroringservice-keys.ts, with its same no-effect/no-@internal/loweringconstraint.@internal/rpcdoes). prisma-cloud's layer order islowering → extensions → modules, so a target import of@internal/streamswould invert the layering. The brand lives in the target and the module imports it downward instead; the writer/reader-share-one-key discipline is unchanged. Documented at both ends.streamsApiKeyProvisionerregistered in the target'sprovisionsmap besideserviceKeyProvisioner— sameServiceKeymint, keyed onedge.providerAddress. No new resource kind.serializewrites the minted value toCOMPOSER_<addr>_STREAMS_API_KEY; compute'srunre-stashes it address-free — the same channel shape RPC's accepted set uses. The streams entrypoint reads it there.streams()nothing depends on never gets a key minted, and the server's only alternative would be serving every endpoint unauthenticated. It refuses to boot with a named error instead. (A deploy-time check isn't cheaply expressible: after the descriptor deletion the streams service is a plaincompute(), and nothing in the target can tell it apart without re-adding the streams-shaped surface this PR removes. The deploy-side half — no consumers → no mint, no landing — is pinned by a target test.)bearer-key.ts,bearer-key-resource.ts,descriptors/bearer-key.ts,descriptors/streams.ts,streams-compute.ts, their registrations, exports, arch-config entries and tests.streams-service.tsis a plaincompute()again; the module provisions no credentials resource.createStreamsClient/StreamsClient(@internal/streams, exported via the umbrella): the client the binding hydrates to — create (ensure-style) / append / read / tail, wrapping@durable-streams/client(ElectricSQL's canonical protocol client, Apache-2.0, pinned 0.2.1 — the version@prisma/streams-server0.1.11's own repo pairs with; pure fetch-based JS inlined by tsdown, so no package grows a runtime dependency). Platform compensations live here with their tickets:tail()long-polls (PRO-218) via the protocol's ownoffset=now; idempotent operations ride out a cold start with a bounded backoff (PRO-219); appends never retry — the wire client's default retries network errors indefinitely on every method including POST, so the writer handle pinsmaxRetries: 0andbatching: false(no idempotency key upstream). The factory is exported standalone so local dev wraps the stand-in URL withoutload().examples/streamscollapses to app logic: routes, the stream name, error mapping, and calls on the hydrated client — zero protocol knowledge in userspace.examples/streamskeeps itsjobsconsumer service (append / read / long-poll tail over the binding), which exercises the key in-deployment and removes the zero-consumer shape from the example.jobsconsumer carries no retry, deliberately. It calls the streams service with a plainfetch; a failure surfaces as a 502 naming its cause. Cold-start handling belongs in the platform, not in every consumer — see the Verification section and PRO-219.@prisma/streams-server0.1.11 doesn't ship — 60/332 failed locally on main before this. The suite must track the pinned server version, not float; both harness headers record why.Verification
Proven live on real Prisma Cloud (stack
streams-example, deployed then destroyed — re-run on the client-hydration commits):destroy; the deploy log double-reports steps). Onestreamskey-streams.servicemint, landed to the consumer's param and the provider's reserved var.POST /jobs→ 201 (first request, cold deploy);GET /jobs→ 200 with events and anextOffset;GET /jobs?offset=<mid>→ the opaque-cursor resume returns exactly the later events;GET /jobs/tail→ the client's long-poll delivered an event appended after it opened. Auth negatives direct to streams: unauthenticated → 401, junk bearer → 401.offset=nowlong-poll answers 204 with no content-type, defeating the wire client's JSON-mode detection (invisible against the stand-in, which sends the header). The client now passes thejson: truehint — streams are JSON by this module's contract.[streamskey-streams.service] noop, key byte-identical in state, consumer green with all prior events intact.ac1e7b1).POSTreturned 502 (the deliberately non-retried append meeting a cold instance) — PRO-217/PRO-219 surfacing exactly as the example intends.Local:
@internal/streams18/18 — including the new client suite against the stand-in (create-ensure semantics, append/read round-trips, opaque-cursor resume, live tail delivery + clean timeout, and a real protocol error surfacing unretried) and the entrypoint integration test end to end.@internal/prisma-cloud188/188 (incl. feat(rpc): auto-provision per-binding RPC service keys (ADR-0030/0031) #93's and docs(rpc): document service keys; deny zero-consumer providers; close the project #100's regression nets and the boot-sweep/landing tests, each checked against a planted defect); local conformance 239/239 (raw-fetch harness — it tests the server, not our client);examples/streams6/6 (the app driven through the client against the stand-in).bun/node:builtins) with the wire client inlined; build / typecheck / test:types green; repopnpm lintgreen;lint:depsclean; cast ratchet delta 0.Alternatives considered
Hydrate to the raw
{ url, apiKey }config and let apps hand-roll the protocol (this PR's earlier revision). Rejected by the operator: Durable Streams protocol logic in a user application is the moral equivalent of RPC users doing their own request encoding. The client library is also the right home for the platform compensations (PRO-218's long-poll default, PRO-219's idempotent-only retry) — in userspace they were boilerplate; in the library they are policy.A module-owned
BearerKeyresource + astreamsnode descriptor (this PR's earlier revision). Rejected on consistency: it duplicated ADR-0031's job with streams-specific surface. The narrower technical comparison only ever showed it was possible, never that it was right.Per-edge keys now. Needs
@prisma/streams-serverto accept a key set (it authenticates oneAPI_KEY). When upstream grows that, this becomes a provisioner cardinality flip plus an accepted-set landing — no resource to add, no core change, nothing here to delete. Recorded in design-notes.md.External (out-of-deployment) key access: the same recorded platform ask as storage's minted SigV4 credentials; test harnesses read deploy state instead.
🤖 Generated with Claude Code