diff --git a/README.md b/README.md index ab993fe8..a4376cba 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ import { z } from 'zod'; const getUser = stitch({ path: 'https://demo.stitchapi.dev/users/{id}', output: z.object({ id: z.number(), name: z.string() }), - unwrap: 'data', + pick: 'data', }); const user = await getUser({ params: { id: 1 } }); // typed · validated @@ -103,7 +103,7 @@ Keep the `fetch` or axios you already have — it's the adapter underneath. A st ## Motivation -In almost every project there's a `src/api/` folder of thin functions that fire an HTTP request and unwrap the response. Everything that actually makes an integration reliable — auth lifecycle, retries, rate limits, timeouts, response validation, drift detection, observability — gets re-implemented at every call site, and each wrapper rots independently. `fetch` hands back opaque bytes, and raw bytes aren't what application code (or an AI agent) needs; both want structured, validated, observable results. +In almost every project there's a `src/api/` folder of thin functions that fire an HTTP request and pull the payload out of the response. Everything that actually makes an integration reliable — auth lifecycle, retries, rate limits, timeouts, response validation, drift detection, observability — gets re-implemented at every call site, and each wrapper rots independently. `fetch` hands back opaque bytes, and raw bytes aren't what application code (or an AI agent) needs; both want structured, validated, observable results. A stitch folds all of that back into the call: @@ -199,7 +199,7 @@ Reach for the full set of knobs only when you need them — they default off: const getUser = stitch({ path: 'https://demo.stitchapi.dev/users/{id}', output: User, // a validator of your choice - unwrap: 'data', + pick: 'data', retry: 3, // ≡ { attempts: 3 } timeout: '5s', // ≡ { total: '5s' } cache: '1m', // ≡ { ttl: '1m' } @@ -227,12 +227,12 @@ const api = seam({ const listUsers = api.stitch({ path: '/users', output: User.array(), - unwrap: 'data', + pick: 'data', }); const getUser = api.stitch({ path: '/users/{id}', output: User, - unwrap: 'data', + pick: 'data', }); ``` @@ -255,7 +255,7 @@ import { z } from 'zod'; const listOrders = stitch({ path: 'https://demo.stitchapi.dev/users/{id}/orders', - unwrap: 'data', + pick: 'data', output: drift( z.array(z.object({ id: z.number(), total: z.number().optional() })), { @@ -277,12 +277,12 @@ const listUsers = stitch({ baseUrl: 'https://demo.stitchapi.dev', path: '/users', retry: { attempts: 4, on: [429, 502, 503], respectRetryAfter: true }, - throttle: { rate: '1/s', concurrency: 2, scope: 'host' }, + throttle: { rate: '1/s', concurrency: 2, pool: 'host' }, timeout: { total: '30s', perAttempt: '10s' }, }); ``` -`throttle` is proactive (keeps you under a limit before it bites; `scope: 'host'` shares a limiter across stitches), `retry` is reactive (backoff + `Retry-After`), and `timeout` aborts with a real `AbortSignal`. Three more knobs round it out: **`circuit`** fast-fails a dependency that's already down, **`idempotency`** injects a stable `Idempotency-Key` on writes, and **`acceptStatus`** treats a non-2xx (e.g. `404`) as a normal result instead of a throw. Full guide: [Resilience](https://stitchapi.dev/docs/guides/resilience/retry). +`throttle` is proactive (keeps you under a limit before it bites; `pool: 'host'` shares a limiter across stitches), `retry` is reactive (backoff + `Retry-After`), and `timeout` aborts with a real `AbortSignal`. Three more knobs round it out: **`circuit`** fast-fails a dependency that's already down, **`idempotency`** injects a stable `Idempotency-Key` on writes, and **`acceptStatus`** treats a non-2xx (e.g. `404`) as a normal result instead of a throw. Full guide: [Resilience](https://stitchapi.dev/docs/guides/resilience/retry). ## Caching @@ -292,19 +292,19 @@ A read-through response cache with in-process request coalescing — **off by de const getUser = stitch({ path: 'https://demo.stitchapi.dev/users/{id}', output: User, - unwrap: 'data', + pick: 'data', cache: '5m', }); const listAnnouncements = stitch({ path: 'https://demo.stitchapi.dev/announcements', output: z.array(z.object({ id: z.number(), title: z.string() })), - unwrap: 'data', + pick: 'data', cache: { ttl: '1h', scope: 'app', vary: ['accept-language'], - maxEntries: 500, + entries: 500, version: 1, // pins the shape — cacheable without a fingerprinter }, }); @@ -331,16 +331,16 @@ const getUser = stitch({ A **surface** is the request _style_ a stitch speaks. `http` is the default; the rest are peer surfaces on the same engine — `auth`, `retry`, `throttle`, `timeout`, validation, and the event stream compose with every one. Each ships as its own subpath import, so `import { stitch }` pulls in `http` alone. -| Surface | Import | Shapes | `await` resolves to | -| ------------- | ----------------------------- | ------------------------------------------ | ------------------------------ | -| `http` | `stitch` (default) | a JSON-over-HTTP call | the validated body | -| `graphql` | `stitchapi/graphql` | POST `{ query, variables }`, unwrap `data` | the `data` payload | -| `sse` | `stitchapi/sse` | a `text/event-stream` reader (over fetch) | every parsed event, collected | -| `stream` | `stitchapi/stream` | a raw `ReadableStream` reader | every decoded chunk, collected | -| `download` | `stitchapi/download` | a buffered binary GET | `{ blob, filename }` | -| `llm` | `stitchapi/llm` | a chat-completion via a provider contract | the normalised `{ text, … }` | -| `shell` | `@stitchapi/shell` (peer pkg) | a local command, args + stdin | the command's stdout | -| `postmessage` | `stitchapi/postmessage` | a typed iframe ↔ parent RPC / event call | the typed RPC response | +| Surface | Import | Shapes | `await` resolves to | +| ------------- | ----------------------------- | ----------------------------------------- | ------------------------------ | +| `http` | `stitch` (default) | a JSON-over-HTTP call | the validated body | +| `graphql` | `stitchapi/graphql` | POST `{ query, variables }`, picks `data` | the `data` payload | +| `sse` | `stitchapi/sse` | a `text/event-stream` reader (over fetch) | every parsed event, collected | +| `stream` | `stitchapi/stream` | a raw `ReadableStream` reader | every decoded chunk, collected | +| `download` | `stitchapi/download` | a buffered binary GET | `{ blob, filename }` | +| `llm` | `stitchapi/llm` | a chat-completion via a provider contract | the normalised `{ text, … }` | +| `shell` | `@stitchapi/shell` (peer pkg) | a local command, args + stdin | the command's stdout | +| `postmessage` | `stitchapi/postmessage` | a typed iframe ↔ parent RPC / event call | the typed RPC response | Full guide: [Surfaces](https://stitchapi.dev/docs/reference/surfaces). diff --git a/apps/docs/AUTHORING.md b/apps/docs/AUTHORING.md index d8bb2df8..87db69e2 100644 --- a/apps/docs/AUTHORING.md +++ b/apps/docs/AUTHORING.md @@ -108,7 +108,7 @@ const listUsers = stitch({ loginInput: () => ({ body: { email: env('APP_USER')(), password: env('APP_PASS')() }, }), - refreshOn: 401, + refresh: 401, }), }); ``` @@ -117,8 +117,8 @@ const listUsers = stitch({ {/* Only the options that matter here, with defaults. Link to Reference for the exhaustive table — never paste a full type table into a guide (rule 6). */} -`refreshOn` re-runs the login on a status code; `refreshWhen` re-runs it on a -content predicate (for soft 200 walls). See +`refresh` re-runs the login on a status code (bare value or `{ on }`); the +`{ when }` form re-runs it on a content predicate (for soft 200 walls). See [Reference → Auth strategies](/docs/reference/auth-strategies) for every field. diff --git a/apps/docs/app/(home)/demo/scene.tsx b/apps/docs/app/(home)/demo/scene.tsx index 38d3df11..c9ecbf12 100644 --- a/apps/docs/app/(home)/demo/scene.tsx +++ b/apps/docs/app/(home)/demo/scene.tsx @@ -202,22 +202,22 @@ const user = await getUser({ len: 4.8, }, { - key: 'unwrap', + key: 'pick', label: 'Data shaping', - filename: 'unwrap.ts', - // Mirrors the README hero example: unwrap peels the transport + filename: 'pick.ts', + // Mirrors the README hero example: pick peels the transport // envelope before validation, so callers get the value itself. code: `const getUser = stitch({ baseUrl: 'https://demo.stitchapi.dev', path: '/users/{id}', output: User, - unwrap: 'data', // peel the envelope + pick: 'data', // peel the envelope }); const user = await getUser({ params: { id: '42' }, });`, - chip: `unwrap: 'data'`, + chip: `pick: 'data'`, chipMono: true, rows: [ { @@ -233,7 +233,7 @@ const user = await getUser({ icon: Scissors, tone: 'brand', text: 'envelope peeled before validation', - meta: "unwrap: 'data'", + meta: "pick: 'data'", }, { at: 2.5, @@ -243,7 +243,7 @@ const user = await getUser({ meta: 'clean shape', }, ], - doing: 'unwrapping', + doing: 'picking', done: 'just the data', doneAt: 3.4, len: 5.0, diff --git a/apps/docs/app/(home)/playground/playground-completions.generated.ts b/apps/docs/app/(home)/playground/playground-completions.generated.ts index 0ef14796..9f22fbb1 100644 --- a/apps/docs/app/(home)/playground/playground-completions.generated.ts +++ b/apps/docs/app/(home)/playground/playground-completions.generated.ts @@ -32,20 +32,20 @@ export const PLAYGROUND_COMPLETIONS: Record = { { label: "multipart", type: "property", - detail: "MultipartOptions", - info: "Multipart serialisation options (ADR 0005 Decision 6) — how nested objects/arrays become field names. Only meaningful with `bodyType: 'multipart'`. Default nesting `'bracket'`.", + detail: "MultipartNesting | AtLeastOne", + info: "Multipart serialisation options (ADR 0005 Decision 6) — how nested objects/arrays become field names. Only meaningful with `bodyType: 'multipart'`. Default nesting `'bracket'`. A bare string is shorthand for the object form — `multipart: 'dot'` ≡ `multipart: { nesting: 'dot' }` (CONTRACT.md P12).", }, { label: "stream", type: "property", - detail: "StreamOptions", - info: "Streaming options (ADR 0005 Decision 5) — how a `stream` surface decodes the live body (`'bytes'` default / `'lines'` / `'ndjson'` / `'json'`). `'json'` is the structural, unframed streaming-JSON decoder (issue #111): one `delta` per complete value / top-level array element, tolerant of internal newlines and concatenated values. Only meaningful for the `stream` surface.", + detail: "StreamDecode | AtLeastOne", + info: "Streaming options (ADR 0005 Decision 5) — how a `stream` surface decodes the live body (`'bytes'` default / `'lines'` / `'ndjson'` / `'json'`). `'json'` is the structural, unframed streaming-JSON decoder (issue #111): one `delta` per complete value / top-level array element, tolerant of internal newlines and concatenated values. Only meaningful for the `stream` surface. A bare string is shorthand for the object form — `stream: 'ndjson'` ≡ `stream: { decode: 'ndjson' }` (CONTRACT.md P12).", }, { label: "sse", type: "property", - detail: "SseOptions", - info: "Resumable-SSE options (issue #71) — sibling to , but for the `sse` surface. **Off by default**: with no `sse.reconnect` the engine opens the live body once (today's behaviour). When enabled, a dropped stream reconnects, replaying the last `id:` as `Last-Event-ID` and honouring a server `retry:` (else `reconnect.backoff` / the `retry` policy), capped at `maxAttempts`. Plain JSON (the contract gate). Only the `sse` surface reads it.", + detail: "boolean | AtLeastOne", + info: "Resumable-SSE options (issue #71) — sibling to , but for the `sse` surface. **Off by default**: with no `sse` block the engine opens the live body once (today's behaviour). When enabled, a dropped stream reconnects, replaying the last `id:` as `Last-Event-ID` and honouring a server `retry:` (else `reconnect.backoff` / the `retry` policy), capped at `reconnect.attempts`. Plain JSON (the contract gate). Only the `sse` surface reads it. `true` is shorthand for `{ reconnect: true }` (CONTRACT.md P13); the object form must set at least one field (P20).", }, { label: "responseType", @@ -78,22 +78,22 @@ export const PLAYGROUND_COMPLETIONS: Record = { info: "Static default headers merged into every request.", }, { - label: "query", + label: "document", type: "property", detail: "string", - info: "GraphQL query string (`kind: 'graphql'`).", + info: "GraphQL document string (`kind: 'graphql'`) — sent as the request body's `query` field.", }, { label: "operationName", type: "property", detail: "string", - info: "GraphQL `operationName` sent alongside `query` + `variables` (`kind: 'graphql'`). Omit to derive it from the first named operation in `query`; set it explicitly to override (e.g. a multi-operation document) or pass `''` to suppress the field entirely.", + info: "GraphQL `operationName` sent alongside the document + `variables` (`kind: 'graphql'`). Omit to derive it from the first named operation in `document`; set it explicitly to override (e.g. a multi-operation document) or pass `''` to suppress the field entirely.", }, { label: "input", type: "property", - detail: "InputSchemas", - info: "Schemas validating params, query, body, headers, and (GraphQL) variables before the request.", + detail: "AtLeastOne", + info: "Schemas validating params, query, body, headers, and (GraphQL) variables before the request. At least one slot must be set — the opaque `input: {}` is rejected (CONTRACT.md P20).", }, { label: "output", @@ -102,16 +102,16 @@ export const PLAYGROUND_COMPLETIONS: Record = { info: "Response schema, or a for leveled drift detection. Accepts any — a raw Zod schema, any Standard Schema (Valibot, ArkType), or a `(value) => boolean` predicate — directly; the stitch infers its result type from it (see `InferOutput`), so a hand-written generic is rarely needed.", }, { - label: "unwrap", + label: "pick", type: "property", detail: "string", - info: "Dot-path selecting the part of the response to return.", + info: "Dot-path picking the part of the response to return (e.g. `'data.items'`).", }, { label: "transform", type: "property", detail: "(body: unknown) => unknown", - info: "Reshape the raw body before unwrap and validation (e.g. scrape HTML to structured data).", + info: "Reshape the raw body before `pick` and validation (e.g. scrape HTML to structured data).", }, { label: "paginate", @@ -128,37 +128,32 @@ export const PLAYGROUND_COMPLETIONS: Record = { { label: "retry", type: "property", - detail: "number | RetryOptions", - info: "Retry-and-backoff policy. A bare number is shorthand for the attempt count — `retry: 3` ≡ `retry: { attempts: 3 }`.", + detail: "number | AtLeastOne", + info: "Retry-and-backoff policy. A bare number is shorthand for the attempt count — `retry: 3` ≡ `retry: { attempts: 3 }`; the opaque `retry: {}` is rejected (CONTRACT.md P20).", }, { label: "acceptStatus", type: "property", - detail: "number[] | ((status: number) => boolean)", - info: "Statuses that are a NORMAL result rather than an error — a number list or a predicate. An accepted non-2xx flows through interpret → transform → unwrap → validate exactly like a 2xx (the response body becomes the result), instead of throwing a . Use this when an endpoint treats e.g. `404`/`400` as expected control flow (resource-gone → fall back to a broader call) so the happy path no longer runs through a `catch`. `retry.on` still wins while attempts remain: a status listed in BOTH is retried until attempts are exhausted, then accepted (returned) on the final attempt. Orthogonal to `rateLimit.delegate`, which surfaces a on rate-limit statuses earlier.", + detail: "StatusMatch", + info: "Status(es) that are a NORMAL result rather than an error — a number, a list, or a predicate (CONTRACT.md P7). An accepted non-2xx flows through interpret → transform → pick → validate exactly like a 2xx (the response body becomes the result), instead of throwing a . Use this when an endpoint treats e.g. `404`/`400` as expected control flow (resource-gone → fall back to a broader call) so the happy path no longer runs through a `catch`. `retry.on` still wins while attempts remain: a status listed in BOTH is retried until attempts are exhausted, then accepted (returned) on the final attempt. Orthogonal to `throttle.delegate`, which surfaces a on rate-limit statuses earlier.", }, { label: "throttle", type: "property", - detail: "ThrottleOptions", - info: "Rate and concurrency limits.", + detail: "string | AtLeastOne", + info: "Rate and concurrency limits. A bare rate string is shorthand — `throttle: '2/s'` ≡ `throttle: { rate: '2/s' }` (CONTRACT.md P12); the opaque `throttle: {}` is rejected (P20).", }, { label: "timeout", type: "property", - detail: "number | string | TimeoutOptions", - info: "Total and per-attempt timeouts. A bare number (ms) or duration string is shorthand for the total — `timeout: '5s'` ≡ `timeout: { total: '5s' }`.", + detail: "number | string | AtLeastOne", + info: "Total and per-attempt timeouts. A bare number (ms) or duration string is shorthand for the total — `timeout: '5s'` ≡ `timeout: { total: '5s' }`; the opaque `timeout: {}` is rejected (CONTRACT.md P20).", }, { label: "circuit", type: "property", - detail: "AtLeastOne", - info: "Circuit breaker that fast-fails a repeatedly failing dependency. `failures` + `cooldown` are required by design (P15), so the empty object is rejected (P20 — `AtLeastOne`).", - }, - { - label: "rateLimit", - type: "property", - detail: "{ /** Surface rate-limit outcomes instead of retrying/throttling them. Default `false`. */ delegate?: boolean; /** Statuses treated as a rate-limit signal. Default `[429]`. */ on?: number[]; }", + detail: "| [failures: number, cooldown: number | string] | AtLeastOne", + info: "Circuit breaker that fast-fails a repeatedly failing dependency. `failures` + `cooldown` are required by design (P15), so the empty object is rejected (P20 — `AtLeastOne`). The positional form names both at once — `circuit: [5, '30s']` ≡ `circuit: { failures: 5, cooldown: '30s' }`.", }, { label: "idempotency", @@ -187,14 +182,14 @@ export const PLAYGROUND_COMPLETIONS: Record = { { label: "hooks", type: "property", - detail: "Hooks", - info: "Request/response/error/retry lifecycle hooks.", + detail: "AtLeastOne", + info: "Request/response/error/retry lifecycle hooks. At least one — the opaque `hooks: {}` is rejected (CONTRACT.md P20).", }, { label: "extends", type: "property", - detail: "(Partial | Stitch | string)[]", - info: "Fragments to deep-merge under this config — strings, partials, or other stitches.", + detail: "| Partial | Stitch | string | (Partial | Stitch | string)[]", + info: "Fragment(s) to deep-merge under this config — strings, partials, or other stitches. A single fragment is shorthand for a one-element list (CONTRACT.md P7).", }, { label: "adapter", @@ -246,14 +241,14 @@ export const PLAYGROUND_INSTANCE_COMPLETIONS: Record = { { label: "inspect", type: "method", - detail: "(...args: [...Args, opts?: InspectOptions]) => Promise>", - info: "Probe a fresh call and return an — `{ value, raw, findings, status, error }` — **without throwing** (ADR 0016). Use it after the fact to ask \"the schema coerced/stripped this; what did the server actually send?\": `raw` is the pre-validation body, `findings` the soft + hard drift between it and `value`. `.inspect()` **always hits the network and bypasses the cache by default**, so it is a fresh probe — *not* an observer of what your cached `await` call did. Pass `{ cache: true }` to honour the cache policy (then `raw` is `null` on a hit). On a streaming surface `raw` is `null` too (no single buffered body). ⚠️ `raw` is unredacted and non-enumerable — read `wrapper.raw` deliberately; never log the whole wrapper.", + detail: "(...args: [...Args, opts?: boolean | AtLeastOne]) => Promise>", + info: "Probe a fresh call and return an — `{ data, raw, findings, status, error }` — **without throwing** (ADR 0016). Use it after the fact to ask \"the schema coerced/stripped this; what did the server actually send?\": `raw` is the pre-validation body, `findings` the soft + hard drift between it and `data`. `.inspect()` **always hits the network and bypasses the cache by default**, so it is a fresh probe — *not* an observer of what your cached `await` call did. Pass `true` (≡ `{ cache: true }`) to honour the cache policy (then `raw` is `null` on a hit). On a streaming surface `raw` is `null` too (no single buffered body). ⚠️ `raw` is unredacted and non-enumerable — read `wrapper.raw` deliberately; never log the whole wrapper.", }, { label: "report", type: "method", - detail: "(...args: [...Args, opts?: InspectOptions]) => Promise>", - info: "Probe a fresh call and return a — an (`{ value, raw, findings, status, error, source }`) **plus** run diagnostics: `attempts`, `timing` (`{ ms, waited? }`), the resolved+redacted `config`, and the fine-grained `cache` outcome (ADR 0019). Like `.inspect()` it **never throws** (a hard contract violation comes back with `error` set and the diagnostics populated) and is a **network probe**: it always hits the network and **bypasses the cache by default** — pass `{ cache: true }` to honour the cache policy (then `cache` reports the real `hit`/`miss` and `raw` is `null`/`source` is `'cache'` on a hit). Use `.report()` to ask \"how did this run go?\"; `.inspect()` stays the minimal \"raw + drift\" probe. ⚠️ `raw` is inherited unredacted and non-enumerable — the rest of the report is safe to log.", + detail: "(...args: [...Args, opts?: boolean | AtLeastOne]) => Promise>", + info: "Probe a fresh call and return a — an (`{ data, raw, findings, status, error, source }`) **plus** run diagnostics: `attempts`, `timing` (`{ elapsed, waited? }`), the resolved+redacted `config`, and the fine-grained `cache` outcome (ADR 0019). Like `.inspect()` it **never throws** (a hard contract violation comes back with `error` set and the diagnostics populated) and is a **network probe**: it always hits the network and **bypasses the cache by default** — pass `true` (≡ `{ cache: true }`) to honour the cache policy (then `cache` reports the real `hit`/`miss` and `raw` is `null`/`source` is `'cache'` on a hit). Use `.report()` to ask \"how did this run go?\"; `.inspect()` stays the minimal \"raw + drift\" probe. ⚠️ `raw` is inherited unredacted and non-enumerable — the rest of the report is safe to log.", }, { label: "with", @@ -264,12 +259,12 @@ export const PLAYGROUND_INSTANCE_COMPLETIONS: Record = { label: "invalidate", type: "method", detail: "(input?: StitchInput) => Promise", - info: "Cache surface (ADR 0003). A no-op unless this stitch has a `cache` block. - `invalidate(input)` — **exact** eviction of the one entry that `input` would hit. - `cache.invalidate()` — **bulk** eviction of every entry this stitch produced (a per-stitch generation bump; prior entries become unreachable and TTL out). - `cache.key(input)` — the derived opaque key, for introspection.", + info: "Cache surface (ADR 0003). A no-op unless this stitch has a `cache` block. - `invalidate(input)` — **exact** eviction of the one entry that `input` would hit. - `cache.invalidate()` — **bulk** eviction of every entry this stitch produced (a per-stitch generation bump; prior entries become unreachable and TTL out). - `cache.keyOf(input)` — the derived opaque key, for introspection (CONTRACT.md P6).", }, { label: "cache", type: "property", - detail: "{ invalidate(): Promise; key(input?: StitchInput): Promise; }", + detail: "{ invalidate(): Promise; keyOf(input?: StitchInput): Promise; }", }, { label: "__config", diff --git a/apps/docs/app/(home)/playground/playground-examples.ts b/apps/docs/app/(home)/playground/playground-examples.ts index 7316d254..face1f54 100644 --- a/apps/docs/app/(home)/playground/playground-examples.ts +++ b/apps/docs/app/(home)/playground/playground-examples.ts @@ -47,22 +47,22 @@ const api = stitch({ }); // 2 - Derive endpoint clients with extends: each inherits everything above. -// unwrap pulls a value out of an envelope ({ data: ... } -> ...), and +// pick pulls a value out of an envelope ({ data: ... } -> ...), and // output validates what you actually receive (pass a Zod / Standard // Schema validator in real code; a plain predicate works too). const getUser = stitch({ extends: [api], path: '/users/{id}', - unwrap: 'data', + pick: 'data', output: (u) => !!u && typeof u.id === 'number' && typeof u.email === 'string', }); // 3 - .with(...) is partial application: bind input now, reuse the call later. const user = await getUser.with({ params: { id: 2 } })(); -console.log('1) validated + unwrapped user:'); +console.log('1) validated + picked user:'); console.log(user); -// 4 - transform reshapes the raw body before unwrap and validation run. +// 4 - transform reshapes the raw body before pick and validation run. const listNames = stitch({ extends: [api], path: '/users', @@ -80,10 +80,10 @@ console.log('3) whoami:', me); // succeeds; the retry policy + onRetry hook recover it. .stream() yields // every lifecycle event (start -> progress -> result -> done) so you can // watch the recovery happen instead of just awaiting a value. -const flaky = stitch({ extends: [api], path: '/users', unwrap: 'data' }); +const flaky = stitch({ extends: [api], path: '/users', pick: 'data' }); let recovered, attempts; for await (const event of flaky.stream({ query: { __flaky: 2 } })) { - if (event.type === 'result') recovered = event.value; + if (event.type === 'result') recovered = event.data; if (event.type === 'done') attempts = event.attempts; } console.log( @@ -115,7 +115,7 @@ import { z } from 'zod'; const getUser = stitch({ baseUrl: 'https://demo.stitchapi.dev', path: '/users/{id}', - unwrap: 'data', // pull the user out of the { data: ... } envelope + pick: 'data', // pull the user out of the { data: ... } envelope // Pass a Zod schema (or any Standard Schema) directly. input: { params: z.object({ id: z.number() }) }, // typed + validated BEFORE the request output: z.object({ id: z.number(), email: z.string() }), // typed + validated AFTER diff --git a/apps/docs/content.manifest.ts b/apps/docs/content.manifest.ts index 83e66bad..2dab5e78 100644 --- a/apps/docs/content.manifest.ts +++ b/apps/docs/content.manifest.ts @@ -404,14 +404,14 @@ export const pages: Page[] = [ path: 'guides/resilience/accept-status', title: 'Accept status', description: - 'Declare statuses that are a normal result, not an error — an accepted non-2xx flows through transform/unwrap/validate instead of throwing.', + 'Declare statuses that are a normal result, not an error — an accepted non-2xx flows through transform/pick/validate instead of throwing.', kind: 'guide', }, { path: 'guides/resilience/throttle', title: 'Throttle', description: - 'Space requests by rate and cap concurrency, scoped per stitch or per host.', + 'Space requests by rate and cap concurrency, pooled per stitch or per host.', kind: 'guide', }, { @@ -445,8 +445,8 @@ export const pages: Page[] = [ // ── Guides · Data shaping ─────────────────────────────────────────────── { - path: 'guides/data/unwrap', - title: 'unwrap', + path: 'guides/data/pick', + title: 'pick', description: 'Pull just the part of the response you want by dot-path.', kind: 'guide', }, @@ -454,7 +454,7 @@ export const pages: Page[] = [ path: 'guides/data/transform', title: 'transform', description: - 'Reshape a response before unwrap and validation — for example, scrape HTML into structured data.', + 'Reshape a response before pick and validation — for example, scrape HTML into structured data.', kind: 'guide', }, { @@ -481,7 +481,7 @@ export const pages: Page[] = [ path: 'guides/data/graphql', title: 'GraphQL', description: - 'Call a GraphQL endpoint with variables, unwrap data, and treat a 200 carrying errors as a failure.', + 'Call a GraphQL endpoint with variables, pick data, and treat a 200 carrying errors as a failure.', kind: 'guide', }, diff --git a/apps/docs/content/blog/auth-as-a-capability-not-a-credential.mdx b/apps/docs/content/blog/auth-as-a-capability-not-a-credential.mdx index e7225e0b..1d944334 100644 --- a/apps/docs/content/blog/auth-as-a-capability-not-a-credential.mdx +++ b/apps/docs/content/blog/auth-as-a-capability-not-a-credential.mdx @@ -101,7 +101,7 @@ const me = stitch({ loginInput: () => ({ body: { user: env('USER')(), pass: env('PASS')() }, }), - refreshOn: [401], // re-login and retry on expiry + refresh: [401], // re-login and retry on expiry }), }); diff --git a/apps/docs/content/blog/axios-alternatives.mdx b/apps/docs/content/blog/axios-alternatives.mdx index cb973c12..d20d8574 100644 --- a/apps/docs/content/blog/axios-alternatives.mdx +++ b/apps/docs/content/blog/axios-alternatives.mdx @@ -35,14 +35,14 @@ const listOrders = stitch({ path: '/orders', output: z.object({ orders: z.array(Order) }), retry: { attempts: 4, on: [429, 502, 503], respectRetryAfter: true }, - throttle: { rate: '1/s', concurrency: 2, scope: 'host' }, + throttle: { rate: '1/s', concurrency: 2, pool: 'host' }, timeout: { total: '30s', perAttempt: '10s' }, }); const { orders } = await listOrders(); // typed · validated · retried · throttled ``` -Everything that was an interceptor is now a field. `retry` backs off and honors `Retry-After`. `throttle` is **proactive** — it keeps you under the limit before the 429s arrive, and `scope: 'host'` shares one limiter across every stitch hitting that host, so it stays correct even as you add calls ([proactive throttling beats reacting to 429s](/blog/proactive-throttling-vs-reactive-429s)). `timeout` is layered: a ceiling for the whole call and a separate budget per attempt. +Everything that was an interceptor is now a field. `retry` backs off and honors `Retry-After`. `throttle` is **proactive** — it keeps you under the limit before the 429s arrive, and `pool: 'host'` shares one limiter across every stitch hitting that host, so it stays correct even as you add calls ([proactive throttling beats reacting to 429s](/blog/proactive-throttling-vs-reactive-429s)). `timeout` is layered: a ceiling for the whole call and a separate budget per attempt. The `output` validator is the part axios never had. A stitch validates the **live** response on every call, so a shape mismatch is caught at the boundary as a typed error instead of surfacing as an `undefined` three layers downstream. Wrap the schema in `drift()` and each response is also diffed against its validated form, with every change leveled by severity — a renamed required field throws, a coercion the schema quietly absorbed surfaces as a `warn` on the event stream ([schema drift is a production bug](/blog/schema-drift-is-a-production-bug)). That's a class of failure an interceptor stack typically ships straight to production. diff --git a/apps/docs/content/blog/call-any-llm-as-a-typed-function.mdx b/apps/docs/content/blog/call-any-llm-as-a-typed-function.mdx index f628fad2..129d4974 100644 --- a/apps/docs/content/blog/call-any-llm-as-a-typed-function.mdx +++ b/apps/docs/content/blog/call-any-llm-as-a-typed-function.mdx @@ -45,7 +45,7 @@ import { anthropic, llm } from 'stitchapi/llm'; const chat = llm({ provider: anthropic, model: 'claude-opus-4-8', - auth: apiKey({ header: 'x-api-key', value: env('ANTHROPIC_API_KEY') }), + auth: apiKey({ name: 'x-api-key', value: env('ANTHROPIC_API_KEY') }), }); const { text } = await chat({ @@ -85,9 +85,9 @@ const { text } = await chat({ }); ``` -`openai` authenticates with `bearer(env('OPENAI_API_KEY'))`; `anthropic` authenticates with `apiKey({ header: 'x-api-key', value: env('ANTHROPIC_API_KEY') })`. The provider absorbs the wire difference — different URL, different header, different body and response shapes — and `result.text` is the same on both sides. The code that sends messages and reads the completion does not move. +`openai` authenticates with `bearer(env('OPENAI_API_KEY'))`; `anthropic` authenticates with `apiKey({ name: 'x-api-key', value: env('ANTHROPIC_API_KEY') })`. The provider absorbs the wire difference — different URL, different header, different body and response shapes — and `result.text` is the same on both sides. The code that sends messages and reads the completion does not move. -When you need a provider that does not ship first-party, you implement the same contract the built-ins do. An `LlmProvider` declares its `id`, its full completions `endpoint`, optional non-secret `headers`, an optional `defaultModel`, a `buildBody` that maps a normalised `LlmRequest` to that vendor's wire body, and a `parse` that lifts the response back into an `LlmResult`: +When you need a provider that does not ship first-party, you implement the same contract the built-ins do. An `LlmProvider` declares its `id`, its full completions `url`, optional non-secret `headers`, an optional `defaultModel`, a `buildBody` that maps a normalised `LlmRequest` to that vendor's wire body, and a `parse` that lifts the response back into an `LlmResult`: ```ts twoslash import { bearer, env } from 'stitchapi'; @@ -100,7 +100,7 @@ interface MistralResponse { const mistral: LlmProvider = { id: 'mistral', - endpoint: 'https://api.mistral.ai/v1/chat/completions', + url: 'https://api.mistral.ai/v1/chat/completions', defaultModel: 'mistral-large-latest', buildBody: (req) => ({ model: req.model, @@ -136,7 +136,7 @@ const fast = llm({ const deep = llm({ provider: anthropic, model: 'claude-opus-4-8', - auth: apiKey({ header: 'x-api-key', value: env('ANTHROPIC_API_KEY') }), + auth: apiKey({ name: 'x-api-key', value: env('ANTHROPIC_API_KEY') }), }); async function summarize(input: string, hard: boolean) { @@ -162,14 +162,14 @@ import { anthropic, llm } from 'stitchapi/llm'; const chat = llm({ provider: anthropic, model: 'claude-opus-4-8', - auth: apiKey({ header: 'x-api-key', value: env('ANTHROPIC_API_KEY') }), + auth: apiKey({ name: 'x-api-key', value: env('ANTHROPIC_API_KEY') }), retry: { attempts: 3, on: [429, 529], backoff: 'expo-jitter', respectRetryAfter: true, }, - throttle: { rate: '5/s', concurrency: 2, scope: 'host' }, + throttle: { rate: '5/s', concurrency: 2, pool: 'host' }, timeout: { total: '60s' }, }); ``` diff --git a/apps/docs/content/blog/distributed-throttle-with-redis-kv.mdx b/apps/docs/content/blog/distributed-throttle-with-redis-kv.mdx index 5a38bfce..a5e35d3d 100644 --- a/apps/docs/content/blog/distributed-throttle-with-redis-kv.mdx +++ b/apps/docs/content/blog/distributed-throttle-with-redis-kv.mdx @@ -38,7 +38,7 @@ import { seam } from 'stitchapi'; const api = seam({ baseUrl: 'https://api.example.com', - throttle: { rate: '2/s', concurrency: 4, scope: 'host' }, + throttle: { rate: '2/s', concurrency: 4, pool: 'host' }, // The one line that makes it fleet-wide: store: redisStore(fromIoredis(new Redis(process.env.REDIS_URL))), }); diff --git a/apps/docs/content/blog/expose-a-stitch-as-an-ai-sdk-tool.mdx b/apps/docs/content/blog/expose-a-stitch-as-an-ai-sdk-tool.mdx index 67af09b7..04f3a707 100644 --- a/apps/docs/content/blog/expose-a-stitch-as-an-ai-sdk-tool.mdx +++ b/apps/docs/content/blog/expose-a-stitch-as-an-ai-sdk-tool.mdx @@ -31,12 +31,12 @@ const Order = z.object({ export const getOrder = stitch({ path: 'https://internal.example.com/orders/{id}', output: Order, - unwrap: 'data', + pick: 'data', // The credential resolves at call time and never reaches the caller. auth: bearer(env('ORDERS_API_TOKEN')), // Resilience is declared once, here — not in the tool, not in the agent. retry: { attempts: 3, on: [429, 503], respectRetryAfter: true }, - throttle: { rate: '10/s', concurrency: 4, scope: 'host' }, + throttle: { rate: '10/s', concurrency: 4, pool: 'host' }, timeout: { total: '5s' }, cache: '30s', }); @@ -152,7 +152,7 @@ It cuts the other way too. The schema is also documentation the model can use: t This is the part that makes a stitch-as-tool different from a hand-rolled fetch tool. Everything declared on the stitch applies no matter who pulls the trigger — and a model is an unusually trigger-happy caller. -- The `throttle` caps how fast the model can hammer the endpoint, even if it gets stuck in a retry-it-yourself loop. With `scope: 'host'`, that ceiling is shared across every stitch hitting the same host, so a chatty agent can't starve the rest of your app. +- The `throttle` caps how fast the model can hammer the endpoint, even if it gets stuck in a retry-it-yourself loop. With `pool: 'host'`, that ceiling is shared across every stitch hitting the same host, so a chatty agent can't starve the rest of your app. - The `retry` absorbs a transient `503` without the model ever seeing it — and honors `Retry-After`, so you back off the way the upstream asked. - The `cache` collapses a model that asks the same question twice in one loop into a single paid upstream call. - The `timeout` bounds how long a single tool call can hang, so one slow dependency doesn't stall the whole generation. diff --git a/apps/docs/content/blog/migrate-from-axios-to-stitchapi.mdx b/apps/docs/content/blog/migrate-from-axios-to-stitchapi.mdx index 22962c86..bf65352d 100644 --- a/apps/docs/content/blog/migrate-from-axios-to-stitchapi.mdx +++ b/apps/docs/content/blog/migrate-from-axios-to-stitchapi.mdx @@ -88,7 +88,7 @@ const getUser = stitch({ respectRetryAfter: true, }, timeout: { total: '30s', perAttempt: '10s' }, - unwrap: 'data', // the API wraps payloads in { data } + pick: 'data', // the API wraps payloads in { data } output: drift(User), }); @@ -106,7 +106,7 @@ const user = await getUser({ params: { id } }); // typed · validated · retried | retry interceptor with backoff | re-issues on 429/502/503/504 | `retry: { attempts, on, backoff: 'expo-jitter', respectRetryAfter: true }` ([retry](/docs/guides/resilience/retry)) | | `baseURL` | one host for many calls | `baseUrl` on the stitch, or once on a `seam()` ([seam](/docs/guides/authoring/seam)) | | `validateStatus` | decide what status throws | the engine never throws on a non-2xx by itself — you declare which statuses `retry`, and a `StitchError` carries the rest | -| `transformResponse` + `as User` | reshape, then assert the type | `unwrap` plus an `output` schema (wrap in `drift()` to flag shape changes) ([drift](/docs/guides/validation/drift)) | +| `transformResponse` + `as User` | reshape, then assert the type | `pick` plus an `output` schema (wrap in `drift()` to flag shape changes) ([drift](/docs/guides/validation/drift)) | The last row is the one axios never had. `output` validates the _live_ response on every call, so a mismatch is a typed error at the boundary, not an `undefined` downstream. Wrap it in `drift()` and each response is diffed against its validated form, every change leveled by severity — a renamed required field throws, a coercion the schema absorbed surfaces as a `warn` on the event stream. diff --git a/apps/docs/content/blog/one-definition-four-front-doors.mdx b/apps/docs/content/blog/one-definition-four-front-doors.mdx index b63b7a1b..a38ace71 100644 --- a/apps/docs/content/blog/one-definition-four-front-doors.mdx +++ b/apps/docs/content/blog/one-definition-four-front-doors.mdx @@ -28,7 +28,7 @@ export const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', output: z.object({ id: z.number(), name: z.string() }), - unwrap: 'data', + pick: 'data', auth: bearer(env('API_TOKEN')), retry: { attempts: 3, on: [429, 503] }, timeout: { total: '10s' }, @@ -39,7 +39,7 @@ Export your stitches by name from a module (the CLI and the servers default to ` ## Door 1 — the in-process function: your app code -The closest door. Import the stitch and call it like any async function; `await` returns the final unwrapped, validated value. +The closest door. Import the stitch and call it like any async function; `await` returns the final picked, validated value. ```ts twoslash import { bearer, env, stitch } from 'stitchapi'; @@ -51,7 +51,7 @@ const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', output: z.object({ id: z.number(), name: z.string() }), - unwrap: 'data', + pick: 'data', auth: bearer(env('API_TOKEN')), }); // ---cut--- diff --git a/apps/docs/content/blog/proactive-throttling-vs-reactive-429s.mdx b/apps/docs/content/blog/proactive-throttling-vs-reactive-429s.mdx index 7689ca59..3a15426f 100644 --- a/apps/docs/content/blog/proactive-throttling-vs-reactive-429s.mdx +++ b/apps/docs/content/blog/proactive-throttling-vs-reactive-429s.mdx @@ -62,7 +62,7 @@ When a call has to wait its turn, it isn't silently stalled — the stitch emits A provider's rate limit is almost never per-endpoint. It's per account, per token, per host — and your code probably hits that one provider from several different stitches. If each stitch enforces its own budget, three stitches at `5/s` apiece can put `15/s` on a provider that only allows five, and you're back to meeting `429`s in production. -`scope: 'host'` fixes this. It pools one limiter across every stitch that targets the same host, so the budget is shared the way the provider actually counts it. +`pool: 'host'` fixes this. It pools one limiter across every stitch that targets the same host, so the budget is shared the way the provider actually counts it. ```ts twoslash import { seam } from 'stitchapi'; @@ -70,7 +70,7 @@ import { seam } from 'stitchapi'; const api = seam({ baseUrl: 'https://api.example.com', // One account-wide budget, shared by every member below. - throttle: { rate: '5/s', concurrency: 4, scope: 'host' }, + throttle: { rate: '5/s', concurrency: 4, pool: 'host' }, }); const search = api.stitch({ path: '/search' }); @@ -78,7 +78,7 @@ const getItem = api.stitch({ path: '/items/{id}' }); const listItems = api.stitch({ path: '/items' }); ``` -Now `search`, `getItem`, and `listItems` draw from one `5/s` budget against `api.example.com`, no matter which one a given caller reaches for. This is exactly the case a `seam` is built for — a group of stitches sharing one base, one auth, and one runtime — but `scope: 'host'` pools the budget across separate stitches too, whenever they hit the same host. Host-scoped pooling works in-process with no extra setup. +Now `search`, `getItem`, and `listItems` draw from one `5/s` budget against `api.example.com`, no matter which one a given caller reaches for. This is exactly the case a `seam` is built for — a group of stitches sharing one base, one auth, and one runtime — but `pool: 'host'` pools the budget across separate stitches too, whenever they hit the same host. Host-scoped pooling works in-process with no extra setup. ## Proactive and reactive, together @@ -92,7 +92,7 @@ const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', // Proactive: stay under the limit so most 429s never happen. - throttle: { rate: '5/s', concurrency: 2, scope: 'host' }, + throttle: { rate: '5/s', concurrency: 2, pool: 'host' }, // Reactive: if one slips through anyway, back off and honor Retry-After. retry: { attempts: 3, on: [429, 503], respectRetryAfter: true }, }); @@ -108,7 +108,7 @@ Flip on `throttle` — a single field on the same stitch — when: - **Your volume brushes the limit.** You're making enough calls that `429`s start appearing in normal traffic, not just spikes. A declared rate gives you smooth pacing instead of a bounce-and-wait cycle. - **You want to stop hammering a shared cap.** Multiple call sites share one account quota. The throttle keeps any one stitch from consuming the whole budget before others get a turn. -- **Many stitches share one host budget.** Set `scope: 'host'` and the limiter counts across every stitch pointed at that origin — the right control when the ceiling is per-domain, not per-endpoint. +- **Many stitches share one host budget.** Set `pool: 'host'` and the limiter counts across every stitch pointed at that origin — the right control when the ceiling is per-domain, not per-endpoint. Two caveats stay real regardless of volume: you have to declare the actual limit (set it too high and `retry` is still doing the work; set it too low and you've throttled yourself for no reason), and the default limiter is per-process (multiple workers each keep their own count — span them with a shared store, covered in the [distributed throttle](/blog/distributed-throttle-with-redis-kv) write-up). @@ -120,4 +120,4 @@ The reactive path is the floor you start from. `throttle` is the field you add w stitchapi ``` -Declare a `throttle` on a stitch, pair it with `retry` as a backstop, and the next time you brush a provider's rate limit you'll pace under it instead of bouncing off it. The full field list and `scope` semantics are in the [Throttle guide](/docs/guides/resilience/throttle); the reactive side is in [Retry & backoff](/docs/guides/resilience/retry). +Declare a `throttle` on a stitch, pair it with `retry` as a backstop, and the next time you brush a provider's rate limit you'll pace under it instead of bouncing off it. The full field list and `pool` semantics are in the [Throttle guide](/docs/guides/resilience/throttle); the reactive side is in [Retry & backoff](/docs/guides/resilience/retry). diff --git a/apps/docs/content/blog/rate-limit-api-calls-typescript.mdx b/apps/docs/content/blog/rate-limit-api-calls-typescript.mdx index 8dbe7152..a8c1451c 100644 --- a/apps/docs/content/blog/rate-limit-api-calls-typescript.mdx +++ b/apps/docs/content/blog/rate-limit-api-calls-typescript.mdx @@ -55,7 +55,7 @@ import { stitch } from 'stitchapi'; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', - throttle: { rate: '5/s', concurrency: 2, scope: 'host' }, + throttle: { rate: '5/s', concurrency: 2, pool: 'host' }, }); const results = await search({ query: { q: 'shoes' } }); // paced and bounded @@ -63,11 +63,11 @@ const results = await search({ query: { q: 'shoes' } }); // paced and bounded `rate` is a string like `'5/s'` — a minimum spacing between successive calls, not a token bucket that lets a burst through. `concurrency` caps simultaneous in-flight calls; set either on its own or both together. When a call has to wait its turn it isn't a silent stall — the stitch emits a `progress` event with phase `throttled`, so a queued call shows up on the event stream instead of looking like a hang. -`scope` is the answer to the multiple-call-sites problem. The default `'stitch'` gives each stitch its own budget; `'host'` pools one budget across every stitch hitting the same host — which is how a provider actually counts, since a rate limit is per account or per host, almost never per endpoint. Set `scope: 'host'` and three stitches against `api.example.com` draw from one `5/s` budget no matter which one a caller reaches for. Host-scoped pooling works in-process with no extra setup. +`pool` is the answer to the multiple-call-sites problem. The default `'stitch'` gives each stitch its own budget; `'host'` pools one budget across every stitch hitting the same host — which is how a provider actually counts, since a rate limit is per account or per host, almost never per endpoint. Set `pool: 'host'` and three stitches against `api.example.com` draw from one `5/s` budget no matter which one a caller reaches for. Host-scoped pooling works in-process with no extra setup. ## Multiple processes: the distributed story -`scope: 'host'` shares a budget across stitches in one process; it does nothing across processes, because the counters live in memory. Run more than one instance against the same upstream budget and you're back to N times your cap. The fix isn't a different limiter — it's a different place to keep the counters. Attach a shared `store` to move them off-box, so every instance pointed at the same store draws from one budget: +`pool: 'host'` shares a budget across stitches in one process; it does nothing across processes, because the counters live in memory. Run more than one instance against the same upstream budget and you're back to N times your cap. The fix isn't a different limiter — it's a different place to keep the counters. Attach a shared `store` to move them off-box, so every instance pointed at the same store draws from one budget: ```ts twoslash // @noErrors @@ -77,7 +77,7 @@ import { seam } from 'stitchapi'; const api = seam({ baseUrl: 'https://api.example.com', - throttle: { rate: '5/s', concurrency: 4, scope: 'host' }, + throttle: { rate: '5/s', concurrency: 4, pool: 'host' }, store: redisStore(fromIoredis(new Redis(process.env.REDIS_URL))), }); ``` @@ -91,7 +91,7 @@ The `setTimeout` version above is the honest floor. A handful of calls well unde Reach for `throttle` when you hit the first trigger: - **You start brushing the cap.** Reactive `429` handling works until volume approaches the ceiling; proactive pacing keeps you under it. That's the moment a declared rate is worth more than a caught error. -- **The budget is shared across call sites or processes.** A second endpoint that draws from the same provider quota, or a second worker process, means the in-memory counter is silently undercounting. `scope: 'host'` and a `store` close that gap — the counter that moves is the one the upstream sees. +- **The budget is shared across call sites or processes.** A second endpoint that draws from the same provider quota, or a second worker process, means the in-memory counter is silently undercounting. `pool: 'host'` and a `store` close that gap — the counter that moves is the one the upstream sees. - **You pair pacing with retry or auth.** A `throttle` alongside `retry` on the same stitch is one declaration; threading both through a hand-rolled limiter wrapping `fetch` is two separate maintenance surfaces. The line is whether the shared budget has grown past a single variable. While it hasn't, the limiter is fine. When it has — crossing that line is a field on the declaration, not a rewrite. The same call gains `throttle` applied by the engine rather than by convention. @@ -104,4 +104,4 @@ If you're weighing this against the interceptor stack you'd otherwise hand-write stitchapi ``` -Declare a `throttle` on a stitch, set `scope: 'host'` to share the budget the way the provider counts, and pace under the limit instead of bouncing off it. The full field list and `scope` semantics are in the [Throttle guide](/docs/guides/resilience/throttle). +Declare a `throttle` on a stitch, set `pool: 'host'` to share the budget the way the provider counts, and pace under the limit instead of bouncing off it. The full field list and `pool` semantics are in the [Throttle guide](/docs/guides/resilience/throttle). diff --git a/apps/docs/content/blog/read-through-caching-and-coalescing.mdx b/apps/docs/content/blog/read-through-caching-and-coalescing.mdx index 860ceb1a..71108cef 100644 --- a/apps/docs/content/blog/read-through-caching-and-coalescing.mdx +++ b/apps/docs/content/blog/read-through-caching-and-coalescing.mdx @@ -34,7 +34,7 @@ const User = z.object({ id: z.number(), name: z.string() }); const getUser = stitch({ path: 'https://api.example.com/users/{id}', output: User, - unwrap: 'data', + pick: 'data', cache: '5m', // ≡ { ttl: '5m' } }); ``` @@ -76,7 +76,7 @@ import { z } from 'zod'; const listAnnouncements = stitch({ path: 'https://api.example.com/announcements', output: z.array(z.object({ id: z.number(), title: z.string() })), - unwrap: 'data', + pick: 'data', cache: { ttl: '1h', scope: 'app', // shared across principals — see below diff --git a/apps/docs/content/blog/retry-fetch-in-typescript.mdx b/apps/docs/content/blog/retry-fetch-in-typescript.mdx index 5dacea88..6f33d0fe 100644 --- a/apps/docs/content/blog/retry-fetch-in-typescript.mdx +++ b/apps/docs/content/blog/retry-fetch-in-typescript.mdx @@ -64,7 +64,7 @@ function parseRetryAfter(res: Response): number | null { async function fetchWithRetry( url: string, - { attempts = 3, baseMs = 200, maxMs = 10_000 } = {}, + { attempts = 3, baseDelay = 200, maxDelay = 10_000 } = {}, ): Promise { let lastError: unknown; for (let i = 0; i < attempts; i++) { @@ -76,7 +76,7 @@ async function fetchWithRetry( if (i < attempts - 1) { const serverWait = parseRetryAfter(res); - const expo = Math.min(maxMs, baseMs * 2 ** i); + const expo = Math.min(maxDelay, baseDelay * 2 ** i); const jittered = Math.random() * expo; await new Promise((r) => setTimeout(r, serverWait ?? jittered)); } @@ -86,7 +86,7 @@ async function fetchWithRetry( await new Promise((r) => setTimeout( r, - Math.random() * Math.min(maxMs, baseMs * 2 ** i), + Math.random() * Math.min(maxDelay, baseDelay * 2 ** i), ), ); } diff --git a/apps/docs/content/blog/stitch-in-react-vue-svelte-solid.mdx b/apps/docs/content/blog/stitch-in-react-vue-svelte-solid.mdx index 6d0a9858..b5282e91 100644 --- a/apps/docs/content/blog/stitch-in-react-vue-svelte-solid.mdx +++ b/apps/docs/content/blog/stitch-in-react-vue-svelte-solid.mdx @@ -20,7 +20,7 @@ Query-core owns the lifecycle and nothing else: status transitions, cancellation That shared core is why the per-framework state shape is the same everywhere: ```ts twoslash -interface StitchQueryState { +interface StitchQueryResult { status: 'idle' | 'pending' | 'streaming' | 'success' | 'error'; data: T | undefined; error: unknown; diff --git a/apps/docs/content/blog/stitch-vs-codegen-api-clients.mdx b/apps/docs/content/blog/stitch-vs-codegen-api-clients.mdx index 40d3b774..11da3bfc 100644 --- a/apps/docs/content/blog/stitch-vs-codegen-api-clients.mdx +++ b/apps/docs/content/blog/stitch-vs-codegen-api-clients.mdx @@ -23,7 +23,7 @@ import { z } from 'zod'; const getUser = stitch({ path: 'https://api.example.com/users/{id}', output: z.object({ id: z.number(), name: z.string() }), - unwrap: 'data', + pick: 'data', }); const user = await getUser({ params: { id: 1 } }); // typed · validated @@ -74,12 +74,12 @@ const listUsers = stitch({ baseUrl: 'https://api.example.com', path: '/users', retry: { attempts: 4, on: [429, 502, 503], respectRetryAfter: true }, - throttle: { rate: '1/s', concurrency: 2, scope: 'host' }, + throttle: { rate: '1/s', concurrency: 2, pool: 'host' }, timeout: { total: '30s', perAttempt: '10s' }, }); ``` -`throttle` is proactive — it keeps you under a limit before the 429s arrive, and `scope: 'host'` shares one limiter across stitches hitting the same host. Auth is a boundary too: `bearer`, `apiKey`, `basic`, `cookieSession` (with auto-login and re-login), and `oauth2` live on the stitch, secrets resolve at call time, and the caller gets data without ever holding the credential. Observability rides the same event stream every call already emits — off by default, opt in per stitch or via `STITCH_TRACE_*` env vars, with bridges to Pino logs or Sentry breadcrumbs. +`throttle` is proactive — it keeps you under a limit before the 429s arrive, and `pool: 'host'` shares one limiter across stitches hitting the same host. Auth is a boundary too: `bearer`, `apiKey`, `basic`, `cookieSession` (with auto-login and re-login), and `oauth2` live on the stitch, secrets resolve at call time, and the caller gets data without ever holding the credential. Observability rides the same event stream every call already emits — off by default, opt in per stitch or via `STITCH_TRACE_*` env vars, with bridges to Pino logs or Sentry breadcrumbs. The honest framing: you _can_ build all of this around a generated client, and plenty of teams have. The difference is whether it's declared once on a primitive or assembled and maintained per project. If your integration is a single well-behaved internal service that never rate-limits you and never flakes, the resilience layer mostly sits dormant, and a generated client's leaner output can be the better fit there. diff --git a/apps/docs/content/blog/stop-writing-fetch-in-your-agents.mdx b/apps/docs/content/blog/stop-writing-fetch-in-your-agents.mdx index 2d591578..d8c1b847 100644 --- a/apps/docs/content/blog/stop-writing-fetch-in-your-agents.mdx +++ b/apps/docs/content/blog/stop-writing-fetch-in-your-agents.mdx @@ -53,9 +53,9 @@ export const getOrders = stitch({ path: 'https://api.example.com/users/{userId}/orders', auth: bearer(env('API_TOKEN')), // resolved at call time; the caller never sees it output: z.array(z.object({ id: z.number(), total: z.number() })), - unwrap: 'data', + pick: 'data', retry: { attempts: 3, on: [429, 503], respectRetryAfter: true }, - throttle: { rate: '10/s', concurrency: 4, scope: 'host' }, + throttle: { rate: '10/s', concurrency: 4, pool: 'host' }, timeout: { total: '10s' }, }); ``` diff --git a/apps/docs/content/blog/stream-a-stitch-as-sse-in-next-app-router.mdx b/apps/docs/content/blog/stream-a-stitch-as-sse-in-next-app-router.mdx index 741915e9..b74c66bb 100644 --- a/apps/docs/content/blog/stream-a-stitch-as-sse-in-next-app-router.mdx +++ b/apps/docs/content/blog/stream-a-stitch-as-sse-in-next-app-router.mdx @@ -40,29 +40,26 @@ Install it alongside the core library: @stitchapi/next stitchapi ``` -Then `sseResponse` takes the stitch's event stream and returns a `text/event-stream` `Response`. Each `delta` becomes one SSE frame; you tell it how to pull the renderable payload out of a chunk with `data`: +Then `streamStitchSse` takes the stitch's event stream and returns a `text/event-stream` `Response`. Each `delta` becomes one SSE frame; you tell it how to pull the renderable payload out of a chunk with `data`: ```ts twoslash // @noErrors // app/api/chat/route.ts import { chat } from '@/lib/api'; -import { - isStitchError, - sseResponse, - stitchErrorResponse, -} from '@stitchapi/next'; +import { stitchErrorResponse, streamStitchSse } from '@stitchapi/next'; export async function POST(request: Request) { const { prompt } = await request.json(); try { - return sseResponse(chat({ body: { prompt } }).stream(), { + return streamStitchSse(chat({ body: { prompt } }).stream(), { data: (chunk) => String(chunk), // pull text out of each delta signal: request.signal, // abort the stitch if the client leaves }); } catch (err) { - if (isStitchError(err)) return stitchErrorResponse(err); + const mapped = stitchErrorResponse(err); + if (mapped) return mapped; throw err; } } @@ -73,7 +70,7 @@ Two lines carry most of the value: - **`data`** maps each `delta` chunk to the string you actually want to send down the wire — text out of a model chunk, a field off a JSON event, whatever the frame should carry. - **`signal: request.signal`** is the cancellation story. When the browser disconnects — the user closed the tab, hit stop, or navigated away — `request.signal` aborts, and passing it through tears the stitch (and its upstream call) down instead of leaving an orphaned request burning a token budget. That early-cancel is half the reason to stream at all. -The `try/catch` with `isStitchError` / `stitchErrorResponse` handles a stitch that fails _before_ the stream opens (auth wall, timeout, the upstream being down). `stitchErrorResponse` maps a `StitchError` to a safe `502` by default rather than leaking the upstream's status. Confirm the exact options for both helpers in the [`@stitchapi/next` docs](/docs/integrations/next) — the surface is small but it's the source of truth, not this post. +The `try/catch` with `stitchErrorResponse` handles a stitch that fails _before_ the stream opens (auth wall, timeout, the upstream being down). `stitchErrorResponse` maps a `StitchError` to a safe `502` by default rather than leaking the upstream's status, and returns `undefined` for anything that isn't a stitch failure — which is why the handler rethrows when there's no mapped `Response`. Confirm the exact options for both helpers in the [`@stitchapi/next` docs](/docs/integrations/next) — the surface is small but it's the source of truth, not this post. ## The client: consuming deltas @@ -107,8 +104,8 @@ Streaming over HTTP has sharp edges that have nothing to do with StitchAPI, and - **Buffering in the middle.** SSE only works if nothing between your server and the browser buffers the response. Reverse proxies (nginx without `proxy_buffering off;` on the route), some CDNs, and corporate proxies will happily collect the whole stream and deliver it as one lump — which silently defeats the entire exercise. Test through your real edge, not just `localhost`. - **Edge vs Node runtime.** App Router handlers run on either runtime. The helpers are Web-standard so they work on both, but your _upstream_ may not — a streaming SDK that needs Node APIs, or a `fetch` quirk on the edge, will surface here. Pick the runtime deliberately (`export const runtime = 'edge' | 'nodejs'`) and test the streaming path on the one you deploy. -- **It's not always worth it.** If your call returns in a few hundred milliseconds, or the consumer can't use partial results (it needs the whole object validated before it does anything), streaming adds plumbing and failure modes for no user-visible win. A normal JSON route — the stitch plus `stitchErrorResponse`, no `sseResponse` — is the right call there. -- **Confirm the API surface.** The exact option names on `sseResponse` and `stitchErrorResponse` live in the package docs and may evolve. Treat the snippets above as the shape, and check [`/docs/integrations/next`](/docs/integrations/next) for the current signatures. +- **It's not always worth it.** If your call returns in a few hundred milliseconds, or the consumer can't use partial results (it needs the whole object validated before it does anything), streaming adds plumbing and failure modes for no user-visible win. A normal JSON route — the stitch plus `stitchErrorResponse`, no `streamStitchSse` — is the right call there. +- **Confirm the API surface.** The exact option names on `streamStitchSse` and `stitchErrorResponse` live in the package docs and may evolve. Treat the snippets above as the shape, and check [`/docs/integrations/next`](/docs/integrations/next) for the current signatures. ## Try it @@ -116,4 +113,4 @@ Streaming over HTTP has sharp edges that have nothing to do with StitchAPI, and stitchapi @stitchapi/next ``` -Declare a streaming stitch, return `sseResponse(stitch.stream())` from a route handler, and consume the deltas with `useStitchStream`. The full guides: [`@stitchapi/next`](/docs/integrations/next), [`@stitchapi/react`](/docs/integrations/react), and the [event stream](/docs/reference/events) that makes all of it one moving part. +Declare a streaming stitch, return `streamStitchSse(stitch.stream())` from a route handler, and consume the deltas with `useStitchStream`. The full guides: [`@stitchapi/next`](/docs/integrations/next), [`@stitchapi/react`](/docs/integrations/react), and the [event stream](/docs/reference/events) that makes all of it one moving part. diff --git a/apps/docs/content/blog/test-api-code-without-the-network.mdx b/apps/docs/content/blog/test-api-code-without-the-network.mdx index 830708fc..b3ac1a21 100644 --- a/apps/docs/content/blog/test-api-code-without-the-network.mdx +++ b/apps/docs/content/blog/test-api-code-without-the-network.mdx @@ -75,7 +75,7 @@ test('validates the user', async () => { }); ``` -The fixture is scoped to this stitch, not to a global. `mockAdapter` returns a real adapter — the same contract `fetchAdapter` satisfies — so the engine cannot tell it apart from a socket. That means `output: User` actually validates the canned body, `unwrap` actually unwraps it, and `paginate` actually walks your fixture pages. You are testing the stitch's behavior, not a fake of it. +The fixture is scoped to this stitch, not to a global. `mockAdapter` returns a real adapter — the same contract `fetchAdapter` satisfies — so the engine cannot tell it apart from a socket. That means `output: User` actually validates the canned body, `pick` actually picks it, and `paginate` actually walks your fixture pages. You are testing the stitch's behavior, not a fake of it. A `MockRoute` is `{ method?, match?, respond }`. `match` is a URL substring, a `RegExp` against the full URL, or a predicate — first match wins. `respond` is a fixed `MockResponse` (status defaults to `200`), a function of the call, or — the useful one for resilience — an array consumed one per call, last entry repeating. That last form scripts a flaky endpoint, which is how you exercise the real retry path: @@ -103,7 +103,7 @@ const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', output: User, - retry: { attempts: 3, on: [503], backoff: 'fixed', baseMs: 0 }, + retry: { attempts: 3, on: [503], backoff: 'fixed', baseDelay: 0 }, adapter, }); @@ -137,7 +137,7 @@ test('loader renders the user', async () => { const getUser = stubStitch({ id: '1', name: 'Ada' }); const view = await loadProfile(getUser); expect(view.heading).toBe('Ada'); - expect(getUser.callCount).toBe(1); + expect(getUser.callCount()).toBe(1); }); test('loader renders the error state', async () => { @@ -147,7 +147,7 @@ test('loader renders the error state', async () => { }); ``` -`stubStitch(impl)` gives a `Stitch & StubSpy`: `impl` is a value or a function of the input, awaiting returns it, and `.safe()` / `.unwrap()` / `.stream()` all behave like a real stitch while the spy records every call. `callCount` is a property on the stub — a number, not a method — so you read it as `getUser.callCount`. `failStitch(error)` always fails — `await` and `.unwrap()` reject with a `StitchError`, `.safe()` resolves to `{ ok: false }`, and `.stream()` yields `start` to `error` to `done`. Use `failStitch` to drive the error branch of code that calls a stitch without staging a real failure. +`stubStitch(impl)` gives a `Stitch & StubSpy`: `impl` is a value or a function of the input, awaiting returns it, and `.safe()` / `.unwrap()` / `.stream()` all behave like a real stitch while the spy records every call. The spy exposes `calls(filter?)` and `callCount(filter?)` as methods — the same shapes `mockAdapter` has — so you read it as `getUser.callCount()`, optionally narrowed by a filter. `failStitch(error)` always fails — `await` and `.unwrap()` reject with a `StitchError`, `.safe()` resolves to `{ ok: false }`, and `.stream()` yields `start` to `error` to `done`. Use `failStitch` to drive the error branch of code that calls a stitch without staging a real failure. The two layers split cleanly: @@ -182,7 +182,7 @@ const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', output: User, - retry: { attempts: 3, on: [503], backoff: 'fixed', baseMs: 0 }, + retry: { attempts: 3, on: [503], backoff: 'fixed', baseDelay: 0 }, adapter, }); @@ -261,7 +261,7 @@ const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', output: User, - retry: { attempts: 3, on: [503], baseMs: 1000 }, + retry: { attempts: 3, on: [503], baseDelay: 1000 }, adapter, clock, }); diff --git a/apps/docs/content/blog/trace-api-calls-without-leaking-secrets.mdx b/apps/docs/content/blog/trace-api-calls-without-leaking-secrets.mdx index c376e40f..6582d514 100644 --- a/apps/docs/content/blog/trace-api-calls-without-leaking-secrets.mdx +++ b/apps/docs/content/blog/trace-api-calls-without-leaking-secrets.mdx @@ -90,7 +90,7 @@ The trace that lands shows the request, every retry, and the result — with the - The `authorization` header reads `[REDACTED]`. So does `cookie`. Both are on the built-in denylist; you opted out of nothing. - The secret value of the `access_token` query parameter is scrubbed out of the logged URL string, and the same value is scrubbed from the structured `input.query` it also appears in. Scrub the URL, miss the copy: not here. -- The request body and the response are truncated to 2048 bytes, so a megabyte of PII never becomes a megabyte of log. +- The request body and the response are truncated to 2048 characters, so a megabyte of PII never becomes a megabyte of log. You did not write a `redact()`. You wrote `trace: 'console'`. The redaction is the default, not the step you can forget. @@ -123,7 +123,7 @@ const trace = createTrace({ console: true, file: './traces.jsonl', redactHeaders: ['x-internal-token', 'x-customer-id'], - maxBodyBytes: 4096, + maxBodyChars: 4096, }); const c = stitch({ url, trace }); @@ -134,7 +134,7 @@ const d = stitch({ }); ``` -`createTrace` is where you tune the two things you might want to widen. `redactHeaders` **adds** your own header names to the built-in denylist — it does not replace `authorization` and `cookie`, it joins them. `maxBodyBytes` defaults to `2048`; pass `maxBodyBytes: false` to capture full bodies when you are debugging in a place you trust and have decided the size is fine. +`createTrace` is where you tune the two things you might want to widen. `redactHeaders` **adds** your own header names to the built-in denylist — it does not replace `authorization` and `cookie`, it joins them. `maxBodyChars` defaults to `2048`; pass `maxBodyChars: false` to capture full bodies when you are debugging in a place you trust and have decided the size is fine. ## What gets redacted, before anything is written @@ -145,7 +145,7 @@ The redaction is not a post-processing pass you hope ran. It happens between the | Secret headers | You print `headers`; the token is in it | `authorization`, `cookie`, and your `redactHeaders` names become `[REDACTED]` | | URL credentials | `https://user:pass@host` logged whole | `scrubUrl` strips inline credentials from the URL string | | Secret query values | The token in `?token=...` is plaintext | Scrubbed from the URL string **and** the structured `input.query` | -| Large bodies | Full PII body, however big | Truncated to `maxBodyBytes` (2048 by default) | +| Large bodies | Full PII body, however big | Truncated to `maxBodyChars` (2048 by default) | | New secret field | You must remember to redact it | On the denylist, or one `redactHeaders` entry away | The contrast is the whole point: with a logger, safety is a habit you maintain. With a stitch, safety is the default and capturing more is the explicit choice. @@ -162,7 +162,7 @@ If your logs go to an OpenTelemetry pipeline, there is an OTLP exporter that tur ## Start narrow, widen on purpose -The smallest safe trace is one field: `trace: 'console'`, and the secrets are already handled. That is the call you have right now, made visible without making it dangerous. When you need durable traces, swap `'console'` for `fileSink(...)`; when you need to fan out, wrap them in `multiplex(...)`; when you need a span pipeline, point the OTLP exporter at your collector. Each step is one edit, and at every step redaction stays on by default — widening capture (`maxBodyBytes: false`, extra `redactHeaders`) is a thing you choose, never a thing you forget. The field that costs nothing when absent is the field that protects you the moment it is present. +The smallest safe trace is one field: `trace: 'console'`, and the secrets are already handled. That is the call you have right now, made visible without making it dangerous. When you need durable traces, swap `'console'` for `fileSink(...)`; when you need to fan out, wrap them in `multiplex(...)`; when you need a span pipeline, point the OTLP exporter at your collector. Each step is one edit, and at every step redaction stays on by default — widening capture (`maxBodyChars: false`, extra `redactHeaders`) is a thing you choose, never a thing you forget. The field that costs nothing when absent is the field that protects you the moment it is present. ## Try it diff --git a/apps/docs/content/blog/typed-graphql-without-apollo.mdx b/apps/docs/content/blog/typed-graphql-without-apollo.mdx index 65d8e156..bc4319fc 100644 --- a/apps/docs/content/blog/typed-graphql-without-apollo.mdx +++ b/apps/docs/content/blog/typed-graphql-without-apollo.mdx @@ -9,7 +9,7 @@ prerequisites: ['/docs/concepts/the-stitch'] You point a `fetch` at a GraphQL endpoint, POST `{ query, variables }`, get back a `200`, read `json.data.user` — and three weeks later that call is silently returning `undefined` because the server moved the failure into a `200`-with-`errors` body instead of a status code. The transport said success; the operation did not. Most GraphQL clients you'd reach for to close that gap — Apollo Client, urql — also bring a normalized cache, a React layer, and a build step you didn't ask for when all you wanted was to call one query and trust the result. -A `graphql()` stitch is that one query as a typed function. It POSTs the operation, treats a `200` carrying an `errors` array as the failure it is, validates the response, and hands you the unwrapped `data` — without a client object, a cache, or codegen. +A `graphql()` stitch is that one query as a typed function. It POSTs the operation, treats a `200` carrying an `errors` array as the failure it is, validates the response, and hands you the `data` payload — without a client object, a cache, or codegen. ## The honest "before" @@ -39,7 +39,7 @@ The sharp edges are real. `res.ok` is `true` for a `200` that carries `{ "errors ## The same job as a stitch -Declare the operation once. `graphql()` is a thin wrapper over `stitch()` that fixes `kind: 'graphql'`, `method: 'POST'`, and `unwrap: 'data'`: +Declare the operation once. `graphql()` is a thin wrapper over `stitch()` that fixes `kind: 'graphql'`, `method: 'POST'`, and `pick: 'data'`: ```ts twoslash import { graphql } from 'stitchapi'; @@ -47,16 +47,16 @@ import { graphql } from 'stitchapi'; const getUser = graphql<{ user: { id: string; name: string } }>({ baseUrl: 'https://api.example.com', path: '/graphql', - query: `query GetUser($id: ID!) { user(id: $id) { id name } }`, + document: `query GetUser($id: ID!) { user(id: $id) { id name } }`, }); const data = await getUser({ variables: { id: '1' } }); -// data is already unwrapped from `data`: { user: { id, name } } +// data is already the response's `data` payload: { user: { id, name } } ``` -Each field maps to glue you no longer write. `graphql()` sends the POST and the JSON body shape for you, so the `method`, the `content-type` header, and the `{ query, variables }` envelope vanish from your code. The arguments travel in the call input's `variables` field at call time, so one declared stitch serves every set of arguments — you do not interpolate values into the query string and mint a stitch per call. Because `unwrap` defaults to `'data'`, the resolved value is the contents of the response's `data` field; override `unwrap` to read a different field or the raw envelope. +Each field maps to glue you no longer write. `graphql()` sends the POST and the JSON body shape for you, so the `method`, the `content-type` header, and the `{ query, variables }` envelope vanish from your code — the `document` you declare travels as the envelope's `query` field. The arguments travel in the call input's `variables` field at call time, so one declared stitch serves every set of arguments — you do not interpolate values into the document and mint a stitch per call. Because `pick` defaults to `'data'`, the resolved value is the contents of the response's `data` field; override `pick` to read a different field or the raw envelope. -The footgun closes here. A GraphQL endpoint can return HTTP `200` while carrying a top-level `errors` array; the stitch treats that as a failure and surfaces it, rather than unwrapping a `data` that isn't there. A failed query rejects the awaitable with a `StitchError` whose message carries the GraphQL error text, prefixed `GraphQL:` (joined with `; ` when there is more than one): +The footgun closes here. A GraphQL endpoint can return HTTP `200` while carrying a top-level `errors` array; the stitch treats that as a failure and surfaces it, rather than handing you a `data` that isn't there. A failed query rejects the awaitable with a `StitchError` whose message carries the GraphQL error text, prefixed `GraphQL:` (joined with `; ` when there is more than one): ```ts twoslash import { StitchError, graphql } from 'stitchapi'; @@ -64,7 +64,7 @@ import { StitchError, graphql } from 'stitchapi'; const getUser = graphql<{ user: { name: string } }>({ baseUrl: 'https://api.example.com', path: '/graphql', - query: `query GetUser($id: ID!) { user(id: $id) { name } }`, + document: `query GetUser($id: ID!) { user(id: $id) { name } }`, }); try { @@ -83,7 +83,7 @@ try { ## It is still a stitch -`graphql()` returns the same object `stitch()` does, so every resilience and validation field applies. Add an `output` schema and the unwrapped `data` is validated before you touch it — the `as User` cast is gone, replaced by a runtime check the compiler can trust: +`graphql()` returns the same object `stitch()` does, so every resilience and validation field applies. Add an `output` schema and the picked `data` is validated before you touch it — the `as User` cast is gone, replaced by a runtime check the compiler can trust: ```ts twoslash import { bearer, env, graphql } from 'stitchapi'; @@ -92,7 +92,7 @@ import { z } from 'zod'; const getUser = graphql<{ user: { id: string; name: string } }>({ baseUrl: 'https://api.example.com', path: '/graphql', - query: `query GetUser($id: ID!) { user(id: $id) { id name } }`, + document: `query GetUser($id: ID!) { user(id: $id) { id name } }`, output: z.object({ user: z.object({ id: z.string(), name: z.string() }), }), @@ -111,11 +111,11 @@ The secret resolves at call time from the environment, so the caller never holds | Axis | Hand-rolled `fetch` | A `graphql()` stitch | | ------------------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| The POST + JSON envelope | You write `method`, `content-type`, `JSON.stringify({ query, variables })` every call | `graphql()` fixes them; you supply `path` + `query` | +| The POST + JSON envelope | You write `method`, `content-type`, `JSON.stringify({ query, variables })` every call | `graphql()` fixes them; you supply `path` + `document` | | Variables | Re-stringified per call site | Travel in `variables` at call time; one stitch, every argument set | | `200` with `errors` | `res.ok` is `true` — failure slips through as `undefined` | A `StitchError` whose message is prefixed `GraphQL:` | -| Response shape | `as User` — unchecked | `output` schema validates the unwrapped `data` at runtime | -| Unwrapping `data` | `json.data.user`, by hand | `unwrap: 'data'` by default; resolved value is `data` | +| Response shape | `as User` — unchecked | `output` schema validates the picked `data` at runtime | +| Picking `data` | `json.data.user`, by hand | `pick: 'data'` by default; resolved value is `data` | | Auth, retry, timeout | More hand-written loops and headers | Config fields on the same declaration | The layer split is clean: the GraphQL document describes **what** you're asking for, and the stitch config describes **how** the call behaves — validation, auth, resilience — without a client object owning either. You keep writing GraphQL; you stop writing the POST around it. @@ -134,12 +134,12 @@ const api = seam({ const getUser = api.graphql({ path: '/graphql', - query: `query GetUser($id: ID!) { user(id: $id) { id name } }`, + document: `query GetUser($id: ID!) { user(id: $id) { id name } }`, }); const listOrders = api.graphql({ path: '/graphql', - query: `query Orders($userId: ID!) { orders(userId: $userId) { id total } }`, + document: `query Orders($userId: ID!) { orders(userId: $userId) { id total } }`, }); ``` @@ -153,7 +153,7 @@ stitchapi zod `graphql()` wraps `stitch()`, so install the core package, bring your own validator, and point a query at an endpoint. -- [Guide: GraphQL](/docs/guides/data/graphql) — how `graphql()` posts the operation and unwraps `data` +- [Guide: GraphQL](/docs/guides/data/graphql) — how `graphql()` posts the operation and picks `data` - [Error: STITCH_GRAPHQL](/docs/errors/stitch-graphql) — what a `200`-with-`errors` failure looks like - [Validate an API response with Zod](/blog/validate-api-response-zod) — wiring an `output` schema - [A type-safe API client without codegen](/blog/type-safe-api-client-without-codegen) — the same idea across REST and GraphQL diff --git a/apps/docs/content/blog/typescript-pagination.mdx b/apps/docs/content/blog/typescript-pagination.mdx index 1f7b3870..52c4f1b4 100644 --- a/apps/docs/content/blog/typescript-pagination.mdx +++ b/apps/docs/content/blog/typescript-pagination.mdx @@ -64,7 +64,7 @@ const allUsers = stitch({ const users = (await allUsers()) as unknown[]; // every page, one array ``` -`next(prevBody, pagesFetched)` reads the cursor off the previous page's raw body and returns the input for the next page, merged over the original call — set only what changes. Return `undefined` to stop. `items(value)` selects the array to aggregate from each page; it defaults to the value itself when that value is already an array. `max` caps the page count as a safety net (default `50`), so a `next` that never returns `undefined` can't spin forever. +`next(prevBody, pagesFetched)` reads the cursor off the previous page's raw body and returns the input for the next page, merged over the original call — set only what changes. Return `undefined` to stop. `items(value)` selects the array to aggregate from each page; it defaults to the value itself when that value is already an array. `pages` caps the page count as a safety net (default `50`), so a `next` that never returns `undefined` can't spin forever. For an offset or page-number API, `next` uses the page count it's handed instead of a cursor: @@ -103,7 +103,7 @@ const mirror = stitch({ backoff: 'expo-jitter', respectRetryAfter: true, }, - throttle: { rate: '5/s', concurrency: 2, scope: 'host' }, + throttle: { rate: '5/s', concurrency: 2, pool: 'host' }, paginate: { next: (prevBody) => { const cursor = (prevBody as { nextCursor?: string }).nextCursor; @@ -119,7 +119,7 @@ const rows = (await mirror()) as unknown[]; A `429` on page seven now recovers on its own through [retry](/docs/guides/resilience/retry), and [throttle](/docs/guides/resilience/throttle) keeps the whole run under five requests a second — applied **per page**, not once for the run. None of that touches the pagination logic; it's declared next to it. The two recipes go end to end: [loop a cursor API into one array](/docs/recipes/paginate-into-one-array) and [mirror a paginated API to NDJSON](/docs/recipes/mirror-a-paginated-api), the latter adding OAuth2 on the same declaration. -One detail to get right: `next` reads the **raw body**, while `items` reads the value after [unwrap](/docs/guides/data/unwrap) runs. Reach for the cursor in `next` from where it actually lives in the response. And without an [`output` schema](/docs/guides/validation/validation) the aggregated rows arrive as `unknown[]` — hence the cast; add a schema and each row is typed and validated, and the cast goes away. +One detail to get right: `next` reads the **raw body**, while `items` reads the value after [pick](/docs/guides/data/pick) runs. Reach for the cursor in `next` from where it actually lives in the response. And without an [`output` schema](/docs/guides/validation/validation) the aggregated rows arrive as `unknown[]` — hence the cast; add a schema and each row is typed and validated, and the cast goes away. ## Where the hand-rolled loop tops out @@ -142,4 +142,4 @@ One caveat in the other direction: `paginate` aggregates every page into a singl stitchapi ``` -Give a stitch a `paginate` with a `next` and an `items`, and one `await` follows every page into a single array — with `retry` and `throttle` applied per page when you add them. The mechanics are in [Pagination](/docs/guides/data/pagination), the envelope handling in [Unwrap](/docs/guides/data/unwrap), and typed rows in [Validation](/docs/guides/validation/validation). +Give a stitch a `paginate` with a `next` and an `items`, and one `await` follows every page into a single array — with `retry` and `throttle` applied per page when you add them. The mechanics are in [Pagination](/docs/guides/data/pagination), the envelope handling in [Pick](/docs/guides/data/pick), and typed rows in [Validation](/docs/guides/validation/validation). diff --git a/apps/docs/content/blog/validate-runtime-discovered-schemas.mdx b/apps/docs/content/blog/validate-runtime-discovered-schemas.mdx index d128dab3..9cde14f8 100644 --- a/apps/docs/content/blog/validate-runtime-discovered-schemas.mdx +++ b/apps/docs/content/blog/validate-runtime-discovered-schemas.mdx @@ -95,7 +95,7 @@ const call = stitch({ }); const inspection = await call.inspect({ body: payload }); -inspection.value; // validated response, or null on a hard failure +inspection.data; // validated response, or null on a hard failure for (const finding of inspection.findings) { // e.g. { path: 'items[].sku', change: 'undeclared' } log.warn(`response drift at ${finding.path}: ${finding.change}`); diff --git a/apps/docs/content/docs/agents/run-stitch-tool.mdx b/apps/docs/content/docs/agents/run-stitch-tool.mdx index 078bb496..c222737b 100644 --- a/apps/docs/content/docs/agents/run-stitch-tool.mdx +++ b/apps/docs/content/docs/agents/run-stitch-tool.mdx @@ -38,7 +38,7 @@ capability boundary. The agent never sees the credential. Before running a name, an agent can ask for its shape. `describe_stitch` takes `{ name }` and returns one text result — a JSON description built from the stitch's public `__config`: its endpoint and surface, which input slots it -takes, whether the output is validated and where it unwraps, the auth **scheme** +takes, whether the output is validated and what it picks, the auth **scheme** (never the token), which policies are on, the pipeline of steps a call runs through, and a Mermaid `diagram`. @@ -59,7 +59,7 @@ returns one text result whose body is JSON like: "body": false, "headers": false }, - "output": { "validated": true, "unwrap": "data" }, + "output": { "validated": true, "pick": "data" }, "auth": "bearer", "policies": { "retry": true, @@ -72,7 +72,7 @@ returns one text result whose body is JSON like: "GET /widgets/{id}", "retry", "validate", - "unwrap: data", + "pick: data", "result" ], "diagram": "flowchart TD\n subgraph ..." diff --git a/apps/docs/content/docs/concepts/principles.mdx b/apps/docs/content/docs/concepts/principles.mdx index 7b4e13ab..6788745d 100644 --- a/apps/docs/content/docs/concepts/principles.mdx +++ b/apps/docs/content/docs/concepts/principles.mdx @@ -43,7 +43,7 @@ declaration _is_ the runtime; there is nothing to commit, diff, and regenerate. ### Composition over configuration -Cross-cutting concerns — `baseUrl`, auth, `unwrap`, retry, throttle, timeout — +Cross-cutting concerns — `baseUrl`, auth, `pick`, retry, throttle, timeout — are named, shareable values you compose, not a central config object far from the call site. Nothing is inherited implicitly: no config file, no ambient defaults, no global a stitch silently reads. Everything that shapes a call was composed into @@ -63,7 +63,7 @@ const api = { }; // Fold the shared fragment into each stitch; it inherits baseUrl + retry. -const getUser = stitch({ extends: [api], path: '/users/{id}', unwrap: 'data' }); +const getUser = stitch({ extends: [api], path: '/users/{id}', pick: 'data' }); ``` ### One source of truth diff --git a/apps/docs/content/docs/concepts/the-seam.mdx b/apps/docs/content/docs/concepts/the-seam.mdx index 92109b38..81752414 100644 --- a/apps/docs/content/docs/concepts/the-seam.mdx +++ b/apps/docs/content/docs/concepts/the-seam.mdx @@ -33,12 +33,12 @@ const api = seam({ // share its store, throttle bucket, vault, and trace sink. const getUser = api.stitch({ path: '/users/{id}', - unwrap: 'data', + pick: 'data', output: User, }); const listUsers = api.stitch({ path: '/users', - unwrap: 'data', + pick: 'data', output: User.array(), }); ``` diff --git a/apps/docs/content/docs/errors/stitch-auth-wall.mdx b/apps/docs/content/docs/errors/stitch-auth-wall.mdx index fe13a882..f4a45443 100644 --- a/apps/docs/content/docs/errors/stitch-auth-wall.mdx +++ b/apps/docs/content/docs/errors/stitch-auth-wall.mdx @@ -7,7 +7,7 @@ description: 'Authentication failed, or a soft 200 login wall was hit and could A call that depends on a session fails after the runtime has tried to re-authenticate. With [`cookieSession`](/docs/guides/auth/cookie-session) the -login is re-run on the configured `refreshOn` status (default `401`) and the +login is re-run on the configured `refresh` status (default `401`) and the request retried once; when that retry still can't get through, the stitch surfaces the failure instead of looping. The downstream response keeps coming back unauthenticated — most often a `401`, sometimes a `200` whose body is @@ -21,8 +21,9 @@ Two distinct triggers land here: rotated form fields, or a login endpoint that now sets a different cookie than the one you capture — so `cookieSession` replays a cookie the API rejects. - **A soft 200 wall.** The API answers an unauthenticated request with `200` and - an HTML login page rather than a `401`. `refreshOn` only matches status codes, - so the wall slips past it and the body is treated as a successful response. + an HTML login page rather than a `401`. `refresh`'s `on` field only matches + status codes, so the wall slips past it and the body is treated as a + successful response. ## How to fix @@ -31,7 +32,7 @@ Confirm the credentials resolve at call time (see captures the cookie the API actually checks — `'*'` captures the whole jar when you're unsure which one matters. -For a soft 200 wall, add `refreshWhen` so a login page served with `200` +For a soft 200 wall, add `refresh: { when }` so a login page served with `200` triggers a re-login instead of passing as data: ```ts twoslash @@ -54,8 +55,10 @@ const me = stitch({ body: { user: env('USER')(), pass: env('PASS')() }, }), // A 200 that is really the login page is a soft wall — re-login on it. - refreshWhen: (res) => - typeof res.body === 'string' && res.body.includes('id="login"'), + refresh: { + when: (res) => + typeof res.body === 'string' && res.body.includes('id="login"'), + }, }), }); ``` diff --git a/apps/docs/content/docs/errors/stitch-graphql.mdx b/apps/docs/content/docs/errors/stitch-graphql.mdx index 080892dd..666769ff 100644 --- a/apps/docs/content/docs/errors/stitch-graphql.mdx +++ b/apps/docs/content/docs/errors/stitch-graphql.mdx @@ -7,7 +7,7 @@ description: 'A GraphQL 200 response carried an errors array and failed the call A stitch built with [`graphql()`](/docs/guides/data/graphql) throws a `StitchError` even though the HTTP status was `200`. The response body carried a -non-empty top-level `errors` array, so the stitch refused to unwrap `data` and +non-empty top-level `errors` array, so the stitch refused to pick `data` and surfaced the failure instead. The thrown error carries the GraphQL message(s), prefixed `GraphQL:` and joined with `; ` when there is more than one: @@ -17,7 +17,7 @@ import { StitchError, graphql } from 'stitchapi'; const user = graphql({ baseUrl: 'https://api.example.com', path: '/graphql', - query: `query ($id: ID!) { user(id: $id) { name } }`, + document: `query ($id: ID!) { user(id: $id) { name } }`, }); try { @@ -38,7 +38,7 @@ throws a `StitchError` today.) GraphQL-over-HTTP commonly answers with HTTP `200` even when the operation failed, reporting the failure in a top-level `errors` array rather than via the status line. A `graphql()` stitch treats a `200` that carries a non-empty -`errors` array as a failure instead of silently unwrapping `data` — so a request +`errors` array as a failure instead of silently picking `data` — so a request that "looked successful" at the transport layer still throws. The entries in `errors` come from the GraphQL server: a malformed query or @@ -63,7 +63,7 @@ own description of what went wrong — and fix the cause: Remember that GraphQL signals failure in the response body, not through the HTTP status: a `200` is not proof of success. See the [GraphQL guide](/docs/guides/data/graphql) for how `graphql()` posts the -operation and unwraps `data` on the success path. +operation and picks `data` on the success path. Stuck, or debugging with an agent? Ask the [docs diff --git a/apps/docs/content/docs/errors/stitch-validation.mdx b/apps/docs/content/docs/errors/stitch-validation.mdx index 0fcef881..c2a0ddbe 100644 --- a/apps/docs/content/docs/errors/stitch-validation.mdx +++ b/apps/docs/content/docs/errors/stitch-validation.mdx @@ -15,7 +15,7 @@ type mismatch on the response. Where it throws tells you which side failed: `params`, `query`, `body`, or `headers`. - **Response, after the request.** When the response doesn't match `output`, the error is thrown after the response returns and has been transformed and - unwrapped — the request did go out, and the failure describes how the payload + picked — the request did go out, and the failure describes how the payload diverged from the contract. diff --git a/apps/docs/content/docs/getting-started/migration-notes.mdx b/apps/docs/content/docs/getting-started/migration-notes.mdx index da4c97df..8d954196 100644 --- a/apps/docs/content/docs/getting-started/migration-notes.mdx +++ b/apps/docs/content/docs/getting-started/migration-notes.mdx @@ -20,7 +20,7 @@ server exposing items behind an API key. ## Header names go on the wire lowercase -You configure `apiKey({ header: 'X-Catalog-Token', ... })` and your fixture +You configure `apiKey({ name: 'X-Catalog-Token', ... })` and your fixture asserts the request carried a header literally named `X-Catalog-Token`. A stitch lowercases the header name: it sends `x-catalog-token`. The default @@ -33,7 +33,7 @@ import { apiKey, env, stitch } from 'stitchapi'; const getItem = stitch({ baseUrl: 'https://api.example.com', path: '/items/{id}', - auth: apiKey({ header: 'X-Catalog-Token', value: env('CATALOG_TOKEN') }), + auth: apiKey({ name: 'X-Catalog-Token', value: env('CATALOG_TOKEN') }), }); // On the wire, the request header is `x-catalog-token`, not `X-Catalog-Token`. ``` diff --git a/apps/docs/content/docs/guides/auth/api-key.mdx b/apps/docs/content/docs/guides/auth/api-key.mdx index a3c94061..1a69ca67 100644 --- a/apps/docs/content/docs/guides/auth/api-key.mdx +++ b/apps/docs/content/docs/guides/auth/api-key.mdx @@ -32,8 +32,8 @@ call time and never committed. `in` chooses where the key goes: `'header'` (the default) or `'query'`. -`header` overrides the header name (default `x-api-key`, lowercased) when -`in: 'header'`: pass `apiKey({ header: 'X-My-Key', value: env('API_KEY') })` to +`name` overrides the header name (default `x-api-key`, lowercased) when +`in: 'header'`: pass `apiKey({ name: 'X-My-Key', value: env('API_KEY') })` to send the key under a custom header. The name goes on the wire **lowercased** (`x-my-key`) — case-insensitive per HTTP, but worth knowing if a fixture asserts the original casing. See [Migration notes](/docs/getting-started/migration-notes). diff --git a/apps/docs/content/docs/guides/auth/cookie-session.mdx b/apps/docs/content/docs/guides/auth/cookie-session.mdx index 68f6d2ec..87fa7fa4 100644 --- a/apps/docs/content/docs/guides/auth/cookie-session.mdx +++ b/apps/docs/content/docs/guides/auth/cookie-session.mdx @@ -31,7 +31,7 @@ const me = stitch({ loginInput: () => ({ body: { user: env('USER')(), pass: env('PASS')() }, }), - refreshOn: [401], + refresh: [401], }), }); ``` @@ -48,10 +48,10 @@ its credentials, resolved at call time with [`env()`](/docs/guides/auth/secret-r `cookie` selects what to keep: a single cookie name, or `'*'` (equivalently `jar: true`) to capture and replay the whole Set-Cookie jar. -`refreshOn` re-runs the login on a status code (default `[401]`); `refreshWhen` -re-runs it on a content predicate, for soft 200 walls where a login page is -served with a `200`. Give two stitches the same `key` plus a shared `store` to -share one session. See +`refresh` re-runs the login on a status code (default `[401]`, via a bare +scalar/array or `{ on }`); pass `{ when }` to re-run it on a content predicate +instead, for soft 200 walls where a login page is served with a `200`. Give two +stitches the same `key` plus a shared `store` to share one session. See [Reference → Auth strategies](/docs/reference/auth-strategies) for every field. ## Durable state and an external recovery loop @@ -69,7 +69,7 @@ never crashes the call. clear or extend a cooldown. - `onAuthFailure(info)` runs when an attempt captured no cookie, with a `category` you map to your own status: - `'unauthenticated'` (a `refreshOn` status, e.g. `401` — bad/expired creds), + `'unauthenticated'` (a `refresh` status, e.g. `401` — bad/expired creds), `'rate-limited'` (`429`, with `retryAfter` parsed from `Retry-After`), `'network'` (the login threw before any response — with the thrown `error`), or `'unknown'`. `info.phase` is `'apply'` for a cold session or `'refresh'` @@ -133,14 +133,14 @@ const me = stitch({ The hooks are purely additive: omit them and `cookieSession` behaves exactly as before. They observe and record — they never change _whether_ the stitch logs in -or retries (that stays governed by `refreshOn`/`refreshWhen`). Your external loop +or retries (that stays governed by `refresh.on`/`refresh.when`). Your external loop reads the durable state and decides when to call the stitch again. - **Anti-pattern:** don't lean on `refreshOn` status codes alone to catch an + **Anti-pattern:** don't lean on `refresh` status codes alone to catch an expired session — a soft 200 wall, where the login page comes back with status `200`, slips straight past it and your stitch reads the login HTML as - data; add a `refreshWhen` content predicate so the session re-logs in on + data; add a `refresh: {when}` content predicate so the session re-logs in on that body too. See [STITCH_AUTH_WALL](/docs/errors/stitch-auth-wall). diff --git a/apps/docs/content/docs/guides/auth/oauth2.mdx b/apps/docs/content/docs/guides/auth/oauth2.mdx index 90fbba28..1d260f9a 100644 --- a/apps/docs/content/docs/guides/auth/oauth2.mdx +++ b/apps/docs/content/docs/guides/auth/oauth2.mdx @@ -38,10 +38,11 @@ expiry, all behind the capability boundary. `env('CLIENT_ID')` — so the credentials are read at call time and never committed. `scope` is an optional space-delimited scope string. -The token is cached and auto-refreshed: `refreshSkew` (default `30_000`) -refreshes it that many milliseconds before expiry, so it is never used -mid-flight, and `refreshOn` (default `[401]`) lists the statuses that mean the -token was rejected and force a fresh fetch plus retry. +The token is cached and auto-refreshed via the `refresh` envelope: `refresh: { +skew }` (default `30_000`) refreshes it that many milliseconds before expiry, +so it is never used mid-flight, and `refresh: { on }` (default `[401]`) lists +the statuses that mean the token was rejected and force a fresh fetch plus +retry. A bare `refresh: ` is shorthand for `{ on: }`. Give two stitches the same `key` plus a shared `store` and one token serves them all — across stitches and across workers, surviving restarts. See diff --git a/apps/docs/content/docs/guides/authoring/seam.mdx b/apps/docs/content/docs/guides/authoring/seam.mdx index a22adb88..e776e0fa 100644 --- a/apps/docs/content/docs/guides/authoring/seam.mdx +++ b/apps/docs/content/docs/guides/authoring/seam.mdx @@ -30,12 +30,12 @@ const api = seam({ // Members are stitches that belong to the seam. const getUser = api.stitch({ path: '/users/{id}', - unwrap: 'data', + pick: 'data', output: User, }); const listUsers = api.stitch({ path: '/users', - unwrap: 'data', + pick: 'data', output: User.array(), }); @@ -57,7 +57,7 @@ const api = seam({ baseUrl: 'https://demo.stitchapi.dev' }); // One handle per request — bind the caller's identity, then create members. const forTenant = api.as('tenant-42'); -const getUser = forTenant.stitch({ path: '/users/{id}', unwrap: 'data' }); +const getUser = forTenant.stitch({ path: '/users/{id}', pick: 'data' }); ``` diff --git a/apps/docs/content/docs/guides/data/body-encoding.mdx b/apps/docs/content/docs/guides/data/body-encoding.mdx index f5cf102a..e09c14cd 100644 --- a/apps/docs/content/docs/guides/data/body-encoding.mdx +++ b/apps/docs/content/docs/guides/data/body-encoding.mdx @@ -36,7 +36,9 @@ because `bodyType` is `'form'`. - `form` — a URL-encoded `application/x-www-form-urlencoded` body, for APIs that expect classic HTML-form fields (login forms, OAuth token exchanges). - `multipart` — multipart form data, for file uploads and mixed - field-plus-binary payloads; the boundary is set for you. + field-plus-binary payloads; the boundary is set for you. Nested + objects/arrays serialize with bracket nesting by default; `multipart: 'dot'` + (≡ `multipart: { nesting: 'dot' }`) switches to dot-path field names. The body itself always comes from the call input (`await submit({ body })`), never from the config — so one stitch encodes whatever you pass it. See diff --git a/apps/docs/content/docs/guides/data/graphql.mdx b/apps/docs/content/docs/guides/data/graphql.mdx index 93f0c233..56629143 100644 --- a/apps/docs/content/docs/guides/data/graphql.mdx +++ b/apps/docs/content/docs/guides/data/graphql.mdx @@ -1,12 +1,12 @@ --- title: 'GraphQL' -description: 'Call a GraphQL endpoint with variables, unwrap data, and treat a 200 carrying errors as a failure.' +description: 'Call a GraphQL endpoint with variables, pick data, and treat a 200 carrying errors as a failure.' prerequisites: ['/docs/concepts/the-stitch'] --- Use `graphql()` when an API speaks GraphQL-over-HTTP and you want a stitch that POSTs a query, passes variables per call, and hands back the `data` payload -already unwrapped — with a `200` that carries an `errors` array treated as a +already picked — with a `200` that carries an `errors` array treated as a failure, not a success. ## Example @@ -17,23 +17,23 @@ import { graphql } from 'stitchapi'; const getUser = graphql<{ user: { id: string; name: string } }>({ baseUrl: 'https://api.example.com', path: '/graphql', - query: `query GetUser($id: ID!) { user(id: $id) { id name } }`, + document: `query GetUser($id: ID!) { user(id: $id) { id name } }`, }); -// Variables travel in the input; the result is already unwrapped from `data`. +// Variables travel in the input; the result is already picked from `data`. const data = await getUser({ variables: { id: '1' } }); ``` ## Options `graphql()` is a thin wrapper over `stitch()`: it fixes `kind: 'graphql'`, -`method: 'POST'`, and `unwrap: 'data'` for you. You supply the GraphQL document -as `query`, plus a `baseUrl`/`path` pointing at the endpoint. +`method: 'POST'`, and `pick: 'data'` for you. You supply the GraphQL document +as `document`, plus a `baseUrl`/`path` pointing at the endpoint. Pass GraphQL variables through the input's `variables` field at call time — `getUser({ variables: { id: '1' } })` — so one stitch serves every set of -arguments. Because `unwrap` defaults to `'data'`, the resolved value is the -contents of the response's `data` object; override `unwrap` if you need a +arguments. Because `pick` defaults to `'data'`, the resolved value is the +contents of the response's `data` object; override `pick` if you need a different field or the raw envelope. @@ -55,7 +55,7 @@ For the full set of fields a stitch accepts, see ## See also - + diff --git a/apps/docs/content/docs/guides/data/meta.json b/apps/docs/content/docs/guides/data/meta.json index ed423b93..f9fc4a19 100644 --- a/apps/docs/content/docs/guides/data/meta.json +++ b/apps/docs/content/docs/guides/data/meta.json @@ -1,7 +1,7 @@ { "title": "Data shaping", "pages": [ - "unwrap", + "pick", "transform", "pagination", "body-encoding", diff --git a/apps/docs/content/docs/guides/data/pagination.mdx b/apps/docs/content/docs/guides/data/pagination.mdx index f2a61a03..5ebf6810 100644 --- a/apps/docs/content/docs/guides/data/pagination.mdx +++ b/apps/docs/content/docs/guides/data/pagination.mdx @@ -38,9 +38,9 @@ page count and returns the [`StitchInput`](/docs/reference/config-types) for the next page, merged over the original call — set only what changes, like a cursor or page number. Return `undefined` to stop. -`items(value)` selects the array to aggregate from each page's unwrapped value; -it defaults to the value itself when that value is already an array. The unwrap -runs first, so pair `items` with [`unwrap`](/docs/guides/data/unwrap) when the +`items(value)` selects the array to aggregate from each page's picked value; +it defaults to the value itself when that value is already an array. The pick +runs first, so pair `items` with [`pick`](/docs/guides/data/pick) when the array sits under an envelope, and use `items` to dig into a nested shape. `max` caps the page count as a safety net (default `50`) so a misbehaving @@ -51,17 +51,17 @@ Auth, retry, and throttle apply per page, not once for the whole run: a and a [`retry`](/docs/guides/resilience/retry) recovers each page on its own. - **Anti-pattern:** don't read the next-page cursor in `next` from the - unwrapped array, expecting it where `items` looks — `next` receives the raw - body and `items` receives the value after `unwrap` runs, so read the cursor - in `next` from where it lives in the raw response instead. See - [unwrap](/docs/guides/data/unwrap). + **Anti-pattern:** don't read the next-page cursor in `next` from the picked + array, expecting it where `items` looks — `next` receives the raw body and + `items` receives the value after `pick` runs, so read the cursor in `next` + from where it lives in the raw response instead. See + [pick](/docs/guides/data/pick). ## See also - + diff --git a/apps/docs/content/docs/guides/data/unwrap.mdx b/apps/docs/content/docs/guides/data/pick.mdx similarity index 61% rename from apps/docs/content/docs/guides/data/unwrap.mdx rename to apps/docs/content/docs/guides/data/pick.mdx index f0ca2519..1c9a4a78 100644 --- a/apps/docs/content/docs/guides/data/unwrap.mdx +++ b/apps/docs/content/docs/guides/data/pick.mdx @@ -1,10 +1,10 @@ --- -title: 'unwrap' +title: 'pick' description: 'Pull just the part of the response you want by dot-path.' prerequisites: ['/docs/concepts/the-stitch'] --- -Use `unwrap` when an API wraps the value you want in an envelope and you want +Use `pick` when an API wraps the value you want in an envelope and you want the stitch to hand back the inner field directly — give it a dot-path and the caller never sees the wrapper. @@ -16,7 +16,7 @@ import { stitch } from 'stitchapi'; const getUser = stitch<{ id: number; name: string }>({ baseUrl: 'https://api.example.com', path: '/users/{id}', - unwrap: 'data.user', + pick: 'data.user', }); // `user` is the inner { id, name } object, not the { data: { user } } envelope. @@ -25,28 +25,28 @@ const user = await getUser({ params: { id: 1 } }); ## Options -`unwrap` takes a **dot-path** — each segment selects a nested key, so +`pick` takes a **dot-path** — each segment selects a nested key, so `'data.user'` reaches into `{ data: { user: ... } }` and returns the inner object. It sits late in the pipeline: a response is reshaped by -[`transform`](/docs/guides/data/transform) first, then `unwrap` selects the +[`transform`](/docs/guides/data/transform) first, then `pick` selects the slice, and only then does [validation](/docs/guides/validation/validation) run — -so a schema sees the unwrapped value, not the envelope. For paged calls, each -page is unwrapped before its items are collected; see +so a schema sees the picked value, not the envelope. For paged calls, each +page is picked before its items are collected; see [Pagination](/docs/guides/data/pagination). -Pair `unwrap` with the generic on `stitch()` to type the result: the stitch -returns `T` (the unwrapped value), not the envelope. See +Pair `pick` with the generic on `stitch()` to type the result: the stitch +returns `T` (the picked value), not the envelope. See [Reference → Config types](/docs/reference/config-types) for the full config shape. **Anti-pattern:** don't reach for the dot-path to reshape, rename, or merge - fields — `unwrap` only _selects_ one already-nested slice, so anything - beyond picking an existing branch belongs in a reshape step that runs first. - Do the restructuring in `transform` instead, then point `unwrap` at the - slice it produces. See [transform](/docs/guides/data/transform). + fields — `pick` only _selects_ one already-nested slice, so anything beyond + picking an existing branch belongs in a reshape step that runs first. Do the + restructuring in `transform` instead, then point `pick` at the slice it + produces. See [transform](/docs/guides/data/transform). ## See also diff --git a/apps/docs/content/docs/guides/data/transform.mdx b/apps/docs/content/docs/guides/data/transform.mdx index e6b730ad..757ea6cd 100644 --- a/apps/docs/content/docs/guides/data/transform.mdx +++ b/apps/docs/content/docs/guides/data/transform.mdx @@ -1,12 +1,12 @@ --- title: 'transform' -description: 'Reshape a response before unwrap and validation — for example, scrape HTML into structured data.' +description: 'Reshape a response before pick and validation — for example, scrape HTML into structured data.' prerequisites: ['/docs/concepts/the-stitch'] --- Use `transform` when the raw response isn't yet the shape the rest of the pipeline expects — scrape HTML text into a structured object, or flatten an odd -envelope — so a single function reshapes the body before unwrap and validation +envelope — so a single function reshapes the body before pick and validation ever see it. ## Example @@ -18,7 +18,7 @@ const listing = stitch({ baseUrl: 'https://api.example.com', path: '/listings', // The body is `unknown` — narrow it, then reshape the raw envelope into a - // plain array before unwrap and validation run. + // plain array before pick and validation run. transform: (body) => (body as { results: unknown[] }).results, }); ``` @@ -29,11 +29,11 @@ inner array, and everything downstream works against that array instead. ## Options `transform?: (body: unknown) => unknown` runs **first** in the response -pipeline — before [`unwrap`](/docs/guides/data/unwrap) and before +pipeline — before [`pick`](/docs/guides/data/pick) and before [validation](/docs/guides/validation/validation) (and [drift](/docs/guides/validation/drift)). That ordering is the point: reshape a raw body — scrape HTML text into a structured object, or flatten an odd -envelope — into the shape unwrap and validation are written for, rather than +envelope — into the shape pick and validation are written for, rather than bending them around the source's quirks. `body` is typed `unknown`, so narrow it before reading (a cast or a type guard) @@ -44,15 +44,15 @@ and return any reshaped value. See **Anti-pattern:** don't reach for `transform` to pull the inner field out of an envelope — a cast like reading `.results` off the body is an opaque function whose output is unchecked, so a wrong narrowing slips past and only - surfaces later as drift. Name the path with `unwrap` instead, which is + surfaces later as drift. Name the path with `pick` instead, which is declarative, round-trips as JSON, and feeds validation and drift directly. - See [unwrap](/docs/guides/data/unwrap). + See [pick](/docs/guides/data/pick). ## See also - + diff --git a/apps/docs/content/docs/guides/observability/otlp.mdx b/apps/docs/content/docs/guides/observability/otlp.mdx index f08d19a5..ded6d8b4 100644 --- a/apps/docs/content/docs/guides/observability/otlp.mdx +++ b/apps/docs/content/docs/guides/observability/otlp.mdx @@ -21,14 +21,14 @@ STITCH_EXPORT=otlp OTEL_EXPORTER_OTLP_ENDPOINT=https://api.example.com node app. ``` To wire it yourself — to set headers, or to tee OTLP next to your own sink — -build the sink with `otlpTrace()` and combine sinks with `multiplex`: +build the sink with `otlpSink()` and combine sinks with `multiplex`: ```ts twoslash -import { createTrace, multiplex, otlpTrace } from 'stitchapi'; +import { createTrace, multiplex, otlpSink } from 'stitchapi'; const trace = multiplex( createTrace({ console: true }), - otlpTrace({ + otlpSink({ endpoint: 'https://api.example.com', headers: { authorization: 'Bearer token' }, }), @@ -40,7 +40,7 @@ const trace = multiplex( `STITCH_EXPORT=otlp` is the toggle (a comma list, e.g. `console,otlp`); `OTEL_EXPORTER_OTLP_ENDPOINT` sets the collector base URL. -`otlpTrace(opts?)` returns a sink. `OtlpOptions` carries `endpoint` (OTLP/HTTP +`otlpSink(opts?)` returns a sink. `OtlpOptions` carries `endpoint` (OTLP/HTTP base URL), `headers` (extra headers on the POST, e.g. auth), and `exporter` (a custom `SpanExporter` destination, defaulting to the built-in HTTP exporter). diff --git a/apps/docs/content/docs/guides/observability/trace-sinks.mdx b/apps/docs/content/docs/guides/observability/trace-sinks.mdx index 8737dc4a..1fbc967b 100644 --- a/apps/docs/content/docs/guides/observability/trace-sinks.mdx +++ b/apps/docs/content/docs/guides/observability/trace-sinks.mdx @@ -77,13 +77,13 @@ for await (const event of search({ query: { q: 'mango' } }).stream()) { The `trace` field accepts: -| Value | Effect | -| ------------- | ---------------------------------------------------------------------- | -| _(unset)_ | Falls back to the env vars below — off unless one is set. | -| `'console'` | The colored one-line-per-event stream to stderr. | -| `fileSink(p)` | JSONL appended to `p` (defaults to `~/.stitch/runs/proto.jsonl`). | -| a `TraceSink` | Any custom sink — e.g. from `createTrace` / `multiplex` / `otlpTrace`. | -| `false` | Forces tracing off, even when `STITCH_TRACE_*` is set. | +| Value | Effect | +| ------------- | --------------------------------------------------------------------- | +| _(unset)_ | Falls back to the env vars below — off unless one is set. | +| `'console'` | The colored one-line-per-event stream to stderr. | +| `fileSink(p)` | JSONL appended to `p` (defaults to `~/.stitch/runs/proto.jsonl`). | +| a `TraceSink` | Any custom sink — e.g. from `createTrace` / `multiplex` / `otlpSink`. | +| `false` | Forces tracing off, even when `STITCH_TRACE_*` is set. | When `trace` is unset, four env vars control the built-in sink: `STITCH_TRACE_CONSOLE=1` turns on the colored stderr stream; @@ -248,7 +248,7 @@ marker: { "truncated": true, "bytes": 51234, "preview": "{\"items\":[{\"id\":1,…" } ``` -`preview` is the first `maxBodyBytes` characters of the (already header-redacted) +`preview` is the first `maxBodyChars` characters of the (already header-redacted) JSON, so you keep a readable head of the payload without storing all of it. The default cap is **2048 characters**; `0` keeps only the marker, no preview. @@ -257,7 +257,7 @@ still contain a body-embedded secret. Header and URL secrets are redacted (above regardless of the cap. Opt into **full capture** — the whole body, untruncated — when you need it, with -`maxBodyBytes: false` on the file sink: +`maxBodyChars: false` on the file sink: ```ts twoslash import { fileSink, stitch } from 'stitchapi'; @@ -266,7 +266,7 @@ const report = stitch({ baseUrl: 'https://api.example.com', path: '/report', // false = capture the whole body; a number sets a custom character cap. - trace: fileSink('./runs/today.jsonl', { maxBodyBytes: false }), + trace: fileSink('./runs/today.jsonl', { maxBodyChars: false }), }); ``` @@ -287,7 +287,7 @@ STITCH_TRACE_MAX_BODY=8192 node run.js values scrubbed. No API changed shape and nothing was removed — if a tool or `stitch trace` workflow relied on whole bodies on disk, restore the old behaviour with `STITCH_TRACE_MAX_BODY=full` (or - `fileSink(path, { maxBodyBytes: false })`). A custom `TraceSink` is unaffected: + `fileSink(path, { maxBodyChars: false })`). A custom `TraceSink` is unaffected: it receives the live events and decides for itself. diff --git a/apps/docs/content/docs/guides/resilience/accept-status.mdx b/apps/docs/content/docs/guides/resilience/accept-status.mdx index 29c35419..d54aaf46 100644 --- a/apps/docs/content/docs/guides/resilience/accept-status.mdx +++ b/apps/docs/content/docs/guides/resilience/accept-status.mdx @@ -1,6 +1,6 @@ --- title: 'Accept status' -description: 'Declare statuses that are a normal result, not an error — an accepted non-2xx flows through transform/unwrap/validate instead of throwing.' +description: 'Declare statuses that are a normal result, not an error — an accepted non-2xx flows through transform/pick/validate instead of throwing.' prerequisites: ['/docs/concepts/the-stitch'] --- @@ -11,7 +11,7 @@ ordinary control flow into exception handling. `acceptStatus` lets you declare which non-2xx statuses are a **normal result**. An accepted status no longer throws: its response body becomes the result and flows -through the same pipeline a `2xx` does — `interpret` → `transform` → `unwrap` → +through the same pipeline a `2xx` does — `interpret` → `transform` → `pick` → `output` validation — so the happy path stays on the happy path. ## Example @@ -40,9 +40,10 @@ A `404` here **resolves** with the response body instead of rejecting. Everythin else (`401`, `500`, …) still throws a `StitchError` exactly as before — `acceptStatus` is additive, never a blanket "ignore failures". -## A list or a predicate +## A status, a list, or a predicate -`acceptStatus` is either a number list or a predicate `(status) => boolean`. Use a +`acceptStatus` is a single status, a number list, or a predicate +`(status) => boolean` — `acceptStatus: 404` is shorthand for `[404]`. Use a predicate to accept a range: ```ts twoslash diff --git a/apps/docs/content/docs/guides/resilience/circuit-breaker.mdx b/apps/docs/content/docs/guides/resilience/circuit-breaker.mdx index 2fbffaa6..2f229a7c 100644 --- a/apps/docs/content/docs/guides/resilience/circuit-breaker.mdx +++ b/apps/docs/content/docs/guides/resilience/circuit-breaker.mdx @@ -33,7 +33,8 @@ through). After `failures` consecutive failures it trips **open** and calls fast-fail for `cooldown`. It then goes **half-open** and lets a single trial call through: a success **closes** it and clears the count, another failure re-**opens** it for a fresh cooldown. The breaker emits `progress` -events with phase `circuit`. +events with phase `circuit`. The positional form names both knobs at once: +`circuit: [5, '30s']` ≡ `circuit: { failures: 5, cooldown: '30s' }`. `failures` and `cooldown` are both required — `failures` is the count of consecutive failures that trips the breaker open, and `cooldown` diff --git a/apps/docs/content/docs/guides/resilience/delegate-backoff.mdx b/apps/docs/content/docs/guides/resilience/delegate-backoff.mdx index 319e8124..79834c15 100644 --- a/apps/docs/content/docs/guides/resilience/delegate-backoff.mdx +++ b/apps/docs/content/docs/guides/resilience/delegate-backoff.mdx @@ -85,7 +85,7 @@ for await (const ev of fetchPage.stream({ params: { id: '42' } })) { ## What still runs Delegate mode changes _only_ how a rate-limit status is handled. Everything else -on a stitch is untouched: input validation, templating, `transform`, `unwrap`, +on a stitch is untouched: input validation, templating, `transform`, `pick`, and [drift](/docs/guides/validation/drift) all still run on a successful response, and non-rate-limit failures (a `500`, a transport error) behave exactly as they do without the flag. A [`circuit`](/docs/guides/resilience/circuit-breaker) diff --git a/apps/docs/content/docs/guides/resilience/throttle.mdx b/apps/docs/content/docs/guides/resilience/throttle.mdx index 926c9a9e..ec00b794 100644 --- a/apps/docs/content/docs/guides/resilience/throttle.mdx +++ b/apps/docs/content/docs/guides/resilience/throttle.mdx @@ -1,6 +1,6 @@ --- title: 'Throttle' -description: 'Space requests by rate and cap concurrency, scoped per stitch or per host.' +description: 'Space requests by rate and cap concurrency, pooled per stitch or per host.' prerequisites: ['/docs/concepts/the-stitch'] --- @@ -16,7 +16,7 @@ import { stitch } from 'stitchapi'; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', - throttle: { rate: '5/s', concurrency: 2, scope: 'host' }, + throttle: { rate: '5/s', concurrency: 2, pool: 'host' }, }); ``` @@ -29,12 +29,13 @@ silently stalled. `rate` is a string like `"5/s"` — a minimum spacing between successive calls, not a token bucket. `concurrency` caps simultaneous in-flight calls; either may -be set on its own. +be set on its own. When rate is all you need, the scalar form reads best: +`throttle: '2/s'` ≡ `throttle: { rate: '2/s' }`. -`scope` decides what shares the budget: `'stitch'` (the default) gives each +`pool` decides what shares the budget: `'stitch'` (the default) gives each stitch its own budget, while `'host'` pools the budget across every stitch hitting the same host — use it when one API publishes a single account-wide -limit. Host-scoped pooling works in-process out of the box: separate stitches in +limit. Host pooling works in-process out of the box: separate stitches in the same process draw from one budget per host with no extra configuration. Giving those stitches a shared [store](/docs/guides/state/pluggable-store) extends that single-process budget @@ -42,7 +43,7 @@ into a [distributed one](/docs/guides/state/distributed-throttle) that spans workers. - **Anti-pattern:** don't rely on a host-scoped throttle to honor an + **Anti-pattern:** don't rely on a host-pooled throttle to honor an account-wide limit once you run more than one process — the budget lives in memory and is process-local, so each of N workers keeps its own count and together they hit `api.example.com` at up to N times the rate you declared; diff --git a/apps/docs/content/docs/guides/state/distributed-throttle.mdx b/apps/docs/content/docs/guides/state/distributed-throttle.mdx index 57abf136..d07020e9 100644 --- a/apps/docs/content/docs/guides/state/distributed-throttle.mdx +++ b/apps/docs/content/docs/guides/state/distributed-throttle.mdx @@ -6,7 +6,7 @@ prerequisites: ['/docs/concepts/the-stitch', '/docs/concepts/the-seam'] Share one rate limit across every worker or server by pointing the throttle at a shared `store`. By default a stitch keeps its throttle counters in the in-memory -store, so the rate budget is per process: with `scope: 'host'` every stitch in +store, so the rate budget is per process: with `pool: 'host'` every stitch in that process already pools into one budget per host (see [Throttle](/docs/guides/resilience/throttle)), but the in-memory store keeps that pooling within a single process — each worker gets its own. Point the throttle at @@ -24,7 +24,7 @@ declare const sharedStore: StitchStore; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', - throttle: { rate: '10/s', scope: 'host' }, + throttle: { rate: '10/s', pool: 'host' }, store: sharedStore, // the budget now spans all workers using this store }); ``` @@ -32,7 +32,7 @@ const search = stitch({ ## Options `throttle` sets the limits — `rate` (e.g. `'10/s'`), `concurrency`, and -`scope` — while `store` decides where the counters live. The default in-memory +`pool` — while `store` decides where the counters live. The default in-memory store is single-process, so each worker enforces `10/s` on its own and the fleet runs at `10/s × workers`. A shared store holds the rate counter every worker increments, so the whole fleet shares one `10/s` budget — and grants are @@ -48,10 +48,10 @@ boundary bursts. (Concurrency stays in-process; only the rate limit is distribut The budget itself still holds. -`scope` decides what shares a budget: `'stitch'` (the default) gives each stitch +`pool` decides what shares a budget: `'stitch'` (the default) gives each stitch its own counter keyed by name, while `'host'` keys by origin so every stitch hitting `api.example.com` draws from one budget. Workers share a budget only when -they point at the same store and resolve to the same key — same scope, same +they point at the same store and resolve to the same key — same pool, same stitch name or host. diff --git a/apps/docs/content/docs/guides/testing/mocking.mdx b/apps/docs/content/docs/guides/testing/mocking.mdx index 95924bd4..882c4a30 100644 --- a/apps/docs/content/docs/guides/testing/mocking.mdx +++ b/apps/docs/content/docs/guides/testing/mocking.mdx @@ -169,7 +169,7 @@ conformant `Stitch`: callable, with `.safe`/`.unwrap`/`.stream`/`.with`, passing `isStitch`, plus a call spy — but with no network behind it. `stubStitch(value | (input) => value, opts?)` resolves to the value (or a -function of the call input). The spy exposes `.calls`, `.callCount`, and +function of the call input). The spy exposes `.calls(filter?)`, `.callCount(filter?)`, and `.reset()`: ```ts twoslash @@ -186,8 +186,8 @@ const getUser = stubStitch({ id: 42, name: 'Ada' }); isStitch(getUser); // true — it's a real Stitch shape await loadProfile(getUser); -getUser.callCount; // 1 -getUser.calls[0]; // { params: { id: 42 } } +getUser.callCount(); // 1 +getUser.calls()[0]; // { params: { id: 42 } } ``` `failStitch(error, opts?)` is the failure twin. Pass a message, an diff --git a/apps/docs/content/docs/integrations/angular.mdx b/apps/docs/content/docs/integrations/angular.mdx index 7cd72bc8..17139260 100644 --- a/apps/docs/content/docs/integrations/angular.mdx +++ b/apps/docs/content/docs/integrations/angular.mdx @@ -107,7 +107,7 @@ Both functions return the same shape — `data`, `error`, `status`, `chunks`, an ## The observable: `state$` -Prefer RxJS? The same state is a multicast `Observable>` on +Prefer RxJS? The same state is a multicast `Observable>` on `state$`, sharing the one query execution with the signals. Read it with the `async` pipe: diff --git a/apps/docs/content/docs/integrations/aws-sigv4.mdx b/apps/docs/content/docs/integrations/aws-sigv4.mdx index 6ed7310f..03faa48e 100644 --- a/apps/docs/content/docs/integrations/aws-sigv4.mdx +++ b/apps/docs/content/docs/integrations/aws-sigv4.mdx @@ -97,7 +97,7 @@ const { authorization } = await signRequestV4({ secretAccessKey: '…', region: 'us-east-1', service: 'service', - dateTime: '20150830T123600Z', + amzDate: '20150830T123600Z', }); ``` diff --git a/apps/docs/content/docs/integrations/elysia.mdx b/apps/docs/content/docs/integrations/elysia.mdx index dd71ff35..02c8c489 100644 --- a/apps/docs/content/docs/integrations/elysia.mdx +++ b/apps/docs/content/docs/integrations/elysia.mdx @@ -63,9 +63,14 @@ shutdown. Stream a streaming/SSE stitch's `.stream()` to the client by returning the `text/event-stream` `Response` `streamStitchSse` builds. Each `delta` becomes a -`data:` message; a terminal `error` (or a throw) becomes a final `event: error` -message; control events are consumed but not forwarded; and a client disconnect -cancels the body and aborts the upstream stitch stream. +`data:` message (shape it with `data`, name it with `event`, make the stream +resumable with `id`); a terminal `error` (or a throw) becomes a final +`event: error` message whose `data` is a **generic `error` token by default** — +the raw upstream message (an internal hostname, an `HTTP 401`) never reaches the +client. Opt in to a client-facing message with `errorData`, and observe the real +failure server-side with `onError`. Control events are consumed but not +forwarded, and a client disconnect cancels the body and aborts the upstream +stitch stream. ```ts import { stitch, streamStitchSse } from '@stitchapi/elysia'; diff --git a/apps/docs/content/docs/integrations/express.mdx b/apps/docs/content/docs/integrations/express.mdx index 086f5e7d..d59c76ca 100644 --- a/apps/docs/content/docs/integrations/express.mdx +++ b/apps/docs/content/docs/integrations/express.mdx @@ -57,15 +57,17 @@ never name another identity. **Borrow, don't own:** the middleware never calls `seam.close()` — you build the seam at startup and close it on shutdown. A generic helper that only holds a loosely-typed `Request` can read the same -handle back with `currentStitch(req)`, which throws if the middleware never ran — -so a missing `app.use(stitch(...))` fails loudly instead of silently. +handle back with `currentStitch(req)`, which returns `undefined` if the +middleware never ran — so a caller can fall back to an explicit seam instead of +crashing. ## Streaming — `streamStitchSse` Stream a streaming/SSE stitch's `.stream()` to the client as Server-Sent Events. Each `delta` becomes a `data:` frame; a terminal `error` (or a throw) becomes a -final `event: error` frame; control events are consumed but not forwarded; and a -client disconnect aborts the upstream stitch stream. +final `event: error` frame carrying a generic `error` token by default (opt in +to a client-facing message with `errorData`); control events are consumed but +not forwarded; and a client disconnect aborts the upstream stitch stream. ```ts import { stitch, streamStitchSse } from '@stitchapi/express'; diff --git a/apps/docs/content/docs/integrations/fastify.mdx b/apps/docs/content/docs/integrations/fastify.mdx index 116ad063..c7e5990e 100644 --- a/apps/docs/content/docs/integrations/fastify.mdx +++ b/apps/docs/content/docs/integrations/fastify.mdx @@ -86,14 +86,15 @@ async function loadProfile() { ## SSE streaming -`sendStitchSse(reply, stream, options?)` streams a stitch's `.stream()` output to a +`streamStitchSse(reply, stream, options?)` streams a stitch's `.stream()` output to a `text/event-stream` reply. Each `delta` chunk becomes one SSE frame; an `error` -event ends the stream as a named `error` frame; stream end closes the response; and -a client disconnect aborts the upstream stitch generator rather than leaving it -running. +event ends the stream as a named `error` frame whose `data` is a generic `error` +token by default (the raw upstream message never reaches the client — opt in with +`errorData`); stream end closes the response; and a client disconnect aborts the +upstream stitch generator rather than leaving it running. ```ts twoslash -import { sendStitchSse } from '@stitchapi/fastify'; +import { streamStitchSse } from '@stitchapi/fastify'; import Fastify from 'fastify'; import { sse } from 'stitchapi/sse'; @@ -101,7 +102,7 @@ const chat = sse({ url: 'https://api.example.com/chat' }); const app = Fastify(); app.get<{ Querystring: { q: string } }>('/chat', (req, reply) => - sendStitchSse(reply, chat.stream({ query: { q: req.query.q } }), { + streamStitchSse(reply, chat.stream({ query: { q: req.query.q } }), { data: (chunk) => (chunk as { data: string }).data, // pull text out }), ); diff --git a/apps/docs/content/docs/integrations/hono.mdx b/apps/docs/content/docs/integrations/hono.mdx index 1938a867..f25fcbc5 100644 --- a/apps/docs/content/docs/integrations/hono.mdx +++ b/apps/docs/content/docs/integrations/hono.mdx @@ -59,8 +59,10 @@ never name another identity. **Borrow, don't own:** the middleware never calls Stream a streaming/SSE stitch's `.stream()` to the client as Server-Sent Events, via Hono's `streamSSE`. Each `delta` becomes a `data:` message; a terminal `error` -(or a throw) becomes a final `event: error` message; control events are consumed -but not forwarded; and a client disconnect aborts the upstream stitch stream. +(or a throw) becomes a final `event: error` message carrying a generic `error` +token by default (opt in to a client-facing message with `errorData`); control +events are consumed but not forwarded; and a client disconnect aborts the upstream +stitch stream. ```ts twoslash import { type StitchEnv, stitch, streamStitchSse } from '@stitchapi/hono'; diff --git a/apps/docs/content/docs/integrations/index.mdx b/apps/docs/content/docs/integrations/index.mdx index b1f0c841..06887d5b 100644 --- a/apps/docs/content/docs/integrations/index.mdx +++ b/apps/docs/content/docs/integrations/index.mdx @@ -59,7 +59,7 @@ an SSE helper, and an error bridge. @@ -129,7 +129,7 @@ stitch is the fetcher; the library owns caching, dedup, and invalidation. diff --git a/apps/docs/content/docs/integrations/nestjs.mdx b/apps/docs/content/docs/integrations/nestjs.mdx index d021bd7e..abc32d1e 100644 --- a/apps/docs/content/docs/integrations/nestjs.mdx +++ b/apps/docs/content/docs/integrations/nestjs.mdx @@ -22,8 +22,8 @@ Install the package alongside core: Configure the shared client once in your root module. `forRootAsync` resolves its options from an injected factory, which is how `ConfigService` reaches the base -URL, secrets, and store. `trace: 'logger'` sends the event stream to the Nest -`Logger`: +URL, secrets, and store. The event stream is bridged to the Nest `Logger` by +default — nothing to switch on: ```ts twoslash // app.module.ts @@ -40,7 +40,6 @@ import { bearer } from 'stitchapi'; useFactory: (config: ConfigService) => ({ baseUrl: config.getOrThrow('API_BASE_URL'), auth: bearer(fromNestConfig(config)('API_TOKEN')), - trace: 'logger', }), }), ], @@ -103,14 +102,19 @@ one store, one trace sink — plus the shared config (`baseUrl`, `auth`, `retry` `throttle`, …) every stitch inherits; `forRootAsync` resolves the same options from an injected factory. `forFeature({ stitches, seam })` registers injectable stitches and, optionally, a per-upstream seam built over the shared store and -trace — omit `seam` to attach the stitches to the default one. +trace via `seam.config` — omit `seam` to attach the stitches to the default one. +`seam.token` exposes the feature seam under a caller-chosen DI token, for +multi-tenant `.as(principal)`. Two bridges connect the framework, and neither changes core: -- **`trace: 'logger'`** forwards a stitch's - [event stream](/docs/concepts/event-stream) to the Nest `Logger`. It logs - method, URL, and status only, and strips the query string; tracing is - otherwise [off by default](/docs/guides/observability/trace-sinks). +- **`logger`** bridges a stitch's + [event stream](/docs/concepts/event-stream) into the Nest `Logger` as the + seam's `TraceSink` — **on by default**, matching the Fastify plugin. It logs + only metadata (name, method, scrubbed URL, status, attempts, timing) — never + bodies or headers. Disable it with `logger: false`, or drop the happy path + with `logger: { lifecycle: false }`; an explicit `trace` wins over the + bridge. - **`fromNestConfig(config)('KEY')`** is a `ConfigService`-backed [secret resolver](/docs/guides/auth/secret-resolvers) — the `ConfigService` twin of `env()`. It resolves synchronously at call time, so the credential diff --git a/apps/docs/content/docs/integrations/next.mdx b/apps/docs/content/docs/integrations/next.mdx index e514be9b..e4f92aa8 100644 --- a/apps/docs/content/docs/integrations/next.mdx +++ b/apps/docs/content/docs/integrations/next.mdx @@ -1,6 +1,6 @@ --- title: Next.js -description: Web-standard helpers for Next App Router route handlers — sseResponse streams a stitch as text/event-stream, and stitchErrorResponse maps a StitchError to a Response. +description: Web-standard helpers for Next App Router route handlers — streamStitchSse streams a stitch as text/event-stream, and stitchErrorResponse maps a StitchError to a Response. prerequisites: ['/docs/concepts/the-stitch', '/docs/concepts/the-seam'] --- @@ -31,7 +31,7 @@ A non-streaming endpoint is the stitch plus the error helper — no per-route // app/api/users/[id]/route.ts import { getUser } from '@/lib/api'; -import { isStitchError, stitchErrorResponse } from '@stitchapi/next'; +import { stitchErrorResponse } from '@stitchapi/next'; export async function GET( _req: Request, @@ -41,31 +41,35 @@ export async function GET( try { return Response.json(await getUser({ params: { id } })); } catch (err) { - if (isStitchError(err)) return stitchErrorResponse(err); + const mapped = stitchErrorResponse(err); + if (mapped) return mapped; throw err; } } ``` -`stitchErrorResponse` maps a `StitchError` to `502` by default — the safe choice that -never leaks the upstream's `401`/`404` semantics. Pass `{ status: (e) => e.status ?? 502 }` -to propagate the upstream status instead. +`stitchErrorResponse` returns a `Response` for a `StitchError` and `undefined` for +anything else — map the stitch failure, rethrow the rest, no `instanceof` dance. +It maps to `502` by default — the safe choice that never leaks the +upstream's `401`/`404` semantics. Pass `{ status: (e) => e.status ?? 502 }` to +propagate the upstream status instead. ## Streaming with SSE -`sseResponse` streams a stitch's events as `text/event-stream`. Each `delta` becomes -one frame; an `error` event ends the stream with a named `event: error` frame; the -terminal `result` closes it: +`streamStitchSse` streams a stitch's events as `text/event-stream`. Each `delta` +becomes one frame; an `error` event ends the stream with a named `event: error` +frame whose `data` is a generic `error` token by default (opt in to a client-facing +message with `errorData`); the terminal `result` closes it: ```ts // app/api/chat/route.ts import { chat } from '@/lib/api'; -import { sseResponse } from '@stitchapi/next'; +import { streamStitchSse } from '@stitchapi/next'; export async function POST(request: Request) { const { prompt } = await request.json(); - return sseResponse(chat({ body: { prompt } }).stream(), { + return streamStitchSse(chat({ body: { prompt } }).stream(), { data: (c) => String(c), // pull text out of each chunk signal: request.signal, // abort the upstream if the client leaves }); diff --git a/apps/docs/content/docs/integrations/rtk-query.mdx b/apps/docs/content/docs/integrations/rtk-query.mdx index ab83188d..d9e7fe35 100644 --- a/apps/docs/content/docs/integrations/rtk-query.mdx +++ b/apps/docs/content/docs/integrations/rtk-query.mdx @@ -70,8 +70,9 @@ chat: build.query({ ``` The cached data is the accumulated chunks (`mode: 'append'`, default) or the latest -chunk (`mode: 'replace'`). Streaming stops when the cache entry is removed (the last -subscriber leaves). +chunk — pass the mode as a scalar shorthand: +`stitchStreamUpdater(chat, 'replace')` ≡ `stitchStreamUpdater(chat, { mode: 'replace' })`. +Streaming stops when the cache entry is removed (the last subscriber leaves). Reach for these adapters when RTK Query already owns your view state and you diff --git a/apps/docs/content/docs/integrations/sentry.mdx b/apps/docs/content/docs/integrations/sentry.mdx index e9530a3a..1ff63cbb 100644 --- a/apps/docs/content/docs/integrations/sentry.mdx +++ b/apps/docs/content/docs/integrations/sentry.mdx @@ -46,16 +46,16 @@ The same sink works on a single stitch — `stitch({ trace: sentrySink(Sentry) } ## What it sends -| Event | Sentry | -| ------------------------------ | -------------------------------------------------------------------- | -| `error` | `captureMessage` (level `error`) + an error breadcrumb | -| `progress` (`retry`/`circuit`) | breadcrumb, level `warning` | -| `progress` (throttle/paginate) | breadcrumb, level `debug` | -| `drift` | breadcrumb (level follows the finding); captured with `captureDrift` | -| `start` / `result` / `done` | breadcrumb only when `lifecycle: true` (off by default) | -| `delta` / `info` | **never sent** (raw response data / strategy announcements) | +| Event | Sentry | +| ------------------------------ | -------------------------------------------------------------------------------- | +| `error` | `captureMessage` (level `error`) + an error breadcrumb | +| `progress` (`retry`/`circuit`) | breadcrumb, level `warning` | +| `progress` (throttle/paginate) | breadcrumb, level `debug` | +| `drift` | breadcrumb (level follows the finding); captured with `capture: { drift: true }` | +| `start` / `result` / `done` | breadcrumb only when `lifecycle: true` (off by default) | +| `delta` / `info` | **never sent** (raw response data / strategy announcements) | -Options: `{ lifecycle?, captureErrors?, captureDrift? }`. +Options: `{ lifecycle?, capture?: boolean | { errors?, drift? } }`. `capture: false` disables both `errors` and `drift` capture (breadcrumbs still flow); `capture: true`/omitted resolves to the documented defaults (`errors: true`, `drift: false`); pass the envelope to set them independently. **Metadata only, by design.** A custom `TraceSink` receives the **raw** @@ -64,7 +64,7 @@ Options: `{ lifecycle?, captureErrors?, captureDrift? }`. — it can carry `?api_key=…`), status, attempt counts, drift path/level/change, phase, and timing. It never sends `event.input` (its headers still hold the live `authorization` / `cookie`), the response - `value`, or a `delta` chunk — safe on a secret-bearing seam regardless of + `data`, or a `delta` chunk — safe on a secret-bearing seam regardless of core's trace redaction. diff --git a/apps/docs/content/docs/integrations/svelte.mdx b/apps/docs/content/docs/integrations/svelte.mdx index 11362902..1a523c84 100644 --- a/apps/docs/content/docs/integrations/svelte.mdx +++ b/apps/docs/content/docs/integrations/svelte.mdx @@ -85,8 +85,7 @@ latest chunk (`mode: 'replace'`), and `status` is `'streaming'` until the termin {#if $tokens.isStreaming}{/if} ``` -Same state shape as `stitchStore`. `useStitch` / `useStitchStream` are exported as -aliases of these two, for callers who prefer the `use*` naming. +Same state shape as `stitchStore`. ## The shared store: `@stitchapi/query-core` diff --git a/apps/docs/content/docs/integrations/vercel-ai.mdx b/apps/docs/content/docs/integrations/vercel-ai.mdx index 4ed411f7..66c98511 100644 --- a/apps/docs/content/docs/integrations/vercel-ai.mdx +++ b/apps/docs/content/docs/integrations/vercel-ai.mdx @@ -53,6 +53,11 @@ const { text } = await generateText({ - `toInput` — maps the model's args onto the stitch's `{ params, query, body }`. Omit it when the model's args _are_ the stitch input. +When the schema is all you need, pass it positionally — +`stitchTool(getUser, z.object({ params: z.object({ id: z.string() }) }))` ≡ +`stitchTool(getUser, { inputSchema: … })` with the args used as the stitch input +directly. + The tool's result is the stitch's validated output, so the model reasons over real data, not a guess; a failure rejects, and the AI SDK's tool-error handling reports it. diff --git a/apps/docs/content/docs/recipes/catch-a-selector-rename.mdx b/apps/docs/content/docs/recipes/catch-a-selector-rename.mdx index 65d3abe9..090caabf 100644 --- a/apps/docs/content/docs/recipes/catch-a-selector-rename.mdx +++ b/apps/docs/content/docs/recipes/catch-a-selector-rename.mdx @@ -16,7 +16,7 @@ want that markup rename to surface the moment it happens, as a loud error on the ## Example -The response pipeline is `transform → unwrap → validation`, so `transform` +The response pipeline is `transform → pick → validation`, so `transform` reshapes the HTML into a structured object **before** drift ever runs — drift then validates the _parsed_ value, not the raw markup. Declare `score` as **required** in the schema: its loss is the breakage you want caught. @@ -47,7 +47,7 @@ const catalog = stitch({ baseUrl: 'https://api.example.com', path: '/catalog', transform: scrape, // HTML string -> { items: [...] } - unwrap: 'items', + pick: 'items', output: drift( // `score` is REQUIRED — its loss is the breakage we want caught. // A required field that goes missing throws (`STITCH_DRIFT`, `change: 'invalid'`). diff --git a/apps/docs/content/docs/recipes/inspect-what-the-server-sent.mdx b/apps/docs/content/docs/recipes/inspect-what-the-server-sent.mdx index 811798c9..5059cbfa 100644 --- a/apps/docs/content/docs/recipes/inspect-what-the-server-sent.mdx +++ b/apps/docs/content/docs/recipes/inspect-what-the-server-sent.mdx @@ -28,7 +28,7 @@ import { z } from 'zod'; const getUser = stitch({ baseUrl: 'https://demo.stitchapi.dev', path: '/users/{id}', - unwrap: 'data', + pick: 'data', output: drift( z.object({ id: z.number(), name: z.string(), role: z.string() }), ), @@ -89,6 +89,9 @@ const getUser = stitch({ const cached = await getUser.inspect({ params: { id: 1 } }, { cache: true }); ``` +`true` is shorthand for `{ cache: true }` — `getUser.inspect(input, true)` reads +the same way at a call site. + `raw` is `null` on a [streaming surface](/docs/concepts/event-stream) too — the engine refuses to buffer an unbounded delta spine — while `findings` and `status` still populate; use `.stream()` for incremental inspection there. diff --git a/apps/docs/content/docs/recipes/mirror-a-paginated-api.mdx b/apps/docs/content/docs/recipes/mirror-a-paginated-api.mdx index 5b2e5163..5e21c415 100644 --- a/apps/docs/content/docs/recipes/mirror-a-paginated-api.mdx +++ b/apps/docs/content/docs/recipes/mirror-a-paginated-api.mdx @@ -33,7 +33,7 @@ const mirror = stitch({ backoff: 'expo-jitter', respectRetryAfter: true, }, - throttle: { rate: '5/s', concurrency: 2, scope: 'host' }, + throttle: { rate: '5/s', concurrency: 2, pool: 'host' }, paginate: { next: (prevBody) => { const cursor = (prevBody as { nextCursor?: string }).nextCursor; @@ -79,8 +79,8 @@ schema and each row is typed and validated, and the cast goes away. Either way, serializing to NDJSON is one `.map()`. - `next` reads the raw body; `items` reads the value after unwrap. Reach for - the cursor in `next` from where it actually lives in the response — see + `next` reads the raw body; `items` reads the value after pick. Reach for the + cursor in `next` from where it actually lives in the response — see [Pagination](/docs/guides/data/pagination). diff --git a/apps/docs/content/docs/recipes/one-rate-limit-across-workers.mdx b/apps/docs/content/docs/recipes/one-rate-limit-across-workers.mdx index 0853bd08..433ff236 100644 --- a/apps/docs/content/docs/recipes/one-rate-limit-across-workers.mdx +++ b/apps/docs/content/docs/recipes/one-rate-limit-across-workers.mdx @@ -24,7 +24,7 @@ declare const sharedStore: StitchStore; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', - throttle: { rate: '10/s', scope: 'host' }, + throttle: { rate: '10/s', pool: 'host' }, store: sharedStore, // the budget now spans all workers using this store }); @@ -36,7 +36,7 @@ const hits = await search({ query: { q: 'mango' } }); By default throttle counters live in the in-memory store, so each worker enforces the limit alone and the fleet runs at `10/s × workers`. Point `store` at one shared backend and the rate counter becomes a single fixed-window count every -worker increments — one `10/s` budget across the fleet. `scope: 'host'` keys the +worker increments — one `10/s` budget across the fleet. `pool: 'host'` keys the budget by origin, so every stitch hitting the host pools into it; workers share only when they point at the same store and resolve to the same key. (Concurrency stays in-process; only the rate limit is distributed.) A diff --git a/apps/docs/content/docs/recipes/paginate-into-one-array.mdx b/apps/docs/content/docs/recipes/paginate-into-one-array.mdx index c0224490..82c2b6d0 100644 --- a/apps/docs/content/docs/recipes/paginate-into-one-array.mdx +++ b/apps/docs/content/docs/recipes/paginate-into-one-array.mdx @@ -40,7 +40,7 @@ const users = (await allUsers()) as unknown[]; `next(prevBody)` reads the cursor off the previous page's raw body and returns the [input](/docs/reference/config-types) for the next page, merged over the original call — set only what changes. Return `undefined` to stop. `items` -selects the array to aggregate from each page (after [unwrap](/docs/guides/data/unwrap)). +selects the array to aggregate from each page (after [pick](/docs/guides/data/pick)). `max` caps the page count as a safety net (default `50`). Auth, retry, and throttle apply per page, not once for the whole run. Without an [`output` schema](/docs/guides/validation/validation) the aggregated result is @@ -54,6 +54,6 @@ typed `unknown[]`, hence the cast — add a schema to type the rows. title="Mirror a paginated API to NDJSON" href="/docs/recipes/mirror-a-paginated-api" /> - + diff --git a/apps/docs/content/docs/reference/conformance-kits.mdx b/apps/docs/content/docs/reference/conformance-kits.mdx index 447be294..93c7fbf4 100644 --- a/apps/docs/content/docs/reference/conformance-kits.mdx +++ b/apps/docs/content/docs/reference/conformance-kits.mdx @@ -169,7 +169,7 @@ fails with every broken rule named: ``` Error: StitchAPI store contract: 2 violation(s), 8 rule(s) passed - - set: a ttlMs entry expires: value survived its 250ms TTL: got "soon-gone" + - set: a ttl entry expires: value survived its 250ms TTL: got "soon-gone" - incr: 20 concurrent calls net exactly +20: sorted results of 20 concurrent incrs ... ``` diff --git a/apps/docs/content/docs/reference/events.mdx b/apps/docs/content/docs/reference/events.mdx index d9af5a77..02861757 100644 --- a/apps/docs/content/docs/reference/events.mdx +++ b/apps/docs/content/docs/reference/events.mdx @@ -25,7 +25,7 @@ fields. Every event carries an `at` field — a millisecond timestamp. - **`start`** — the request is about to go out. Fields: `name` (the stitch label), `method`, `url`, `input` (the `StitchInput` for this call), `at`. - **`progress`** — a lifecycle beat within an attempt. Fields: `phase` (a - `ProgressPhase`), `attempt` (1-based), `detail?`, `waitedMs?` (set when the + `ProgressPhase`), `attempt` (1-based), `detail?`, `waited?` (set when the phase involved a wait, e.g. `throttled` or `retry`), `at`. - **`info`** — an auth strategy announcing a decision it made (e.g. which env var a `bearer` token resolved from, or that `oauth2` fetched a token); it @@ -39,7 +39,7 @@ fields. Every event carries an `at` field — a millisecond timestamp. `T`), `status` (HTTP status), `attempts` (how many it took), `at`. - **`error`** — the call failed. Fields: `name`, `message`, `status?`, `attempts`, `at`. -- **`done`** — the stream is finished, success or failure. Fields: `ok`, `ms` +- **`done`** — the stream is finished, success or failure. Fields: `ok`, `elapsed` (total duration), `attempts`, `at`. `ProgressPhase` is one of `'auth'`, `'request'`, `'throttled'`, `'retry'`, diff --git a/apps/docs/content/docs/reference/helpers.mdx b/apps/docs/content/docs/reference/helpers.mdx index d6b60512..ebfd4afd 100644 --- a/apps/docs/content/docs/reference/helpers.mdx +++ b/apps/docs/content/docs/reference/helpers.mdx @@ -150,14 +150,14 @@ const sink = consoleSink(); A file-only sink: append every event as JSONL to `path` (defaults to `~/.stitch/runs/proto.jsonl`). Writing to disk is a side effect, so you pass it explicitly — a stitch never opens a trace file on its own. An optional second -argument tunes privacy: `maxBodyBytes` (default `2048`; `false` for full capture) +argument tunes privacy: `maxBodyChars` (default `2048`; `false` for full capture) caps body/result [truncation](/docs/guides/observability/trace-sinks#body-truncation-and-full-capture), and `redactHeaders` widens the redaction denylist. ```ts twoslash import { fileSink } from 'stitchapi'; -const sink = fileSink('./runs/today.jsonl', { maxBodyBytes: false }); +const sink = fileSink('./runs/today.jsonl', { maxBodyChars: false }); ``` ### createTrace @@ -165,7 +165,7 @@ const sink = fileSink('./runs/today.jsonl', { maxBodyBytes: false }); Build a zero-infra trace sink: a compact one-line-per-event summary on the console and/or an appended JSONL record per event. `console` defaults to `true`; `file` defaults to a path under `$HOME`, or `false` to disable the JSONL stream; -`maxBodyBytes` and `redactHeaders` tune the JSONL sink's +`maxBodyChars` and `redactHeaders` tune the JSONL sink's [privacy defaults](/docs/guides/observability/trace-sinks#default-secret-redaction) (header/URL redaction and body truncation are always applied to the file stream). (This is the low-level builder behind `consoleSink`/`fileSink`; the off-by-default @@ -183,9 +183,9 @@ Fan one event stream out to several sinks — for example console/JSONL alongsid OTLP — flushing each in turn. ```ts twoslash -import { createTrace, multiplex, otlpTrace } from 'stitchapi'; +import { createTrace, multiplex, otlpSink } from 'stitchapi'; -const sink = multiplex(createTrace(), otlpTrace()); +const sink = multiplex(createTrace(), otlpSink()); ``` ### loggerSink @@ -193,7 +193,7 @@ const sink = multiplex(createTrace(), otlpTrace()); Bridge the event stream to any host logger — pino, winston, the `console`, anything with `error` / `warn` / `info` / `debug` methods (`LoggerLike`). It is **payload-free** (logs only metadata — name, method, scrubbed URL, status, attempt -counts, drift path/level, timing — never the body, result value, or a `delta` +counts, drift path/level, timing — never the body, the result's `data`, or a `delta` chunk) and maps each event to a level: `result` → `info`, `error` → `error`, `drift` → the finding's level, `start`/`progress`/`info`/`done` → `debug`, and `delta` is never logged. Override per type with `levels`. The logger-agnostic core @@ -206,16 +206,16 @@ import { loggerSink } from 'stitchapi'; const sink = loggerSink(console, { levels: { result: 'debug' } }); ``` -### otlpTrace +### otlpSink An opt-in OpenTelemetry sink: it maps each stitch call's events to a single OTel `CLIENT` span and hands finished spans to a `SpanExporter`. With no exporter supplied it uses `otlpHttpExporter`. Configured by `OtlpOptions` (below). ```ts twoslash -import { otlpTrace } from 'stitchapi'; +import { otlpSink } from 'stitchapi'; -const sink = otlpTrace({ endpoint: 'https://api.example.com:4318' }); +const sink = otlpSink({ endpoint: 'https://api.example.com:4318' }); ``` ### otlpHttpExporter diff --git a/apps/docs/content/docs/reference/stitch.mdx b/apps/docs/content/docs/reference/stitch.mdx index cf49d4c9..665ce9c4 100644 --- a/apps/docs/content/docs/reference/stitch.mdx +++ b/apps/docs/content/docs/reference/stitch.mdx @@ -22,14 +22,14 @@ const user = stitch<{ id: string }>({ ## graphql() `graphql(config)` is a thin wrapper over `stitch()` for GraphQL-over-HTTP: it POSTs -`{ query, variables }` and unwraps `data`. The config must carry a `query`. +`{ query, variables }` and picks `data`. The config must carry a `document`. ```ts twoslash import { graphql } from 'stitchapi'; const viewer = graphql<{ viewer: { id: string } }>({ path: 'https://api.example.com/graphql', - query: 'query { viewer { id } }', + document: 'query { viewer { id } }', }); ``` diff --git a/apps/docs/content/docs/reference/surfaces.mdx b/apps/docs/content/docs/reference/surfaces.mdx index 10f7a573..c5737e71 100644 --- a/apps/docs/content/docs/reference/surfaces.mdx +++ b/apps/docs/content/docs/reference/surfaces.mdx @@ -19,13 +19,13 @@ Every non-`http` surface ships as its own **subpath import**, so `import { stitch }` from the root pulls in only the `http` engine — a surface's code (and its parser/decoder) loads only when you import it. -| Surface | Import | Shapes | `await` resolves to | -| ---------- | -------------------- | ------------------------------------------ | ------------------------------ | -| `http` | `stitch` (default) | a JSON-over-HTTP call | the validated body | -| `graphql` | `stitchapi/graphql` | POST `{ query, variables }`, unwrap `data` | the `data` payload | -| `sse` | `stitchapi/sse` | a `text/event-stream` reader (over fetch) | every parsed event, collected | -| `stream` | `stitchapi/stream` | a raw `ReadableStream` reader | every decoded chunk, collected | -| `download` | `stitchapi/download` | a buffered binary GET | `{ blob, filename }` | +| Surface | Import | Shapes | `await` resolves to | +| ---------- | -------------------- | ----------------------------------------- | ------------------------------ | +| `http` | `stitch` (default) | a JSON-over-HTTP call | the validated body | +| `graphql` | `stitchapi/graphql` | POST `{ query, variables }`, pick `data` | the `data` payload | +| `sse` | `stitchapi/sse` | a `text/event-stream` reader (over fetch) | every parsed event, collected | +| `stream` | `stitchapi/stream` | a raw `ReadableStream` reader | every decoded chunk, collected | +| `download` | `stitchapi/download` | a buffered binary GET | `{ blob, filename }` | ## http — the default @@ -40,7 +40,7 @@ const getUser = stitch('https://api.example.com/users/{id}'); ## graphql -`graphql()` POSTs `{ query, variables }` and unwraps `data`; a `200` carrying +`graphql()` POSTs `{ query, variables }` and picks `data`; a `200` carrying `errors[]` is treated as a failure, never silently passed. ```ts @@ -48,7 +48,7 @@ import { graphql } from 'stitchapi/graphql'; const getThing = graphql({ baseUrl: 'https://api.example.com', - query: 'query ($id: ID) { thing(id: $id) { name } }', + document: 'query ($id: ID) { thing(id: $id) { name } }', }); const thing = await getThing({ variables: { id: 1 } }); @@ -78,7 +78,8 @@ for await (const ev of ticks.stream()) { `stream` is the raw streaming sibling — it hands back the response body decoded per `stream.decode`: `'bytes'` (the default, lossless `Uint8Array` chunks), -`'lines'` (UTF-8 lines), or `'ndjson'` (a parsed value per line). +`'lines'` (UTF-8 lines), or `'ndjson'` (a parsed value per line). The scalar +form names the decoder directly: `stream: 'ndjson'` ≡ `stream: { decode: 'ndjson' }`. ```ts import { stream } from 'stitchapi/stream'; @@ -104,9 +105,10 @@ concurrency budget. A streaming surface has no single response body, so an `output` contract validates **each `delta` before it is emitted** — never the collected array. A -value that fails a `critical`/schema check fails the stream and is never -delivered; a `drift` `watch` path warns and the value still flows, so a -`.stream()` consumer only ever sees values that passed the contract. +value that fails the schema fails the stream and is never delivered; soft +drift (a coerced or undeclared field) surfaces as a leveled `drift` finding +and the value still flows, so a `.stream()` consumer only ever sees values +that passed the contract. ```ts import { stream } from 'stitchapi/stream'; @@ -120,9 +122,8 @@ const logs = stream({ ``` For `sse` the contract describes the event's `data` payload, not the -`{ event, data, id, retry }` envelope. `transform` and `unwrap` reshape a whole -buffered body and aren't applied to the delta path, and drift **snapshots** are -a whole-body concept — not taken per-`delta`. +`{ event, data, id, retry }` envelope. `transform` and `pick` reshape a whole +buffered body and aren't applied to the delta path. ## download diff --git a/apps/docs/content/docs/surfaces/cli.mdx b/apps/docs/content/docs/surfaces/cli.mdx index ddbc3705..3fa65dc5 100644 --- a/apps/docs/content/docs/surfaces/cli.mdx +++ b/apps/docs/content/docs/surfaces/cli.mdx @@ -89,7 +89,7 @@ there. **`stitch diagram [--module ] [--name ]`** prints a [Mermaid](https://mermaid.js.org) flowchart of each stitch's configured pipeline — throttle, request, retry, the request [surface](/docs/reference/surfaces), -pagination, validation, transform, unwrap, and cache — read straight from the +pagination, validation, transform, pick, and cache — read straight from the definition, with no run. Pass `--name` to diagram a single stitch by export name or configured `name`. Auth is redacted from a stitch's public config, so the credential never appears in the diagram. diff --git a/apps/docs/content/docs/surfaces/function.mdx b/apps/docs/content/docs/surfaces/function.mdx index 96e51223..b79db396 100644 --- a/apps/docs/content/docs/surfaces/function.mdx +++ b/apps/docs/content/docs/surfaces/function.mdx @@ -5,7 +5,7 @@ prerequisites: ['/docs/concepts/the-stitch'] --- A stitch is a typed async function — no server, no transport. `await` it for the -final unwrapped, validated value, or iterate its event stream for everything that +final picked, validated value, or iterate its event stream for everything that happened on the way there. This is the closest of the four surfaces: the same definition you call here also runs from the CLI, over HTTP, and through MCP. @@ -31,7 +31,7 @@ for await (const event of getUser.stream({ params: { id: 1 } })) { ## Options **Awaited vs streamed.** `await getUser(input)` resolves to the final value, -already unwrapped and validated. `getUser.stream(input)` yields the typed +already picked and validated. `getUser.stream(input)` yields the typed [event stream](/docs/concepts/event-stream) — `start → progress → drift → result → done` — so you can observe retries, throttling, and drift as they happen. Same call, two altitudes. diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index c48b4be4..10cf1150 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -101,6 +101,17 @@ const config = { // The playground consumes the in-repo sandbox engine (@stitchapi/sandbox), a // workspace package that ships raw TS/TSX source — Next must transpile it. transpilePackages: ['@stitchapi/sandbox'], + // The 2026-07 contract sweep renamed the config key `unwrap` to `pick`; the + // guide page moved with it. Permanent redirect keeps published links alive. + async redirects() { + return [ + { + source: '/docs/guides/data/unwrap', + destination: '/docs/guides/data/pick', + permanent: true, + }, + ]; + }, // CSP backstop for the sandbox Worker (egress confinement + worker-confined // eval). See lib/security-headers.mjs; proved by e2e/sandbox-egress.spec.ts. async headers() { diff --git a/apps/docs/test/search-golden.json b/apps/docs/test/search-golden.json index 5fd6567c..c594cfe9 100644 --- a/apps/docs/test/search-golden.json +++ b/apps/docs/test/search-golden.json @@ -68,8 +68,8 @@ "expect": "guides/auth/oauth2" }, { - "query": "unwrap the data field out of a JSON envelope response", - "expect": "guides/data/unwrap" + "query": "pick the data field out of a JSON envelope response", + "expect": "guides/data/pick" }, { "query": "reshape or transform a response body before it returns", diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 59be6419..f2b75fff 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -17,22 +17,27 @@ already exist locally in the core (`retry: 3`, `timeout: '5s'`, `cache: '1m'`) i global law, and pins the cross-cutting vocabulary so the same word never means two things. -The rules are **normative** (MUST / SHOULD / MUST NOT). The per-field rename -proposals in [§6](#6-migration-backlog) are the **backlog**, not part of the -normative text — exact target spellings are confirmed during the migration sweep. +The rules are **normative** (MUST / SHOULD / MUST NOT). The migration they mandated +is **done**: the 2026-07-08 hard-break sweep +([§6](#6-migration-record-2026-07-08-hard-break-sweep)) applied every rename and +cleared the enforcement baseline, including the one pre-existing match the +same-day P24 addition found and initially baselined rather than fixed — now +converted too (§6, §7). Rule sections keep their worked examples as _Resolved_ +history so the reasoning survives the renames. --- ## 0. Resolved decisions -Four forks were decided by the maintainer on adoption; the rules below assume them. +Five forks were decided by the maintainer; the rules below assume them. -| # | Decision | Resolution | Drives | -| --- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| D1 | Success-payload field name | **`data`** (align with axios / React Query / SWR / RTK Query, which every hook package wraps; `SafeResult` already uses it). Stream increments keep **`chunk`**; the Standard-Schema validation layer keeps spec-mandated **`value`/`issues`**. | [P5](#p5--one-success-field-one-failure-field) | -| D2 | Cap-word convention | **Bare nouns, no `max-` prefix**, for **count** caps (`attempts`, `entries`, `pages`, `failures`, `concurrency`). `max-` is retained only where it bounds a continuous **magnitude** and a bare noun would be ambiguous (a delay ceiling). | [P4](#p4--one-cap-vocabulary) | -| D3 | Duration style | **ms is the one house unit; drop the `Ms` suffix _everywhere_** (input and emitted; the unit lives in JSDoc). Consumer-authored durations additionally accept **`number \| string`** (`'5s'` or raw ms) via one `parseDuration`. | [P17](#p17--one-canonical-duration-form) | -| D4 | Home of the contract | **This `CONTRACT.md` (living doc) + an enforcement lint** in the verify gate. | [§7](#7-enforcement) | +| # | Decision | Resolution | Drives | +| --- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| D1 | Success-payload field name | **`data`** (align with axios / React Query / SWR / RTK Query, which every hook package wraps; `SafeResult` already uses it). Stream increments keep **`chunk`**; the Standard-Schema validation layer keeps spec-mandated **`value`/`issues`**. | [P5](#p5--one-success-field-one-failure-field) | +| D2 | Cap-word convention | **Bare nouns, no `max-` prefix**, for **count** caps (`attempts`, `entries`, `pages`, `failures`, `concurrency`). `max-` is retained only where it bounds a continuous **magnitude** and a bare noun would be ambiguous (a delay ceiling). | [P4](#p4--one-cap-vocabulary) | +| D3 | Duration style | **ms is the one house unit; drop the `Ms` suffix _everywhere_** (input and emitted; the unit lives in JSDoc). Consumer-authored durations additionally accept **`number \| string`** (`'5s'` or raw ms) via one `parseDuration`. | [P17](#p17--one-canonical-duration-form) | +| D4 | Home of the contract | **This `CONTRACT.md` (living doc) + an enforcement lint** in the verify gate. | [§7](#7-enforcement) | +| D5 | Pre-GA break policy | **Alias-free hard breaks are maintainer-sanctioned before 1.0 GA** (exercised once — the 2026-07-08 sweep, few adopters, every prior `@deprecated` shim deleted). From 1.0 GA, every rename/narrowing/removal requires a deprecation cycle and lands in a major. | [P19](#p19--breaking-changes-sanctioned-pre-ga-deprecation-cycle-from-ga) | --- @@ -72,11 +77,14 @@ A public field or symbol **MUST** be the shortest unambiguous token for its conc (prefer one word), and that token **MUST** denote exactly one concept and one value-space across the entire surface. -_Current violations:_ `key` is a `string` namespace in `CircuitOptions` but a -`(input) => string` in `IdempotencyOptions` / `CacheConfig`; `query` is a GraphQL -document string in `StitchConfig` but URL params in `StitchInput` / `InputSchemas`; -`on` is retry-trigger statuses **and** rate-limit-signal statuses; `bodyKind` -(`from-curl`) vs `bodyType` (everywhere else) for one `'json'|'form'` concept. +_Resolved (2026-07 sweep):_ the key-derivation functions in `IdempotencyOptions` / +`CacheOptions` are **`keyOf`** ([P6](#p6--key-is-a-string-keyof-is-a-function)), so +`key` is a string everywhere; the GraphQL document string moved off `query` to +**`document`**, leaving `query` to mean URL params only; `retry.on` and `throttle.on` +share one shape and one meaning — the statuses that trigger that envelope's capability +(`StatusMatch`, [P7](#p7--status-classification-parity)); `bodyKind` survives only on +the CLI-internal `from-curl` parser type (`ParsedRequest`), off the published surface — +every published field is `bodyType`. ### P2 · Don't reuse one word for genuinely different concepts — rename one @@ -84,11 +92,13 @@ When two fields legitimately mean **different** things, they **MUST NOT** share name even if each is individually defensible; rename one so a reader never has to know they differ. -_Canonical case:_ `scope` is a **pool** axis (`'stitch'|'host'`) in `ThrottleOptions` -and a **tenancy** axis (`'principal'|'app'`) in `CacheConfig`/`CookieSessionOpts`. -These are real, distinct concepts → **`throttle.scope` is renamed to `pool`**, freeing -`scope` to mean tenancy everywhere. (This is a rename, **not** an assertion that they -were the same concept.) +_Canonical case (applied in the 2026-07 sweep):_ `scope` was a **pool** axis +(`'stitch'|'host'`) in `ThrottleOptions` and a **tenancy** axis in the cache and auth +envelopes. Three concepts now carry three words: **`throttle.pool`** (where the +limiter's counter is pooled), **`cache.scope`** (`'principal'|'app'` — whose responses +a cached entry may be served to), and **`tenancy`** on +`OAuth2Options`/`CookieSessionOptions` (whose credential a token/session is). (Renames, +**not** an assertion that they were one concept.) ### P3 · One suffix system @@ -101,11 +111,20 @@ A type's role **MUST** be predictable from its suffix: | Duck-typed foreign contract | `*Like` (or none) | — | `*Options` **MUST NOT** appear on a returned value. -_Violations:_ `CacheConfig`, `OAuth2Opts`, `CookieSessionOpts`, `McpServerInfo`, -`SignV4Params`; `StitchQueryOptions` is actually a `{ queryKey, queryFn }` **result**. -_Carve-out:_ `StitchConfig`/`SeamConfig`/`RedactedStitchConfig` keep `*Config` as the +_Resolved (2026-07 sweep):_ `CacheConfig`→`CacheOptions`, `OAuth2Opts`→`OAuth2Options`, +`CookieSessionOpts`→`CookieSessionOptions`, `McpServerInfo`→`McpServerOptions`, +`LlmConfig`→`LlmOptions`, `SignV4Params`→`SignV4Options`, +`AuthFailureInfo`→`AuthFailureResult`, `StitchQueryState`→`StitchQueryResult`, +`UseStitchReturn`→`UseStitchResult`. +_Carve-outs:_ `StitchConfig`/`SeamConfig`/`RedactedStitchConfig` keep `*Config` as the one well-known top-level authoring type family (the thing you literally call -`stitch(config)` with); the ban targets the **sibling capability bags**. +`stitch(config)` with) — the ban targets the **sibling capability bags**. `OpenApiInfo` +mirrors the OpenAPI spec's `InfoObject` (P18). **`StitchQueryOptions`** keeps its name +**and** its flat `queryKey`/`queryFn` fields as a deliberate +[P22](#p22--a-standards-interop-contract-uses-the-standards-field-names)-style mirror +of TanStack's own `queryOptions()` vocabulary — it is the object you hand `useQuery`, +so it speaks TanStack, not house (hoisted into `query-core`, re-exported by all five +bindings). ### P4 · One cap vocabulary @@ -115,9 +134,9 @@ bare `max`. A **magnitude** ceiling (a delay) MAY keep `max` when a bare noun wo ambiguous. Plural **`attempts`** = a running total; singular **`attempt`** = the current index. -_Violations:_ `ReconnectOptions.maxAttempts` (→ `attempts`), `CacheConfig.maxEntries` -(→ `entries`), `CircuitOptions.failureThreshold` (→ `failures`), `paginate.max` -(→ `pages`), `deno-kv maxIncrRetries`. +_Resolved (2026-07 sweep):_ `ReconnectOptions.maxAttempts`→`attempts`, +`CacheOptions.maxEntries`→`entries`, `CircuitOptions.failureThreshold`→`failures`, +`paginate.max`→`pages`. --- @@ -133,11 +152,12 @@ success payload as **`data`** and its failure payload as **`error`**. - The **Standard-Schema validation layer** (`ValidationResult` / `StandardResult`) keeps **`value`** / **`issues`** — that is the external Standard-Schema spec, not ours to rename. -- `value` **MUST NOT** be overloaded for non-payload tokens (e.g. rename - `SchemaFingerprint.value` → `token`). +- `value` **MUST NOT** be overloaded for non-payload tokens (this is why + `SchemaFingerprint.value` became `token`). -_Violations:_ success is `data` in `SafeResult` but `value` in `Inspection` / -`StitchEvent.result`; those two move to `data`. +_Resolved (2026-07 sweep):_ success is `data` on every runtime envelope that had +drifted to `value` — `Inspection`, `StitchEvent.result`, `SurfaceOutcome`, `CacheHit`; +`SchemaFingerprint.value`→`token`. ### P6 · `key` is a string; `keyOf` is a function @@ -145,29 +165,35 @@ A field named **`key` MUST be a string** identifier/namespace. A key-**derivatio function **MUST** be named **`keyOf`** (a `(input) => string`) and **MUST NOT** be called `key`. -_Violations:_ `IdempotencyOptions.key`, `CacheConfig.key` (both `(input) => string`) -→ `keyOf`. `CircuitOptions.key` and `StitchStore.get/set/incr(key)` are correct as-is. +_Resolved (2026-07 sweep):_ `IdempotencyOptions.key` and `CacheOptions.key` (both +`(input) => string`) → `keyOf`, living on `__rawConfig` as sugar per P0. +`CircuitOptions.key` and `StitchStore.get/set/incr(key)` were correct as-is. ### P7 · Status-classification parity -Any field that answers "does this HTTP status match?" **MUST** share one shape: -**`number[] | ((status: number) => boolean)`**. Any list-shaped field whose -single-value case is common **MUST** accept **`T | T[]`** and normalize internally. +Any field that answers "does this HTTP status match?" **MUST** share one shape — the +exported **`StatusMatch`** (`number | number[] | ((status: number) => boolean)`). Any +list-shaped field whose single-value case is common **MUST** accept **`T | T[]`** and +normalize internally. -_Violations:_ `acceptStatus` takes a predicate but `retry.on` / `rateLimit.on` are -`number[]`-only (widen them); `acceptStatus: 404` should be legal (today needs -`[404]`); `DriftOptions.ignore`, `refreshOn` are list-only while `DriftOptions.severity` -already widens `'warn' ≡ ['warn']` — make widening uniform. +_Resolved (2026-07 sweep):_ `acceptStatus`, `retry.on`, `throttle.on`, and auth +`refreshOn` all take `StatusMatch` (so `acceptStatus: 404` is legal); `DriftOptions.ignore` +and `cache.vary` accept `string | string[]`, matching `DriftOptions.severity`'s +existing `'warn' ≡ ['warn']` widening. ### P8 · Same concept → same default across packages A field reused across packages **MUST** carry the same default, or the divergence **MUST** be reconciled deliberately and documented — never left as a silent inversion. -_Violations:_ `PinoSinkOptions.lifecycle` defaults `true` but `SentrySinkOptions.lifecycle` -defaults `false`; `OAuth2Opts.tenancy` defaults `'app'` but `CookieSessionOpts.scope` -defaults `'principal'` (same tenancy concept, two names **and** inverted defaults — fix -both under P2). +_Documented divergences (deliberate, per this rule's own clause):_ `lifecycle` +defaults **`true`** in `@stitchapi/pino` (log lines are cheap and level-filtered) but +**`false`** in `@stitchapi/sentry` (breadcrumbs/events cost quota); `tenancy` defaults +**`'app'`** in `OAuth2Options` (a client-credentials token belongs to the application) +but **`'principal'`** in `CookieSessionOptions` (sessions are per-user; fails closed); +`keyPrefix` defaults **`'stitch:'`** in `@stitchapi/react-native` (AsyncStorage is +app-shared, the prefix namespaces it) but empty in the dedicated-namespace KV stores. +Each divergence is stated in the field's JSDoc with a cross-link to its counterpart. ### P9 · Unique-by-shape exported types @@ -176,11 +202,25 @@ packages. A genuinely per-framework shape **MUST** be framework-qualified (`SolidStitchStore` vs `SvelteStitchStore`, ADR 0012 rule 6); a shared shape **MUST** be hoisted into `query-core`/`core` and re-exported. -_Violations:_ `StitchStore` means two incompatible things in `@stitchapi/solid` -(nested `.state`) vs `@stitchapi/svelte` (a `Readable`); `StitchLike` is redefined -three ways across `query-core` / `swr` / `rtk-query`; `RequestSeam` (express/elysia) -vs `HonoRequestSeam` vs `StitchHost` (fastify/nest) for the per-request seam; -`StitchError` vs `StitchErrorLike` for one runtime shape. +_Resolved (2026-07 sweep):_ the per-framework query stores are framework-qualified +(`SolidStitchStore` nests `.state`, `SvelteStitchStore` is a `Readable` — genuinely +incompatible); the per-request host seam is ecosystem-qualified (`ExpressRequestSeam` / +`ElysiaRequestSeam` / `FastifyRequestSeam` / `NestRequestSeam`, extending hono's +`HonoRequestSeam`); the host adapters' error duck-type is `StitchErrorLike` +(`Error & { status? }`), one structural contract across all six hosts, so bare +`StitchError` is core's class only. + +_Blessed tiers and identities (P9-clean by design):_ `StitchLike` is two deliberate, +**compatible** tiers, not a clash — the RICH canonical +`(input?) => StitchCallResult` (awaitable + streamable) lives in +`@stitchapi/query-core` and is re-exported by the five TanStack-family bindings; the +stream-less adapters (swr / rtk-query / vercel-ai) use an intentional MINIMAL +await-only `(input?) => PromiseLike` — they never call `.stream()`. A real stitch +satisfies both tiers. `StreamableStitchLike` is rtk-query's streaming tier of the same +family; swr's `QueryOutput`/`QueryInput` inference helpers are also surfaced by +vercel-ai. `StreamStitchSseOptions` / `StitchErrorOptions` are intentionally +**identical** envelopes declared per host adapter — same name, same shape, by design; +`StitchEventSource` is core-owned and re-exported verbatim. ### P10 · Error-class taxonomy parity @@ -194,30 +234,35 @@ The same verb **MUST** keep the same sync/async shape across every surface and a A `close()` is `() => Promise` everywhere; a verb is not sync in one driver and async in another. -_Violation:_ `DenoKvLike.close` is synchronous while every other store `close` returns -`Promise`. +_Resolved (2026-07 sweep):_ `DenoKvLike.close` keeps Deno's synchronous spelling (it +is a P18 mirror); the house store `denoKvStore()` wraps it, so `StitchStore.close()` +is `() => Promise` everywhere. --- ## 4. Shorthands & envelopes (the preferred authoring model) > The maintainer's model: **every capability is a config object (an envelope), and an -> envelope with one dominant field also accepts that field's scalar as shorthand.** > `retry`/`timeout`/`cache` already prove it; the rest of the surface MUST follow. -> Every shorthand normalizes to the canonical field per **P0**. +> envelope with one dominant field also accepts that field's scalar as shorthand.** > `retry`/`timeout`/`cache` proved it; since the 2026-07 sweep the whole surface +> follows. Every shorthand normalizes to the canonical field per **P0**. ### P12 · Envelope + scalar shorthand Every capability **MUST** be expressible as a config object. An envelope whose meaningful surface is a **single dominant field MUST** also accept that field's scalar -at its `StitchConfig` slot. - -| Slot | Today | MUST also accept | Shorthand | -| ------------------------- | ------------------ | ------------------------------------------- | ---------------------- | -| `retry` `timeout` `cache` | ✅ | — | done | -| `stream` | `StreamOptions` | `StreamDecode \| StreamOptions` | `stream: 'ndjson'` | -| `multipart` | `MultipartOptions` | `MultipartNesting \| MultipartOptions` | `multipart: 'dot'` | -| `sse` | `{ reconnect }` | `boolean \| ReconnectOptions \| SseOptions` | `sse: true` | -| `.inspect()` | `{ cache }` | `boolean \| InspectOptions` | `inspect(input, true)` | +at its `StitchConfig` slot. All slots below are shipped — the 2026-07 sweep added the +last four scalars and the `AtLeastOne<…>` object forms +([P20](#p20--no-empty-object-config-enable-with-defaults-is-a-scalar)): + +| Slot | Envelope | Scalar accepted | Shorthand | +| ------------ | ------------------ | ----------------------------- | ---------------------- | +| `retry` | `RetryOptions` | `number` (attempts) | `retry: 3` | +| `timeout` | `TimeoutOptions` | `number \| string` (duration) | `timeout: '5s'` | +| `cache` | `CacheOptions` | `number \| string` (ttl) | `cache: '1m'` | +| `stream` | `StreamOptions` | `StreamDecode` | `stream: 'ndjson'` | +| `multipart` | `MultipartOptions` | `MultipartNesting` | `multipart: 'dot'` | +| `sse` | `SseOptions` | `boolean` | `sse: true` | +| `.inspect()` | `InspectOptions` | `boolean` | `inspect(input, true)` | ### P13 · Boolean toggle means enable-with-defaults @@ -225,9 +270,9 @@ A capability whose primary act is an on-switch **MUST** accept **`boolean | Opti where `true` = enable-with-documented-defaults; it **MUST NOT** require an object literal merely to switch on. -_Apply to:_ `idempotency?: boolean | IdempotencyOptions` and `inspect(input, true)`. -`sse.reconnect: true` already sets the precedent. (Rate-limit delegation becomes a -`throttle` mode after the P14 fold, not its own toggle.) +_Applied:_ `idempotency?: boolean | AtLeastOne` and +`inspect(input, true)`; `sse.reconnect: true` set the precedent. (Rate-limit +delegation is a `throttle` mode since the P14 fold, not its own toggle.) ### P14 · Multi-field envelopes are named, exported, and MAY shorthand their dominant field @@ -236,14 +281,14 @@ interface (never an anonymous inline shape), so it can be imported, extended, an referenced. A multi-field envelope **MAY** still offer a scalar shorthand for an **unambiguously dominant** field (this is **not** the single-field collapse of P12). -_Violations:_ `paginate` (`{ next; items?; max? }`) is anonymous → extract -`PaginateOptions`. `rateLimit` (`{ delegate?; on? }`) is anonymous **and** a near-synonym -of `throttle` → **fold it into the `throttle` envelope** (`delegate` / `on` become -throttle modes), collapsing two top-level keys into one and turning the buried "delegate +_Resolved (2026-07 sweep):_ the anonymous `paginate` shape is the named, exported +`PaginateOptions`. The anonymous `rateLimit` — a near-synonym of `throttle` — was +**folded into the `throttle` envelope** (`delegate` / `on` are throttle modes) and the +top-level key removed, collapsing two keys into one and turning the buried "delegate makes throttle inert" interaction into a within-envelope rule. -_Allowed example:_ `throttle?: string | ThrottleOptions` where `'2/s' ≡ { rate: '2/s' }` -— `rate` dominates although `concurrency` also exists (so this is a P14 dominant-field -shorthand, not a P12 collapse). +_Allowed example:_ `throttle?: string | AtLeastOne` where +`'2/s' ≡ { rate: '2/s' }` — `rate` dominates although `concurrency` also exists (so +this is a P14 dominant-field shorthand, not a P12 collapse). ### P15 · Required fields are deliberate and get a named/positional shorthand — not silent defaults @@ -253,7 +298,8 @@ is a footgun, it **MUST** stay required and the envelope **MUST** offer a scalar positional shorthand naming the required value(s). - `CircuitOptions.failures`/`cooldown` **stay required** (a breaker with invisible - thresholds fails open/closed silently) — add a positional shorthand instead. + thresholds fails open/closed silently) — the positional shorthand is the tuple + `circuit: [5, '30s']` ≡ `circuit: { failures: 5, cooldown: '30s' }`. - `CacheOptions.ttl` **stays required** — its shorthand `cache: '1m'` already names it. > This corrects the audit draft, which tried to defend `ttl`-required while attacking @@ -268,16 +314,19 @@ positional shorthand naming the required value(s). A concept **MUST** use the same field name, shorthand, and envelope on every surface (`stitch` / `seam` / `pipe`) and every framework package, varying only the -framework-idiomatic verb (`use` / `create` / `inject`). New config fields are added to -`StitchConfig` and **projected** (`SeamConfig = Omit` is the model), -never re-declared per surface. ADR 0012's `stitchQueryOptions` rename **MUST** be -applied to all five TanStack adapters, not react only. - -_Violations:_ SSE helper is `streamStitchSse` / `sendStitchSse` / `stitchSse`; -error-options is `StitchErrorHandlerOptions` / `StitchErrorOptions` / -`ToHttpExceptionOptions` (with `body` present in some, absent in others); the hook -result interface is `UseStitchResult` / `UseStitchReturn` / `InjectStitchResult` / -`StitchStore`; `queryOptions` is still bare in vue/solid/svelte/angular. +framework-idiomatic verb (`use` / `create` / `inject` — and svelte's `stitchStore*` +family: `use:` is Svelte **directive syntax**, so a `useStitch` there would collide +with the language, not echo it). New config fields are added to `StitchConfig` and +**projected** (`SeamConfig = Omit` is the model), never re-declared +per surface. ADR 0012's `stitchQueryOptions` spelling applies to all five TanStack +adapters, not react only. + +_Resolved (2026-07 sweep):_ the SSE helper is `streamStitchSse` on every host (was +also `sendStitchSse` / `stitchSse`); error-options is one `StitchErrorOptions` shape +(with `body`) everywhere (was `StitchErrorHandlerOptions` / `ToHttpExceptionOptions`); +the hook result is `UseStitchResult` / `InjectStitchResult` over query-core's shared +`StitchQueryResult`; `stitchQueryOptions` replaced the bare `queryOptions` in +vue/solid/svelte/angular. ### P17 · One canonical duration form @@ -293,18 +342,19 @@ everywhere. On inputs, accepting `'5s'` on top is pure ergonomic gain (and a `Ms on a field that takes `'5s'` would be a lie). On outputs, one uniform de-suffixed vocabulary beats a split convention; the JSDoc carries the unit. -_Violations (inputs — widen + de-suffix):_ `RetryOptions.baseMs`/`maxMs`, -`CircuitOptions.cooldownMs`/`halfOpenAfterMs`, `ReconnectOptions.backoffMs`, -`OAuth2Opts.refreshSkewMs`, `CookieSessionOpts.ttlMs`, store-contract `ttlMs`. -_Violations (emitted — de-suffix):_ `StitchEvent` `waitedMs`→`waited`, -`retryAfterMs`→`retryAfter`, the `done` event's `ms`→`elapsed`; `SseEvent.retry` stays -(already bare; it mirrors the SSE `retry:` wire field); `MockResponse.delayMs`→`delay`. +_Resolved (2026-07 sweep, inputs — widened + de-suffixed):_ `RetryOptions.baseDelay`/ +`maxDelay` (were `baseMs`/`maxMs`), `CircuitOptions.cooldown`/`halfOpenAfter`, +`ReconnectOptions.backoff`, `OAuth2Options.refreshSkew`, `CookieSessionOptions.ttl`, +store-contract `ttl` — all `number | string` via the one shared `parseDuration`. +_Resolved (2026-07 sweep, emitted — de-suffixed):_ `StitchEvent` `waited`, +`retryAfter`, the `done` event's `elapsed` (was `ms`), `MockResponse.delay`; +`SseEvent.retry` stays (already bare; it mirrors the SSE `retry:` wire field). _Unit hazard (the exception to "all JS time is ms"):_ a few fields are **seconds** -because they mirror a wire format — `MockResponse.retryAfter` and the HTTP -`Retry-After` header (delta-seconds), Cloudflare KV `expirationTtl`. Every -StitchAPI-_authored_ duration stays ms; a field that must speak a foreign unit converts -at the adapter edge and is named with its true unit (`expirationSeconds`, -`retryAfterSeconds`) so the unit is never silent. +because they mirror a wire format — the HTTP `Retry-After` header (delta-seconds), +Cloudflare KV `expirationTtl`. Every StitchAPI-_authored_ duration stays ms; a field +that must speak a foreign unit converts at the adapter edge and is named with its true +unit (`MockResponse.retryAfterSeconds` — it sets the wire header) so the unit is never +silent. ### P18 · Adapter mirrors keep upstream spelling; house contracts use house vocabulary @@ -315,12 +365,18 @@ per P17; convert foreign units at the edge), `delete` (not `del`), `close(): Promise` (async, per P11), with one optionality per parameter (`ttl` MUST NOT be optional on `set` but required on `incr`). -### P19 · No pre-GA hard break +### P19 · Breaking changes: sanctioned pre-GA, deprecation cycle from GA -Every rename, narrowing, or removal mandated here **MUST** ship a `@deprecated` -re-export/field alias pinned by an identity test, removed at the **1.0 GA cut** -(extending ADR 0012's precedent from symbols to fields). Widening -(`number → number | string`, P17) is non-breaking and needs no alias. +Per **D5**: **before 1.0 GA** the maintainer MAY sanction an **alias-free hard break** +— a coordinated sweep that renames/narrows/removes without `@deprecated` shims — when +the adopter base is small enough that shim upkeep costs more than the break. Exercised +once: the 2026-07-08 sweep +([§6](#6-migration-record-2026-07-08-hard-break-sweep)), which also deleted every shim +earlier alias-first migrations had accrued. **From 1.0 GA** every rename, narrowing, +or removal **MUST** ship a `@deprecated` re-export/field alias pinned by an identity +test and be removed only in a subsequent **major** (extending ADR 0012's precedent +from symbols to fields). Widening (`number → number | string`, P17) is non-breaking +and needs no cycle in either regime. ### P20 · No empty-object config; enable-with-defaults is a scalar @@ -343,10 +399,11 @@ all-defaults case is the scalar, not `{}`. _Helper:_ `AtLeastOne` = a value with at least one property of `T` set (`{}` matches none of its per-key-required variants, so it is rejected). -_Violations:_ `idempotency?: IdempotencyOptions` (so `idempotency: {}` is legal — **fixed -here**); the other all-optional bare-`*Options` slots accept `{}` too: `multipart`, -`stream`, `sse`, `throttle`, `hooks`, `input` (each → `Scalar | AtLeastOne` per -P12/P13; lint **R6** flags them). +_Resolved (2026-07 sweep):_ every capability slot is `Scalar | AtLeastOne` +(or `boolean | AtLeastOne<…>`): `stream`, `multipart`, `sse`, `throttle`, +`idempotency`, `hooks`, `input`, `retry`, `timeout`, and `circuit` (whose scalar is +the `[failures, cooldown]` tuple, P15). `{}` is a compile error at each of them; lint +**R6** keeps it that way. ### P21 · Every contract has an extension seam @@ -419,134 +476,99 @@ all take `SchemaLike` and return `ValidationResult`; `JsonSchema.adapt(json, { a bridge from JSON Schema, producing a `SchemaLike` those consumers treat identically. The standalone `validate`/`compile` verbs replaced app-level `schema['~standard'].validate(…)`. +### P24 · A shared field-name prefix in a house contract is an envelope + +**≥2 public flat fields sharing a leading-word prefix in a house-owned contract MUST** fold into +**one** envelope: a named, exported `*Options` interface (P14), typed so `{}` is a compile error +(P20), with a scalar shorthand for the dominant field where one field dominates (P12). + +_Carve-outs:_ + +- **(a) Foreign mirrors keep the foreign shape.** A contract that exists to structurally or + nominally match a foreign SDK, standard, or wire format (P18/P22) keeps **every** field of the + pair — it is not house vocabulary to fold. This covers TanStack's `queryKey`/`queryFn`, RFC + 6749's `clientId`/`clientSecret`/`clientAuth`, the XHR `responseType`/`responseText` pair (and + its React Native mirror), RTK Query's lifecycle names (`cacheDataLoaded`/`cacheEntryRemoved`), + and Orama's own index-document schema (`DocSearchHit.pageUrl`/`pageTitle`) — all exempt. +- **(b) A single-field group collapses per P12 instead of nesting.** When only **one** member of + the pair is a genuine option and the other is a discriminator/tag describing it (not an + independent knob), the pair **stays flat** — nesting would turn a scalar-plus-tag into a + needless envelope for zero added configurability. +- **(c) Conventional prefixes are not groups:** `on*` handlers, `is*` guards, and a percentile + family (`p50`/`p95`/`p99`) share a prefix by naming convention, not by being facets of one + capability. + +_Canonical case (converted 2026-07-08):_ `OAuth2Options.refresh` and +`CookieSessionOptions.refresh` fold what were `refreshOn`+`refreshSkew` and `refreshOn`+ +`refreshWhen` into `refresh?: StatusMatch | AtLeastOne<…RefreshOptions>` (a bare `StatusMatch` is +the P12 shorthand for `{ on: … }`); `SentrySinkOptions.capture` folds `captureErrors`+ +`captureDrift` into `capture?: boolean | AtLeastOne`. All three landed as +hard breaks, no aliases (P19). + +_Named exemptions verified against this rule_ (carve-out (a); named explicitly because each is the +literal shape this rule would otherwise flag): **`StitchQueryOptions.queryKey`/`queryFn`** (the +TanStack mirror, P3 — note this rule's own motivating example is itself exempt); +**`OAuth2Options.clientId`/`clientSecret`/`clientAuth`** (RFC 6749); **`DocSearchHit.pageUrl`/ +`pageTitle`** (mirrors the persisted Orama index document schema; maintainer-exempted 2026-07-08). + +Enforced by lint **R8** (§7); its allow-list carries the one-line rationale for every verified +exemption beyond this rule's named list — a discriminated-union pair (mutually exclusive by +`X?: never`), a derived/internal read-view that is not itself an authored config, or a +plugin-extension-hook bag, are all real shapes this rule does not reach. + --- -## 6. Migration backlog (proposed renames — confirm during sweep) - -Not normative. The rule is the law; these are the proposed target spellings the sweep -will apply under `@deprecated` aliases (P18). Severity = consumer blast radius. - -| Sev | Current | Proposed | Rule | -| ---- | --------------------------------------------------------------------- | -------------------------------------------------------------- | ------ | -| High | `SafeResult.data` ↔ `Inspection.value` ↔ `StitchEvent.result.value` | `data` everywhere | P5 | -| High | `IdempotencyOptions.key`, `CacheConfig.key` (fn) | `keyOf` | P6 | -| High | `ThrottleOptions.scope` (`'stitch'|'host'`) | `pool` | P2 | -| High | `rateLimit` (separate top-level key) vs `throttle` | fold into one `throttle` envelope (`delegate` / `on` as modes) | P2/P14 | -| High | `retry.on` + rate-limit `on` (`number[]`) | `number[] | (status)=>boolean` | P7 | -| High | `StitchStore`/`StitchLike`/`RequestSeam` cross-pkg clashes | hoist or qualify | P9 | -| High | `queryOptions` bare in vue/solid/svelte/angular | `stitchQueryOptions` | P16 | -| Med | `CacheConfig` → | `CacheOptions` | P3 | -| Med | `OAuth2Opts`, `CookieSessionOpts` | `OAuth2Options`, `CookieSessionOptions` | P3 | -| Med | `McpServerInfo`, `SignV4Params` | `…Options` | P3 | -| Med | `StitchQueryOptions` (a result) | `StitchQueryResult` | P3 | -| Med | `ReconnectOptions.maxAttempts` | `attempts` | P4 | -| Med | `CacheConfig.maxEntries` | `entries` | P4 | -| Med | `CircuitOptions.failureThreshold` | `failures` | P4 | -| Med | `paginate.max` | `pages` | P4 | -| Med | `*Ms` duration inputs (`cooldownMs`, `backoffMs`, `ttlMs`, …) | de-suffix + `number|string` | P17 | -| Med | `paginate` inline shape | `PaginateOptions` | P14 | -| Med | SSE helper `sendStitchSse`/`stitchSse` | `streamStitchSse` | P16 | -| Med | error-options `StitchErrorHandlerOptions`/`ToHttpExceptionOptions` | `StitchErrorOptions` (+ `body`) | P16 | -| Low | `RedisDriver.del`, `…quit`, sync `close` | `delete`, async `close` | P18 | -| Low | `SchemaFingerprint.value` | `token` | P5 | -| Low | `bodyKind` (from-curl) | `bodyType` | P1 | -| Low | emitted `*Ms` (`waitedMs`, `retryAfterMs`, done `ms`) | de-suffix (`waited`, `retryAfter`, `elapsed`); units → JSDoc | P17 | - -New shorthand/toggle slots to **add** (additive, non-breaking): `stream`, `multipart`, -`sse`, `.inspect()` scalars (P12); `idempotency` boolean (P13-toggle); -`throttle` string (P14). - -**Shipped (migration in progress)** — all under `@deprecated` aliases read until the GA -cut; the lint skips the deprecated members so each rename ratchets the baseline down: - -- **P2** `ThrottleOptions.scope`→`pool`; runtime prefers `pool ?? scope`. -- **P6** `IdempotencyOptions.key`/`CacheOptions.key`→`keyOf`; runtime prefers `keyOf ?? key`. -- **P3** suffix renames (type-only, zero runtime): `CacheConfig`→`CacheOptions`, - `OAuth2Opts`→`OAuth2Options`, `CookieSessionOpts`→`CookieSessionOptions`, - `McpServerInfo`→`McpServerOptions`, `LlmConfig`→`LlmOptions`, - `SignV4Params`→`SignV4Options`, and the read-back `AuthFailureInfo`→`AuthFailureResult`. - (`OAuth2Opts`/`CookieSessionOpts` are auth-internal — renamed without an alias.) -- **P4** caps → bare nouns: `ReconnectOptions.maxAttempts`→`attempts`, - `CacheOptions.maxEntries`→`entries`, `paginate.max`→`pages`; runtime prefers the new - field. (`CircuitOptions.failureThreshold`→`failures` is deferred to the P17 CircuitOptions - overhaul, where its required-ness + `cooldownMs`/`halfOpenAfterMs` are handled together.) -- **P7** `RetryOptions.on` (and the folded `throttle.on`) now accept - `number[] | (status) => boolean` — additive widening, no alias. The engine normalizes via the - shared `acceptsStatus` matcher. -- **P14** `rateLimit` folded into `throttle` (`throttle.delegate` / `throttle.on`); the top-level - `rateLimit` is `@deprecated` (runtime prefers `throttle.* ?? rateLimit.*`). `PaginateOptions` - extracted from the inline `paginate` shape (named + exported). `throttle: { rate, delegate }` is - the unified envelope — delegate makes the rate inert, now legible within one object. -- **P17 (inputs)** consumer-authored durations de-suffixed and widened to `number | string` (parsed - by `parseDuration`): `RetryOptions.baseMs`→`baseDelay`, `maxMs`→`maxDelay`; - `ReconnectOptions.backoffMs`→`backoff`; `OAuth2Options.refreshSkewMs`→`refreshSkew`; - `CookieSessionOptions.ttlMs`→`ttl`. Each keeps a `@deprecated` `*Ms` alias (runtime prefers - `new ?? old`). House store contracts use the bare `ttl` param (ms, no suffix): `StitchStore` / - `RedisDriver` `set`/`incr` and the redis/deno-kv/cloudflare-kv drivers; `verifyStoreContract`'s - knob is `ttl` (deprecated `ttlMs` alias). -- **P17 (circuit) + P4** `CircuitOptions` overhaul: `failureThreshold`→`failures` (P4), - `cooldownMs`→`cooldown`, `halfOpenAfterMs`→`halfOpenAfter` (P17, widened to `number | string`). - `failures`/`cooldown` become type-optional so the `@deprecated` aliases can stand in; - `createCircuit` throws if neither spelling is set (required-by-design, P15). The - `StitchConfig.circuit` slot is `AtLeastOne`, so the empty object is rejected (P20) - while the breaker stays required-by-design. -- **P17 (emitted: waited/elapsed)** `StitchEvent` `progress.waitedMs`→`waited` and `done.ms`→`elapsed`; - `Throttle.acquire` now returns `{ waited }`. The engine **co-emits** the `@deprecated` aliases for - back-compat (the type carries both): `done.ms` as a plain literal, `progress.waitedMs` by assignment - (a helper, so the literal-`*Ms:` lint R2 stays clean). Every first-party sink (core trace/cli/otlp, - `@stitchapi/pino`/`sentry`/`fastify`/`nest`) reads the canonical field. Parity tested in - `contract-event-aliases.spec.ts`. -- **P17 (emitted: retryAfter)** `retryAfterMs`→`retryAfter` on the three read-back surfaces that carry - a parsed `Retry-After`: `RateLimitError`, `StitchEvent.error`, and `AuthFailureResult`. Each co-sets - the `@deprecated` alias for back-compat (by assignment, R2-clean); the engine/auth set both. Docs and - the delegate-backoff / cookie-session tests move to the canonical name (alias parity asserted). -- **P17 (mock fixtures)** `MockResponse.delayMs`→`delay` (widened to `number | string`) and the - adapter-conformance `FixtureResponse.delayMs`→`delay`; both keep the `@deprecated` `delayMs` alias. - **Unit hazard:** `MockResponse.retryAfter` is **seconds** (it sets the `Retry-After` wire header), so - it is renamed to `retryAfterSeconds` (deprecated `retryAfter` alias) — the unit is in the name, per - P17. -- **P17 (internal `*Ms`)** the remaining internal duration fields are de-suffixed (no public alias — - none are consumer-authored): the `Throttle.acquire` result `{ waited }`, `TotalBudget.total`, - `parseRate`'s `{ count, per }`, `StitchStats.avg` (the `stitch summary` mean). `Surface.resumeRetryMs` - →`resumeRetry` keeps a `@deprecated` alias (a `Surface` is the public extension seam). This completes - the R2 (`*Ms`) clearance; the remaining baseline is R5 (P9/P16) + R6's P20 slots. -- **P5 (success payload `data`)** `value`→`data` on the runtime result envelopes: `StitchEvent.result` - and `Inspection`. Both co-set the `@deprecated` `value` alias for back-compat (the engine / - `makeInspection` set both; the trace JSONL caps both keys so the body never leaks uncapped). Stream - increments keep `chunk`; the Standard-Schema layer keeps spec `value`/`issues`. Every sink/adapter - (core trace/cli/serve, `@stitchapi/query-core`, all five TanStack/RTK/SWR readers) and the docs read - the canonical `data`. (Also folds the cross-package done-event `ms`→`elapsed` fixtures missed when - P17(c) only typechecked core + four sinks.) -- **P5 (`SchemaFingerprint.value`→`token`)** the fingerprint token is renamed off the overloaded - `value` (P5 reserves `value` for the success payload); all five vendor fingerprint-\* packages co-set - the `@deprecated` `value` alias, and both the cache-generation deriver and the `verifyFingerprintContract` - kit read `token` (normalizing either spelling, preserving the `null` ABSTAIN sentinel via a presence - check, not `??`). -- **P9 (`StitchStore`) + P16 (`queryOptions`)** — first R5 pair. The per-framework query store is - framework-qualified (ADR 0012 rule 6): `@stitchapi/solid`'s `StitchStore`→`SolidStitchStore`, - `@stitchapi/svelte`'s →`SvelteStitchStore` (genuinely incompatible — Solid nests `.state`, Svelte is - a `Readable` — and both collided with core's state-store `StitchStore`). The ADR 0012 - `stitchQueryOptions` rename now covers vue/solid/svelte/angular (was react-only); the bare - `queryOptions` survives as a uniform `@deprecated` alias (identity-tested) and is **de-listed from the - R5 watch-list** since it is no longer a competing canonical. -- **P9 (`StitchError` / `StitchErrorLike`)** — second R5 pair. The host adapters - (express/fastify/nest/next) mis-named their error **duck-type** `StitchError`, shadowing core's real - `StitchError` **class**. Renamed to `StitchErrorLike` (`Error & { status? }`), matching elysia/hono — - so bare `StitchError` is now core-only (R5 clears, watch-list unchanged), and `StitchErrorLike` is one - structural contract across all six host adapters (de-listed from R5, the `isStitchError` guard stays). -- **P9/P16 (per-request seam)** — third R5 pair. The per-request seam handle is ecosystem-qualified - per ADR 0012 (extending hono's `HonoRequestSeam`): express `RequestSeam`→`ExpressRequestSeam`, elysia - →`ElysiaRequestSeam`, fastify `StitchHost`→`FastifyRequestSeam`, nest `StitchHost`→`NestRequestSeam`. - Each keeps the bare name as a `@deprecated` alias (re-exported with a leading comment in the index - block, the codebase's established way to keep an alias off R5's name count). Bare `RequestSeam` / - `StitchHost` are no longer a canonical export anywhere, so both R5 findings clear (watch-list unchanged). -- **P9 (`StitchLike`)** — final R5. It is two deliberate, compatible tiers, not a clash: the canonical - RICH `(input?) => StitchCallResult` (awaitable + streamable) in `@stitchapi/query-core`, re-exported - by the five TanStack-family bindings; and an intentional MINIMAL await-only `(input?) => PromiseLike` - in the three stream-less adapters (swr/rtk-query/vercel-ai), which never call `.stream()`. query-core's - rich shape is assignable to the minimal one (a real stitch satisfies both), so it is **de-listed**. - With this, **R5 is fully cleared** — the baseline is now 6, exactly R6's P20 backlog - (multipart/stream/sse/throttle/hooks/input → `Scalar | AtLeastOne`). +## 6. Migration record (2026-07-08 hard-break sweep) + +Historical, not normative. The rename backlog this section used to carry was executed +**in full** by the 2026-07-08 hard-break sweep, under the pre-GA sanction recorded in +**D5** / [P19](#p19--breaking-changes-sanctioned-pre-ga-deprecation-cycle-from-ga): + +- **Every rename applied, without aliases.** The maintainer waived P19's alias + requirement pre-GA (few adopters): the canonical spellings landed directly — P2 + `pool`/`scope`/`tenancy`, P3 suffixes, P4 bare-noun caps, P5 `data`/`token`, P6 + `keyOf`, P7 `StatusMatch`, P16 `streamStitchSse`/`StitchErrorOptions`/ + `stitchQueryOptions`, P17 de-suffixed durations — plus `query`→`document`, + `unwrap`→`pick`, and the binder/`serverInfo`/secret-key renames. +- **Every prior `@deprecated` shim deleted**, along with their alias-parity tests: + the `rateLimit` slot, `throttle.scope`, the function-typed `key`s, all `*Ms` + fields and co-emitted event aliases, the `value` co-sets, bare + `queryOptions`/`RequestSeam`/`StitchHost`, `SchemaFingerprint.value`, and the rest + accrued by the 2026-06 alias-first migrations. +- **P0 restored:** all function sugar (url thunks, `transform`, predicates, `keyOf`, + paginate fns, hooks) lives on `__rawConfig`; `RedactedStitchConfig` narrowed; + pinned by a runtime regression spec. +- **P20 closed:** every capability slot is `Scalar | AtLeastOne`, including + the `circuit` tuple and `inspect(input, true)`. +- **P24 added** (same-day follow-up): a new rule — a shared leading-word prefix across + ≥2 flat fields is an envelope — landed alongside the sweep. Two pairs matched it + immediately and were converted without aliases: `OAuth2Options`/ + `CookieSessionOptions` `refreshOn`+`refreshSkew`/`refreshWhen` → `refresh`, and + `SentrySinkOptions` `captureErrors`+`captureDrift` → `capture`. Three exemptions + were named on adoption: `StitchQueryOptions.queryKey`/`queryFn` (TanStack mirror), + `OAuth2Options.clientId`/`clientSecret`/`clientAuth` (RFC 6749), + `DocSearchHit.pageUrl`/`pageTitle` (Orama index schema). Enforced by lint **R8**, + which found one pre-existing, real match P24's landing did **not** fix at the time: + `@stitchapi/nest`'s `StitchFeatureOptions.seamConfig`/`seamToken` (a feature seam's + build config and its DI exposure token — genuinely two facets of one capability). + Initially baselined rather than fixed (folding them ripples through + `forFeature`/`forFeatureScoped`/`StitchScopedFeatureOptions`); **since converted** + (R8 follow-up) into `StitchFeatureOptions.seam?: AtLeastOne` + with `NestFeatureSeamOptions { config?: AtLeastOne; token?: InjectionToken }` + — a hard break, no alias (P19/D5), `forFeature`/`forFeatureScoped` updated to read + `opts.seam?.config`/`opts.seam?.token`. +- **Baseline: 0.** `scripts/contract-violations.baseline.json` is empty again; the + ratchet fails on **any** new violation ([§7](#7-enforcement)). + +The blessed carve-outs and deliberate divergences that survive the sweep are recorded +inline in their rules: [P3](#p3--one-suffix-system) (`StitchQueryOptions`, the TanStack +mirror), [P8](#p8--same-concept--same-default-across-packages) (documented default +divergences), [P9](#p9--unique-by-shape-exported-types) (`StitchLike` tiers, identical +host envelopes), [P16](#p16--cross-surface--cross-package-parity) (svelte +`stitchStore*`). One further deliberate asymmetry: the query layer's key-redaction +sentinel is `'[redacted]'` while core's trace scrubber emits `'REDACTED'` — cache/query +keys are a **stable format** consumers may have persisted, so the query sentinel is +frozen independently of core's. ## 7. Enforcement @@ -555,20 +577,37 @@ cut; the lint skips the deprecated members so each rename ratchets the baseline `check:exports` / `check:release`. - It scans the published packages' public surface and reports contract violations. -- The current backlog is frozen in - [`scripts/contract-violations.baseline.json`](../scripts/contract-violations.baseline.json). - The lint **fails only when a NEW violation appears** (or — once the migration - starts — when the baseline is not shrunk to match). This mirrors the repo's existing - ESLint-suppression ratchet: the gate never blocks unrelated work, but the surface can - only get more consistent, never less. -- Rules implemented today (high-precision, source-text level): **R1** banned type-name - suffix (P3), **R2** any `*Ms`-suffixed duration field, input or emitted (P17), - **R3** function-typed `key` (P6), **R4** `scope: 'stitch'|'host'` overload (P2), - **R5** same identifier exported by ≥2 published packages (P9), **R6** a `StitchConfig` - slot typed as a bare all-optional `*Options` bag that accepts `{}` (P20). +- The baseline + ([`scripts/contract-violations.baseline.json`](../scripts/contract-violations.baseline.json)) + was **zero from the 2026-07-08 sweep**; the same-day P24 addition + ([§6](#6-migration-record-2026-07-08-hard-break-sweep)) briefly carried one + pre-existing real match it had not yet fixed, since converted — the baseline is + **zero again**, and the lint fails on **any** new violation. The ratchet mechanics + stay (mirroring the repo's ESLint-suppression ratchet) purely as the shrink-only + guarantee: the surface can only get more consistent, never less. +- Rules implemented (high-precision, source-text level): **R1** banned type-name + suffix (P3) — input-side `*Opts`/`*Info`/`*Params`/`*Config` **and** produced-side + `*Return`/`*State`; **R2** any `*Ms`-suffixed duration field, input or emitted + (P17); **R3** function-typed `key` (P6); **R4** a `scope: 'stitch'|'host'` pool + overload (P2); **R5** the same identifier exported by ≥2 published packages + (P9/P16), against an allow-list of the blessed one-declaration-site re-exports and + identical-by-design host envelopes; **R6** a config capability slot — top-level or + nested — typed as a bare all-optional `*Options` bag that accepts `{}` (P20); + **R7** any `@deprecated` marker on a published surface — the surface is shim-free + since the sweep, so a post-GA deprecation alias (mandated by P19) enters the + baseline **deliberately** for its cycle and is flagged until the major removes it; + **R8** a shared leading-word prefix across ≥2 flat members of the same exported + interface, not on the curated allow-list of verified foreign-mirror, + discriminated-union, and P12 dominant-field pairs (P24) — high-precision by + construction: the conventional `on*`/`is*`/percentile prefixes are structurally + excluded before grouping, and every remaining match is either fixed at the source + or gets a one-line-rationale allow-list entry, never silently dropped. - Deferred to a type-aware phase (needs the TS checker, not regex): full same-name-different-**shape** detection, duration-type conformance, default-value - inversion (P8). Tracked as comments in the lint. + inversion (P8). Tracked as comments in the lint. R8 is also source-text-only in a + second sense — it scans exported `interface` bodies, not `type`-literal object + shapes or class fields; no group was found in either at the 2026-07-08 audit, but + a future one wouldn't be caught until it grows an `interface`. --- diff --git a/docs/DESIGN.md b/docs/DESIGN.md index c9d4eebc..7496940d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -25,7 +25,7 @@ A stitch is a typed, declarative, composable unit: `input → validated output`, 1. **Progressive disclosure.** Zero-config to start; opt-in depth. `stitch('https://…')` just works. Every capability (validation, auth, retries, observability, streaming) has a sane default and reveals knobs only when you reach for them. _Simple to begin, opportunistic about what's possible._ 2. **Atomic stitches.** A stitch is fully self-contained. It can be exactly one endpoint and nothing else. **No global config is ever required.** -3. **Composition over configuration.** Cross-cutting concerns (baseUrl, auth, unwrap, retry, throttle, timeout, hooks) are **named, shareable values** you compose — not a central config object far from the call site. +3. **Composition over configuration.** Cross-cutting concerns (baseUrl, auth, pick, retry, throttle, timeout, hooks) are **named, shareable values** you compose — not a central config object far from the call site. 4. **The stitch is the boundary.** Auth, validation, and observability live _at_ the stitch. Agents receive **capabilities, not credentials** — they call a stitch and get data without ever seeing the secret. 5. **One definition, many surfaces.** The same stitch is callable as an in-process function, a CLI command, an HTTP endpoint, and an MCP/agent tool. 6. **The event stream is the spine.** Streaming output, observability, and drift detection all read the _same_ event stream a stitch emits. @@ -49,7 +49,7 @@ const listWebsites = stitch({ input: { query: WebsiteQuery }, // schemas for params / query / body / headers output: Website.array(), // response contract → types + validation + drift - unwrap: 'data', // pluck the payload + pick: 'data', // pluck the payload auth: session, // a co-located auth strategy value (§5) retry: { attempts: 3, on: [429, 503] }, @@ -117,7 +117,7 @@ const session = cookieSession({ login: signIn, // ← another stitch cookie: 'session_token', secret: keychain('app'), - refreshOn: [401], + refresh: [401], }); ``` @@ -128,7 +128,7 @@ const listWebsites = stitch({ extends: [base, session], // left→right precedence; own fields win last path: '/api/websites', output: Website.array(), - unwrap: 'data', + pick: 'data', }); ``` @@ -153,7 +153,7 @@ A stitch is itself a composable value — inherit one and override the diff: ```ts const getWebsite = stitch({ - extends: [listWebsites], // inherits base + auth + retry + unwrap + extends: [listWebsites], // inherits base + auth + retry + pick path: '/api/websites/{id}', // override output: Website, // override }); @@ -161,7 +161,7 @@ const getWebsite = stitch({ ### Merge semantics **[proposed]** -- **Scalars** (`path`, `method`, `baseUrl`, `url`, `unwrap`): replace. The endpoint is one slot: `url` and `baseUrl`/`path` are mutually exclusive, so the last fragment to set either spelling wins it whole — a child `url` clears an inherited `baseUrl`/`path`, and a child `baseUrl`/`path` clears an inherited `url`. +- **Scalars** (`path`, `method`, `baseUrl`, `url`, `pick`): replace. The endpoint is one slot: `url` and `baseUrl`/`path` are mutually exclusive, so the last fragment to set either spelling wins it whole — a child `url` clears an inherited `baseUrl`/`path`, and a child `baseUrl`/`path` clears an inherited `url`. - **Objects** (`retry`, `throttle`, `timeout`, `input`, auth options): deep-merge field-wise. - **`hooks`**: **chain**, don't replace — base `onRequest` runs, then child's; `onResponse` unwinds child→base (middleware order). This is what makes a base like "always log + add trace header" actually composable. - **`output` / contracts**: replace (a child declares its own); compose explicitly with `schema.merge(...)` when you want to extend. @@ -202,12 +202,12 @@ This is a concrete cookie wall (`GET /api/websites` needs a `session_token` cook ## 6. Resilience — retry, throttle, timeout ```ts -retry: { attempts: 3, backoff: 'expo+jitter', on: [429, 503], respectRetryAfter: true }, -throttle: { rate: '1/s', concurrency: 2, scope: 'host' }, // proactive limiter +retry: { attempts: 3, backoff: 'expo-jitter', on: [429, 503], respectRetryAfter: true }, +throttle: { rate: '1/s', concurrency: 2, pool: 'host' }, // proactive limiter timeout: { total: '30s', perAttempt: '10s' }, ``` -- **`throttle`** is _proactive_ — a token-bucket/concurrency cap to stay _under_ a vendor's limit (replaces the hand-rolled 1/s buckets and per-request delays integrations write by hand). `scope: 'host'` shares one limiter across all stitches hitting the same host. +- **`throttle`** is _proactive_ — a token-bucket/concurrency cap to stay _under_ a vendor's limit (replaces the hand-rolled 1/s buckets and per-request delays integrations write by hand). `pool: 'host'` shares one limiter across all stitches hitting the same host. - **`retry`** is _reactive_ — backoff+jitter, honoring `Retry-After`. - All emit events (`retry`, `throttled`) onto the stream → visible in the trace for free. @@ -247,7 +247,7 @@ type StitchEvent = | { type: 'progress'; phase: 'auth'|'request'|'throttled'|'retry'|'paginate'; ... } | { type: 'delta'; chunk } // streamed body / LLM tokens (future kinds) | { type: 'drift'; level: 'error'|'warn'|'info'; path; change } - | { type: 'result'; value: T } // validated, unwrapped + | { type: 'result'; data: T } // validated, picked | { type: 'error'; error } | { type: 'done'; timing; usage }; ``` @@ -305,7 +305,7 @@ await user({ params: { id: 1 }, query: { expand: 'roles' } }); const users = stitch({ url: 'https://reqres.in/api/users', output: User.array(), - unwrap: 'data', + pick: 'data', }); const list = await users(); // typed User[]; drift-checked ``` @@ -375,12 +375,12 @@ const listWebsites = stitch({ extends: [api], path: '/api/websites', output: Website.array(), - unwrap: 'data', + pick: 'data', auth: cookieSession({ login: signIn, cookie: 'session_token', secret: secretsFile('app'), - refreshOn: [401], + refresh: [401], }), }); await listWebsites(); // logs in, manages cookie, retries wall, returns Website[] @@ -403,7 +403,7 @@ const metadata = stitch({ for await (const ev of listWebsites.stream()) { if (ev.type === 'progress' && ev.phase === 'retry') log('retrying…'); if (ev.type === 'drift' && ev.level === 'info') log('new field:', ev.path); - if (ev.type === 'result') render(ev.value); + if (ev.type === 'result') render(ev.data); } ``` @@ -486,8 +486,8 @@ The two gaps both audits flagged _critical_ — distributed rate limiting and pe ```ts interface StitchStore { get(key: string): Promise; - set(key: string, value: unknown, ttlMs?: number): Promise; - incr(key: string, ttlMs: number): Promise; // atomic — for rate windows + set(key: string, value: unknown, ttl?: number): Promise; + incr(key: string, ttl?: number): Promise; // atomic — for rate windows } const api = seam({ diff --git a/docs/adr/0006-nestjs-integration.md b/docs/adr/0006-nestjs-integration.md index fcd73cec..2aa245c9 100644 --- a/docs/adr/0006-nestjs-integration.md +++ b/docs/adr/0006-nestjs-integration.md @@ -8,6 +8,10 @@ > > The integration is a thin **adapter package**, exactly the class [ADR 0001](./0001-package-naming-and-distribution.md) Decision 2 already anticipated ("framework adapters (e.g. React)… `@stitchapi/`"). It contributes _DI wiring + two bridge helpers + a Logger sink_ over primitives core already exposes — no new capability, so it cannot regress the **contract-not-dependency** gate ([`two-gates`](../DESIGN.md)). +> [!IMPORTANT] +> +> **Amendment — `StitchFeatureOptions.seam`/`seamToken` folded into one `seam` envelope.** Decision 4 below shipped its bare `seam?: SeamConfig` renamed to `seamConfig` (to stop reading as the `seam()` function) plus a sibling `seamToken?: InjectionToken`. CONTRACT.md's P24 rule (a shared leading-word prefix across ≥2 flat fields is an envelope) later flagged that `seamConfig`/`seamToken` pair; it is now `seam?: AtLeastOne` with `NestFeatureSeamOptions { config?: AtLeastOne; token?: InjectionToken }` — a hard break, no alias (P19/D5). Read `seam: cfg` / `seamToken` in the code below as `seam?.config` / `seam?.token`. See [CONTRACT.md §6](../CONTRACT.md#6-migration-record-2026-07-08-hard-break-sweep) (P24) and [`packages/nest/src/module.ts`](../../packages/nest/src/module.ts). + ## Context A stitch is a plain function: `stitch(config)` returns a callable that runs an HTTP/GraphQL/streaming call ([`stitch.ts`](../../packages/core/src/stitch.ts)). That works in any runtime, but it is **unwired** in a NestJS backend — there is no module to import, no provider to inject, no lifecycle hook, and no idiomatic path from Nest's `ConfigService`/`Logger`/request scope into a stitch. Today a Nest user hand-rolls all of that, and gets three things subtly wrong: diff --git a/docs/sandbox/B1-SPIKE.md b/docs/sandbox/B1-SPIKE.md index 2a9ae908..05f121af 100644 --- a/docs/sandbox/B1-SPIKE.md +++ b/docs/sandbox/B1-SPIKE.md @@ -5,6 +5,7 @@ > **Answers open risk:** [SANDBOX.md](./SANDBOX.md) §10.4 ("browser `stitch` build … largest single unknown") · [REQUIREMENTS.md](../playground/REQUIREMENTS.md) §6, §10.1 > **Keys on:** [`contracts/dispatch.ts`](./contracts/dispatch.ts) `NODE_ONLY_SURFACES` · **Fallback if NO-GO:** [RATIONALE.md](../playground/RATIONALE.md) §"Confidence & escape hatch" (self-hosted LiveCodes) > **Downstream:** R1 (Worker runner — injects this build), D1 (dispatcher) +> **Note (2026-07):** this is a point-in-time record; `otlpTrace` was renamed to `otlpSink` (and `toValidator` was replaced by `validate`/`compile`) in the 2026-07 contract sweep — the current lists live in `contracts/surface.ts` and `runtime/stitch-browser.ts`. ## Verdict diff --git a/docs/sandbox/SANDBOX.md b/docs/sandbox/SANDBOX.md index 2c071e48..f9b832ae 100644 --- a/docs/sandbox/SANDBOX.md +++ b/docs/sandbox/SANDBOX.md @@ -73,7 +73,7 @@ Classification of the **develop** core exports ([`packages/core/src/index.ts`](. | `drift`, `graphql` | `env` — reads `process.env` | | `bearer`, `apiKey`, `basic`, `oauth2` (token held in memory) | `cookieSession` — persistent cookie jar | | `fetchAdapter` (global `fetch`, shimmed to the simulator) | `createTrace` / `multiplex` — JSONL trace files (fs) | -| `toValidator`, `memoryStore`, resilience (retry/throttle — pure) | `otlpTrace` / `otlpHttpExporter` — real network egress | +| `validate`/`compile`, `memoryStore`, resilience (retry/throttle) | `otlpSink` / `otlpHttpExporter` — real network egress | | | `cli`, `serve`, `mcp` — process / stdio / server surfaces | **Detection** is a static scan of the (pre-transpile) source for references to the diff --git a/docs/sandbox/contracts/surface.ts b/docs/sandbox/contracts/surface.ts index d8aaa867..98cc20bd 100644 --- a/docs/sandbox/contracts/surface.ts +++ b/docs/sandbox/contracts/surface.ts @@ -63,7 +63,7 @@ export const NODE_ONLY_SURFACES = [ 'cookieSession', 'createTrace', 'multiplex', - 'otlpTrace', + 'otlpSink', 'otlpHttpExporter', 'cli', 'serve', diff --git a/docs/sandbox/runtime/B1-README.md b/docs/sandbox/runtime/B1-README.md index e75762ea..8f09356a 100644 --- a/docs/sandbox/runtime/B1-README.md +++ b/docs/sandbox/runtime/B1-README.md @@ -31,7 +31,7 @@ docs/sandbox/runtime/ shims/ process.ts ← `process` object injected into the snippet scope notices.ts ← RunNotice collection channel (drainNotices) - otlp-browser.ts ← no-op OTLP exporter (no egress) + shimmed otlpTrace + otlp-browser.ts ← no-op OTLP exporter (no egress) + shimmed otlpSink node-surfaces.ts ← keychain/env (demo values) + cookieSession (in-mem jar) server-tier-stubs.ts ← cli/serve/mcp throwing stubs (server-tier only) ``` @@ -76,14 +76,14 @@ The Node built-ins (`node:crypto`/`fs`/`path`) and `process.env` are **no longer shimmed** — core handles them isomorphically (GAP-AUDIT §1.5). What remains are the Node-only **surfaces** the browser entry deliberately replaces with sandbox policy: -| Surface | Shim | Behaviour | -| -------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------- | -| `keychain(name)` | `node-surfaces.ts` | documented **demo value** + `RunNotice{kind:'shim'}` | -| `env(name)` | `node-surfaces.ts` | documented **demo value** + `RunNotice{kind:'shim'}` | -| `cookieSession` | `node-surfaces.ts` | core's pure-JS strategy; **in-memory jar** (lives in the StitchStore) + notice | -| `createTrace` | `stitch-browser.ts` | JSONL = no-op; **console forced off** (trace via StitchTraceEntry); notice if a file set | -| `otlpTrace` / `otlpHttpExporter` | `otlp-browser.ts` | **no-op exporter, zero network egress** + notice | -| `cli` / `serve` / `mcp` | `server-tier-stubs.ts` | **throw** — server-tier only | +| Surface | Shim | Behaviour | +| ------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------- | +| `keychain(name)` | `node-surfaces.ts` | documented **demo value** + `RunNotice{kind:'shim'}` | +| `env(name)` | `node-surfaces.ts` | documented **demo value** + `RunNotice{kind:'shim'}` | +| `cookieSession` | `node-surfaces.ts` | core's pure-JS strategy; **in-memory jar** (lives in the StitchStore) + notice | +| `createTrace` | `stitch-browser.ts` | JSONL = no-op; **console forced off** (trace via StitchTraceEntry); notice if a file set | +| `otlpSink` / `otlpHttpExporter` | `otlp-browser.ts` | **no-op exporter, zero network egress** + notice | +| `cli` / `serve` / `mcp` | `server-tier-stubs.ts` | **throw** — server-tier only | ### Shim-notice channel @@ -122,7 +122,7 @@ Lifted from B1-SPIKE §7, plus what this implementation adds: `crypto` into the snippet scope (whose `globalThis` is shadowed). 3. **No-op OTLP.** Real OTLP egress is server-tier only. The browser build's default exporter is a no-op (`otlp-browser.ts`) — do **not** rely on CSP `connect-src` - alone (SANDBOX §7). `otlpTrace()`/`otlpHttpExporter()` are in `NODE_ONLY_SURFACES`, + alone (SANDBOX §7). `otlpSink()`/`otlpHttpExporter()` are in `NODE_ONLY_SURFACES`, so a snippet naming them routes to the server tier when it exists; pre-server it runs shimmed-with-notice. 4. **Core's `createTrace` console path probes `process.stderr`.** In a Worker there is diff --git a/docs/sandbox/runtime/shims/otlp-browser.ts b/docs/sandbox/runtime/shims/otlp-browser.ts index 206acb79..9d3318e3 100644 --- a/docs/sandbox/runtime/shims/otlp-browser.ts +++ b/docs/sandbox/runtime/shims/otlp-browser.ts @@ -6,19 +6,19 @@ * but the spike was explicit: don't rely on CSP alone (B1-SPIKE §7 / SANDBOX §7) — * make the browser build's default OTLP exporter a no-op. * - * `otlpTrace()` and `otlpHttpExporter()` are NODE_ONLY_SURFACES, so a snippet that + * `otlpSink()` and `otlpHttpExporter()` are NODE_ONLY_SURFACES, so a snippet that * names them routes to the server tier when it exists; pre-server it runs shimmed. * The browser entry re-exports THESE in place of core's versions: * - `otlpHttpExporter()` → a no-op SpanExporter (no network), + a shim notice. - * - `otlpTrace()` → core's real otlpTrace, but defaulted to this no-op + * - `otlpSink()` → core's real otlpSink, but defaulted to this no-op * exporter so no egress is ever attempted, + a shim notice. * - * The `node:crypto` alias already covers `otlpTrace`'s `randomBytes` span ids, so + * The `node:crypto` alias already covers `otlpSink`'s `randomBytes` span ids, so * the trace mapping itself still works — it just exports nowhere. */ import { emitShimNotice } from './notices'; -import { otlpTrace as coreOtlpTrace } from 'stitchapi'; +import { otlpSink as coreOtlpSink } from 'stitchapi'; import type { OtelSpan, OtlpOptions, SpanExporter, TraceSink } from 'stitchapi'; const OTLP_NOTICE = @@ -42,10 +42,10 @@ export function otlpHttpExporter( return noopOtlpExporter(); } -/** Drop-in for core `otlpTrace` — builds spans, exports to the no-op exporter. */ -export function otlpTrace(opts: OtlpOptions = {}): TraceSink { - emitShimNotice('otlpTrace', OTLP_NOTICE); - return coreOtlpTrace({ +/** Drop-in for core `otlpSink` — builds spans, exports to the no-op exporter. */ +export function otlpSink(opts: OtlpOptions = {}): TraceSink { + emitShimNotice('otlpSink', OTLP_NOTICE); + return coreOtlpSink({ ...opts, exporter: opts.exporter ?? noopOtlpExporter(), }); diff --git a/docs/sandbox/runtime/stitch-browser.ts b/docs/sandbox/runtime/stitch-browser.ts index c87c0f41..a06ff4b0 100644 --- a/docs/sandbox/runtime/stitch-browser.ts +++ b/docs/sandbox/runtime/stitch-browser.ts @@ -27,7 +27,7 @@ import type { TraceSink } from 'stitchapi'; * keychain, env → ./shims/node-surfaces (demo values) * cookieSession → ./shims/node-surfaces (in-memory jar) * createTrace → shimmed below (JSONL is a no-op) - * otlpTrace, otlpHttpExporter → ./shims/otlp-browser (no-op exporter, no egress) + * otlpSink, otlpHttpExporter → ./shims/otlp-browser (no-op exporter, no egress) * Server-tier only, THROWS here: * cli, serve, mcp → ./shims/server-tier-stubs */ @@ -38,7 +38,7 @@ export { seam, drift, graphql, - // `validate`/`compile` are pure schema-normalisation (validator.ts → toValidator); + // `validate`/`compile` are pure schema-normalisation (validator.ts); // no Node touch-points — safe to re-export verbatim. Needed so the blog's // runtime-schema snippets (`compile(JsonSchema.adapt(...))`) run in the playground. validate, @@ -69,7 +69,7 @@ export type * from 'stitchapi'; /* ---- Node-only surfaces, shimmed (emit a RunNotice) ---------------------- */ export { keychain, env, cookieSession } from './shims/node-surfaces'; export { - otlpTrace, + otlpSink, otlpHttpExporter, noopOtlpExporter, } from './shims/otlp-browser'; diff --git a/packages/angular/README.md b/packages/angular/README.md index c772b08f..ff102b44 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -14,7 +14,7 @@ These functions are a thin layer over [`@stitchapi/query-core`](../query-core), pnpm add @stitchapi/angular@rc @stitchapi/query-core@rc stitchapi@rc @angular/core rxjs ``` -`stitchapi`, `@angular/core` (`>=16`), and `rxjs` (`>=7`) are peer dependencies. `@tanstack/angular-query-experimental` is an **optional** peer — only needed if you use `queryOptions`. +`stitchapi`, `@angular/core` (`>=16`), and `rxjs` (`>=7`) are peer dependencies. `@tanstack/angular-query-experimental` is an **optional** peer — only needed if you use `stitchQueryOptions`. ## `injectStitch` — request / response @@ -89,7 +89,7 @@ Every result exposes the same state two ways, from **one shared query execution* ```ts interface InjectStitchResult { // signals - state: Signal>; + state: Signal>; data: Signal; error: Signal; status: Signal<'idle' | 'pending' | 'streaming' | 'success' | 'error'>; @@ -99,7 +99,7 @@ interface InjectStitchResult { isSuccess: Signal; isStreaming: Signal; // observable — for the async pipe / RxJS operators - state$: Observable>; + state$: Observable>; // imperative refetch: () => void; cancel: () => void; @@ -110,14 +110,14 @@ Read `user.data()` in a template, or `user.state$ | async` if you prefer RxJS ## Optional: TanStack Query -`queryOptions(stitch, input)` returns a plain `{ queryKey, queryFn }` object — no import of `@tanstack/angular-query-experimental` required, so it works even if you never install it. +`stitchQueryOptions(stitch, input)` returns a plain `{ queryKey, queryFn }` object — no import of `@tanstack/angular-query-experimental` required, so it works even if you never install it. (The `stitch` prefix avoids a clash with TanStack's own `queryOptions` export; the implementation is re-exported from [`@stitchapi/query-core`](../query-core), so the key format is identical across every framework binding.) ```ts import { injectQuery } from '@tanstack/angular-query-experimental'; -import { queryOptions } from '@stitchapi/angular'; +import { stitchQueryOptions } from '@stitchapi/angular'; readonly user = injectQuery(() => - queryOptions(getUser, { params: { id: this.id() } }), + stitchQueryOptions(getUser, { params: { id: this.id() } }), ); ``` diff --git a/packages/angular/package.json b/packages/angular/package.json index 6bb5e227..d13cded3 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -2,7 +2,7 @@ "name": "@stitchapi/angular", "version": "1.0.0-rc.5", "type": "commonjs", - "description": "Angular bindings for StitchAPI — injectStitch/injectStitchStream expose a stitch's lifecycle as both signals and an RxJS observable over @stitchapi/query-core. Streaming-first: re-render as `delta` chunks arrive. Plus an optional TanStack Query queryOptions helper.", + "description": "Angular bindings for StitchAPI — injectStitch/injectStitchStream expose a stitch's lifecycle as both signals and an RxJS observable over @stitchapi/query-core. Streaming-first: re-render as `delta` chunks arrive. Plus an optional TanStack Query stitchQueryOptions helper.", "main": "lib/index.js", "module": "lib/index.mjs", "types": "lib/index.d.ts", diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts index 6b2c7e4e..111ac55c 100644 --- a/packages/angular/src/index.ts +++ b/packages/angular/src/index.ts @@ -11,8 +11,11 @@ // - `injectStitchStream` — the streaming primitive: emits as `delta` chunks // arrive. This is the differentiator over plain // request/response query libraries. -// - `queryOptions` — an OPTIONAL TanStack Query adapter (returns a plain -// POJO, so it needs no import of the Angular adapter). +// - `stitchQueryOptions` — an OPTIONAL TanStack Query adapter, re-exported from +// `@stitchapi/query-core` (returns a plain POJO, so it +// needs no import of the Angular adapter; named with +// the `stitch` prefix because TanStack exports its own +// `queryOptions`, see ADR 0012). // // The observable is the bridge off the store; the signal is derived from it via // `toSignal`, so a single query feeds both. `state$` and the signals share one @@ -36,7 +39,7 @@ import { type QueryOutput, type StitchLike, type StitchQuery, - type StitchQueryState, + type StitchQueryResult, type StitchQueryStatus, createStitchQuery, } from '@stitchapi/query-core'; @@ -50,20 +53,42 @@ export type { QueryOutput, StitchLike, StitchQuery, - StitchQueryState, + StitchQueryOptions, + StitchQueryResult, StitchQueryStatus, } from '@stitchapi/query-core'; +// The TanStack Query adapter and its key derivation live in +// `@stitchapi/query-core` — ONE shared implementation across every framework +// binding, so the key format (and its secret-redaction guarantees) cannot drift +// between frameworks. Re-exported here so Angular apps import everything from +// `@stitchapi/angular`. +export { + deriveQueryKey, + keyInputFor, + nameOf, + stitchQueryOptions, +} from '@stitchapi/query-core'; + // --------------------------------------------------------------------------- // Public surface // --------------------------------------------------------------------------- /** A value, a `Signal` of it, or a zero-arg getter — Angular's idiom for "static - * or reactive". Passing a signal/getter recreates the query (and re-fetches) when - * the tracked value changes; a plain value is read once. */ -export type InjectInput = T | Signal | (() => T); + * or reactive" (the sibling of Vue's `MaybeRefOrGetter` and Solid's + * `MaybeAccessor`). Passing a signal/getter recreates the query (and re-fetches) + * when the tracked value changes; a plain value is read once. */ +export type MaybeSignal = T | Signal | (() => T); -export interface InjectStitchOptions extends CreateStitchQueryOptions { +/** + * Options accepted by {@link injectStitch} / {@link injectStitchStream}. + * + * The store's `streaming` flag is deliberately OMITTED: each injector hard-sets + * it (`injectStitch` → unary, `injectStitchStream` → streaming), so passing it + * would be silently ignored — the type forbids it instead. + */ +export interface InjectStitchOptions + extends Omit, 'streaming'> { /** Injection context to use when `injectStitch` is called outside one (e.g. a * test, or a non-injection callback). Defaults to the ambient context. */ injector?: Injector; @@ -75,7 +100,7 @@ export interface InjectStitchOptions extends CreateStitchQueryOptions { export interface InjectStitchResult { /** The whole snapshot as a signal — read `state().data` etc. in a template or * `computed`. */ - readonly state: Signal>; + readonly state: Signal>; /** The validated output (unary) or the latest streamed value (streaming). */ readonly data: Signal; /** The thrown reason on failure. */ @@ -94,7 +119,7 @@ export interface InjectStitchResult { readonly isStreaming: Signal; /** The same state as an observable — for the `async` pipe / RxJS consumers. * Multicast: it shares the one query execution with the signals above. */ - readonly state$: Observable>; + readonly state$: Observable>; /** Abort the in-flight run and re-run from scratch. */ refetch: () => void; /** Abort the in-flight run, if any. */ @@ -105,89 +130,10 @@ export interface InjectStitchResult { // Shared driver // --------------------------------------------------------------------------- -// --- key derivation (shared logic; duplicated in @stitchapi/react) ---------- -// These helpers are intentionally copied verbatim from `@stitchapi/react`'s key -// derivation: they are separate published packages, so a cross-package import -// would add a runtime dependency. Keep the copies in lock-step. - -/** The `__config` slice a key derives from. Mirrors core's `nameOf` - * (`name ?? path ?? 'stitch'`) plus a `url` fallback for URL-configured stitches. */ -type KeyConfig = { name?: string; path?: string; url?: string }; - -/** A stable, human-meaningful name for the stitch. Mirrors core's `nameOf` - * (`packages/core/src/engine.ts`) — `name ?? path ?? url ?? 'stitch'` — so two - * DISTINCT nameless stitches (`/users/{id}` vs `/orders/{id}`) don't collapse to - * the literal `'stitch'` and collide on one cache entry. */ -function nameOf(stitch: unknown): string { - const cfg = (stitch as { __config?: KeyConfig }).__config; - return cfg?.name ?? cfg?.path ?? cfg?.url ?? 'stitch'; -} - -// Header names whose VALUES are secrets — mirrors core's private `SECRET_HEADERS` -// trace denylist (`packages/core/src/trace.ts`), which is not exported. We redact -// the value (rather than dropping the header) so the key stays stable per token -// AND callers who legitimately vary a response by a non-secret header (e.g. -// `accept-language`) keep separate cache entries. Compared case-insensitively; the -// `*-token` / `*-api-key` suffix rules catch vendor spellings without enumerating. -const SECRET_HEADERS = new Set([ - 'authorization', - 'proxy-authorization', - 'cookie', - 'set-cookie', - 'x-api-key', - 'x-auth-token', -]); -const REDACTED = '[redacted]'; - -function isSecretHeader(name: string): boolean { - const k = name.toLowerCase(); - return ( - SECRET_HEADERS.has(k) || k.endsWith('-token') || k.endsWith('-api-key') - ); -} - -/** - * Build the value that goes into a cache/query key from a stitch's per-call input. - * Never puts the raw input in the key: - * - * - drops `signal` / `onProgress` — runtime-only, never-serialised (CONTRACT.md); - * `onProgress` in particular churns identity every render, which would refetch - * forever if it entered the key; - * - redacts the VALUES of secret-bearing headers (`authorization`, `cookie`, …) - * so a bearer token can't leak into a persisted / devtools-visible key, while - * keeping non-secret headers so they still vary the cache; - * - keeps every other field (`params` / `query` / `body` / `variables` / …) as-is. - * - * `null` / `undefined` inputs stay `null`; a primitive input is returned unchanged. - */ -function keyInputFor(input: unknown): unknown { - if (input === null || input === undefined) return null; - if (typeof input !== 'object') return input; - - const { - signal: _signal, - onProgress: _onProgress, - ...rest - } = input as { - signal?: unknown; - onProgress?: unknown; - headers?: Record; - } & Record; - - if (rest.headers && typeof rest.headers === 'object') { - const headers: Record = {}; - for (const [k, v] of Object.entries(rest.headers)) { - headers[k] = isSecretHeader(k) ? REDACTED : v; - } - rest.headers = headers; - } - return rest; -} - // The store's seed value, surfaced before the first real snapshot (an instant). // It matches core's idle snapshot exactly so the signals are well-formed from the // start. -const IDLE_STATE: StitchQueryState = { +const IDLE_STATE: StitchQueryResult = { status: 'idle', data: undefined, error: undefined, @@ -202,7 +148,7 @@ const IDLE_STATE: StitchQueryState = { * A signal/getter becomes a `toObservable(computed(...))` that re-emits on change; * a plain value becomes a single-shot `of(value)`. */ function inputObservable( - input: InjectInput, + input: MaybeSignal, injector: Injector, ): Observable { if (typeof input === 'function') { @@ -216,23 +162,22 @@ function inputObservable( function injectStitchInternal( stitch: StitchLike, - input: InjectInput, + input: MaybeSignal, options: InjectStitchOptions, - stream: boolean, + streaming: boolean, ): InjectStitchResult { if (!options.injector) assertInInjectionContext(injectStitch); const injector = options.injector ?? inject(Injector); const destroyRef = injector.get(DestroyRef); const { mode, enabled, onSuccess, onError } = options; - const coreOptions: CreateStitchQueryOptions & { stream: boolean } = - compact({ - stream, - mode, - enabled, - onSuccess, - onError, - }); + const coreOptions: CreateStitchQueryOptions = compact({ + streaming, + mode, + enabled, + onSuccess, + onError, + }); // The live query handle, captured for the imperative refetch/cancel. It is // reassigned whenever the input changes (switchMap tears down the old one). @@ -243,7 +188,7 @@ function injectStitchInternal( // store subscription and tears the handle down on unsubscribe. switchMap( (value) => - new Observable>((subscriber) => { + new Observable>((subscriber) => { const handle = createStitchQuery( stitch, value, @@ -268,7 +213,7 @@ function injectStitchInternal( ); const state = toSignal(state$, { - initialValue: IDLE_STATE as StitchQueryState, + initialValue: IDLE_STATE as StitchQueryResult, injector, }); @@ -310,17 +255,17 @@ function injectStitchInternal( */ export function injectStitch>( stitch: S, - input: InjectInput>, + input: MaybeSignal>, options?: InjectStitchOptions>, ): InjectStitchResult>; export function injectStitch( stitch: StitchLike, - input: InjectInput, + input: MaybeSignal, options?: InjectStitchOptions, ): InjectStitchResult; export function injectStitch( stitch: StitchLike, - input: InjectInput, + input: MaybeSignal, options: InjectStitchOptions = {}, ): InjectStitchResult { return injectStitchInternal(stitch, input, options, false); @@ -345,75 +290,18 @@ export function injectStitch( */ export function injectStitchStream>( stitch: S, - input: InjectInput>, + input: MaybeSignal>, options?: InjectStitchOptions>, ): InjectStitchResult>; export function injectStitchStream( stitch: StitchLike, - input: InjectInput, + input: MaybeSignal, options?: InjectStitchOptions, ): InjectStitchResult; export function injectStitchStream( stitch: StitchLike, - input: InjectInput, + input: MaybeSignal, options: InjectStitchOptions = {}, ): InjectStitchResult { return injectStitchInternal(stitch, input, options, true); } - -// --------------------------------------------------------------------------- -// queryOptions — optional TanStack Query adapter -// --------------------------------------------------------------------------- - -// IDENTICAL to the other bindings' `queryOptions` (no framework import) — the -// POJO shape `@tanstack/angular-query-experimental`'s `injectQuery(() => ...)` -// consumes is the same `{ queryKey, queryFn }` TanStack uses everywhere. - -/** The plain object {@link stitchQueryOptions} returns — structurally compatible with - * TanStack Query's options without importing the library. */ -export interface StitchQueryOptions { - queryKey: readonly unknown[]; - queryFn: (ctx?: { signal?: AbortSignal }) => Promise; -} - -/** - * Build a TanStack-Query-compatible options object for a stitch, WITHOUT a hard - * dependency on `@tanstack/angular-query-experimental` — it just returns a POJO: - * - * ```ts - * import { injectQuery } from '@tanstack/angular-query-experimental'; - * import { stitchQueryOptions } from '@stitchapi/angular'; - * - * readonly user = injectQuery(() => stitchQueryOptions(getUser, { params: { id: this.id() } })); - * ``` - * - * The `queryFn` awaits the stitch (the validated output); the `queryKey` is a - * stable name for the stitch plus a sanitised copy of the input (secret header - * values redacted, runtime-only `signal`/`onProgress` dropped), so TanStack caches - * per call without leaking a bearer token into the key or refetching every render. - */ -export function stitchQueryOptions>( - stitch: S, - input: QueryInput, -): StitchQueryOptions>; -export function stitchQueryOptions( - stitch: StitchLike, - input: Input, -): StitchQueryOptions; -export function stitchQueryOptions( - stitch: StitchLike, - input: unknown, -): StitchQueryOptions { - return { - queryKey: [nameOf(stitch), keyInputFor(input)], - queryFn: () => Promise.resolve(stitch(input)), - }; -} - -/** - * @deprecated Renamed to {@link stitchQueryOptions} — a bare `queryOptions` collides - * with TanStack Query's own `queryOptions` export when both are imported. See - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). Kept through the - * `1.0.0-rc` line and removed at the 1.0 GA cut. - */ -export const queryOptions = stitchQueryOptions; diff --git a/packages/angular/test/bindings.spec.ts b/packages/angular/test/bindings.spec.ts index d363f1b0..aadf11eb 100644 --- a/packages/angular/test/bindings.spec.ts +++ b/packages/angular/test/bindings.spec.ts @@ -2,12 +2,7 @@ // in a jsdom env. Driven by FAKE stitches — no engine, no network. Each binding is // created in `TestBed.runInInjectionContext`, and the module is reset between tests // to exercise context teardown. -import { - injectStitch, - injectStitchStream, - queryOptions, - stitchQueryOptions, -} from '../src'; +import { injectStitch, injectStitchStream, stitchQueryOptions } from '../src'; import { ApplicationRef, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; @@ -305,6 +300,24 @@ describe('state$', () => { }); }); +// --- InjectStitchOptions surface --------------------------------------------- + +describe('InjectStitchOptions', () => { + test("the store's 'streaming' flag cannot be passed to the injectors", () => { + const stitch = unaryStitch(async () => 1); + // Never executed — compile-time assertions only: each injector hard-sets + // `streaming`, so passing it must be a TYPE ERROR rather than being + // silently ignored. + void function TypeOnly(): void { + // @ts-expect-error — 'streaming' is omitted from InjectStitchOptions + injectStitch(stitch, {}, { streaming: true }); + // @ts-expect-error — 'streaming' is omitted from InjectStitchOptions + injectStitchStream(stitch, {}, { streaming: false }); + }; + expect(true).toBe(true); + }); +}); + // --- stitchQueryOptions ---------------------------------------------------------- describe('stitchQueryOptions', () => { @@ -324,16 +337,11 @@ describe('stitchQueryOptions', () => { }); }); -describe('queryOptions (deprecated alias)', () => { - test('queryOptions stays a deprecated alias of stitchQueryOptions (ADR 0012)', () => { - expect(queryOptions).toBe(stitchQueryOptions); - }); -}); - // --- stitchQueryOptions: cache-key derivation regressions ------------------ -// The `queryKey` derivation shared the same three bugs as `@stitchapi/react`'s -// (fixed there in #406). Each of these FAILED before the port. Keep in lock-step -// with `@stitchapi/react`'s `packages/react/test/hooks.spec.tsx`. +// The derivation now lives in `@stitchapi/query-core` (`deriveQueryKey`) and is +// re-exported here; these regressions stay to guard the re-export wiring. Each +// of these FAILED before the original fix (the local copy shared the same three +// bugs as `@stitchapi/react`'s, fixed there in #406). describe('stitchQueryOptions — no cache collision between nameless stitches', () => { // Bug 1 (correctness): `name ?? 'stitch'` keyed every nameless stitch as the diff --git a/packages/aws-sigv4/README.md b/packages/aws-sigv4/README.md index 04b44d6b..70e81b86 100644 --- a/packages/aws-sigv4/README.md +++ b/packages/aws-sigv4/README.md @@ -71,7 +71,7 @@ const { authorization, signature } = await signRequestV4({ secretAccessKey: '…', region: 'us-east-1', service: 'service', - dateTime: '20150830T123600Z', + amzDate: '20150830T123600Z', }); ``` diff --git a/packages/aws-sigv4/src/index.ts b/packages/aws-sigv4/src/index.ts index e5d8dfdc..13daac3a 100644 --- a/packages/aws-sigv4/src/index.ts +++ b/packages/aws-sigv4/src/index.ts @@ -143,8 +143,9 @@ export interface SignV4Options { sessionToken?: string; region: string; service: string; - /** Amz datetime, `YYYYMMDDTHHMMSSZ`. */ - dateTime: string; + /** Amz datetime, `YYYYMMDDTHHMMSSZ` — echoed back as `SignV4Result.amzDate` + * and sent as the `x-amz-date` header. */ + amzDate: string; } export interface SignV4Result { @@ -156,14 +157,14 @@ export interface SignV4Result { } /** - * Compute a SigV4 signature for a request. Pure (given `dateTime`), so it is + * Compute a SigV4 signature for a request. Pure (given `amzDate`), so it is * verifiable against the official AWS `aws-sig-v4-test-suite` vectors. The * {@link awsSigV4} strategy wraps this with timestamping, payload hashing, and * header attachment. */ export async function signRequestV4(p: SignV4Options): Promise { const u = new URL(p.url); - const amzDate = p.dateTime; + const amzDate = p.amzDate; const dateStamp = amzDate.slice(0, 8); const headers: Record = {}; @@ -376,7 +377,7 @@ export function awsSigV4(opts: AwsSigV4Options): AuthStrategy { ...(sessionToken ? { sessionToken } : {}), region: opts.region, service: opts.service, - dateTime: amzDate, + amzDate, }); req.headers['authorization'] = authorization; @@ -384,7 +385,3 @@ export function awsSigV4(opts: AwsSigV4Options): AuthStrategy { }, }; } - -// CONTRACT.md P3 — deprecated alias, removed at the 1.0 GA cut. -/** @deprecated Renamed to {@link SignV4Options} (CONTRACT.md P3). Imported name kept until the 1.0 GA cut. */ -export type SignV4Params = SignV4Options; diff --git a/packages/aws-sigv4/test/sigv4.spec.ts b/packages/aws-sigv4/test/sigv4.spec.ts index 1e7d725c..f1285553 100644 --- a/packages/aws-sigv4/test/sigv4.spec.ts +++ b/packages/aws-sigv4/test/sigv4.spec.ts @@ -47,7 +47,7 @@ describe('signRequestV4 (AWS official vectors)', () => { secretAccessKey: SECRET_KEY, region: 'us-east-1', service: 'service', - dateTime: '20150830T123600Z', + amzDate: '20150830T123600Z', }, ); @@ -72,7 +72,7 @@ describe('signRequestV4 (AWS official vectors)', () => { secretAccessKey: SECRET_KEY, region: 'us-east-1', service: 'service', - dateTime: '20150830T123600Z', + amzDate: '20150830T123600Z', } as const; const a = await signRequestV4({ ...base, @@ -94,7 +94,7 @@ describe('signRequestV4 (AWS official vectors)', () => { secretAccessKey: SECRET_KEY, region: 'us-east-1', service: 'service', - dateTime: '20150830T123600Z', + amzDate: '20150830T123600Z', } as const; // A mixed-case name and a value with leading/trailing/inner whitespace must // canonicalise to the already-clean form → an identical signature. @@ -122,7 +122,7 @@ describe('signRequestV4 (AWS official vectors)', () => { secretAccessKey: SECRET_KEY, region: 'us-east-1', service: 'service', - dateTime: '20150830T123600Z', + amzDate: '20150830T123600Z', } as const; const explicit = await signRequestV4({ ...base, @@ -351,7 +351,7 @@ describe('awsSigV4 strategy', () => { secretAccessKey: SECRET_KEY, region: 'us-east-1', service: 's3', - dateTime: '20150830T123600Z', + amzDate: '20150830T123600Z', } as const; const aclPublic = await signRequestV4({ ...base, diff --git a/packages/cloudflare-kv/README.md b/packages/cloudflare-kv/README.md index 134744a8..271d0f6c 100644 --- a/packages/cloudflare-kv/README.md +++ b/packages/cloudflare-kv/README.md @@ -49,9 +49,9 @@ is the overwhelmingly common edge need. KV's `expirationTtl` is in **seconds** and has a **60-second minimum**. The store reconciles this with the StitchStore contract's millisecond TTLs for you: -- `ttlMs` → `Math.max(60, Math.ceil(ttlMs / 1000))` seconds. +- `ttl` (ms) → `Math.max(60, Math.ceil(ttl / 1000))` seconds. - So a value asked to live for 5s lives for 60s (harmless for caches/sessions). -- No `ttlMs` → no expiry. +- No `ttl` → no expiry (`ttl` is optional on both `set` and `incr`). `set(key, undefined)` deletes the key (the cache's delete, ADR 0003 §8). The store owns no connection, so there is no `close()`. diff --git a/packages/cloudflare-kv/test/store.spec.ts b/packages/cloudflare-kv/test/store.spec.ts index b8663b31..fe592d8c 100644 --- a/packages/cloudflare-kv/test/store.spec.ts +++ b/packages/cloudflare-kv/test/store.spec.ts @@ -183,6 +183,9 @@ describe('@stitchapi/cloudflare-kv incr is unsupported', () => { await expect(store.incr('rate', 1_000)).rejects.toThrow( /not supported on Cloudflare Workers KV/i, ); + // `ttl` is optional on `incr` (StitchStore contract) — the no-window + // call shape must typecheck, and still fails loud on KV. + await expect(store.incr('rate')).rejects.toThrow(/Durable Object/i); }); test('cloudflareKvStore has no close() (it owns no connection)', () => { diff --git a/packages/core/README.md b/packages/core/README.md index 4b4ee601..e55a1ef0 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -113,7 +113,7 @@ No server, no codegen, no config files, no implicit inheritance — **only expli - **Declared resilience** - retry with backoff and `Retry-After`, proactive throttle (rate + concurrency, per stitch or per host), total / per-attempt timeouts with real aborts, a circuit breaker, and idempotency keys. - **Read-through caching** - an opt-in response cache with in-process request coalescing, keyed by a derived, principal-scoped key — sound by construction (it refuses to cache a shape it can't fingerprint) and loaded lazily from `stitchapi/cache`. - **Auth as a boundary** - `bearer`, `apiKey`, `basic`, `cookieSession` (auto-login and re-login), and `oauth2` client credentials; secrets resolve at call time via `env()` / `secretsFile()` and never reach the caller. -- **Data shaping** - `unwrap` dot-paths, `transform` (e.g. scrape HTML into structure), auto-looping pagination, and `json` / `form` / `multipart` request bodies. +- **Data shaping** - `pick` dot-paths, `transform` (e.g. scrape HTML into structure), auto-looping pagination, and `json` / `form` / `multipart` request bodies. - **Any request style** - `http` is the default; `graphql`, `sse`, `stream`, `download`, `llm`, `shell`, and `postmessage` are peer **surfaces**, each a subpath import (`stitchapi/sse`, …) on the same engine — so `import { stitch }` bundles `http` alone. - **Pluggable state store** - throttle counters and sessions/tokens live behind a 3-method store; in-memory by default, a shared store makes throttling distributed and sessions shared across workers. - **Zero-infra observability** - tracing is **off by default** (a stitch's only effect is its call); opt in per stitch with `trace: 'console'` / `fileSink(path)` / a `TraceSink`, or globally with `STITCH_TRACE_CONSOLE=1` / `STITCH_TRACE_FILE=` / `STITCH_EXPORT=otlp`. `stitch trace` then summarizes runs, retries, drift, and latency percentiles. @@ -212,7 +212,7 @@ const User = z.object({ const getUser = stitch({ path: 'https://demo.stitchapi.dev/users/{id}', output: User, - unwrap: 'data', // the demo sim wraps payloads in a { data } envelope + pick: 'data', // the demo sim wraps payloads in a { data } envelope }); const user = await getUser({ params: { id: 1 } }); // typed User @@ -222,7 +222,7 @@ One endpoint is one `stitch`. The moment a service has more than one — sharing ## The event stream -A stitch does not return `Promise`. It yields a typed event stream — `await` is sugar that consumes the stream and returns the final, unwrapped, validated `result` (or throws a `StitchError` carrying `.status`, plus `.body` (the parsed error payload) and `.url` (the final request URL) when the failure came from a response): +A stitch does not return `Promise`. It yields a typed event stream — `await` is sugar that consumes the stream and returns the final, picked, validated `result` (or throws a `StitchError` carrying `.status`, plus `.body` (the parsed error payload) and `.url` (the final request URL) when the failure came from a response): ```ts const users = await getUsers(); // sugar: consume the stream → the result value @@ -245,15 +245,15 @@ for await (const ev of getUsers.stream()) { switch (ev.type) { case 'start': // { name, method, url, input } break; - case 'progress': // { phase: 'auth'|'request'|'throttled'|'retry'|'paginate', attempt, waitedMs? } + case 'progress': // { phase: 'auth'|'request'|'throttled'|'retry'|'paginate', attempt, waited? } break; case 'drift': // { finding: { level: 'error'|'warn'|'info', path, change } } break; - case 'result': // { value, status, attempts } + case 'result': // { data, status, attempts } break; case 'error': // { message, status?, attempts } break; - case 'done': // { ok, ms, attempts } + case 'done': // { ok, elapsed, attempts } break; } } @@ -285,12 +285,12 @@ const api = seam({ const listUsers = api.stitch({ path: '/users', output: User.array(), - unwrap: 'data', + pick: 'data', }); const getUser = api.stitch({ path: '/users/{id}', output: User, - unwrap: 'data', + pick: 'data', }); ``` @@ -309,7 +309,7 @@ const listUsers = stitch({ extends: [base], path: '/users', output: User.array(), - unwrap: 'data', + pick: 'data', }); ``` @@ -317,13 +317,13 @@ A stitch is itself a composable value — extend one and override only the diff: ```ts const getOneUser = stitch({ - extends: [listUsers], // inherits baseUrl + retry + unwrap + extends: [listUsers], // inherits baseUrl + retry + pick path: '/users/{id}', output: User, }); ``` -Merge semantics: scalars (`path`, `method`, `baseUrl`, `unwrap`) replace; objects (`retry`, `throttle`, `timeout`, `input`) deep-merge; `hooks` chain across layers (`onRequest` runs base→child, the rest unwind child→base). +Merge semantics: scalars (`path`, `method`, `baseUrl`, `pick`) replace; objects (`retry`, `throttle`, `timeout`, `input`) deep-merge; `hooks` chain across layers (`onRequest` runs base→child, the rest unwind child→base). `.with()` pre-binds part of the input and returns a new stitch that reuses the same runtime — so cookies, tokens, and throttle state persist across the bound and unbound forms: @@ -350,7 +350,7 @@ import { z } from 'zod'; const listOrders = stitch({ path: 'https://demo.stitchapi.dev/users/{id}/orders', - unwrap: 'data', + pick: 'data', output: drift( z.array(z.object({ id: z.number(), total: z.number().optional() })), { @@ -377,7 +377,7 @@ const createUser = stitch({ }), }, output: User, // { id, name, email, role } - unwrap: 'data', + pick: 'data', }); ``` @@ -392,13 +392,13 @@ const listUsers = stitch({ baseUrl: 'https://demo.stitchapi.dev', path: '/users', retry: { attempts: 4, on: [429, 502, 503], respectRetryAfter: true }, - throttle: { rate: '1/s', concurrency: 2, scope: 'host' }, + throttle: { rate: '1/s', concurrency: 2, pool: 'host' }, timeout: { total: '30s', perAttempt: '10s' }, }); ``` -- **`throttle` is proactive** - a rate (`'1/s'`) and a concurrency cap that keep you under a vendor's limit before it bites; `scope: 'host'` shares one limiter across every stitch hitting the same host. -- **`retry` is reactive** - `attempts` is the total including the first; retried statuses default to `[429, 502, 503, 504]`; backoff is `'expo'` / `'expo-jitter'` / `'fixed'` with `baseMs` / `maxMs` clamps; `respectRetryAfter` honors the `Retry-After` header (delta-seconds or HTTP-date). +- **`throttle` is proactive** - a rate (`'1/s'`) and a concurrency cap that keep you under a vendor's limit before it bites; `pool: 'host'` shares one limiter across every stitch hitting the same host. +- **`retry` is reactive** - `attempts` is the total including the first; retried statuses default to `[429, 502, 503, 504]`; backoff is `'expo'` / `'expo-jitter'` / `'fixed'` with `baseDelay` / `maxDelay` clamps; `respectRetryAfter` honors the `Retry-After` header (delta-seconds or HTTP-date). - **`timeout` aborts** - `total` and/or `perAttempt`, as milliseconds or `'30s'`-style strings, enforced with a real `AbortSignal` instead of a request left hanging. Throttle waits and retries emit `throttled` / `retry` events on the stream, so the waiting is visible in the trace for free. @@ -407,27 +407,27 @@ Throttle waits and retries emit `throttled` / `retry` events on the stream, so t Three more knobs round out the resilience set: -- **`circuit`** fast-fails a dependency that is already down — after `failureThreshold` consecutive failures the breaker opens for `cooldownMs`, then allows a half-open trial. A repeatedly-failing dependency stops eating your latency budget (and throws `STITCH_CIRCUIT_OPEN` while open): +- **`circuit`** fast-fails a dependency that is already down — after `failures` consecutive failures the breaker opens for `cooldown`, then allows a half-open trial. A repeatedly-failing dependency stops eating your latency budget (and throws `STITCH_CIRCUIT_OPEN` while open): ```ts - circuit: { failureThreshold: 5, cooldownMs: 30_000 } + circuit: { failures: 5, cooldown: '30s' } // or the positional [5, '30s'] ``` - **`idempotency`** injects a stable `Idempotency-Key` header on writes, so a safe retry can't duplicate a side effect: ```ts idempotency: { - key: (input) => input.body.requestId; + keyOf: (input) => input.body.requestId; } ``` -- **`acceptStatus`** treats a non-2xx as a _normal_ result rather than a throw — for endpoints where, say, `404` is expected control flow. The body flows through `transform` → `unwrap` → validate exactly like a `2xx`: +- **`acceptStatus`** treats a non-2xx as a _normal_ result rather than a throw — for endpoints where, say, `404` is expected control flow. The body flows through `transform` → `pick` → validate exactly like a `2xx`: ```ts acceptStatus: [404]; // resource-gone → fall back, no try/catch on the happy path ``` -When an _outer_ gate owns backoff (its own `Retry-After` budget, a DB-persisted limiter), `rateLimit: { delegate: true }` surfaces a `RateLimitError` (carrying `retryAfterMs`) instead of retrying internally — so StitchAPI's retry + throttle don't double-count against it. +When an _outer_ gate owns backoff (its own `Retry-After` budget, a DB-persisted limiter), `throttle: { delegate: true }` surfaces a `RateLimitError` (carrying `retryAfter`) instead of retrying internally — so StitchAPI's retry + throttle don't double-count against it. ## Caching @@ -439,7 +439,7 @@ A bare duration is the TTL shorthand: const getUser = stitch({ path: 'https://demo.stitchapi.dev/users/{id}', output: User, - unwrap: 'data', + pick: 'data', cache: '5m', // ≡ { ttl: '5m' } }); ``` @@ -450,12 +450,12 @@ Pass a config object for the full control surface: const listAnnouncements = stitch({ path: 'https://demo.stitchapi.dev/announcements', output: z.array(z.object({ id: z.number(), title: z.string() })), - unwrap: 'data', + pick: 'data', cache: { ttl: '1h', scope: 'app', // public, unauthenticated data → share one entry across callers vary: ['accept-language'], // request headers that vary the response - maxEntries: 500, // in-process LRU cap (default 1000) + entries: 500, // in-process LRU cap (default 1000) }, }); ``` @@ -477,7 +477,7 @@ const getUser = stitch({ }); ``` -**OAuth2 client credentials** — `oauth2()` POSTs the token endpoint (form-encoded `client_credentials` grant), caches the access token in the [store](#pluggable-state-store) with the TTL from `expires_in`, refreshes it `refreshSkewMs` (default 30s) before expiry, and attaches it as `Authorization: Bearer …`. A rejected token (status in `refreshOn`, default `[401]`) forces a fresh fetch and an uncounted re-run of the attempt: +**OAuth2 client credentials** — `oauth2()` POSTs the token endpoint (form-encoded `client_credentials` grant), caches the access token in the [store](#pluggable-state-store) with the TTL from `expires_in`, refreshes it `refresh: { skew }` (default 30s) before expiry, and attaches it as `Authorization: Bearer …`. A rejected token (status in `refresh: { on }`, default `[401]`) forces a fresh fetch and an uncounted re-run of the attempt: ```ts import { env, oauth2, stitch } from 'stitchapi'; @@ -510,7 +510,7 @@ const signIn = stitch({ const listUsers = stitch({ baseUrl: 'https://demo.stitchapi.dev', path: '/users', - unwrap: 'data', + pick: 'data', auth: cookieSession({ login: signIn, cookie: 'session_token', // captured from Set-Cookie, replayed each call @@ -520,7 +520,7 @@ const listUsers = stitch({ password: secretsFile('APP_PASS')(), }, }), - refreshOn: [401], // the wall → re-login, then retry (default) + refresh: [401], // the wall → re-login, then retry (default) }), }); @@ -535,8 +535,9 @@ A `200` that is really a login page is a soft wall — catch it with a content p auth: cookieSession({ login: signIn, cookie: 'session_token', - refreshWhen: (res) => - typeof res.body === 'string' && /log in/i.test(res.body), + refresh: { + when: (res) => typeof res.body === 'string' && /log in/i.test(res.body), + }, }); ``` @@ -550,8 +551,8 @@ Throttle counters and session/token state live behind one small seam — a `stor ```ts export interface StitchStore { get(key: string): Promise; - set(key: string, value: unknown, ttlMs?: number): Promise; - incr(key: string, ttlMs: number): Promise; // atomic — rate windows + set(key: string, value: unknown, ttl?: number): Promise; + incr(key: string, ttl?: number): Promise; // atomic — rate windows } ``` @@ -637,29 +638,29 @@ A **surface** is the request _style_ a stitch speaks. `http` is the default — Every non-`http` surface ships as its own **subpath import**, so `import { stitch }` from the root pulls in only the `http` engine; a surface's code loads only when you import it. -| Surface | Import | Shapes | `await` resolves to | -| ------------- | ----------------------------- | ------------------------------------------ | ------------------------------ | -| `http` | `stitch` (default) | a JSON-over-HTTP call | the validated body | -| `graphql` | `stitchapi/graphql` | POST `{ query, variables }`, unwrap `data` | the `data` payload | -| `sse` | `stitchapi/sse` | a `text/event-stream` reader (over fetch) | every parsed event, collected | -| `stream` | `stitchapi/stream` | a raw `ReadableStream` reader | every decoded chunk, collected | -| `download` | `stitchapi/download` | a buffered binary GET | `{ blob, filename }` | -| `llm` | `stitchapi/llm` | a chat-completion via a provider contract | the normalised `{ text, … }` | -| `shell` | `@stitchapi/shell` (peer pkg) | a local command, args + stdin | the command's stdout | -| `postmessage` | `stitchapi/postmessage` | a typed iframe ↔ parent RPC / event call | the typed RPC response | +| Surface | Import | Shapes | `await` resolves to | +| ------------- | ----------------------------- | ----------------------------------------- | ------------------------------ | +| `http` | `stitch` (default) | a JSON-over-HTTP call | the validated body | +| `graphql` | `stitchapi/graphql` | POST `{ query, variables }`, pick `data` | the `data` payload | +| `sse` | `stitchapi/sse` | a `text/event-stream` reader (over fetch) | every parsed event, collected | +| `stream` | `stitchapi/stream` | a raw `ReadableStream` reader | every decoded chunk, collected | +| `download` | `stitchapi/download` | a buffered binary GET | `{ blob, filename }` | +| `llm` | `stitchapi/llm` | a chat-completion via a provider contract | the normalised `{ text, … }` | +| `shell` | `@stitchapi/shell` (peer pkg) | a local command, args + stdin | the command's stdout | +| `postmessage` | `stitchapi/postmessage` | a typed iframe ↔ parent RPC / event call | the typed RPC response | (Distinct from the four _invocation_ surfaces — function, CLI, HTTP, MCP — which are how you _call_ a stitch. A request surface is how a stitch shapes its _request_.) ### GraphQL -`graphql()` POSTs `{ query, variables }` and unwraps `data`. A `200` carrying `errors[]` is a failure — it will not silently pass: +`graphql()` POSTs `{ query, variables }` and picks `data`. A `200` carrying `errors[]` is a failure — it will not silently pass: ```ts import { graphql } from 'stitchapi'; const getUser = graphql({ baseUrl: 'https://demo.stitchapi.dev', - query: 'query ($id: ID) { user(id: $id) { name } }', + document: 'query ($id: ID) { user(id: $id) { name } }', }); const user = await getUser({ variables: { id: 1 } }); @@ -759,12 +760,12 @@ Because every surface is just a stitch underneath, `auth`, `retry`, `throttle`, ## Pagination -One logical call follows pages until `next` returns `undefined` (or the `max` safety cap, default 50, is hit), aggregating items into a single result. Each page is a full request — auth, retry, and throttle apply per page — and each page emits a `paginate` progress event: +One logical call follows pages until `next` returns `undefined` (or the `pages` safety cap, default 50, is hit), aggregating items into a single result. Each page is a full request — auth, retry, and throttle apply per page — and each page emits a `paginate` progress event: ```ts const listOrders = stitch({ path: 'https://demo.stitchapi.dev/users/{id}/orders', - unwrap: 'data', + pick: 'data', paginate: { // previous page's raw body + pages fetched so far → input for the // next page (merged over the original), or undefined to stop @@ -776,17 +777,17 @@ const listOrders = stitch({ const everything = await listOrders({ params: { id: 1 } }); // [...page1, ...page2, ...] as one array ``` -When the unwrapped page is not itself the array, pass `items` to pull the array out of each page. +When the picked page is not itself the array, pass `items` to pull the array out of each page. ## Transform -`transform` runs before `unwrap` and validation — turn an arbitrary payload (HTML, text, a legacy shape) into structured data, then let `unwrap` + `output` / `drift` treat it like any other contract: +`transform` runs before `pick` and validation — turn an arbitrary payload (HTML, text, a legacy shape) into structured data, then let `pick` + `output` / `drift` treat it like any other contract: ```ts const listOrders = stitch({ path: 'https://demo.stitchapi.dev/users/{id}/orders', transform: (html) => scrape(html), // your parser: HTML/text → { items: [...] } - unwrap: 'items', + pick: 'items', output: z.array(z.object({ id: z.number(), total: z.number() })), }); ``` @@ -811,7 +812,7 @@ STITCH_EXPORT=otlp node app.js STITCH_TRACE_MAX_BODY=full node app.js ``` -That is per-call latency, status, attempts, throttle waits, and drift findings — recorded for free once you opt in, inspectable with [`stitch trace`](#the-stitch-cli) or plain `jq`. Drift rides the same events, so a leveled drift signal shows up in the trace with no extra wiring. The built-in JSONL and console sinks are safe by default — scrubbing happens at the sink boundary, so the live request is never touched, only the trace copy. Header values on a secret denylist (`authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`; widen it with `redactHeaders`) become `[REDACTED]`; credentials in the resolved URL are scrubbed (userinfo removed, secret query values like `api_key`/`access_token` replaced with `REDACTED`); and request bodies and response values are truncated to 2048 characters, with anything larger replaced by a `{ truncated, bytes, preview }` marker. Opt into full, untruncated capture with `STITCH_TRACE_MAX_BODY=full` (or `fileSink(path, { maxBodyBytes: false })`). Need a custom sink? Consume `.stream()` yourself — the built-in trace is just one consumer of the same events. +That is per-call latency, status, attempts, throttle waits, and drift findings — recorded for free once you opt in, inspectable with [`stitch trace`](#the-stitch-cli) or plain `jq`. Drift rides the same events, so a leveled drift signal shows up in the trace with no extra wiring. The built-in JSONL and console sinks are safe by default — scrubbing happens at the sink boundary, so the live request is never touched, only the trace copy. Header values on a secret denylist (`authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`; widen it with `redactHeaders`) become `[REDACTED]`; credentials in the resolved URL are scrubbed (userinfo removed, secret query values like `api_key`/`access_token` replaced with `REDACTED`); and request bodies and response values are truncated to 2048 characters, with anything larger replaced by a `{ truncated, bytes, preview }` marker. Opt into full, untruncated capture with `STITCH_TRACE_MAX_BODY=full` (or `fileSink(path, { maxBodyChars: false })`). Need a custom sink? Consume `.stream()` yourself — the built-in trace is just one consumer of the same events. ## The stitch CLI @@ -820,8 +821,8 @@ One definition, more than one front door: the same stitch your code imports is c ```bash $ stitch run getUser --id 7 --query.expand roles {"type":"start","name":"getUser","method":"GET","url":"https://demo.stitchapi.dev/users/7?expand=roles",...} -{"type":"result","value":{"id":7,"name":"Ada"},"status":200,"attempts":1,...} -{"type":"done","ok":true,"ms":142,...} +{"type":"result","data":{"id":7,"name":"Ada"},"status":200,"attempts":1,...} +{"type":"done","ok":true,"elapsed":142,...} ``` Flags map onto the stitch's single input object: a bare `--id 7` routes to `params` when `{id}` appears in the path (otherwise to `query`); `--body ''` or `--body. ` set the body; `--headers. ` sets a header. The exit code is non-zero when an `error` event was seen. (`.ts` modules need a TypeScript-aware runner such as `tsx`; otherwise point `--module` at compiled JS.) diff --git a/packages/core/scripts/bundle-size.mjs b/packages/core/scripts/bundle-size.mjs index 5dcd1d2b..213bb7f3 100644 --- a/packages/core/scripts/bundle-size.mjs +++ b/packages/core/scripts/bundle-size.mjs @@ -120,16 +120,25 @@ const KB = 1024; // / ~0.28 KB gzip. The step restores the same tight ~0.2 KB headroom the gate is meant to hold. The // cost buys closing a HIGH credential-exfiltration hole — a deliberate trade the maintainer signs off // on by merging (see PR body for the exact before/after/Δ). +// Budgets raised for the 2026-07 contract-freeze sweep (24.80→25.25 / 20.00→20.40 KB; measured +// 25.02 / 20.19). The sweep DELETED every @deprecated shim (rateLimit fallbacks, alias co-emits, +// *Ms fields) but added more than it removed on the hot path: every config slot now accepts a +// scalar shorthand normalized in compose() (stream/multipart/sse/throttle/idempotency/circuit +// tuple), status fields accept number|number[]|predicate via the shared acceptsStatus matcher, +// and redactConfig deep-strips every function-valued field so __config is strictly JSON (P0 — +// the load-bearing introspection invariant). All of it is intake normalization or the redaction +// gate itself, so none of it can move to a subpath. Net +0.22 / +0.19 KB gzip for the frozen +// 1.0 surface; the step restores the same tight ~0.2 KB headroom the gate is meant to hold. const SCENARIOS = [ { name: 'stitchapi — whole entry', code: `export * from './index.mjs';`, - budget: 24.8 * KB, + budget: 25.25 * KB, }, { name: 'import { stitch }', code: `export { stitch } from './index.mjs';`, - budget: 20.0 * KB, + budget: 20.4 * KB, }, ]; diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 4961f710..31f60985 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -3,13 +3,15 @@ // a login (another stitch) and manages the cookie jar, refreshing on a 401 wall. import { compact } from './compact'; import { fetchAdapter } from './http-adapter'; -import { parseRetryAfter } from './resilience'; +import { acceptsStatus, parseRetryAfter } from './resilience'; import type { Adapter, AdapterResponse, + AtLeastOne, AuthContext, AuthStrategy, RunContext, + StatusMatch, Stitch, StitchInput, } from './types'; @@ -20,7 +22,7 @@ import { now, parseDuration, readEnv, - registerSecretQueryKey, + registerSecretKey, } from './util'; export type Secret = string | (() => string); @@ -174,12 +176,31 @@ export function bearer(token: Secret | OptionalSecret): AuthStrategy { }; } +/** + * Where {@link apiKey} puts the key, discriminated on `in`. Either arm names the key's location + * with the same field — `name` is the header name in the header arm and the query-param name in + * the query arm. + */ +export type ApiKeyOptions = + | { + in?: 'header'; + /** Header name the key is written to (sent lower-cased). Default `'X-API-Key'`. */ + name?: string; + value: Secret; + } + | { + in: 'query'; + /** Query-param name the key is appended as. Default `'api_key'`. */ + name?: string; + value: Secret; + }; + /** * API-key auth, in a request **header** (the default) or a **query param**. The key is a * {@link Secret} resolved at call time — the caller (an agent) never sees it. * - * - `in: 'header'` (default): writes `header` (default `'x-api-key'`, lower-cased) — byte-for-byte - * the original behaviour, so existing stitches are unaffected. + * - `in: 'header'` (default): writes the `name` header (default `'X-API-Key'`, lower-cased on + * the wire). * - `in: 'query'`: appends `name=` (default `'api_key'`) to the request URL, * URL-encoded. The strategy runs in the attempt loop on the fully-built `req` (after * templating/query-building), so it safely appends onto whatever query the URL already carries. @@ -191,16 +212,12 @@ export function bearer(token: Secret | OptionalSecret): AuthStrategy { * `name` is registered with the URL-credential scrubber, so if it does surface in a sink (an OTLP * `url.full`, the structured `input.query`) it is REDACTED, like `api_key`/`access_token`/… are. */ -export function apiKey( - opts: - | { in?: 'header'; header?: string; value: Secret } - | { in: 'query'; name?: string; value: Secret }, -): AuthStrategy { +export function apiKey(opts: ApiKeyOptions): AuthStrategy { if (opts.in === 'query') { const name = opts.name ?? 'api_key'; // Teach the trace scrubber this param name carries a secret, so the key never reaches a // sink in the clear — even when `name` is a vendor spelling the built-in stems don't catch. - registerSecretQueryKey(name); + registerSecretKey(name); return { name: 'apiKey', scheme: { type: 'apiKey', in: 'query', name }, @@ -215,7 +232,7 @@ export function apiKey( }, }; } - const headerName = opts.header ?? 'X-API-Key'; + const headerName = opts.name ?? 'X-API-Key'; const header = headerName.toLowerCase(); return { name: 'apiKey', @@ -226,7 +243,23 @@ export function apiKey( }; } -export function basic(opts: { user: Secret; pass: Secret }): AuthStrategy { +export interface BasicOptions { + user: Secret; + pass: Secret; +} + +/** HTTP Basic auth. Positional `basic(user, pass)` ≡ `basic({ user, pass })` (CONTRACT.md P15). */ +export function basic(user: Secret, pass: Secret): AuthStrategy; +export function basic(opts: BasicOptions): AuthStrategy; +export function basic( + userOrOpts: Secret | BasicOptions, + pass?: Secret, +): AuthStrategy { + // A Secret is a string or a thunk, never a plain object — so an object IS the options form. + const opts: BasicOptions = + typeof userOrOpts === 'string' || typeof userOrOpts === 'function' + ? { user: userOrOpts, pass: pass as Secret } + : userOrOpts; return { name: 'basic', scheme: { type: 'http', scheme: 'basic' }, @@ -237,6 +270,21 @@ export function basic(opts: { user: Secret; pass: Secret }): AuthStrategy { }; } +/** + * Envelope for {@link OAuth2Options.refresh} (CONTRACT.md P24: `refreshOn` + `refreshSkew` shared + * the `refresh` prefix, so they fold into one envelope; a bare {@link StatusMatch} is the P12 + * dominant-field shorthand for `{ on }`). + */ +export interface OAuth2RefreshOptions { + /** + * Status(es) — or a predicate — that mean the token was rejected and should force a refresh + * (CONTRACT.md P7: `401` ≡ `[401]`). Default `[401]`. + */ + on?: StatusMatch; + /** Refresh this long BEFORE the token's expiry, so it is never used mid-flight — `30_000`, `'30s'`. Default 30s. */ + skew?: number | string; +} + export interface OAuth2Options { /** The `client_credentials` token endpoint (POST, form-encoded). */ tokenUrl: string; @@ -268,21 +316,27 @@ export interface OAuth2Options { * cannot override the `Authorization` header that `clientAuth: 'basic'` sets. */ headers?: Record; - /** Statuses that mean the token was rejected and should force a refresh. Default [401]. */ - refreshOn?: number[]; - /** Refresh this long BEFORE the token's expiry, so it is never used mid-flight — `30_000`, `'30s'`. Default 30s. */ - refreshSkew?: number | string; - /** @deprecated Renamed to {@link OAuth2Options.refreshSkew} (CONTRACT.md P17). Read until the 1.0 GA cut. */ - refreshSkewMs?: number; + /** + * When to force a fresh token (CONTRACT.md P24 envelope). A bare {@link StatusMatch} — a + * number, a list, or a predicate — is shorthand for `{ on }` (P12): the status(es) that mean + * the token was rejected (CONTRACT.md P7: `401` ≡ `[401]`). Default `[401]`. Reach `skew` — + * how long BEFORE the token's expiry to refresh it, so it is never used mid-flight (`30_000`, + * `'30s'`; default 30s) — through the envelope form: `refresh: { skew: '1m' }`. + */ + refresh?: StatusMatch | AtLeastOne; /** Store namespace — give two stitches the same `key` + a shared `store` to share one token. Default: `tokenUrl`. */ key?: string; /** * Token tenancy (ADR 0002 §3). Default **`'app'`**: one token serves every caller — the right * model for `client_credentials`, which authenticates the *application*, not a user. Set * `'principal'` to fold the seam-bound principal into the token's cache key (and **throw if no - * principal is bound**, mirroring {@link CookieSessionOptions.scope}); each tenant then caches its - * own token and one tenant's 401/refresh never disturbs another's in-flight calls. Pair it with - * per-tenant `clientId`/`clientSecret`/`scope` for full multi-tenant separation. + * principal is bound**, mirroring {@link CookieSessionOptions.tenancy}); each tenant then caches + * its own token and one tenant's 401/refresh never disturbs another's in-flight calls. Pair it + * with per-tenant `clientId`/`clientSecret`/`scope` for full multi-tenant separation. + * + * The two defaults deliberately diverge (CONTRACT.md P8): `oauth2` defaults to `'app'` because + * a client-credentials token belongs to the application, while {@link CookieSessionOptions.tenancy} + * defaults fail-closed to `'principal'` because a cookie session belongs to a user. */ tenancy?: 'principal' | 'app'; /** Test seam / custom transport for the token request (default `fetchAdapter()`). */ @@ -311,17 +365,37 @@ function singleFlight(): (key: string, run: () => Promise) => Promise { }; } +/** + * Normalize a `refresh` slot (CONTRACT.md P24) to its envelope form, ONCE, at strategy + * construction — every internal read then goes through the normalized object, never the raw + * union. A bare {@link StatusMatch} (number, list, or predicate) is the P12 dominant-field + * shorthand for `{ on: }`; an object is already the envelope (possibly `undefined`, + * meaning "use the caller's defaults"). + */ +function normalizeRefresh( + refresh: StatusMatch | AtLeastOne | undefined, +): Partial { + if (refresh === undefined) return {}; + if ( + typeof refresh === 'number' || + typeof refresh === 'function' || + Array.isArray(refresh) + ) + return { on: refresh } as Partial; + return refresh; +} + /** * OAuth2 `client_credentials`: POST the token endpoint, cache the access token in the - * StitchStore (TTL from `expires_in`), refresh it `refreshSkew` before expiry, and attach + * StitchStore (TTL from `expires_in`), refresh it `refresh.skew` before expiry, and attach * it as `Authorization: Bearer …`. A SHARED store makes one token serve many stitches/workers - * and survive restarts; a rejected token (status in `refreshOn`) forces a fresh fetch + retry. + * and survive restarts; a rejected token (status matched by `refresh`/`refresh.on`) forces a + * fresh fetch + retry. */ export function oauth2(opts: OAuth2Options): AuthStrategy { - const refreshOn = opts.refreshOn ?? [401]; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `refreshSkewMs` is the @deprecated alias of `refreshSkew`, read for back-compat until the GA cut (CONTRACT.md P17) - const skewInput = opts.refreshSkew ?? opts.refreshSkewMs; - const skew = parseDuration(skewInput) ?? 30_000; + const refreshOpts = normalizeRefresh(opts.refresh); + const refreshMatch = acceptsStatus(refreshOpts.on ?? [401]); + const skew = parseDuration(refreshOpts.skew) ?? 30_000; const baseKey = 'oauth2:' + (opts.key ?? opts.tokenUrl); const tenancy = opts.tenancy ?? 'app'; const adapter = opts.adapter ?? fetchAdapter(); @@ -330,7 +404,7 @@ export function oauth2(opts: OAuth2Options): AuthStrategy { // The vault key for THIS call. Default 'app' shares one token across all callers (correct for // client_credentials — the token authenticates the application, not a user). 'principal' folds - // the seam-bound principal in (fail-closed if none, mirroring cookieSession's scope), so each + // the seam-bound principal in (fail-closed if none, mirroring cookieSession's tenancy), so each // tenant caches its own token and one tenant's 401/refresh never disturbs another's. const keyFor = (ctx: AuthContext): string => { if (tenancy === 'app') return baseKey; @@ -444,7 +518,7 @@ export function oauth2(opts: OAuth2Options): AuthStrategy { req.headers['authorization'] = `Bearer ${await tokenFor(ctx)}`; }, shouldRefresh(res) { - return refreshOn.includes(res.status); + return refreshMatch(res.status); }, async refresh(ctx) { // Force a fresh token, ignoring the cache — but simultaneous 401s @@ -468,12 +542,10 @@ export interface AuthFailureResult { status?: number; /** `Retry-After` parsed to ms when the login was rate-limited (status 429). */ retryAfter?: number; - /** @deprecated Renamed to {@link AuthFailureResult.retryAfter} (CONTRACT.md P17). Read until the 1.0 GA cut. */ - retryAfterMs?: number; /** The thrown value when the login stitch itself threw (network/transport failure). */ error?: unknown; /** - * - `'unauthenticated'` — login responded with a `refreshOn` status (e.g. 401) and set no cookie (bad/expired creds); + * - `'unauthenticated'` — login responded with a status matched by `refresh` (e.g. 401) and set no cookie (bad/expired creds); * - `'rate-limited'` — login responded `429` (back off, then retry; see `retryAfter`); * - `'network'` — the login stitch threw before any response (DNS/connection/transport); * - `'unknown'` — login responded but captured no cookie for some other reason. @@ -489,6 +561,22 @@ export interface RefreshResult { status?: number; } +/** + * Envelope for {@link CookieSessionOptions.refresh} (CONTRACT.md P24: `refreshOn` + `refreshWhen` + * shared the `refresh` prefix, so they fold into one envelope; a bare {@link StatusMatch} — incl. + * a bare function, which is the STATUS predicate — is the P12 dominant-field shorthand for + * `{ on }`; reaching `when` requires the envelope form). + */ +export interface CookieSessionRefreshOptions { + /** + * Status(es) — or a predicate — that mean "the wall" and should trigger a re-login + * (CONTRACT.md P7: `401` ≡ `[401]`). Default `[401]`. + */ + on?: StatusMatch; + /** Inspect the response (status + body) for a soft wall — e.g. a 200 that is actually a login page. */ + when?: (res: AdapterResponse) => boolean; +} + export interface CookieSessionOptions { /** The login stitch — its raw response (the Set-Cookie headers) seeds the session. */ login: Stitch; @@ -505,24 +593,31 @@ export interface CookieSessionOptions { * identity to that user's credentials — credentials still never originate from the caller. */ loginInput?: (principal?: string) => StitchInput; - /** Statuses that mean "the wall" and should trigger a re-login. Default [401]. */ - refreshOn?: number[]; - /** Inspect the response (status + body) for a soft wall — e.g. a 200 that is actually a login page. */ - refreshWhen?: (res: AdapterResponse) => boolean; + /** + * When to trigger a re-login (CONTRACT.md P24 envelope). A bare function is the P12 + * dominant-field shorthand for `{ on: }` — a {@link StatusMatch} predicate over the + * response STATUS; likewise a bare number/list is `{ on: }`: status(es) that mean + * "the wall" (CONTRACT.md P7: `401` ≡ `[401]`). Default `[401]`. Reach `when` — inspecting the + * full response (status + body) for a SOFT wall, e.g. a 200 that is actually a login page — + * only through the envelope form: `refresh: { when: (res) => … }`. + */ + refresh?: StatusMatch | AtLeastOne; /** Vault namespace — give two stitches the same `key` + a shared seam/store to share one session. */ key?: string; - /** Optional TTL for the stored session — `60_000`, `'1m'`. With `scope: 'principal'`, set this — per-user sessions multiply. */ + /** Optional TTL for the stored session — `60_000`, `'1m'`. With `tenancy: 'principal'`, set this — per-user sessions multiply. */ ttl?: number | string; - /** @deprecated Renamed to {@link CookieSessionOptions.ttl} (CONTRACT.md P17). Read until the 1.0 GA cut. */ - ttlMs?: number; /** * Who the session belongs to (ADR 0002 §3). **Fail-closed default `'principal'`**: the * session is keyed by the seam-bound principal and the call **throws if no principal is * bound** — per-user auth can never silently run app-wide. `'app'` is the explicit opt-in to * sharing ONE session across all callers (the only safe choice for a standalone `stitch()`, * which never has a principal). Sessions always live in the {@link AuthContext.vault}. + * + * The two defaults deliberately diverge (CONTRACT.md P8): a cookie session belongs to a user, + * so it fails closed to `'principal'`, while {@link OAuth2Options.tenancy} defaults to `'app'` + * because a client-credentials token belongs to the application. */ - scope?: 'principal' | 'app'; + tenancy?: 'principal' | 'app'; /** * Host-owned hook fired once per ACTUAL login attempt that failed to capture a cookie — NOT * per coalesced waiter (it runs inside the single-flight-guarded `doRefresh`). The host maps the @@ -541,9 +636,12 @@ export interface CookieSessionOptions { } export function cookieSession(opts: CookieSessionOptions): AuthStrategy { - const refreshOn = opts.refreshOn ?? [401]; + const refreshOpts = normalizeRefresh( + opts.refresh, + ); + const refreshMatch = acceptsStatus(refreshOpts.on ?? [401]); const jarMode = opts.jar === true || opts.cookie === '*'; - const scope = opts.scope ?? 'principal'; + const tenancy = opts.tenancy ?? 'principal'; const baseKey = (jarMode ? 'jar:' : 'cookie:') + (opts.key ?? opts.cookie); const flight = singleFlight(); @@ -554,13 +652,13 @@ export function cookieSession(opts: CookieSessionOptions): AuthStrategy { const sessionFor = ( ctx: AuthContext, ): { key: string; principal?: string } => { - if (scope === 'app') return { key: baseKey }; + if (tenancy === 'app') return { key: baseKey }; const principal = ctx.principal; if (principal == null || principal === '') { const e = new Error( - "cookieSession with scope 'principal' (the default) requires a bound principal: " + + "cookieSession with tenancy 'principal' (the default) requires a bound principal: " + 'create the stitch through a seam and call `seam.as(principalId)`, or set ' + - "`scope: 'app'` to deliberately share one session across all callers.", + "`tenancy: 'app'` to deliberately share one session across all callers.", ); e.name = 'StitchAuthError'; throw e; @@ -589,8 +687,8 @@ export function cookieSession(opts: CookieSessionOptions): AuthStrategy { }; // Categorise a login response that captured no cookie, given its status + headers. A 429 means - // rate-limited (back off per `Retry-After`); a `refreshOn` status (e.g. 401) means the creds were - // rejected; anything else — including a soft 200 wall that set no cookie — is `unknown`. + // rate-limited (back off per `Retry-After`); a status matched by `refresh` (e.g. 401) means the + // creds were rejected; anything else — including a soft 200 wall that set no cookie — is `unknown`. const classify = ( phase: 'apply' | 'refresh', status: number, @@ -599,20 +697,14 @@ export function cookieSession(opts: CookieSessionOptions): AuthStrategy { if (status === 429) { // Omit `retryAfter` entirely when the header is absent/unparseable — // `exactOptionalPropertyTypes` forbids setting an optional prop to `undefined`. - const retryAfter = parseRetryAfter(headers['retry-after']); - const result: AuthFailureResult = compact({ + return compact({ phase, status, category: 'rate-limited', - retryAfter, + retryAfter: parseRetryAfter(headers['retry-after']), }); - // Co-set the @deprecated `retryAfterMs` alias for back-compat (CONTRACT.md P17/P19), by - // assignment (not a literal `*Ms:` key) so the contract lint's R2 stays clean. - // eslint-disable-next-line @typescript-eslint/no-deprecated -- writing the @deprecated alias for back-compat - if (retryAfter !== undefined) result.retryAfterMs = retryAfter; - return result; } - if (refreshOn.includes(status)) + if (refreshMatch(status)) return { phase, status, category: 'unauthenticated' }; return { phase, status, category: 'unknown' }; }; @@ -690,8 +782,7 @@ export function cookieSession(opts: CookieSessionOptions): AuthStrategy { const setCookie = res.headers['set-cookie'] ?? res.headers['Set-Cookie']; let captured = false; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `ttlMs` is the @deprecated alias of `ttl`, read for back-compat until the GA cut (CONTRACT.md P17) - const sessionTtl = parseDuration(opts.ttl ?? opts.ttlMs); + const sessionTtl = parseDuration(opts.ttl); if (jarMode) { // Capture the full jar: every name=value pair the login set. const jar = parseCookieJar(setCookie); @@ -753,7 +844,7 @@ export function cookieSession(opts: CookieSessionOptions): AuthStrategy { } }, shouldRefresh(res) { - return refreshOn.includes(res.status) || !!opts.refreshWhen?.(res); + return refreshMatch(res.status) || !!refreshOpts.when?.(res); }, async refresh(ctx) { const { key, principal } = sessionFor(ctx); @@ -792,7 +883,3 @@ function serializeJar(jar: Record | undefined): string { .map(([k, v]) => `${k}=${v}`) .join('; '); } - -// CONTRACT.md P3 — deprecated alias, removed at the 1.0 GA cut. -/** @deprecated Renamed to {@link AuthFailureResult} (CONTRACT.md P3). Imported name kept until the 1.0 GA cut. */ -export type AuthFailureInfo = AuthFailureResult; diff --git a/packages/core/src/cache.ts b/packages/core/src/cache.ts index cf6b161f..6f97ce75 100644 --- a/packages/core/src/cache.ts +++ b/packages/core/src/cache.ts @@ -10,7 +10,7 @@ import { resolveFingerprint } from './fingerprint'; import type { CachePolicy } from './fingerprint'; import { xxh128 } from './hash'; -import type { CacheOptions, StitchInput, StitchStore } from './types'; +import type { ResolvedCacheOptions, StitchInput, StitchStore } from './types'; import { parseDuration } from './util'; // The 128-bit synchronous non-crypto key hash now lives in the shared `./hash` module so the cache @@ -186,6 +186,13 @@ interface Inflight { onCancel?: () => void; } +/** Options for {@link InflightCoalescer.join}: ref-count this participant via `signal`; the + * leader may set `onCancel` to be told when the LAST participant aborts. */ +export interface CoalesceJoinOptions { + signal?: AbortSignal; + onCancel?: () => void; +} + export interface LeaderClaim { leader: true; promise: Promise; @@ -209,7 +216,7 @@ export class InflightCoalescer { * run is dropped and `onCancel` (set by the leader) is invoked. */ join( key: string, - opts?: { signal?: AbortSignal; onCancel?: () => void }, + opts?: CoalesceJoinOptions, ): LeaderClaim | FollowerClaim { let entry = this.map.get(key); const leading = entry === undefined; @@ -298,7 +305,7 @@ interface StoredEntry { } export interface CacheHit { - value: unknown; + data: unknown; status: number; } @@ -317,8 +324,8 @@ export interface CacheOp { export interface CacheController { /** Is `method` in the cacheable set (and so eligible for coalescing)? */ cacheableMethod(method: string): boolean; - /** Derive the base key for a resolved request, or `undefined` when it is not hashable. */ - key(d: RequestDescriptor, input: StitchInput): string | undefined; + /** Derive the base key for a resolved request, or `undefined` when it is not hashable (CONTRACT.md P6). */ + keyOf(d: RequestDescriptor, input: StitchInput): string | undefined; /** Open a cache operation for `baseKey` (reads the live generation prefix once). */ open(baseKey: string, d: RequestDescriptor): Promise; /** @@ -340,7 +347,8 @@ export interface CacheController { } export interface CacheControllerOptions { - config: CacheOptions; + /** The stitch's cache block, post-`compose` — list fields are always arrays. */ + config: ResolvedCacheOptions; store: StitchStore; stitchId: string; principal?: string; @@ -348,8 +356,8 @@ export interface CacheControllerOptions { output?: unknown; /** The stitch's `transform` closure — opaque, so it forces a version/trust decision (ADR 0004). */ transform?: ((body: unknown) => unknown) | undefined; - /** The stitch's `unwrap` dot-path — serialisable, always folds soundly into the generation. */ - unwrap?: string | undefined; + /** The stitch's `pick` dot-path — serialisable, always folds soundly into the generation. */ + pick?: string | undefined; } export function createCache(opts: CacheControllerOptions): CacheController { @@ -359,8 +367,7 @@ export function createCache(opts: CacheControllerOptions): CacheController { const methods = (config.methods ?? ['GET', 'HEAD']).map((m) => m.toUpperCase(), ); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `maxEntries` is the @deprecated alias of `entries`, read for back-compat until the GA cut (CONTRACT.md P4) - const maxEntries = config.entries ?? config.maxEntries ?? 1000; + const maxEntries = config.entries ?? 1000; const explicitVary = config.vary?.length ? config.vary .map((n) => n.toLowerCase()) @@ -368,15 +375,15 @@ export function createCache(opts: CacheControllerOptions): CacheController { : undefined; // Fold the Standard Schema fingerprint (ADR 0004) ONCE, here at controller creation (which is // once per stitch — `ensureCache` memoises it). It resolves three things from the stitch's - // output/transform/unwrap + cache options: the GENERATION token (a changed output schema / - // unwrap / versioned transform yields a new token → a new bucket → old entries unreachable), + // output/transform/pick + cache options: the GENERATION token (a changed output schema / + // pick / versioned transform yields a new token → a new bucket → old entries unreachable), // the POLICY (fast / revalidate / refuse), and a human-readable REASON for traces. The token is // folded into the namespace ALONGSIDE the per-stitch generation counter (decision 8) — it does // not replace it: bulk-invalidate bumps the counter, a schema change bumps this token. const fp = resolveFingerprint({ output: opts.output, transform: opts.transform, - unwrap: opts.unwrap, + pick: opts.pick, version: config.version, transformVersion: config.transformVersion, trustTransform: config.trustTransform, @@ -436,16 +443,15 @@ export function createCache(opts: CacheControllerOptions): CacheController { return methods.includes(method.toUpperCase()); }, - key(d, input) { + keyOf(d, input) { // Scope handling lives here: fold the bound principal in under 'principal' scope, // omit it under 'app'. The descriptor itself carries no principal (engine concern). const scoped: RequestDescriptor = principalForScope !== undefined ? { ...d, principal: principalForScope } : d; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `key` is the @deprecated alias of `keyOf`, read as the back-compat fallback until the GA cut (CONTRACT.md P6) - const keyOf = config.keyOf ?? config.key; - const userKey = keyOf ? keyOf(input) : undefined; + const userKeyOf = config.keyOf; + const userKey = userKeyOf ? userKeyOf(input) : undefined; return deriveCacheKey(scoped, explicitVary, userKey); }, @@ -461,7 +467,7 @@ export function createCache(opts: CacheControllerOptions): CacheController { const prefix = await genPrefix(); // The stored key is prefixed with the frozen key-schema version (a derivation change is // a mass self-healing miss, never a stale-key hit — ADR 0003 follow-up) and the - // fingerprint generation token `fpTag` (an output-schema/unwrap/transform change moves + // fingerprint generation token `fpTag` (an output-schema/pick/transform change moves // the bucket — ADR 0004). For the 'revalidate' policy the token is empty and freshness // comes from re-validation on the hit path instead. const valueKey = (suffix = ''): string => @@ -473,7 +479,7 @@ export function createCache(opts: CacheControllerOptions): CacheController { ): CacheHit | null => { if (entry.v === undefined) return null; remember(k); - return { value: entry.v, status: entry.s ?? 200 }; + return { data: entry.v, status: entry.s ?? 200 }; }; return { diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 3d613501..c6ee407f 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -31,6 +31,7 @@ import { } from './rules-template'; import { serve } from './serve'; import type { Stitch, StitchEvent, StitchInput } from './types'; +import { parseDuration } from './util'; import { existsSync, readFileSync } from 'node:fs'; import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'; @@ -234,8 +235,6 @@ interface TraceRecord { at?: number; ok?: boolean; elapsed?: number; - /** Pre-rename JSONL still carries `ms`; read it as a fallback (CONTRACT.md P17). */ - ms?: number; phase?: string; finding?: { level?: string }; } @@ -302,10 +301,7 @@ export function summarizeTrace(records: TraceRecord[]): TraceSummary { e.stats.runs++; if (r.ok) e.stats.ok++; else e.stats.failed++; - { - const dur = r.elapsed ?? r.ms; - if (typeof dur === 'number') e.durations.push(dur); - } + if (typeof r.elapsed === 'number') e.durations.push(r.elapsed); break; case 'progress': if (r.phase === 'retry') e.stats.retries++; @@ -385,16 +381,6 @@ export function formatTraceSummary(summary: TraceSummary): string { ].join('\n'); } -// "1h" | "30m" | "45s" | "2d" → milliseconds (trace's --since window). -function parseSince(s: string): number | undefined { - const m = /^(\d+(?:\.\d+)?)\s*(s|m|h|d)$/.exec(s.trim()); - if (!m) return undefined; - const [, num, unitKey] = m; - if (num === undefined || unitKey === undefined) return undefined; - const units: Record = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }; - return parseFloat(num) * (units[unitKey] ?? 1); -} - // ---- process glue --------------------------------------------------------- export interface CliIO { @@ -478,7 +464,7 @@ by default (no side effects) — opt in with --trace or the STITCH_TRACE_* env v diagram: --name diagram only this stitch (by export name or configured name) Emits a Mermaid flowchart of each stitch's configured pipeline (throttle, request, - retry, surface, pagination, validation, transform, unwrap, cache). Auth is redacted + retry, surface, pagination, validation, transform, pick, cache). Auth is redacted from a stitch's public config, so it is not shown. export: @@ -588,10 +574,12 @@ function traceCommand(args: string[], io: CliIO): number { let cutoff: number | undefined; if (since !== undefined) { - const sinceMs = parseSince(since); + // Shared duration grammar (util.parseDuration): "500ms" | "45s" | "30m" | "1h" | "2d", + // or a bare number of milliseconds. + const sinceMs = parseDuration(since); if (sinceMs === undefined) { io.writeErr( - `invalid --since value: '${since}'; expected a duration like 1h, 30m, 45s, 2d\n`, + `invalid --since value: '${since}'; expected a duration like 45s, 30m, 1h, 2d\n`, ); return 2; } diff --git a/packages/core/src/config-summary.ts b/packages/core/src/config-summary.ts index 35f299b0..fe470437 100644 --- a/packages/core/src/config-summary.ts +++ b/packages/core/src/config-summary.ts @@ -7,19 +7,10 @@ import type { RedactedStitchConfig } from './types'; // A compact "METHOD endpoint" label for the request node. Reused by `stitch init --project` (the // consumer rule list) and the MCP `endpoint` field, so the one-line summary stays identical. export function endpointLabel(cfg: RedactedStitchConfig): string { + // A thunked url/baseUrl is redacted off `__config` entirely (P0 — no functions on the public + // view), so a missing endpoint here may simply be a dynamic one. const method = (cfg.method ?? 'GET').toUpperCase(); - let where: string; - if (typeof cfg.url === 'string') where = cfg.url; - else if (typeof cfg.url === 'function') where = '(dynamic url)'; - else { - const base = - typeof cfg.baseUrl === 'string' - ? cfg.baseUrl - : cfg.baseUrl - ? '(dynamic)' - : ''; - where = base + (cfg.path ?? ''); - } + const where = cfg.url ?? (cfg.baseUrl ?? '') + (cfg.path ?? ''); return `${method} ${where || '(no endpoint)'}`; } @@ -40,14 +31,13 @@ export function pipelineStages( ); if (kind !== 'http') stages.push(`${kind} interpret`); if (cfg.paginate) { - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `max` is the @deprecated alias of `pages`, read for back-compat until the GA cut (CONTRACT.md P4) - const pages = cfg.paginate.pages ?? cfg.paginate.max ?? 50; + const pages = cfg.paginate.pages ?? 50; stages.push(opts.detailed ? `paginate (max ${pages})` : 'paginate'); } - // Post-response order matches the engine (engine.ts): transform → unwrap → validate. The body - // is transformed, then the unwrap path is read, then the result is validated against `output`. - if (cfg.transform) stages.push('transform'); - if (cfg.unwrap) stages.push(`unwrap: ${cfg.unwrap}`); + // Post-response order matches the engine (engine.ts): pick → validate. The pick path is read, + // then the result is validated against `output`. (`transform` is a live closure and lives only + // on `__rawConfig` — P0 — so the redacted view can't report it as a stage.) + if (cfg.pick) stages.push(`pick: ${cfg.pick}`); if (cfg.output) stages.push('validate'); if (cfg.cache) stages.push('cache'); stages.push('result'); diff --git a/packages/core/src/download.ts b/packages/core/src/download.ts index 71b2bcdd..c30009d3 100644 --- a/packages/core/src/download.ts +++ b/packages/core/src/download.ts @@ -84,12 +84,12 @@ export const downloadSurface: Surface = { responseType: 'blob', }), interpret: (res): SurfaceOutcome => { - const value: DownloadResult = { blob: res.body as Blob }; + const data: DownloadResult = { blob: res.body as Blob }; const filename = filenameFromDisposition(res.headers['content-disposition']) ?? filenameFromUrl(res.url); - if (filename !== undefined) value.filename = filename; - return { ok: true, value }; + if (filename !== undefined) data.filename = filename; + return { ok: true, data }; }, }; @@ -108,7 +108,7 @@ export interface DownloadSeamApi { // to `{ blob, filename }`. The `as` retypes the loose `makeStitch` result (`Stitch<…, StitchInput>`) // to the declared `InputOf` call-arg type: now that `InputOf` reads `extends`-fragment schemas // (#76) it is no longer a clean supertype of `StitchInput` under an unresolved `C`, so this loose -// body needs the same retype `stitch()`/`seam` get for free from their inferring overloads. Sound — +// body needs the same retype `stitch()`/`bind` get for free from their inferring overloads. Sound — // the runtime stitch is byte-identical; only the static call-arg richness is restored (the type // tests check every concrete config). const downloadStitch = < @@ -138,13 +138,13 @@ function bindSeam(s: Seam): DownloadSeamApi { /** * The download surface's authoring helper — callable for the terse form (`download(config)`) plus: * - `download.stitch(config)` — a standalone download stitch (alias of the callable). - * - `download.seam(existingSeam)` — bind download members to an existing seam. - * - `download.seam(options)` — a new seam whose members default to download. + * - `download.bind(existingSeam)` — bind download members to an existing seam. + * - `download.bind(options)` — a new seam whose members default to download. * - `download.surface` — the download {@link Surface} identity. */ export const download = Object.assign(downloadStitch, { surface: downloadSurface, stitch: downloadStitch, - seam: (arg: Seam | SeamOptions): DownloadSeamApi => + bind: (arg: Seam | SeamOptions): DownloadSeamApi => bindSeam(isSeam(arg) ? arg : makeSeam(arg)), }); diff --git a/packages/core/src/drift.ts b/packages/core/src/drift.ts index 0ecd76c4..e5b19f4a 100644 --- a/packages/core/src/drift.ts +++ b/packages/core/src/drift.ts @@ -116,6 +116,10 @@ export function classifyDiff( opts: DriftOptions = {}, ): DriftFinding[] { const { levelOf, allow } = resolveSeverity(opts.severity); + // P7: a bare `ignore` string is shorthand for a one-element list. `compose`/`drift()` already + // normalize, but a hand-built DriftOptions may still carry the scalar. + const ignore = + typeof opts.ignore === 'string' ? [opts.ignore] : opts.ignore; // Group diffs by `change|path` (the collapse key). const groups = new Map< @@ -138,7 +142,7 @@ export function classifyDiff( for (const { change, path, diffs } of groups.values()) { // Apply ignore (path-based) after grouping — all elements share the same [] path. - if (matchAny(opts.ignore, path)) continue; + if (matchAny(ignore, path)) continue; const level = levelOf(change); if (allow && !allow.has(level)) continue; diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index d654a5a9..5f7d2a3e 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -13,6 +13,7 @@ import { CircuitOpenError, RateLimitError, TimeoutError, + acceptsStatus, backoffDelay, createCircuit, parseRetryAfter, @@ -166,8 +167,7 @@ function applyIdempotency( ) ) return; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `key` is the @deprecated alias of `keyOf`, read as the back-compat fallback until the GA cut (CONTRACT.md P6) - const keyOf = cfg.idempotency.keyOf ?? cfg.idempotency.key; + const keyOf = cfg.idempotency.keyOf; headers[header] = keyOf ? keyOf(input) : randomUUID(); } @@ -256,8 +256,7 @@ const cloneReq = (r: AdapterRequest): AdapterRequest => ({ headers: { ...r.headers }, }); const hostKey = (req: AdapterRequest, cfg: ResolvedStitchConfig): string => { - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `scope` is the @deprecated alias of `pool`, read as the back-compat fallback until the GA cut (CONTRACT.md P2) - if ((cfg.throttle?.pool ?? cfg.throttle?.scope) === 'host') { + if (cfg.throttle?.pool === 'host') { try { return new URL(req.url).host; } catch { @@ -350,11 +349,7 @@ function errEvt(err: unknown, name: string, attempts: number): StitchEvent { // Delegate-backoff signal: stamp the structured `retryAfter` onto the event (so `.stream()` // consumers get it) and pin the live RateLimitError so the awaited path re-throws it intact. if (err instanceof RateLimitError) { - if (err.retryAfter !== undefined) { - evt.retryAfter = err.retryAfter; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- co-emit the @deprecated alias for back-compat (CONTRACT.md P17) - evt.retryAfterMs = err.retryAfter; - } + if (err.retryAfter !== undefined) evt.retryAfter = err.retryAfter; Object.defineProperty(evt, ERROR_SOURCE, { value: err, enumerable: false, @@ -371,22 +366,13 @@ function errEvt(err: unknown, name: string, attempts: number): StitchEvent { } return evt; } -const doneEvt = (ok: boolean, t0: number, attempts: number): StitchEvent => { - const elapsed = now() - t0; - // `elapsed` is canonical; `ms` is set alongside it as the @deprecated alias (CONTRACT.md P17). - return { type: 'done', ok, elapsed, ms: elapsed, attempts, at: now() }; -}; - -// Co-emit a `progress` event's @deprecated `waitedMs` alias by ASSIGNMENT (not a literal `waitedMs:` -// key) so back-compat holds until the GA cut (CONTRACT.md P17/P19) without re-tripping the contract -// lint's R2, which flags a literal `*Ms:` declaration. The canonical field is `waited`. -function coemitWaitedMs( - evt: Extract, -): Extract { - // eslint-disable-next-line @typescript-eslint/no-deprecated -- writing the @deprecated alias for back-compat (CONTRACT.md P17/P19) - if (evt.waited !== undefined) evt.waitedMs = evt.waited; - return evt; -} +const doneEvt = (ok: boolean, t0: number, attempts: number): StitchEvent => ({ + type: 'done', + ok, + elapsed: now() - t0, + attempts, + at: now(), +}); async function validateInput( cfg: ResolvedStitchConfig, @@ -558,17 +544,6 @@ function abortReason(signal: AbortSignal): Error { : new Error('the operation was aborted'); } -// Normalize `acceptStatus` (a number list, a predicate, or unset) into a single predicate. Unset → -// accept nothing (every `>= 400` still throws). Shared by the buffered/paginated `attemptLoop` and -// the streaming path so both honour the same per-stitch policy. -function acceptsStatus( - accept: number[] | ((status: number) => boolean) | undefined, -): (status: number) => boolean { - if (accept === undefined) return () => false; - if (typeof accept === 'function') return accept; - return (status) => accept.includes(status); -} - // Materialize a streaming-path error body for StitchError.body. A streaming adapter hands back the // live `ReadableStream` unparsed (so it can be decoded into deltas); on the error branch the stream // is never decoded, so read it to text and best-effort JSON-parse it — the same shape the buffered @@ -617,18 +592,15 @@ async function* attemptLoop( const perAttemptMs = parseDuration(cfg.timeout?.perAttempt); const key = hostKey(baseReq, cfg); let refreshed = false; - // Delegate-backoff mode (issue #145): the host owns the gate. We bypass the internal throttle - // for the call (no acquire/release, so `throttle` is inert and no `throttled` event fires) and, - // on a response whose status matches `rlMatch` (default [429]), surface a RateLimitError instead - // of retrying. Everything else — auth, the success path, non-rate-limit failures — is unchanged. - // P14: `rateLimit` folded into `throttle` — read `throttle.delegate`/`throttle.on`, falling back - // to the @deprecated top-level `rateLimit` (read once here) until the GA cut. - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `rateLimit` folded into `throttle` (P14); back-compat fallback until GA - const legacyRl = cfg.rateLimit; - const delegate = (cfg.throttle?.delegate ?? legacyRl?.delegate) === true; - const rlMatch = acceptsStatus(cfg.throttle?.on ?? legacyRl?.on ?? [429]); + // Delegate-backoff mode (issue #145, folded into `throttle` — P14): the host owns the gate. We + // bypass the internal throttle for the call (no acquire/release, so `throttle` is inert and no + // `throttled` event fires) and, on a response whose status matches `rlMatch` (default [429]), + // surface a RateLimitError instead of retrying. Everything else — auth, the success path, + // non-rate-limit failures — is unchanged. + const delegate = cfg.throttle?.delegate === true; + const rlMatch = acceptsStatus(cfg.throttle?.on ?? [429]); // acceptStatus (issue #155): statuses the caller declares NORMAL — an accepted non-2xx returns - // `res` like a 2xx (flowing through interpret → transform → unwrap → validate) instead of + // `res` like a 2xx (flowing through interpret → transform → pick → validate) instead of // throwing. Checked at the `>= 400` site, i.e. AFTER the retry-on-status path, so `retry.on` // still wins while attempts remain (retried, then accepted on the final attempt). const accepts = acceptsStatus(cfg.acceptStatus); @@ -646,13 +618,13 @@ async function* attemptLoop( baseReq.signal, ); if (waited > 0) - yield coemitWaitedMs({ + yield { type: 'progress', phase: 'throttled', attempt, waited, at: now(), - }); + }; } try { const req = cloneReq(baseReq); @@ -748,6 +720,7 @@ async function* attemptLoop( rt.clock, ), response: res, + attempts: attempt, }); } @@ -877,8 +850,7 @@ async function* paginated( const { cfg } = rt; const name = nameOf(cfg); const pg = cfg.paginate!; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `max` is the @deprecated alias of `pages`, read for back-compat until the GA cut (CONTRACT.md P4) - const max = pg.pages ?? pg.max ?? 50; + const max = pg.pages ?? 50; const acc: unknown[] = []; let pageInput = input; let page = 0; @@ -908,9 +880,9 @@ async function* paginated( return; } - let value: unknown = outcome.value; + let value: unknown = outcome.data; if (cfg.transform) value = await cfg.transform(value); - if (cfg.unwrap) value = getPath(value, cfg.unwrap); + if (cfg.pick) value = getPath(value, cfg.pick); const items = pg.items ? pg.items(value) : Array.isArray(value) @@ -970,10 +942,10 @@ async function ensureCache(rt: Runtime): Promise { store: rt.store, stitchId: m.cacheStitchId(cfg), // The RAW output schema (not the Validator wrapper) so the fingerprinter can read its - // `~standard.vendor`; transform/unwrap are already raw on the config (ADR 0004 fold). + // `~standard.vendor`; transform/pick are already raw on the config (ADR 0004 fold). output: outputSchemaSource(cfg), transform: cfg.transform, - unwrap: cfg.unwrap, + pick: cfg.pick, principal: rt.authCtx.principal, }), ), @@ -1073,11 +1045,9 @@ const resultEvt = ( data: unknown, status: number, attempts: number, - // `data` is canonical; `value` is co-set as the @deprecated alias (CONTRACT.md P5). ): StitchEvent => ({ type: 'result', data, - value: data, status, attempts, at: now(), @@ -1091,7 +1061,7 @@ function interpretResponse( ): SurfaceOutcome { return cfg.kind?.interpret ? cfg.kind.interpret(res, cfg) - : { ok: true, value: res.body }; + : { ok: true, data: res.body }; } // Build the `error` event for a surface that interpreted the response as a failure. @@ -1134,16 +1104,16 @@ async function* runFrom( } // The surface interprets the response (graphql's "200-with-`errors`-is-a-failure" lives in - // its interpret hook); with no hook the body is the value. Then transform → unwrap → validate. + // its interpret hook); with no hook the body is the value. Then transform → pick → validate. const outcome = interpretResponse(cfg, res); if (!outcome.ok) { yield surfaceErrEvt(outcome, name, state.attempts); yield doneEvt(false, t0, state.attempts); return { ok: false }; } - let value: unknown = outcome.value; + let value: unknown = outcome.data; if (cfg.transform) value = await cfg.transform(value); - if (cfg.unwrap) value = getPath(value, cfg.unwrap); + if (cfg.pick) value = getPath(value, cfg.pick); // The pre-validation body — the left side of 0015's `diff(raw, validated)`, the coordinate space a // finding's `path` is anchored to. `.inspect()` (ADR 0016) surfaces it; otherwise it's discarded. const rawBody = value; @@ -1173,14 +1143,14 @@ async function* runFrom( // the cache), but resumable when the surface opts in: a surface that exposes the resume hooks // (`resumeToken`/`applyResume`) AND a stitch that set `sse.reconnect` (off by default — issue #71) // reconnect a dropped body, replaying the last resume token (sse → `Last-Event-ID`) and honouring a -// server-sent backoff (sse → the `retry:` field), capped at `maxAttempts`. The engine stays +// server-sent backoff (sse → the `retry:` field), capped at `reconnect.attempts`. The engine stays // surface-agnostic: it never branches on `kind.id === 'sse'`; it reads the resume token / server // backoff through the surface's generic hooks and the reconnect policy through one config accessor. // It charges the rate gate at every open (each reconnect is a fresh request) but takes NO concurrency // slot (a rate-only acquire, never released), so a long-lived connection can never pin a seam's // concurrency budget (Decision 12). The await/`consume` path resolves to the COLLECTED array of // every emitted chunk ACROSS reconnects — the terminal `result` mirrors the whole delta spine (Stage -// 5 sub-decision); `.stream()` yields the chunks incrementally and buffers nothing. transform/unwrap +// 5 sub-decision); `.stream()` yields the chunks incrementally and buffers nothing. transform/pick // reshape a whole buffered body and are NOT applied here; the `output` contract, by contrast, // validates each chunk before its `delta` is emitted (per-`delta`, via the surface's `contractValue` // hook — ADR 0005 Addendum) and keeps firing across reconnects, snapshot-drift omitted. @@ -1202,8 +1172,7 @@ async function* runStreaming( // The resume hooks (issue #71) stay optional; the surface is resumable only when both are set. const streamHook: NonNullable = surface.stream; const resumeToken = surface.resumeToken; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `resumeRetryMs` is the @deprecated alias of `resumeRetry`, read for back-compat until the GA cut (CONTRACT.md P17) - const resumeRetry = surface.resumeRetry ?? surface.resumeRetryMs; + const resumeRetry = surface.resumeRetry; const applyResume = surface.applyResume; let baseReq: AdapterRequest; @@ -1268,13 +1237,13 @@ async function* runStreaming( return 'fail'; } if (waited > 0) - yield coemitWaitedMs({ + yield { type: 'progress', phase: 'throttled', attempt, waited, at: now(), - }); + }; let res: AdapterResponse; try { @@ -1412,13 +1381,13 @@ async function* runStreaming( lastRetryMs ?? policy.backoff ?? backoffDelay(attempt + 1, cfg.retry); - yield coemitWaitedMs({ + yield { type: 'progress', phase: 'reconnect', attempt, waited: backoff, at: now(), - }); + }; await sleepWithin(backoff, budget, baseReq.signal, rt.clock); } @@ -1439,13 +1408,10 @@ function resolveReconnect(cfg: ResolvedStitchConfig): { if (!r) return { enabled: false, maxAttempts: 0, backoff: undefined }; if (r === true) return { enabled: true, maxAttempts: 3, backoff: undefined }; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `backoffMs` is the @deprecated alias of `backoff`, read for back-compat until the GA cut (CONTRACT.md P17) - const backoff = parseDuration(r.backoff ?? r.backoffMs); return { enabled: true, - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `maxAttempts` is the @deprecated alias of `attempts`, read for back-compat until the GA cut (CONTRACT.md P4) - maxAttempts: r.attempts ?? r.maxAttempts ?? 3, - backoff, + maxAttempts: r.attempts ?? 3, + backoff: parseDuration(r.backoff), }; } @@ -1522,7 +1488,7 @@ async function* runCached( } const d = describe(baseReq); - const key = ctl.key(d, input); + const key = ctl.keyOf(d, input); if (key === undefined) { yield cacheEvt('bypass: unhashable request'); yield* runFrom(rt, baseReq, name, state, t0, run, budget); @@ -1541,7 +1507,7 @@ async function* runCached( if (ctl.revalidateOnHit && cfg.output) { // Only the hard validation result matters on a cache hit: a stored value that no longer // satisfies the schema is stale-shaped. Soft drift (raw-vs-validated) is meaningless here. - const { errors } = await validateValue(cfg, found.value); + const { errors } = await validateValue(cfg, found.data); for (const finding of errors) { yield { type: 'drift', finding, at: now() }; if (finding.level === 'error') stale = true; @@ -1549,7 +1515,7 @@ async function* runCached( } if (!stale) { yield cacheEvt(ctl.revalidateOnHit ? 'hit (revalidated)' : 'hit'); - yield resultEvt(found.value, found.status, 0); + yield resultEvt(found.data, found.status, 0); yield doneEvt(true, t0, 0); return; } @@ -1579,7 +1545,7 @@ async function* runCached( } if (out.ok) { await store(out); - claim.settle({ value: out.value, status: out.status }); + claim.settle({ data: out.value, status: out.status }); } else { // Failure is not shared — waiters re-run on their own. claim.fail(new Error('cache: leader run failed')); @@ -1596,7 +1562,7 @@ async function* runCached( return; } yield cacheEvt('coalesced'); - yield resultEvt(shared.value, shared.status, 0); + yield resultEvt(shared.data, shared.status, 0); yield doneEvt(true, t0, 0); } @@ -1678,7 +1644,7 @@ export async function cacheInvalidateExact( } if (!ctl.cacheableMethod(baseReq.method)) return; const d = describe(baseReq); - const key = ctl.key(d, input); + const key = ctl.keyOf(d, input); if (key === undefined) return; await (await ctl.open(key, d)).delete(); } @@ -1691,7 +1657,7 @@ export async function cacheInvalidateBulk(rt: Runtime): Promise { await ctl.invalidate(); } -/** The derived opaque key for `input`, for introspection. Backs `stitch.cache.key(input)`. */ +/** The derived opaque key for `input`, for introspection. Backs `stitch.cache.keyOf(input)`. */ export async function cacheKeyOf( rt: Runtime, input: StitchInput = {}, @@ -1704,7 +1670,7 @@ export async function cacheKeyOf( } catch { return undefined; } - return ctl.key(describe(baseReq), input); + return ctl.keyOf(describe(baseReq), input); } export async function executeRaw( diff --git a/packages/core/src/fingerprint.ts b/packages/core/src/fingerprint.ts index 9464dcaa..ae01b886 100644 --- a/packages/core/src/fingerprint.ts +++ b/packages/core/src/fingerprint.ts @@ -2,7 +2,7 @@ // // The response cache (ADR 0003) stores the post-validation value and skips // re-validation on a hit, so a stored value is bound to the `output` schema -// (plus `transform`/`unwrap`) it was validated against. Ship a changed schema and +// (plus `transform`/`pick`) it was validated against. Ship a changed schema and // a hit would serve an old-shape value for the whole TTL. This module computes a // stable FINGERPRINT that folds into the cache GENERATION so a contract change // auto-invalidates. @@ -56,8 +56,6 @@ export function hash(input: string): string { */ export interface SchemaFingerprint { readonly token: string | null; - /** @deprecated Renamed to {@link SchemaFingerprint.token} (CONTRACT.md P5: `value` is the success payload, not a token). Read until the 1.0 GA cut. */ - readonly value?: string | null; readonly strength: 'strong' | 'weak'; } @@ -134,8 +132,8 @@ export interface FingerprintInput { readonly output?: unknown; /** `config.transform` — opaque; cannot be soundly hashed (see ADR 0004 §6). */ readonly transform?: ((body: unknown) => unknown) | undefined; - /** `config.unwrap` — a dot-path string; serialisable, so always sound. */ - readonly unwrap?: string | undefined; + /** `config.pick` — a dot-path string; serialisable, so always sound. */ + readonly pick?: string | undefined; /** Explicit `cache.version` — authoritative override; always wins. */ readonly version?: string | number | undefined; /** A user tag making an opaque `transform` sound. */ @@ -181,12 +179,12 @@ function vendorOf(schema: unknown): string | undefined { export function resolveFingerprint( input: FingerprintInput, ): FingerprintResolution { - const unwrap = input.unwrap ?? ''; + const pick = input.pick ?? ''; // rung 1 — explicit cache.version is authoritative. if (input.version != null) { return { - generation: hash(`v|${input.version}|u|${unwrap}`), + generation: hash(`v|${input.version}|u|${pick}`), policy: 'fast', reason: 'explicit cache.version', }; @@ -227,14 +225,12 @@ export function resolveFingerprint( }; } - // rung 3 — sound structural fingerprint → fast path. Prefer `token`; fall back to the - // @deprecated `value` so an external fingerprinter still on the old spelling keeps working. - // eslint-disable-next-line @typescript-eslint/no-deprecated -- back-compat fallback for the renamed `value` alias (CONTRACT.md P5) - const token = fp?.token ?? fp?.value; + // rung 3 — sound structural fingerprint → fast path. + const token = fp?.token; if (token != null) { return { generation: hash( - `s|${vendor}|${token}|${fp?.strength}|u|${unwrap}|${xTag}`, + `s|${vendor}|${token}|${fp?.strength}|u|${pick}|${xTag}`, ), policy: 'fast', reason: 'sound structural fingerprint', @@ -242,10 +238,10 @@ export function resolveFingerprint( } // rung 4 — no output schema: the stored value is bound to no shape, so there - // is nothing to go stale. Cache fast; `unwrap`/transform tag still fold in. + // is nothing to go stale. Cache fast; `pick`/transform tag still fold in. if (input.output == null) { return { - generation: hash(`noschema|u|${unwrap}|${xTag}`), + generation: hash(`noschema|u|${pick}|${xTag}`), policy: 'fast', reason: 'no output schema', }; diff --git a/packages/core/src/from-curl.ts b/packages/core/src/from-curl.ts index 154f453b..9edb9323 100644 --- a/packages/core/src/from-curl.ts +++ b/packages/core/src/from-curl.ts @@ -21,7 +21,7 @@ export interface ParsedRequest { /** Raw request body (already a string); `bodyType` says how to read it. */ body?: string; /** How the body was supplied: a JSON document, a urlencoded form, or unknown. */ - bodyKind?: 'json' | 'form'; + bodyType?: 'json' | 'form'; /** `-G`/`--get`: fold `-d` data into the query string instead of the body. */ asQuery?: boolean; /** Anything we recognised but could not faithfully map — surfaced, never swallowed. */ @@ -267,7 +267,7 @@ function finalizeRequest(acc: CurlAcc): ParsedRequest { const joined = acc.dataParts.map((p) => p.value).join('&'); req.body = joined; const anyUrlencode = acc.dataParts.some((p) => p.urlencode); - req.bodyKind = anyUrlencode ? 'form' : sniffBodyKind(joined); + req.bodyType = anyUrlencode ? 'form' : sniffBodyType(joined); } return req; } @@ -303,7 +303,7 @@ export function parseCurl(curl: string | string[]): ParsedRequest { // Classify a raw `-d` payload: a JSON-parseable document → 'json', else (k=v&… or anything else) // → 'form'. Conservative — only valid JSON is treated as JSON. -function sniffBodyKind(raw: string): 'json' | 'form' { +function sniffBodyType(raw: string): 'json' | 'form' { const trimmed = raw.trim(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) { try { @@ -380,11 +380,11 @@ export function parseHar(har: unknown, entryIndex = 0): ParsedRequest { typeof request.postData?.mimeType === 'string' ? request.postData.mimeType : ''; - req.bodyKind = mime.includes('x-www-form-urlencoded') + req.bodyType = mime.includes('x-www-form-urlencoded') ? 'form' : mime.includes('json') ? 'json' - : sniffBodyKind(text); + : sniffBodyType(text); } return req; } @@ -760,7 +760,7 @@ function analyzeRequest(req: ParsedRequest): AnalyzedRequest { // Static headers: drop auth/transport/implied-content-type noise. const bodyType: StitchConfig['bodyType'] | undefined = !req.asQuery && req.body - ? req.bodyKind === 'form' + ? req.bodyType === 'form' ? 'form' : 'json' : undefined; @@ -941,7 +941,7 @@ function renderAuth(auth: AuthEmit): string { return auth.queryName ? `apiKey({ in: 'query', name: '${auth.queryName}', value: env('${auth.envName}') })` : auth.header && auth.header.toLowerCase() !== 'x-api-key' - ? `apiKey({ header: '${auth.header}', value: env('${auth.envName}') })` + ? `apiKey({ name: '${auth.header}', value: env('${auth.envName}') })` : `apiKey({ value: env('${auth.envName}') })`; case 'basic': return `basic({ user: env('${auth.userEnv}'), pass: env('${auth.passEnv}') })`; diff --git a/packages/core/src/graphql.ts b/packages/core/src/graphql.ts index 28234d26..e180915a 100644 --- a/packages/core/src/graphql.ts +++ b/packages/core/src/graphql.ts @@ -1,8 +1,8 @@ // The `stitchapi/graphql` surface subpath (ADR 0005 Decisions 3, 10): the graphql surface's // monomorphic authoring helpers. `graphql(config)` stays the terse callable form; `graphql.stitch` -// is its alias, `graphql.seam(...)` binds graphql members to a seam, and `graphql.surface` is the +// is its alias, `graphql.bind(...)` binds graphql members to a seam, and `graphql.surface` is the // Surface identity. The seam stays surface-agnostic — graphql members are created through the -// seam's own `graphql()` method (in Stage 4 graphql's shaping/unwrap move behind the surface +// seam's own `graphql()` method (in Stage 4 graphql's shaping/pick move behind the surface // hooks; today they ride the engine's id-keyed handling + this helper). import { seam as makeSeam } from './seam'; import { graphql as graphqlStitch } from './stitch'; @@ -17,7 +17,7 @@ export interface GraphqlSeamApi { readonly seam: Seam; } -const bind = (s: Seam): GraphqlSeamApi => ({ +const bindSeam = (s: Seam): GraphqlSeamApi => ({ stitch: s.graphql.bind(s), seam: s, }); @@ -25,13 +25,13 @@ const bind = (s: Seam): GraphqlSeamApi => ({ /** * The graphql surface's authoring helper — callable for the terse form (`graphql(config)`) plus: * - `graphql.stitch(config)` — a standalone graphql stitch (alias of the callable). - * - `graphql.seam(existingSeam)` — bind graphql members to an existing seam. - * - `graphql.seam(options)` — a new seam whose members default to graphql. + * - `graphql.bind(existingSeam)` — bind graphql members to an existing seam. + * - `graphql.bind(options)` — a new seam whose members default to graphql. * - `graphql.surface` — the graphql {@link Surface} identity. */ export const graphql = Object.assign(graphqlStitch, { surface: graphqlSurface, stitch: graphqlStitch, - seam: (arg: Seam | SeamOptions): GraphqlSeamApi => - bind(isSeam(arg) ? arg : makeSeam(arg)), + bind: (arg: Seam | SeamOptions): GraphqlSeamApi => + bindSeam(isSeam(arg) ? arg : makeSeam(arg)), }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f03933ca..0d5cef2f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,10 +14,19 @@ export { secretsFile, secretFrom, } from './auth'; -export type { SecretSource, AuthFailureResult, RefreshResult } from './auth'; -// CONTRACT.md P3 — deprecated alias re-export, removed at GA. -// eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional back-compat re-export of the @deprecated `AuthFailureInfo` (now `AuthFailureResult`) until the GA cut -export type { AuthFailureInfo } from './auth'; +export type { + Secret, + OptionalSecret, + SecretSource, + ApiKeyOptions, + BasicOptions, + OAuth2Options, + OAuth2RefreshOptions, + CookieSessionOptions, + CookieSessionRefreshOptions, + AuthFailureResult, + RefreshResult, +} from './auth'; export { fetchAdapter } from './http-adapter'; export type { FetchAdapterOptions } from './http-adapter'; export { axiosAdapter } from './axios-adapter'; @@ -35,27 +44,28 @@ export { multiplex, loggerSink, } from './trace'; -export type { LoggerLike, LoggerSinkOptions, LogLevel } from './trace'; -export { otlpTrace, otlpHttpExporter, toOtlpJson } from './otlp'; +export type { + LoggerLike, + LoggerSinkOptions, + LogLevel, + TraceOptions, +} from './trace'; +export { otlpSink, otlpHttpExporter, toOtlpJson } from './otlp'; export type { SpanExporter, OtelSpan, OtelSpanEvent, SpanAttributes, OtlpOptions, + OtlpExporterOptions, } from './otlp'; // Trace-redaction escape hatch: widen the secret-key denylist so a host's custom credential // param name is scrubbed in every trace sink (start.url, OTLP url.full, input.query). `apiKey({ in: // 'query', name })` registers its name here automatically; this is the manual hook for a credential -// the built-in set/stems don't catch. `isSecretKey` is the matching predicate (alias: -// `isSecretQueryKey`), exposed so a host can audit which of its query params / body keys the -// scrubbers already cover. `redactSecretsDeep` walks a plain value and replaces secret-named keys. -export { - registerSecretQueryKey, - isSecretKey, - isSecretQueryKey, - redactSecretsDeep, -} from './util'; +// the built-in set/stems don't catch. `isSecretKey` is the matching predicate, exposed so a host +// can audit which of its query params / body keys the scrubbers already cover. `redactSecretsDeep` +// walks a plain value and replaces secret-named keys. +export { registerSecretKey, isSecretKey, redactSecretsDeep } from './util'; export type { Issue, ValidationResult, Validator } from './validator'; // Standalone validation, uniform with what `input`/`output` consume: `validate(schema, value)` // checks a value now; `compile(schema)` coerces once and returns a reusable checker. Both take any @@ -75,6 +85,6 @@ export { systemClock } from './util'; export { compact } from './compact'; export type { Compact } from './compact'; // The delegate-backoff error (issue #145): thrown on the awaited path and surfaced as an `error` -// event when `rateLimit.delegate` is on, so a host's outer gate owns the rate-limit backoff. +// event when `throttle.delegate` is on, so a host's outer gate owns the rate-limit backoff. export { RateLimitError } from './resilience'; export * from './types'; diff --git a/packages/core/src/llm.ts b/packages/core/src/llm.ts index 869e3532..03d680db 100644 --- a/packages/core/src/llm.ts +++ b/packages/core/src/llm.ts @@ -11,9 +11,17 @@ // onto it yet.) Bundle-frugal: reached only through the `llm` subpath; `import { stitch }` pulls in // none of it. import { compact } from './compact'; +import { seam as makeSeam } from './seam'; import { makeStitch } from './stitch'; import type { Surface, SurfaceOutcome } from './surface'; -import type { Stitch, StitchConfig, StitchInput } from './types'; +import { + type Seam, + type SeamOptions, + type Stitch, + type StitchConfig, + type StitchInput, + isSeam, +} from './types'; /** A chat message in the normalised request. */ export interface LlmMessage { @@ -41,15 +49,15 @@ export interface LlmResult { /** * The provider-mapping CONTRACT (ADR 0008) — the contract-not-dependency primitive for LLMs, the - * same shape the fingerprint contract and `axiosAdapter` follow. A provider declares its endpoint + + * same shape the fingerprint contract and `axiosAdapter` follow. A provider declares its url + * non-secret headers, maps a {@link LlmRequest} to its HTTP body, and lifts an {@link LlmResult} out * of the response. First-party {@link anthropic} / {@link openai} implement it; BYO any other by * writing one. The CREDENTIAL is the stitch's `auth` (`bearer`/`apiKey`), never the provider. */ export interface LlmProvider { id: string; - /** Completions endpoint — the default `url` when the config sets none. */ - endpoint: string; + /** Completions URL — the default `url` when the config sets none. */ + url: string; /** Non-secret default headers (e.g. an API version), merged UNDER the request headers. Never a credential. */ headers?: Record; /** Model used when neither the config nor the call sets one. */ @@ -91,9 +99,17 @@ function toRequest(d: LlmDefaults, input: StitchInput): LlmRequest { return req; } -// The llm surface for one provider + defaults: pack the request via `provider.buildBody` as a JSON -// POST (provider headers under the user's), and `interpret` lifts the result via `provider.parse`. -function llmSurface(d: LlmDefaults): Surface { +/** + * The llm surface identity (ADR 0005 Decision 11: only the `id` round-trips). The live surface is + * built per stitch — it closes over the provider + defaults ({@link makeLlmSurface}) — so this + * exported identity is the redaction/inspection anchor: an llm stitch exposes `kind: 'llm'` on + * `__config`, round-tripping as JSON. + */ +export const llmSurface: Surface = { id: 'llm' }; + +// The live llm surface for one provider + defaults: pack the request via `provider.buildBody` as a +// JSON POST (provider headers under the user's), and `interpret` lifts the result via `provider.parse`. +function makeLlmSurface(d: LlmDefaults): Surface { const { provider } = d; return { id: 'llm', @@ -108,13 +124,14 @@ function llmSurface(d: LlmDefaults): Surface { // successful body. A provider's "200 with an error envelope" can be handled in its `parse`. interpret: (res): SurfaceOutcome => ({ ok: true, - value: provider.parse(res.body), + data: provider.parse(res.body), }), }; } -/** Config for {@link llm}: the shared {@link StitchConfig} keys plus the llm defaults. */ -export type LlmOptions = Partial & { +/** Config for {@link llm}: the shared {@link StitchConfig} keys (minus `kind` — the surface owns + * it) plus the llm defaults. */ +export type LlmOptions = Partial> & { provider: LlmProvider; model?: string; system?: string; @@ -122,10 +139,27 @@ export type LlmOptions = Partial & { temperature?: number; }; +// Split an LlmOptions into the surface-bound defaults and the plain stitch config carrying the +// live surface — shared by the standalone stitch and the seam binder. +function llmConfig(config: LlmOptions): Partial { + const { provider, model, system, maxTokens, temperature, ...rest } = config; + const defaults: LlmDefaults = { provider }; + if (model !== undefined) defaults.model = model; + if (system !== undefined) defaults.system = system; + if (maxTokens !== undefined) defaults.maxTokens = maxTokens; + if (temperature !== undefined) defaults.temperature = temperature; + const urlless = rest.url === undefined && rest.path === undefined; + return { + ...rest, + kind: makeLlmSurface(defaults), + ...(urlless ? { url: provider.url } : {}), + }; +} + /** * `llm({ provider, ... })` — a chat-completion stitch resolving to an {@link LlmResult}. The * provider (and `model`/`system`/`maxTokens`/`temperature` defaults) bind to the surface; the call - * passes `{ body: { messages: [...] } }` (and may override the defaults). The endpoint defaults to + * passes `{ body: { messages: [...] } }` (and may override the defaults). The `url` defaults to * the provider's. Bring the credential as the stitch's `auth` (`bearer(env(...))` for OpenAI, * `apiKey({ name: 'x-api-key', ... })` for Anthropic). * @@ -142,21 +176,39 @@ export type LlmOptions = Partial & { * const { text } = await chat({ body: { messages: [{ role: 'user', content: 'hi' }] } }); * ``` */ -export function llm(config: LlmOptions): Stitch { - const { provider, model, system, maxTokens, temperature, ...rest } = config; - const defaults: LlmDefaults = { provider }; - if (model !== undefined) defaults.model = model; - if (system !== undefined) defaults.system = system; - if (maxTokens !== undefined) defaults.maxTokens = maxTokens; - if (temperature !== undefined) defaults.temperature = temperature; - const endpointless = rest.url === undefined && rest.path === undefined; - return makeStitch({ - ...rest, - kind: llmSurface(defaults), - ...(endpointless ? { url: provider.endpoint } : {}), - }); +const llmStitch = (config: LlmOptions): Stitch => + makeStitch(llmConfig(config)); + +/** llm members bound to a seam. `stitch(config)` creates an llm member of `seam`; `seam` is the + * underlying handle for lifecycle/principal (`.as`/`.flush`/`.close`). */ +export interface LlmSeamApi { + readonly stitch: (config: LlmOptions) => Stitch; + readonly seam: Seam; } +// Bind llm members to a seam through the seam's surface-agnostic `stitch({ kind })` (ADR 0005 +// Decision 3) — no per-surface seam method; one shared runtime / principal boundary. +function bindSeam(s: Seam): LlmSeamApi { + return { + stitch: (config: LlmOptions) => s.stitch(llmConfig(config)), + seam: s, + }; +} + +/** + * The llm surface's authoring helper — callable for the terse form (`llm(config)`) plus: + * - `llm.stitch(config)` — a standalone llm stitch (alias of the callable). + * - `llm.bind(existingSeam)` — bind llm members to an existing seam. + * - `llm.bind(options)` — a new seam whose members default to llm. + * - `llm.surface` — the llm {@link Surface} identity. + */ +export const llm = Object.assign(llmStitch, { + surface: llmSurface, + stitch: llmStitch, + bind: (arg: Seam | SeamOptions): LlmSeamApi => + bindSeam(isSeam(arg) ? arg : makeSeam(arg)), +}); + // ---- first-party provider mappings (plain config — no SDK dependency) ------ /** @@ -166,7 +218,7 @@ export function llm(config: LlmOptions): Stitch { */ export const anthropic: LlmProvider = { id: 'anthropic', - endpoint: 'https://api.anthropic.com/v1/messages', + url: 'https://api.anthropic.com/v1/messages', headers: { 'anthropic-version': '2023-06-01' }, defaultModel: 'claude-opus-4-8', buildBody: (req) => { @@ -218,7 +270,7 @@ export const anthropic: LlmProvider = { */ export const openai: LlmProvider = { id: 'openai', - endpoint: 'https://api.openai.com/v1/chat/completions', + url: 'https://api.openai.com/v1/chat/completions', defaultModel: 'gpt-4o', buildBody: (req) => compact({ @@ -259,7 +311,3 @@ export const openai: LlmProvider = { return result; }, }; - -// CONTRACT.md P3 — deprecated alias, removed at the 1.0 GA cut. -/** @deprecated Renamed to {@link LlmOptions} (CONTRACT.md P3). Imported name kept until the 1.0 GA cut. */ -export type LlmConfig = LlmOptions; diff --git a/packages/core/src/mcp.ts b/packages/core/src/mcp.ts index 9d70e8aa..776bed89 100644 --- a/packages/core/src/mcp.ts +++ b/packages/core/src/mcp.ts @@ -10,7 +10,12 @@ import { endpointLabel, pipelineStages } from './config-summary'; import { toMermaid } from './diagram'; import { type StitchRegistry, selectStitch } from './registry'; -import type { RedactedStitchConfig, Stitch, StitchInput } from './types'; +import type { + AtLeastOne, + RedactedStitchConfig, + Stitch, + StitchInput, +} from './types'; import type { Readable, Writable } from 'node:stream'; @@ -79,7 +84,7 @@ const DESCRIBE_STITCH_TOOL = { name: 'describe_stitch', description: "Describe a named stitch's shape WITHOUT running it: its endpoint, surface, per-slot " + - 'input presence, output (validated/unwrap), auth scheme (never the credential), the ' + + 'input presence, output (validated/pick), auth scheme (never the credential), the ' + 'configured policies (retry/throttle/cache/timeout), the request pipeline in engine ' + 'order, and a Mermaid flowchart. Call this to learn a stitch before run_stitch.', inputSchema: { @@ -142,11 +147,11 @@ export interface McpServerOptions { // its response (or null for notifications), independent of any transport. export function createMcpServer( registry: StitchRegistry, - info: McpServerOptions = {}, + info?: AtLeastOne, ): McpServer { const serverInfo = { - name: info.name ?? SERVER_NAME, - version: info.version ?? SERVER_VERSION, + name: info?.name ?? SERVER_NAME, + version: info?.version ?? SERVER_VERSION, }; async function callRunStitch(args: unknown): Promise { @@ -214,7 +219,7 @@ export function createMcpServer( }, output: { validated: cfg.output !== undefined, - unwrap: cfg.unwrap ?? null, + pick: cfg.pick ?? null, }, auth: authTagOf(cfg), policies: { @@ -282,7 +287,7 @@ export function createMcpServer( export interface StdioOptions { input?: Readable; output?: Writable; - info?: McpServerOptions; + serverInfo?: AtLeastOne; } // Wire an McpServer to the stdio transport: read newline-delimited JSON-RPC from @@ -292,7 +297,7 @@ export function serveStdio( registry: StitchRegistry, opts: StdioOptions = {}, ): { server: McpServer; close: () => void } { - const server = createMcpServer(registry, opts.info); + const server = createMcpServer(registry, opts.serverInfo); const input = opts.input ?? process.stdin; const output = opts.output ?? process.stdout; input.setEncoding('utf8'); @@ -327,7 +332,3 @@ export function serveStdio( input.on('data', onData); return { server, close: () => input.off('data', onData) }; } - -// CONTRACT.md P3 — deprecated alias, removed at the 1.0 GA cut. -/** @deprecated Renamed to {@link McpServerOptions} (CONTRACT.md P3). Imported name kept until the 1.0 GA cut. */ -export type McpServerInfo = McpServerOptions; diff --git a/packages/core/src/openapi.ts b/packages/core/src/openapi.ts index 00ca8880..d49b0add 100644 --- a/packages/core/src/openapi.ts +++ b/packages/core/src/openapi.ts @@ -13,8 +13,9 @@ // projects onto `__config` from the live `auth` (the credential itself is stripped; a strategy with // no declarable scheme, e.g. a jar-mode `cookieSession`, is simply left unannotated). Query/header // parameters declared via an `input` SCHEMA (rather than the URL template) are still not enumerated. -// A stitch whose endpoint is a thunk (resolved at call time) cannot be exported statically; it is -// reported as a warning, never dropped silently. +// A stitch whose endpoint is a thunk (resolved at call time) cannot be exported statically; the +// thunk never survives redaction (P0 — no functions on `__config`), so it shows up here as an +// absent endpoint and is reported as a warning, never dropped silently. import { compact } from './compact'; import type { StitchRegistry } from './registry'; import { isStandardSchema } from './standard-schema'; @@ -181,17 +182,16 @@ const BODY_CONTENT_TYPE: Record< // Resolve a stitch's endpoint to a single string. `url` (string) is the whole endpoint; otherwise // `baseUrl` + `path` are joined — and when there is no `baseUrl`, `path` carries the whole endpoint -// (the `stitch('https://…')` string form composes to `path`). A thunk `url`/`baseUrl` is resolved -// at call time and cannot be exported statically. -// `null` = nothing to export; `{ thunk: true }` = endpoint resolved at call time (unexportable). -type ResolvedEndpoint = { value: string } | { thunk: true } | null; +// (the `stitch('https://…')` string form composes to `path`). A thunk `url`/`baseUrl` never +// survives redaction (P0), so an absent endpoint here means "never configured OR resolved at call +// time" — either way there is nothing static to export. +// `null` = nothing to export. +type ResolvedEndpoint = { value: string } | null; function endpointOf(cfg: RedactedStitchConfig): ResolvedEndpoint { - if (typeof cfg.url === 'function' || typeof cfg.baseUrl === 'function') - return { thunk: true }; - if (typeof cfg.url === 'string') return { value: cfg.url }; + if (cfg.url !== undefined) return { value: cfg.url }; const base = - typeof cfg.baseUrl === 'string' ? cfg.baseUrl.replace(/\/+$/, '') : ''; + cfg.baseUrl !== undefined ? cfg.baseUrl.replace(/\/+$/, '') : ''; const path = cfg.path ?? ''; if (!base && !path) return null; if (!base) return { value: path }; @@ -346,12 +346,8 @@ export function toOpenApi( const cfg = stitch.__config; const endpoint = endpointOf(cfg); if (endpoint === null) { - warnings.push(`skipped "${key}": no url/baseUrl/path to export`); - continue; - } - if ('thunk' in endpoint) { warnings.push( - `skipped "${key}": endpoint is a thunk (resolved at call time), not a static string`, + `skipped "${key}": no static url/baseUrl/path to export (a thunk endpoint is resolved at call time and never survives redaction)`, ); continue; } diff --git a/packages/core/src/otlp.ts b/packages/core/src/otlp.ts index 3edc3df1..0c3db5c1 100644 --- a/packages/core/src/otlp.ts +++ b/packages/core/src/otlp.ts @@ -132,7 +132,7 @@ function buildChildSpans(run: OtelSpan): OtelSpan[] { * `traceId`/`spanId`/`parentSpanId` tree — falling back to the stitch name (a tolerant stack) only * when a sink is fed events by hand without ids (e.g. synthetic test events). */ -export function otlpTrace(opts: OtlpOptions = {}): TraceSink { +export function otlpSink(opts: OtlpOptions = {}): TraceSink { const exporter = opts.exporter ?? otlpHttpExporter( @@ -362,14 +362,15 @@ export function toOtlpJson(spans: OtelSpan[]): unknown { }; } +/** Options for {@link otlpHttpExporter} — {@link OtlpOptions} minus the exporter it builds. */ +export type OtlpExporterOptions = Omit; + /** * Default exporter: POST spans as OTLP/JSON to `${endpoint}/v1/traces` (endpoint defaults to * `OTEL_EXPORTER_OTLP_ENDPOINT` or `http://localhost:4318`). Fire-and-forget — failures are * swallowed so a missing collector never breaks a stitch call. */ -export function otlpHttpExporter( - opts: { endpoint?: string; headers?: Record } = {}, -): SpanExporter { +export function otlpHttpExporter(opts: OtlpExporterOptions = {}): SpanExporter { const base = opts.endpoint ?? readEnv('OTEL_EXPORTER_OTLP_ENDPOINT') ?? diff --git a/packages/core/src/pipe.ts b/packages/core/src/pipe.ts index 3f3973fb..c1806d87 100644 --- a/packages/core/src/pipe.ts +++ b/packages/core/src/pipe.ts @@ -43,8 +43,9 @@ export interface Composable { readonly __composable: true; } -/** A composition member: a single-endpoint {@link Stitch} or a nested {@link Composable}. */ -export type Node = Stitch | Composable; +// The runtime view of a composition member — the call-signature union the internals cast members +// to. The PUBLIC member type is {@link Member} (the brand gate the combinator signatures use). +type Node = Stitch | Composable; // ---- abort plumbing (auto-cancel losers) ---------------------------------- @@ -176,14 +177,17 @@ async function runRace( // ---- types ----------------------------------------------------------------- /** - * The public member constraint: gate on the stitch / composable BRAND, not the call signature. A + * A composition member — a {@link Stitch} or a nested {@link Composable} — as the combinator + * signatures constrain it: gated on the stitch / composable BRAND, not the call signature. A * stitch's input is CONTRAVARIANT, so constraining on the call signature would (exactly like `pipe` * before #365) reject a stitch built from a templated URL — its narrow `TIn` is not assignable to the * bare `Stitch` default. Gating on `__stitch` / `__composable` accepts every real stitch or composable, - * narrow input or not, while still rejecting a plain function. The internal {@link Node} keeps the call - * signature — the runtime reaches members through a cast, not through this constraint. + * narrow input or not, while still rejecting a plain function. The runtime reaches members through a + * cast to their call-signature view, not through this constraint. */ -type Member = { readonly __stitch: true } | { readonly __composable: true }; +export type Member = + | { readonly __stitch: true } + | { readonly __composable: true }; /** * A member's resolved output — `Awaited` of its call-signature return (a `StitchResult` or a diff --git a/packages/core/src/postmessage.ts b/packages/core/src/postmessage.ts index 25666421..75db420e 100644 --- a/packages/core/src/postmessage.ts +++ b/packages/core/src/postmessage.ts @@ -136,15 +136,14 @@ function freshId(): string { // --------------------------------------------------------------------------- // per-method options // --------------------------------------------------------------------------- -// Each verb's options extend the shared StitchConfig keys MINUS `kind` (the surface owns it) and -// `url` (synthesised as a `postmessage:` pseudo-endpoint), plus the verb's own fields. The -// resilience chain (`retry`/`throttle`/`timeout`/`circuit`/`trace`/`signal`) all apply via the -// engine, exactly as for every other surface. +// The message `type` is POSITIONAL on every verb (CONTRACT.md P15 — the one required address goes +// first, like `stitch(url)`); each verb's options extend the shared StitchConfig keys MINUS `kind` +// (the surface owns it) and `url` (synthesised as a `postmessage:` pseudo-endpoint), plus the +// verb's own fields. The resilience chain (`retry`/`throttle`/`timeout`/`circuit`/`trace`/`signal`) +// all apply via the engine, exactly as for every other surface. /** Options for {@link PostMessageChannel.request}. */ export type RequestOptions = Partial> & { - /** The message `type` posted to the peer. */ - type: string; /** The `type` the correlated reply must carry. Default `` `${type}-result` ``. */ reply?: string; /** Schema validating the outbound `body` payload (the call argument). */ @@ -155,20 +154,28 @@ export type RequestOptions = Partial> & { /** Options for {@link PostMessageChannel.emit}. */ export type EmitOptions = Partial> & { - /** The message `type` posted to the peer. */ - type: string; /** Schema validating the outbound `body` payload (the call argument). */ input?: StitchConfig['input']; }; /** Options for {@link PostMessageChannel.events}. */ export type EventsOptions = Partial> & { - /** The inbound event `type` to subscribe to. */ - type: string; /** Schema validating each inbound event payload (the collected/streamed value). */ output?: StitchConfig['output']; }; +/** Options for {@link PostMessageChannel.respond}. `input`/`output` are BARE schemas validating + * the single inbound payload / the single result (not slotted `InputSchemas` — a responder + * answers one value, not a request with `body`/`query`/… slots). */ +export interface RespondOptions { + /** Schema validating the inbound request payload (a failure DROPS the request). */ + input?: SchemaLike; + /** Schema validating the handler result (a failure suppresses the reply). */ + output?: SchemaLike; + /** The `type` the answer is posted under. Default `` `${type}-result` ``. */ + reply?: string; +} + /** * The typed, validated, observable channel — the entry point this surface exposes. Built by * {@link channel} / {@link windowChannel} / {@link portChannel}; binds the transport + the allowed @@ -182,8 +189,9 @@ export interface PostMessageChannel { * `` `${type}-result` ``. The call argument is inferred from `opts.input`; the result tracks * `opts.output` (the reply payload schema). Timeout/abort reuse the engine's `timeout`/`signal`. */ - request( - opts: C, + request( + type: string, + opts?: C, ): Stitch, InputOf>; /** * Fire-and-forget send (no reply awaited): post `{ type, payload }` and resolve immediately. A @@ -195,7 +203,10 @@ export interface PostMessageChannel { * `void send(input).catch(() => {})`. A bare `send(input)` whose result is never awaited sends * NOTHING. */ - emit(opts: C): Stitch>; + emit( + type: string, + opts?: C, + ): Stitch>; /** * Subscribe to inbound events of `opts.type`. A STREAMING surface (id `'postmessage-event'`): * `.stream()` yields live deltas; `await` collects until the stream ends (so prefer `.stream()` @@ -205,8 +216,9 @@ export interface PostMessageChannel { * malformed message should NOT end the subscription, omit `output` and validate each payload in * the consumer — drop the bad one, keep listening (an `onInvalid: 'drop'` mode is future work). */ - events( - opts: C, + events( + type: string, + opts?: C, ): Stitch[], InputOf>; /** * Register a handler that ANSWERS inbound requests of `type` (the receiving side — e.g. an @@ -224,14 +236,10 @@ export interface PostMessageChannel { respond( type: string, handler: (payload: TIn) => TOut | Promise, - opts?: { - input?: SchemaLike; - output?: SchemaLike; - reply?: string; - }, + opts?: RespondOptions, ): () => void; /** Detach the listener, reject every pending request, close every event stream. */ - close(): void; + close(): Promise; } // --------------------------------------------------------------------------- @@ -263,21 +271,32 @@ export const postMessageEventSurface: Surface = { // the channel builder // --------------------------------------------------------------------------- +// The shared `T | T[]` list normalisation (CONTRACT.md P7): one origin reads as itself. +const originList = (v: string | string[] | undefined): string[] => + v === undefined ? [] : Array.isArray(v) ? v : [v]; + +/** Options for {@link channel}. */ +export interface ChannelOptions { + /** + * Origin(s) inbound messages may come from. An origin-bearing transport (a Window) drops + * anything else BEFORE dispatch/validation; a `MessagePort` (origin `''`) skips the gate (a + * port is already private). + */ + allowedOrigins: string | string[]; +} + /** * Build a {@link PostMessageChannel} over ANY {@link MessageTransport} — the core builder the other * two delegate to (and what the tests drive with a fake transport). Attaches the single demux * listener at construction; binds `allowedOrigins` as the security policy. * * @param transport The raw channel (a Window / port / a fake pair in tests). - * @param opts.allowedOrigins Origins inbound messages may come from. An origin-bearing transport - * (a Window) drops anything else BEFORE dispatch/validation; a `MessagePort` (origin `''`) skips - * the gate (a port is already private). */ export function channel( transport: MessageTransport, - opts: { allowedOrigins: string[] }, + opts: ChannelOptions, ): PostMessageChannel { - return makeChannel(transport, opts.allowedOrigins); + return makeChannel(transport, originList(opts.allowedOrigins)); } /** @@ -290,11 +309,16 @@ export function channel( * wildcard target posts the message to whatever document currently occupies the frame — a classic * postMessage data leak. The type forbids it; this catches a `as any` cast too. */ -export function windowChannel(opts: { +export interface WindowChannelOptions { + /** The window to post to — or a thunk resolving it lazily (a frame that mounts late). */ target: Window | (() => Window); + /** The concrete (non-wildcard) origin outbound messages are addressed to. */ targetOrigin: Origin; - allowedOrigins?: string[]; -}): PostMessageChannel { + /** Origin(s) inbound messages may come from. Default `[targetOrigin]`. */ + allowedOrigins?: string | string[]; +} + +export function windowChannel(opts: WindowChannelOptions): PostMessageChannel { if ((opts.targetOrigin as string) === '*') throw new Error( "postmessage: targetOrigin '*' is forbidden — name the exact origin (e.g. 'https://app.example.com'). A wildcard posts to whatever document occupies the frame.", @@ -335,7 +359,12 @@ export function windowChannel(opts: { }; }, }; - return makeChannel(transport, opts.allowedOrigins ?? [opts.targetOrigin]); + return makeChannel( + transport, + opts.allowedOrigins !== undefined + ? originList(opts.allowedOrigins) + : [opts.targetOrigin], + ); } /** @@ -346,7 +375,7 @@ export function windowChannel(opts: { */ export function portChannel( port: MessagePort, - opts?: { allowedOrigins?: string[] }, + opts?: { allowedOrigins?: string | string[] }, ): PostMessageChannel { const transport: MessageTransport = { post: (message, transfer) => { @@ -364,7 +393,7 @@ export function portChannel( }, }; // A port has no origin, so allowedOrigins is moot; carry whatever was passed for symmetry. - return makeChannel(transport, opts?.allowedOrigins ?? []); + return makeChannel(transport, originList(opts?.allowedOrigins)); } // The single implementation behind all three builders. Holds the per-channel registry (pending @@ -463,10 +492,12 @@ function makeChannel( } // ---- request: a buffered execute surface -------------------------------- - function request( - opts: C, + function request( + type: string, + opts?: C, ): Stitch, InputOf> { - const { type, reply, input, output, ...rest } = opts; + const { reply, input, output, ...rest } = (opts ?? + {}) as RequestOptions; const replyType = reply ?? `${type}-result`; const url = `postmessage:${type}`; // `execute` (ADR 0008) replaces the transport at the engine's adapter call site, INSIDE the @@ -552,10 +583,11 @@ function makeChannel( } // ---- emit: a buffered execute surface, no reply ------------------------- - function emit( - opts: C, + function emit( + type: string, + opts?: C, ): Stitch> { - const { type, input, ...rest } = opts; + const { input, ...rest } = (opts ?? {}) as EmitOptions; const url = `postmessage:${type}`; const surface: Surface = { id: 'postmessage', @@ -591,10 +623,11 @@ function makeChannel( } // ---- events: a streaming surface ---------------------------------------- - function events( - opts: C, + function events( + type: string, + opts?: C, ): Stitch[], InputOf> { - const { type, output, ...rest } = opts; + const { output, ...rest } = (opts ?? {}) as EventsOptions; const url = `postmessage:${type}`; // `execute` returns a live `ReadableStream` of PAYLOADS the channel feeds via an event // subscription (the inbound envelope's `payload` is extracted at enqueue), so a `delta` and @@ -689,11 +722,7 @@ function makeChannel( function respond( type: string, handler: (payload: TIn) => TOut | Promise, - opts?: { - input?: SchemaLike; - output?: SchemaLike; - reply?: string; - }, + opts?: RespondOptions, ): () => void { const responder: Responder = { handler: handler as (payload: unknown) => unknown, @@ -714,8 +743,10 @@ function makeChannel( } // ---- close: tear everything down ---------------------------------------- - function close(): void { - if (closed) return; + // Promise-returning for interface symmetry with the other teardown verbs (`seam.close`); + // the teardown itself is synchronous. + function close(): Promise { + if (closed) return Promise.resolve(); closed = true; detach(); for (const [id, p] of pending) { @@ -728,6 +759,7 @@ function makeChannel( responders.clear(); eventSubs.clear(); liveStreams.clear(); + return Promise.resolve(); } return { request, emit, events, respond, close }; diff --git a/packages/core/src/resilience.ts b/packages/core/src/resilience.ts index 23997c77..c71d10df 100644 --- a/packages/core/src/resilience.ts +++ b/packages/core/src/resilience.ts @@ -7,6 +7,7 @@ import type { CircuitOptions, Clock, RetryOptions, + StatusMatch, StitchStore, ThrottleOptions, } from './types'; @@ -14,6 +15,21 @@ import { parseDuration, parseRate, systemClock } from './util'; export class TimeoutError extends Error {} +/** + * Normalize a status-match field (a bare number, a number list, a predicate, or unset) into a + * single predicate — the shared CONTRACT.md P7 matcher every status-match slot runs through + * (`retry.on`, `throttle.on`, `acceptStatus`, the auth strategies' `refresh`/`refresh.on`, …). Unset → + * accept nothing. + */ +export function acceptsStatus( + accept: StatusMatch | undefined, +): (status: number) => boolean { + if (accept === undefined) return () => false; + if (typeof accept === 'function') return accept; + if (typeof accept === 'number') return (status) => status === accept; + return (status) => accept.includes(status); +} + /** * Backoff (ms) BEFORE the given 1-based `attempt` (attempt=2 is the first retry). * 'expo' = base * 2^(attempt-2); 'expo-jitter' adds random jitter in [0, computed]; @@ -21,10 +37,8 @@ export class TimeoutError extends Error {} */ export function backoffDelay(attempt: number, opts?: RetryOptions): number { const kind = opts?.backoff ?? 'expo-jitter'; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `baseMs` is the @deprecated alias of `baseDelay`, read for back-compat until the GA cut (CONTRACT.md P17) - const base = parseDuration(opts?.baseDelay ?? opts?.baseMs) ?? 100; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `maxMs` is the @deprecated alias of `maxDelay`, read for back-compat until the GA cut (CONTRACT.md P17) - const max = parseDuration(opts?.maxDelay ?? opts?.maxMs) ?? 10_000; + const base = parseDuration(opts?.baseDelay) ?? 100; + const max = parseDuration(opts?.maxDelay) ?? 10_000; const exp = Math.max(0, attempt - 2); // attempt 2 -> 2^0 let delay: number; if (kind === 'fixed') { @@ -83,8 +97,7 @@ export function createThrottle( const limit = opts?.concurrency; const rate = opts?.rate ? parseRate(opts.rate) : undefined; const spacing = rate ? rate.per / rate.count : 0; // ms between grants - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `scope` is the @deprecated alias of `pool`, read as the back-compat fallback until the GA cut (CONTRACT.md P2) - const hostPooled = (opts?.pool ?? opts?.scope) === 'host'; + const hostPooled = opts?.pool === 'host'; const states = hostPooled ? hostStates : new Map(); const stateFor = (key: string): KeyState => { @@ -239,37 +252,44 @@ export class CircuitOpenError extends Error { /** * Thrown (and surfaced as an `error` event) when a stitch runs in **delegate-backoff** mode - * (`rateLimit.delegate`) and the response carries a rate-limit status (default `429`). Instead of + * (`throttle.delegate`) and the response carries a rate-limit status (default `429`). Instead of * retrying internally or pacing on the built-in throttle, the engine surfaces the outcome so an * OUTER gate/circuit — owned by the host — decides the backoff (issue #145). Carries the structured * signal that gate needs: the `status`, the `retryAfter` parsed from `Retry-After` (delta-seconds * OR HTTP-date; `undefined` when the header is absent/unparseable), and the raw `response` so the - * host can read other rate headers (`X-RateLimit-*`, etc.). The full `response` rides on the live - * instance only — never the serialized `error` event — so it cannot leak into a trace sink. + * host can read other rate headers (`X-RateLimit-*`, etc.). `attempts`/`body`/`url` mirror + * {@link StitchError}'s field set (CONTRACT.md P10), lifted from the response/run state at + * construction. The full `response` rides on the live instance only — never the serialized `error` + * event — so it cannot leak into a trace sink. */ export class RateLimitError extends Error { readonly status: number; /** `Retry-After` parsed to ms (delta-seconds OR HTTP-date); `undefined` when absent/unparseable. */ readonly retryAfter: number | undefined; - /** @deprecated Renamed to {@link RateLimitError.retryAfter} (CONTRACT.md P17). Read until the 1.0 GA cut. */ - readonly retryAfterMs: number | undefined; + /** Attempts made before the rate-limit outcome surfaced (1 = the first request). Mirrors `StitchError.attempts` (P10). */ + readonly attempts: number; + /** The parsed body of the rate-limited response, lifted from `response.body` (P10). */ + readonly body?: unknown; + /** The final request URL of the rate-limited response, when the transport exposes it (P10). */ + readonly url?: string; readonly response: AdapterResponse; constructor(opts: { status: number; retryAfter?: number | undefined; - /** @deprecated Use `retryAfter` (CONTRACT.md P17). */ - retryAfterMs?: number | undefined; response: AdapterResponse; + attempts?: number | undefined; message?: string; }) { super(opts.message ?? `rate limited (HTTP ${opts.status})`); this.name = 'RateLimitError'; this.status = opts.status; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- read the @deprecated constructor alias for back-compat (CONTRACT.md P17) - this.retryAfter = opts.retryAfter ?? opts.retryAfterMs; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- co-set the @deprecated field alias for back-compat (CONTRACT.md P17) - this.retryAfterMs = this.retryAfter; + this.retryAfter = opts.retryAfter; + this.attempts = opts.attempts ?? 0; this.response = opts.response; + // Lift the P10 fields off the response at construction so the error matches StitchError's + // shape without the caller reaching into `.response`. + if (opts.response.body !== undefined) this.body = opts.response.body; + if (opts.response.url !== undefined) this.url = opts.response.url; } } @@ -285,8 +305,8 @@ interface CircuitRecord { * a success closes it, another failure re-opens it. State lives in the StitchStore, so a shared * store gives a breaker shared across workers (DESIGN.md §13). * - * `failures` and `cooldown` are required by design (CONTRACT.md P15); this throws if neither they - * nor their deprecated `failureThreshold`/`cooldownMs` aliases are set. + * `failures` and `cooldown` are required by design (CONTRACT.md P15); this throws when either is + * missing. */ export function createCircuit( opts: CircuitOptions, @@ -298,17 +318,13 @@ export function createCircuit( onSuccess(): Promise; onFailure(): Promise; } { - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `failureThreshold` is the @deprecated alias of `failures` (CONTRACT.md P4) - const failureThreshold = opts.failures ?? opts.failureThreshold; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `cooldownMs` is the @deprecated alias of `cooldown` (CONTRACT.md P17) - const cooldown = parseDuration(opts.cooldown ?? opts.cooldownMs); + const failureThreshold = opts.failures; + const cooldown = parseDuration(opts.cooldown); if (failureThreshold == null || cooldown == null) throw new Error( - 'circuit requires `failures` and `cooldown`. Fix: set both, e.g. `circuit: { failures: 5, cooldown: "30s" }`.', + 'circuit requires `failures` and `cooldown`. Fix: set both, e.g. `circuit: { failures: 5, cooldown: "30s" }` or `circuit: [5, "30s"]`.', ); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `halfOpenAfterMs` is the @deprecated alias of `halfOpenAfter` (CONTRACT.md P17) - const halfOpenInput = opts.halfOpenAfter ?? opts.halfOpenAfterMs; - const halfOpenAfter = parseDuration(halfOpenInput) ?? cooldown; + const halfOpenAfter = parseDuration(opts.halfOpenAfter) ?? cooldown; const nsKey = 'circuit:' + (opts.key ?? fallbackKey); const read = async (): Promise => diff --git a/packages/core/src/seam.ts b/packages/core/src/seam.ts index 2343f7a5..c811da89 100644 --- a/packages/core/src/seam.ts +++ b/packages/core/src/seam.ts @@ -37,6 +37,12 @@ import { systemClock } from './util'; // Per-seam id so the shared bucket's store-counter key never collides across seams sharing a store. let seamCounter = 0; +// The seam builds throttles from the RAW authoring config (before `compose` runs for the member), +// so expand the P12 rate-string shorthand here: `'2/s'` ≡ `{ rate: '2/s' }`. +const throttleOptions = ( + t: StitchConfig['throttle'], +): ThrottleOptions | undefined => (typeof t === 'string' ? { rate: t } : t); + /** * The seam's shared throttle bucket. Member stitches all acquire it under ONE seam-stable key, so * the budget (rate + in-process concurrency) pools across every stitch — "one shared bucket" @@ -49,8 +55,7 @@ function seamBucket( clock: Clock, ): Throttle { const inner = createStoreThrottle(opts, store, clock); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `scope` is the @deprecated alias of `pool`, read as the back-compat fallback until the GA cut (CONTRACT.md P2) - if ((opts?.pool ?? opts?.scope) === 'host') return inner; // host key already pools across the seam + if (opts?.pool === 'host') return inner; // host key already pools across the seam const key = `seam:${seamId}`; return { // Re-key every acquire onto the one seam-stable key, forwarding the acquire options (e.g. @@ -89,19 +94,30 @@ function makeBuild(shared: SharedSeam, principal: string | undefined) { // keys it per-stitch, so it limits just this stitch ON TOP OF the shared budget — it can // add a stricter gate but never replace or escape the seam's. const local = own.throttle - ? createStoreThrottle(own.throttle, shared.store, shared.clock) + ? createStoreThrottle( + throttleOptions(own.throttle), + shared.store, + shared.clock, + ) : undefined; const throttle = local ? chainThrottle([shared.throttle, local]) : shared.throttle; + // A member's `extends` may be the single-fragment shorthand (P7) — fold it into the list. + const ownExtends = + own.extends === undefined + ? [] + : Array.isArray(own.extends) + ? own.extends + : [own.extends]; const cfg: Partial = { ...own, - extends: [shared.fragment, ...(own.extends ?? [])], + extends: [shared.fragment, ...ownExtends], }; if (isGql) { cfg.kind = graphqlSurface; - cfg.unwrap = own.unwrap ?? 'data'; + cfg.pick = own.pick ?? 'data'; // Default the endpoint to `/graphql` when the member gives neither `url` nor `path` // (method/body shaping is the surface's). if (own.url === undefined && own.path === undefined) @@ -146,7 +162,7 @@ function principalHandle(shared: SharedSeam, principal: string): PrincipalSeam { // of that return under an unresolved `C` — so `graphql` needs the `as`. Sound — runtime is // identical; only the static call-arg richness is restored. stitch: (config: string | Partial) => build(config), - graphql: ((config: Partial & { query: string }) => + graphql: ((config: Partial & { document: string }) => build(config, true)) as PrincipalSeam['graphql'], as: (p) => principalHandle(shared, p), get __config() { @@ -165,7 +181,7 @@ function rootHandle(shared: SharedSeam): Seam { // restore its rich `InputOf` return after #76 widened `InputOf`; `stitch` satisfies its // loose fallback overload as-is. stitch: (config: string | Partial) => build(config), - graphql: ((config: Partial & { query: string }) => + graphql: ((config: Partial & { document: string }) => build(config, true)) as Seam['graphql'], as: (p) => principalHandle(shared, p), // Bulk cache invalidation over the seam's shared store (ADR 0003 §8). No argument bumps @@ -215,7 +231,12 @@ export function seam(options: SeamOptions = {}): Seam { clock, vault, trace, - throttle: seamBucket(fragment.throttle, store, seamId, clock), + throttle: seamBucket( + throttleOptions(fragment.throttle), + store, + seamId, + clock, + ), // `compact`'s `const` generic would freeze `[]` to `readonly []`; SharedSeam.stitches // is mutable, so pin the element type. stitches: [] as Stitch[], diff --git a/packages/core/src/sse.ts b/packages/core/src/sse.ts index a9712818..522539f1 100644 --- a/packages/core/src/sse.ts +++ b/packages/core/src/sse.ts @@ -190,7 +190,7 @@ export interface SseSeamApi { // keeping `SseEvent[]` identical to the old `SseEvent[]`. The `as` retypes the loose // `makeStitch` result to the declared `InputOf`/`SseEvent>[]`: now that `InputOf` reads // `extends`-fragment schemas (#76) it is no longer a clean supertype of `StitchInput` under an -// unresolved `C`, so this loose body needs the same retype `stitch()`/`seam` get from their +// unresolved `C`, so this loose body needs the same retype `stitch()`/`bind` get from their // inferring overloads. Sound — the runtime stitch is byte-identical (the type tests cover it). const sseStitch = < const C extends Partial = Partial, @@ -220,13 +220,13 @@ function bindSeam(s: Seam): SseSeamApi { /** * The sse surface's authoring helper — callable for the terse form (`sse(config)`) plus: * - `sse.stitch(config)` — a standalone sse stitch (alias of the callable). - * - `sse.seam(existingSeam)` — bind sse members to an existing seam. - * - `sse.seam(options)` — a new seam whose members default to sse. + * - `sse.bind(existingSeam)` — bind sse members to an existing seam. + * - `sse.bind(options)` — a new seam whose members default to sse. * - `sse.surface` — the sse {@link Surface} identity. */ export const sse = Object.assign(sseStitch, { surface: sseSurface, stitch: sseStitch, - seam: (arg: Seam | SeamOptions): SseSeamApi => + bind: (arg: Seam | SeamOptions): SseSeamApi => bindSeam(isSeam(arg) ? arg : makeSeam(arg)), }); diff --git a/packages/core/src/stitch.ts b/packages/core/src/stitch.ts index 672677c1..d333edc4 100644 --- a/packages/core/src/stitch.ts +++ b/packages/core/src/stitch.ts @@ -16,7 +16,7 @@ import { makeRuntime, } from './engine'; import type { InferOutput, InputOf, ResolveOutput } from './infer'; -import { otlpTrace } from './otlp'; +import { otlpSink } from './otlp'; import { RateLimitError, createThrottle } from './resilience'; import { createStoreThrottle, memoryStore } from './store'; import { graphqlSurface } from './surface'; @@ -75,11 +75,18 @@ function asConfig(f: Fragment): Partial { return f; } +// A single fragment is shorthand for a one-element list (P7); `Stitch` is a function and a bare +// partial is an object, so `Array.isArray` cleanly separates the two spellings. +function fragmentList(ext: StitchConfig['extends']): Fragment[] { + if (ext === undefined) return []; + return Array.isArray(ext) ? ext : [ext]; +} + function flatten(layers: Fragment[]): Partial[] { const out: Partial[] = []; for (const layer of layers) { const cfg = asConfig(layer); - if (cfg.extends) out.push(...flatten(cfg.extends)); + if (cfg.extends) out.push(...flatten(fragmentList(cfg.extends))); const rest = { ...cfg }; delete (rest as { extends?: unknown }).extends; out.push(rest); @@ -113,7 +120,15 @@ function chainHooks(layers: Hooks[]): Hooks | undefined { function normalizeOutput(out: StitchConfig['output']): StitchConfig['output'] { if (!out) return undefined; - if ((out as Partial).__kind === 'drift') return out; + if ((out as Partial).__kind === 'drift') { + // P7: `ignore` accepts a bare string — fold it into its list form so the drift + // classifier always sees an array (a hand-built DriftSpec normalizes here too). + const spec = out as DriftSpec; + const { ignore } = spec.options; + if (typeof ignore === 'string') + return { ...spec, options: { ...spec.options, ignore: [ignore] } }; + return out; + } return toValidator(out); } @@ -137,21 +152,57 @@ function normalizeInput( return out; } -// Expand the scalar shorthands (`retry: 3`, `timeout: '5s'`, `cache: '1m'`) to their object form -// IN PLACE, before the deep-merge, so a literal in one layer folds cleanly into an object in -// another and the resolved config the engine reads is always the normalised shape. +// Expand every authoring shorthand to its canonical envelope field (P0/P12/P13) IN PLACE, before +// the deep-merge, so a literal in one layer folds cleanly into an object in another and the +// resolved config the engine reads is always the normalised shape. `cfg` is always a per-layer +// shallow copy; nested objects are cloned before being rewritten so a shared fragment is never +// mutated. function expandShorthand(cfg: Partial): void { if (typeof cfg.retry === 'number') cfg.retry = { attempts: cfg.retry }; if (typeof cfg.timeout === 'number' || typeof cfg.timeout === 'string') cfg.timeout = { total: cfg.timeout }; if (typeof cfg.cache === 'number' || typeof cfg.cache === 'string') cfg.cache = { ttl: cfg.cache }; + if (typeof cfg.stream === 'string') cfg.stream = { decode: cfg.stream }; + if (typeof cfg.multipart === 'string') + cfg.multipart = { nesting: cfg.multipart }; + if (typeof cfg.throttle === 'string') cfg.throttle = { rate: cfg.throttle }; + // P13: `sse: true` enables reconnection with defaults; `false`/absent is off. + if (cfg.sse === true) cfg.sse = { reconnect: true }; + else if (cfg.sse === false) delete cfg.sse; + // P15: the positional circuit names both required fields — `[5, '30s']` ≡ + // `{ failures: 5, cooldown: '30s' }`. + if (Array.isArray(cfg.circuit)) { + const [failures, cooldown] = cfg.circuit; + cfg.circuit = { failures, cooldown }; + } // P20: `idempotency: true` enables it with defaults; `false`/absent is off. Normalize the // boolean toggle to the object form the engine reads (the opaque `idempotency: {}` is a type // error at the slot, so the all-defaults case arrives here as `true`). if (cfg.idempotency === true) (cfg as { idempotency?: IdempotencyOptions }).idempotency = {}; else if (cfg.idempotency === false) delete cfg.idempotency; + // P7: fold every bare scalar of a status-match / list field into its list form, so the engine + // and `__config` only ever see `number[]` (or a predicate) / `string[]`. + if (typeof cfg.acceptStatus === 'number') + cfg.acceptStatus = [cfg.acceptStatus]; + if (typeof cfg.retry === 'object' && typeof cfg.retry.on === 'number') + cfg.retry = { ...cfg.retry, on: [cfg.retry.on] }; + if (typeof cfg.throttle === 'object' && typeof cfg.throttle.on === 'number') + cfg.throttle = { ...cfg.throttle, on: [cfg.throttle.on] }; + if (typeof cfg.cache === 'object') { + const cache = cfg.cache; + if (typeof cache.vary === 'string' || typeof cache.methods === 'string') + cfg.cache = { + ...cache, + ...(typeof cache.vary === 'string' + ? { vary: [cache.vary] } + : {}), + ...(typeof cache.methods === 'string' + ? { methods: [cache.methods] } + : {}), + }; + } } export function compose(config: Fragment): ResolvedStitchConfig { @@ -196,16 +247,19 @@ export function compose(config: Fragment): ResolvedStitchConfig { // last to set it and set it off. if (idempotencyToggle === false) delete merged.idempotency; } + // The chained hooks / normalized input are the RESOLVED shapes (plain `Hooks`/`InputSchemas`), + // past the authoring-side `AtLeastOne` gate — write them through the resolved view. + const resolved = merged as ResolvedStitchConfig; const hooks = chainHooks(hookLayers); - if (hooks) merged.hooks = hooks; + if (hooks) resolved.hooks = hooks; if (store) merged.store = store; if (kind) merged.kind = kind; const output = normalizeOutput(merged.output); if (output !== undefined) merged.output = output; const input = normalizeInput(merged.input); - if (input !== undefined) merged.input = input; - // `expandShorthand` ran on every layer, so retry/timeout/cache are now their object form. - return merged as ResolvedStitchConfig; + if (input !== undefined) resolved.input = input; + // `expandShorthand` ran on every layer, so every scalar shorthand is now its envelope form. + return resolved; } // Construction-time nudges for `idempotency` misuse — hints with an out, never errors. Two cases, @@ -230,10 +284,9 @@ function warnIdempotency(cfg: ResolvedStitchConfig): void { ); return; } - // A derived key (either spelling — `keyOf`, or the @deprecated `key` alias) dedupes - // resubmissions on its own, so only the random default with no retry is the inert case. - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `key` is the back-compat alias of `keyOf` (CONTRACT.md P6) - if (idem.keyOf || idem.key || cfg.retry) return; + // A derived key (`keyOf`) dedupes resubmissions on its own, so only the random default with + // no retry is the inert case. + if (idem.keyOf || cfg.retry) return; console.warn( `stitchapi: \`${name}\` has \`idempotency\` with a random key and no \`retry\`, so it ` + `only dedupes its own retries — add \`retry\`, or set \`idempotency.keyOf\`.`, @@ -270,16 +323,16 @@ function maxBodyFromEnv(value: string | undefined): number | false | undefined { function getTrace(): TraceSink { const file = fileFromEnv(readEnv('STITCH_TRACE_FILE')); - const maxBodyBytes = maxBodyFromEnv(readEnv('STITCH_TRACE_MAX_BODY')); + const maxBodyChars = maxBodyFromEnv(readEnv('STITCH_TRACE_MAX_BODY')); const base = createTrace( compact({ console: readEnv('STITCH_TRACE_CONSOLE') === '1', file, - maxBodyBytes, + maxBodyChars, }), ); if (!exportsFromEnv(readEnv('STITCH_EXPORT')).includes('otlp')) return base; - return multiplex(base, otlpTrace()); + return multiplex(base, otlpSink()); } // A sink that drops every event — `trace: false` forces tracing off even when the @@ -374,11 +427,9 @@ function makeInspection( error: StitchError | null, source: Inspection['source'], ): Inspection { - // `data` is canonical; `value` is co-set as the @deprecated alias (CONTRACT.md P5). // `source` (ADR 0019) rides as a normal enumerable field — the interpretant of `raw`. const wrapper = { data: value, - value, findings, status, error, @@ -401,7 +452,7 @@ interface Drained { error: StitchError | null; source: Inspection['source']; attempts: number; - ms: number; + elapsed: number; waited: number; /** The `phase:'cache'` event detail seen this run, if any (e.g. 'hit', 'miss', 'bypass: …'). */ cacheDetail: string | undefined; @@ -427,7 +478,7 @@ async function drainRun( let streamed = false; let cacheHit = false; let attempts = 0; - let ms = 0; + let elapsed = 0; let waited = 0; let cacheDetail: string | undefined; const readRaw = (carrier: object): void => { @@ -456,7 +507,7 @@ async function drainRun( error = asStitchError(rebuilt); readRaw(rebuilt); } else if (ev.type === 'done') { - ms = ev.elapsed; + elapsed = ev.elapsed; if (ev.attempts) attempts = ev.attempts; } } @@ -481,12 +532,22 @@ async function drainRun( error, source, attempts, - ms, + elapsed, waited, cacheDetail, }; } +// P13/P20: the probe opts scalar — `true` ≡ `{ cache: true }` (honour the cache policy); +// `false`/absent is the default fresh, cache-bypassing probe. +function inspectOptions( + opts: boolean | InspectOptions | undefined, +): InspectOptions | undefined { + if (opts === true) return { cache: true }; + if (opts === false) return undefined; + return opts; +} + // ADR 0018: opt-in redaction of `raw` — applied AFTER findings are computed so the diff runs on the // unredacted body. `raw` is only non-null when a live request ran (streaming / cache hits stay // null, so redaction is a no-op on null). @@ -531,10 +592,11 @@ function cacheOutcome( } // `.report()` consumer (ADR 0019): the same drained run as `.inspect()`, assembled into a -// `RunReport` — the `Inspection` fields plus `attempts`, `timing` (`{ ms, waited? }`), the resolved -// redacted `config`, and the fine-grained `cache` outcome. `config` is the stitch's ALREADY-redacted -// `__config` (never `__rawConfig`). Never throws. `waited` is omitted entirely when nothing waited -// (exactOptionalPropertyTypes), so its absence reads as "no backoff/throttle wait". +// `RunReport` — the `Inspection` fields plus `attempts`, `timing` (`{ elapsed, waited? }`), the +// resolved redacted `config`, and the fine-grained `cache` outcome. `config` is the stitch's +// ALREADY-redacted `__config` (never `__rawConfig`). Never throws. `waited` is omitted entirely +// when nothing waited (exactOptionalPropertyTypes), so its absence reads as "no backoff/throttle +// wait". async function consumeReport( gen: AsyncGenerator, void>, config: RedactedStitchConfig, @@ -550,7 +612,9 @@ async function consumeReport( d.source, ); const timing: RunReport['timing'] = - d.waited > 0 ? { ms: d.ms, waited: d.waited } : { ms: d.ms }; + d.waited > 0 + ? { elapsed: d.elapsed, waited: d.waited } + : { elapsed: d.elapsed }; const report = base as RunReport; report.attempts = d.attempts; report.timing = timing; @@ -663,8 +727,12 @@ export interface SharedRuntime { } // `__config` is the PUBLIC view; strip the live secret-bearing handles so the running store, -// credential, and transport cannot be read back off a stitch (ADR 0002 §4/§6, exfil-at-rest). -// The full config lives on `__rawConfig` for fragment composition (see `asConfig`). +// credential, and transport cannot be read back off a stitch (ADR 0002 §4/§6, exfil-at-rest), and +// strip EVERY function-valued field (deep) so `__config` is plain JSON data (CONTRACT.md P0): the +// url/baseUrl thunks, `transform`, `hooks`, `paginate.next`/`items`, the predicate forms of +// `retry.on`/`throttle.on`/`acceptStatus` (a `number[]` stays), `idempotency.keyOf`, and +// `cache.keyOf`. The engine reads all that sugar off the non-enumerable `__rawConfig` (which also +// backs fragment composition — see `asConfig`). export function redactConfig(cfg: ResolvedStitchConfig): RedactedStitchConfig { // Split off the live `Surface` so the spread carries no `kind: Surface`; the rest still holds // the live store/auth/adapter handles, stripped next. @@ -674,10 +742,11 @@ export function redactConfig(cfg: ResolvedStitchConfig): RedactedStitchConfig { auth?: unknown; adapter?: unknown; clock?: unknown; + transform?: unknown; + hooks?: unknown; }; // Strip the live, secret-bearing handles so the running store, credential, and transport cannot - // be read back off a stitch (ADR 0002 §4/§6, exfil-at-rest). The full config lives on the - // non-enumerable `__rawConfig` for fragment composition (see `asConfig`). + // be read back off a stitch (ADR 0002 §4/§6, exfil-at-rest). delete redacted.store; delete redacted.auth; delete redacted.adapter; @@ -691,6 +760,36 @@ export function redactConfig(cfg: ResolvedStitchConfig): RedactedStitchConfig { // Normalise the surface to its id string so __config round-trips as JSON (ADR 0005 Decision 11): // never expose the live Surface (its hooks don't serialise), only its identity. if (kind) redacted.kind = kind.id; + // P0: everything below strips the function-valued sugar. A thunked endpoint has no static + // string to expose, so the slot is simply absent on the public view. + if (typeof cfg.url === 'function') delete redacted.url; + if (typeof cfg.baseUrl === 'function') delete redacted.baseUrl; + delete redacted.transform; + delete redacted.hooks; + if (cfg.paginate) + redacted.paginate = + cfg.paginate.pages !== undefined + ? { pages: cfg.paginate.pages } + : {}; + if (typeof cfg.acceptStatus === 'function') delete redacted.acceptStatus; + if (cfg.retry) { + const { on, ...retry } = cfg.retry; + redacted.retry = Array.isArray(on) ? { ...retry, on } : retry; + } + if (cfg.throttle) { + const { on, ...throttle } = cfg.throttle; + redacted.throttle = Array.isArray(on) ? { ...throttle, on } : throttle; + } + if (cfg.idempotency) { + const idempotency = { ...cfg.idempotency }; + delete idempotency.keyOf; + redacted.idempotency = idempotency; + } + if (cfg.cache) { + const cache = { ...cfg.cache }; + delete cache.keyOf; + redacted.cache = cache; + } return redacted; } @@ -716,7 +815,7 @@ function attachCacheSurface( Object.defineProperty(target, 'cache', { value: { invalidate: () => cacheInvalidateBulk(rt), - key: (input?: StitchInput) => cacheKeyOf(rt, resolve(input)), + keyOf: (input?: StitchInput) => cacheKeyOf(rt, resolve(input)), }, }); } @@ -756,22 +855,31 @@ export function makeStitch( const streamFn = (input?: StitchInput) => streamWith(input ?? {}, newRunContext()); // `.inspect()` (ADR 0016 / ADR 0018): one fresh root run with the raw body retained and the - // cache bypassed by default (`{ cache: true }` opts caching back in). Consumed by the + // cache bypassed by default (`true` / `{ cache: true }` opts caching back in). Consumed by the // never-throwing `consumeInspect`, which also applies opt-in redaction (ADR 0018). - const inspectFn = (input: StitchInput, opts?: InspectOptions) => - consumeInspect( + const inspectFn = ( + input: StitchInput, + rawOpts?: boolean | InspectOptions, + ) => { + const opts = inspectOptions(rawOpts); + return consumeInspect( streamWith(input, newRunContext(), { retainRaw: true, bypassCache: !opts?.cache, }), opts, ); + }; // `.report()` (ADR 0019): the same fresh, raw-retaining, cache-bypassing-by-default probe as // `.inspect()`, drained by `consumeReport` into a `RunReport` (the Inspection plus run // diagnostics). The config echo is the stitch's ALREADY-redacted `__config` — never `__rawConfig`. const reportConfig = redactConfig(cfg); - const reportFn = (input: StitchInput, opts?: InspectOptions) => - consumeReport( + const reportFn = ( + input: StitchInput, + rawOpts?: boolean | InspectOptions, + ) => { + const opts = inspectOptions(rawOpts); + return consumeReport( streamWith(input, newRunContext(), { retainRaw: true, bypassCache: !opts?.cache, @@ -779,6 +887,7 @@ export function makeStitch( reportConfig, opts, ); + }; const result = (input?: StitchInput): StitchResult => { const make = () => streamFn(input); @@ -825,9 +934,9 @@ export function makeStitch( stitchFn.stream = streamFn; stitchFn.safe = (input?: StitchInput) => consumeSafe(streamFn(input)); stitchFn.unwrap = (input?: StitchInput) => consume(streamFn(input)); - stitchFn.inspect = (input?: StitchInput, opts?: InspectOptions) => + stitchFn.inspect = (input?: StitchInput, opts?: boolean | InspectOptions) => inspectFn(input ?? {}, opts); - stitchFn.report = (input?: StitchInput, opts?: InspectOptions) => + stitchFn.report = (input?: StitchInput, opts?: boolean | InspectOptions) => reportFn(input ?? {}, opts); stitchFn.with = (partial: StitchInput) => { // bind partial input; the bound stitch reuses the same runtime (cookies/throttle persist) @@ -849,9 +958,11 @@ export function makeStitch( consumeSafe(streamFn(mergeInput(partial, input))); bound.unwrap = (input?: StitchInput) => consume(streamFn(mergeInput(partial, input))); - bound.inspect = (input?: StitchInput, opts?: InspectOptions) => - inspectFn(mergeInput(partial, input), opts); - bound.report = (input?: StitchInput, opts?: InspectOptions) => + bound.inspect = ( + input?: StitchInput, + opts?: boolean | InspectOptions, + ) => inspectFn(mergeInput(partial, input), opts); + bound.report = (input?: StitchInput, opts?: boolean | InspectOptions) => reportFn(mergeInput(partial, input), opts); bound.with = (more: StitchInput) => stitchFn.with(mergeInput(partial, more)); @@ -932,20 +1043,25 @@ export function drift( schema: S, options: DriftOptions = {}, ): DriftSpec> { + // P7: fold a bare `ignore` string into its list form at construction. + const normalized = + typeof options.ignore === 'string' + ? { ...options, ignore: [options.ignore] } + : options; return { __kind: 'drift', schema: toValidator(schema) as Validator>, - options, + options: normalized, }; } -/** graphql(): a stitch preset for GraphQL-over-HTTP — POST { query, variables }, unwrap `data`. */ +/** graphql(): a stitch preset for GraphQL-over-HTTP — POST { query, variables }, picks `data`. */ export function graphql< TExplicit = never, const C extends Partial & { - query: string; + document: string; } = Partial & { - query: string; + document: string; }, >(config: C): Stitch, InputOf> { // Default the endpoint to `/graphql` only when neither `url` nor `path` is given (preserves the @@ -960,6 +1076,6 @@ export function graphql< ...config, ...(endpointless ? { path: '/graphql' } : {}), kind: graphqlSurface, - unwrap: config.unwrap ?? 'data', + pick: config.pick ?? 'data', }) as unknown as Stitch, InputOf>; } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 26b03f2f..3b42fca6 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -50,7 +50,8 @@ export function memoryStore(): StitchStore { sweepExpired(); data.set(key, { value: n, - expires: live(e) ? e!.expires : now() + ttl, + // Absent `ttl` = no window: the counter never expires (0 marks "live forever"). + expires: live(e) ? e!.expires : ttl ? now() + ttl : 0, }); return n; }, diff --git a/packages/core/src/stream.ts b/packages/core/src/stream.ts index fff41471..ed3b062f 100644 --- a/packages/core/src/stream.ts +++ b/packages/core/src/stream.ts @@ -131,7 +131,7 @@ export interface StreamSeamApi { // `'bytes'`/`'lines'` decoders, `OutputOf` for `'ndjson'`. The `as` retypes the loose `makeStitch` // result to the declared `InputOf`/`StreamElement[]`: now that `InputOf` reads `extends`-fragment // schemas (#76) it is no longer a clean supertype of `StitchInput` under an unresolved `C`, so this -// loose body needs the same retype `stitch()`/`seam` get from their inferring overloads. Sound — the +// loose body needs the same retype `stitch()`/`bind` get from their inferring overloads. Sound — the // runtime stitch is byte-identical (the type tests cover it). const streamStitch = < const C extends Partial = Partial, @@ -160,13 +160,13 @@ function bindSeam(s: Seam): StreamSeamApi { /** * The stream surface's authoring helper — callable for the terse form (`stream(config)`) plus: * - `stream.stitch(config)` — a standalone stream stitch (alias of the callable). - * - `stream.seam(existingSeam)` — bind stream members to an existing seam. - * - `stream.seam(options)` — a new seam whose members default to stream. + * - `stream.bind(existingSeam)` — bind stream members to an existing seam. + * - `stream.bind(options)` — a new seam whose members default to stream. * - `stream.surface` — the stream {@link Surface} identity. */ export const stream = Object.assign(streamStitch, { surface: streamSurface, stitch: streamStitch, - seam: (arg: Seam | SeamOptions): StreamSeamApi => + bind: (arg: Seam | SeamOptions): StreamSeamApi => bindSeam(isSeam(arg) ? arg : makeSeam(arg)), }); diff --git a/packages/core/src/surface.ts b/packages/core/src/surface.ts index e1ce99a9..e15b80ee 100644 --- a/packages/core/src/surface.ts +++ b/packages/core/src/surface.ts @@ -16,9 +16,9 @@ import type { StitchInput, } from './types'; -/** The result a surface's {@link Surface.interpret} produces from a buffered response. */ +/** The result a surface's {@link Surface.interpret} produces from a buffered response. The success arm carries `data` (CONTRACT.md P5). */ export type SurfaceOutcome = - | { ok: true; value: T } + | { ok: true; data: T } | { ok: false; message: string; status?: number }; /** @@ -78,8 +78,6 @@ export interface Surface { * connection. `sse` returns the event's `retry` field. Omitted ⇒ always use the fallback backoff. */ readonly resumeRetry?: (chunk: unknown) => number | undefined; - /** @deprecated Renamed to {@link Surface.resumeRetry} (CONTRACT.md P17). Read until the 1.0 GA cut. */ - readonly resumeRetryMs?: (chunk: unknown) => number | undefined; /** * Inject a resume token into the NEXT request before it is reopened (issue #71) — mutates `req` * in place. `sse` sets the `Last-Event-ID` header. Paired with {@link Surface.resumeToken}; both @@ -106,9 +104,10 @@ export const httpSurface: Surface = { id: 'http' }; /** * GraphQL-over-HTTP. Its behaviour lives entirely in these hooks (ADR 0005 Stage 4): `buildRequest` - * packs `{ query, variables, operationName? }` as JSON and forces POST; `interpret` treats a 200 - * carrying `errors` as a failure. The `data` unwrap is a plain config key the `graphql(...)` helper - * / `seam.graphql()` set (the engine applies it after `interpret`), as is the `/graphql` default + * packs the `document` as the wire body's `query` field (`{ query, variables, operationName? }`, + * the GraphQL-over-HTTP protocol shape) as JSON and forces POST; `interpret` treats a 200 carrying + * `errors` as a failure. The `data` pick is a plain config key the `graphql(...)` helper / + * `seam.graphql()` set (the engine applies it after `interpret`), as is the `/graphql` default * path. * * `operationName` is derived the way graphql clients (e.g. graphql-request) do: the name token of @@ -120,16 +119,18 @@ export const httpSurface: Surface = { id: 'http' }; export const graphqlSurface: Surface = { id: 'graphql', buildRequest: (cfg, input, base) => { - const query = cfg.query ?? ''; + const document = cfg.document ?? ''; const operationName = cfg.operationName ?? - /\b(?:query|mutation|subscription)\s+(\w+)/.exec(query)?.[1]; + /\b(?:query|mutation|subscription)\s+(\w+)/.exec(document)?.[1]; return { ...base, method: (cfg.method ?? 'POST').toUpperCase(), bodyType: 'json', body: { - query, + // `query` is the GraphQL-over-HTTP wire field for the document — protocol shape, + // not the config spelling. + query: document, variables: input.variables ?? input.body ?? {}, // Only carry the key when a name is known — anonymous documents omit it, matching // graphql-request and keeping the body clean. `cfg.operationName: ''` suppresses it. @@ -147,6 +148,6 @@ export const graphqlSurface: Surface = { message: `GraphQL: ${errs.map((e) => e.message ?? 'error').join('; ')}`, status: res.status, }; - return { ok: true, value: res.body }; + return { ok: true, data: res.body }; }, }; diff --git a/packages/core/src/test-events.ts b/packages/core/src/test-events.ts index d48f4be1..5cf2d0f2 100644 --- a/packages/core/src/test-events.ts +++ b/packages/core/src/test-events.ts @@ -2,7 +2,11 @@ // Accepts the generator from `someStitch(input).stream()`, OR the `StitchResult` itself (anything // with a `.stream()` method), so `await collectStitchEvents(getUser({ params: { id: 1 } }))` works. // Browser-safe: no `node:*`, no test framework. -import type { DriftFinding, StitchEvent } from './types'; +import type { DriftFinding, StitchEvent, StitchEventSource } from './types'; + +// Hoisted to the core barrel (types.ts) so every event-stream consumer shares one intake type; +// re-exported here so `stitchapi/testing` keeps its historical import site. +export type { StitchEventSource } from './types'; /** The parts of a drained event stream, ready to assert on. */ export interface CollectedEvents { @@ -22,11 +26,6 @@ export interface CollectedEvents { events: StitchEvent[]; } -/** A stitch event generator, or anything that hands one back (a `StitchResult`). */ -export type StitchEventSource = - | AsyncGenerator, void> - | { stream(): AsyncGenerator, void> }; - /** * Drain a stitch event stream into its parts: every delta chunk, every drift finding, and the * terminal result/error/done — plus the full event list. Replaces the hand-rolled `collect()` @@ -37,10 +36,8 @@ export async function collectStitchEvents( ): Promise> { const gen = typeof (source as { stream?: unknown }).stream === 'function' - ? ( - source as { stream(): AsyncGenerator, void> } - ).stream() - : (source as AsyncGenerator, void>); + ? (source as { stream(): AsyncIterable> }).stream() + : (source as AsyncIterable>); const events: StitchEvent[] = []; const types: string[] = []; diff --git a/packages/core/src/test-mock.ts b/packages/core/src/test-mock.ts index e8721fac..b3e9078a 100644 --- a/packages/core/src/test-mock.ts +++ b/packages/core/src/test-mock.ts @@ -23,14 +23,10 @@ export interface MockResponse { /** Wait this long before responding — `100`, `'100ms'`, `'1s'`; abortable, so a stitch `timeout` * cancels it like a real slow endpoint. Drives timeout / `Retry-After` pacing tests. */ delay?: number | string; - /** @deprecated Renamed to {@link MockResponse.delay} (CONTRACT.md P17). Read until the 1.0 GA cut. */ - delayMs?: number; /** Sets the `Retry-After` header in SECONDS (or an HTTP-date string) — for `429`/`503` retry and * `throttle.delegate` tests. Named with its true unit per CONTRACT.md P17 (a wire format speaks * seconds, not the house ms). */ retryAfterSeconds?: number | string; - /** @deprecated Renamed to {@link MockResponse.retryAfterSeconds} (CONTRACT.md P17 unit-hazard). Read until the 1.0 GA cut. */ - retryAfter?: number | string; } /** One call's context, passed to a function responder. */ @@ -144,10 +140,8 @@ export function mockAdapter( const headers: Record = {}; for (const [k, v] of Object.entries(r.headers ?? {})) headers[k.toLowerCase()] = v; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `retryAfter` is the @deprecated alias of `retryAfterSeconds`, read for back-compat until the GA cut (CONTRACT.md P17) - const retryAfterSeconds = r.retryAfterSeconds ?? r.retryAfter; - if (retryAfterSeconds !== undefined) - headers['retry-after'] = String(retryAfterSeconds); + if (r.retryAfterSeconds !== undefined) + headers['retry-after'] = String(r.retryAfterSeconds); const body = r.stream ? Array.isArray(r.stream) ? streamOf(r.stream) @@ -181,8 +175,7 @@ export function mockAdapter( ? await route.respond({ index: callIndex, req }) : route.respond; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `delayMs` is the @deprecated alias of `delay`, read for back-compat until the GA cut (CONTRACT.md P17) - const delay = parseDuration(r.delay ?? r.delayMs); + const delay = parseDuration(r.delay); if (delay && delay > 0) await sleep(delay, req.signal); return build(r); }) as MockAdapter; diff --git a/packages/core/src/test-stream.ts b/packages/core/src/test-stream.ts index e4b8221b..720f3b4b 100644 --- a/packages/core/src/test-stream.ts +++ b/packages/core/src/test-stream.ts @@ -71,7 +71,7 @@ export function streamThenError( } /** One Server-Sent Event for {@link sseStream}. A bare string is shorthand for `{ data }`. */ -export interface SseEvent { +export interface SseFixtureEvent { /** The `data:` payload — a string is sent verbatim (split across `data:` lines on `\n`); any * other value is `JSON.stringify`'d. */ data: unknown; @@ -91,12 +91,12 @@ export interface SseEvent { * {@link streamAdapter}) to drive the `sse` surface, including `id:`/`retry:` for reconnect tests. */ export function sseStream( - events: (SseEvent | string)[], + events: (SseFixtureEvent | string)[], ): ReadableStream { return streamOf(events.map(frameSse)); } -function frameSse(ev: SseEvent | string): string { +function frameSse(ev: SseFixtureEvent | string): string { if (typeof ev === 'string') return `data: ${ev}\n\n`; const lines: string[] = []; if (ev.comment !== undefined) lines.push(`: ${ev.comment}`); @@ -109,6 +109,14 @@ function frameSse(ev: SseEvent | string): string { return `${lines.join('\n')}\n\n`; } +/** Options for {@link streamAdapter}. */ +export interface StreamAdapterOptions { + /** HTTP status of the streaming response. Default `200`. */ + status?: number; + /** Response headers (lowercased names, like a real adapter). */ + headers?: Record; +} + /** * An adapter that requires a streaming request (`req.stream`) and returns `body` as the live * response body. The minimal transport for a single streaming call; for routing, status sequences, @@ -116,14 +124,14 @@ function frameSse(ev: SseEvent | string): string { */ export function streamAdapter( body: ReadableStream, - init: { status?: number; headers?: Record } = {}, + opts: StreamAdapterOptions = {}, ): Adapter { return (req: AdapterRequest): Promise => { if (!req.stream) return Promise.reject(new Error('expected req.stream to be set')); return Promise.resolve({ - status: init.status ?? 200, - headers: init.headers ?? {}, + status: opts.status ?? 200, + headers: opts.headers ?? {}, body, }); }; diff --git a/packages/core/src/test-stub.ts b/packages/core/src/test-stub.ts index e0e4f210..d54b9b09 100644 --- a/packages/core/src/test-stub.ts +++ b/packages/core/src/test-stub.ts @@ -15,12 +15,15 @@ import { } from './types'; import { now } from './util'; -/** The call spy attached to every stub. */ +/** How a spy filter selects recorded calls: a predicate over the call input. */ +export type StubMatch = (input: StitchInput) => boolean; + +/** The call spy attached to every stub — same shape as {@link MockAdapter}'s spy surface. */ export interface StubSpy { - /** The input passed to each invocation, in order (`{}` when called with no argument). */ - readonly calls: StitchInput[]; - /** How many times the stub was invoked. */ - readonly callCount: number; + /** Every call's input, in order (`{}` when called with no argument), optionally filtered. */ + calls(filter?: StubMatch): StitchInput[]; + /** How many times the stub was invoked (optionally counting only matching inputs). */ + callCount(filter?: StubMatch): number; /** Forget all recorded calls. */ reset(): void; } @@ -88,13 +91,13 @@ function defaultEvents( return [ start, err, - { type: 'done', ok: false, elapsed: 0, ms: 0, attempts: 1, at }, + { type: 'done', ok: false, elapsed: 0, attempts: 1, at }, ]; } return [ start, { type: 'result', data: value as TOut, status, attempts: 1, at }, - { type: 'done', ok: true, elapsed: 0, ms: 0, attempts: 1, at }, + { type: 'done', ok: true, elapsed: 0, attempts: 1, at }, ]; } @@ -191,7 +194,7 @@ function assemble( Object.defineProperty(stub, 'cache', { value: { invalidate: () => Promise.resolve(), - key: () => Promise.resolve(undefined), + keyOf: () => Promise.resolve(undefined), }, }); @@ -202,8 +205,10 @@ function assemble( }; Object.defineProperty(stub, '__config', { value: config }); Object.defineProperty(stub, '__stitch', { value: true }); - Object.defineProperty(stub, 'calls', { get: () => calls }); - Object.defineProperty(stub, 'callCount', { get: () => calls.length }); + const filter = (f?: StubMatch): StitchInput[] => + f === undefined ? calls.slice() : calls.filter(f); + stub.calls = filter; + stub.callCount = (f) => filter(f).length; stub.reset = () => { calls.length = 0; }; @@ -218,8 +223,8 @@ function assemble( * ```ts * const getUser = stubStitch({ id: 42, name: 'Ada' }); * await loadProfile(getUser); // your code under test - * expect(getUser.callCount).toBe(1); - * expect(getUser.calls[0]).toEqual({ params: { id: 42 } }); + * expect(getUser.callCount()).toBe(1); + * expect(getUser.calls()[0]).toEqual({ params: { id: 42 } }); * ``` */ export function stubStitch( diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index b12763c0..8284ebf8 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -26,7 +26,7 @@ import type { StitchStore, TraceSink, } from './types'; -import { stripTrailingSlashes } from './util'; +import { parseDuration, stripTrailingSlashes } from './util'; /** * The outcome of one `verify*Contract` run. @@ -167,19 +167,17 @@ function asJsonObject(body: unknown, label: string): Record { * * @param makeStore Factory for the store under test; awaited, so it may * connect to a real backend. - * @param opts `ttl` — the expiry window (ms) the TTL rules use (default 60). + * @param opts `ttl` — the expiry window the TTL rules use, as ms or a + * duration string (`'250ms'`, `'1s'`). Default 60ms. */ export async function verifyStoreContract( makeStore: () => StitchStore | Promise, opts?: { - /** Expiry window (ms) the TTL rules use. Default 60. */ - ttl?: number; - /** @deprecated Renamed to `ttl` (CONTRACT.md P17). Read until the 1.0 GA cut. */ - ttlMs?: number; + /** Expiry window the TTL rules use — ms, or a duration string. Default 60ms. */ + ttl?: number | string; }, ): Promise { - // eslint-disable-next-line @typescript-eslint/no-deprecated -- `ttlMs` is the @deprecated alias of `ttl`, read for back-compat until the GA cut (CONTRACT.md P17) - const ttlMs = opts?.ttl ?? opts?.ttlMs ?? 60; + const ttlMs = parseDuration(opts?.ttl) ?? 60; const store = await makeStore(); const ns = `stitch-conformance:${Date.now().toString(36)}-${Math.random() .toString(36) @@ -241,7 +239,7 @@ export async function verifyStoreContract( }, ], [ - 'set: a ttlMs entry expires', + 'set: a ttl entry expires', async () => { await store.set(k('ttl'), 'soon-gone', ttlMs); expectDeepEqual( @@ -259,7 +257,7 @@ export async function verifyStoreContract( }, ], [ - 'set: no ttlMs means no expiry', + 'set: no ttl means no expiry', async () => { await store.set(k('keep'), 'kept'); await sleep(ttlMs + 50); @@ -307,7 +305,7 @@ export async function verifyStoreContract( }, ], [ - 'incr: the counter expires after ttlMs', + 'incr: the counter expires after its ttl', async () => { await store.incr(k('window'), ttlMs); await sleep(ttlMs + 50); @@ -384,8 +382,6 @@ export interface FixtureResponse { body: string; /** When set, the host MUST delay sending the response by this many ms. */ delay?: number; - /** @deprecated Renamed to {@link FixtureResponse.delay} (CONTRACT.md P17). Read until the 1.0 GA cut. */ - delayMs?: number; } /** @@ -459,13 +455,7 @@ export function adapterContractFixture(req: FixtureRequest): FixtureResponse { return json(200, JSON_BODY, { 'x-stitch-echo': 'json' }); } if (path === '/slow' && method === 'GET') { - const res: FixtureResponse = { - ...json(200, { slow: true }), - delay: SLOW_DELAY_MS, - }; - // eslint-disable-next-line @typescript-eslint/no-deprecated -- co-set the @deprecated `delayMs` alias for back-compat (CONTRACT.md P17) - res.delayMs = SLOW_DELAY_MS; - return res; + return { ...json(200, { slow: true }), delay: SLOW_DELAY_MS }; } return json(404, { error: 'not_found' }); } @@ -486,14 +476,16 @@ export function adapterContractFixture(req: FixtureRequest): FixtureResponse { * rejects promptly instead of waiting out the response. * * @param adapter The transport under test. - * @param opts `baseUrl` — origin of a server mounting the fixture, e.g. - * `http://127.0.0.1:4123`. + * @param opts Origin of a server mounting the fixture — a bare string, or + * `{ baseUrl }`, e.g. `'http://127.0.0.1:4123'`. */ export async function verifyAdapterContract( adapter: Adapter, - opts: { baseUrl: string }, + opts: string | { baseUrl: string }, ): Promise { - const base = stripTrailingSlashes(opts.baseUrl); + const base = stripTrailingSlashes( + typeof opts === 'string' ? opts : opts.baseUrl, + ); const request = ( method: string, path: string, @@ -696,7 +688,6 @@ const SINK_EVENT_FIXTURES: readonly StitchEvent[] = [ type: 'done', ok: true, elapsed: 34, - ms: 34, attempts: 1, at: SINK_AT + 7, }, @@ -710,13 +701,13 @@ const SINK_EVENT_FIXTURES: readonly StitchEvent[] = [ * conforming sink must already tolerate it), and the optional `flush()` must * settle without throwing when present. * - * @param makeSink Factory for the sink under test; one sink instance receives - * the whole sequence in order. + * @param makeSink Factory for the sink under test; awaited, so it may set up + * async state. One sink instance receives the whole sequence in order. */ -export function verifySinkContract( - makeSink: () => TraceSink, +export async function verifySinkContract( + makeSink: () => TraceSink | Promise, ): Promise { - const sink = makeSink(); + const sink = await makeSink(); const ctx = { name: 'conformance' }; const rules: Rule[] = SINK_EVENT_FIXTURES.map( (event): Rule => [ @@ -758,16 +749,23 @@ function runRulesSync(seam: string, rules: SyncRule[]): ContractReport { } /** One labelled schema fixture; the thunk builds a fresh instance per call. */ -interface SchemaFixture { +export interface SchemaFixture { readonly label: string; readonly schema: () => unknown; } +/** One labelled pair of independently-built schemas that must share a fingerprint. */ +export interface EquivalentSchemaPair { + readonly label: string; + readonly a: () => unknown; + readonly b: () => unknown; +} + /** Fixtures a vendor package supplies to prove its fingerprint strategy. */ export interface FingerprintFixtures { /** * Schemas that must each produce a STABLE, non-null fingerprint: building the - * schema twice (via the thunk) and fingerprinting both yields the same value. + * schema twice (via the thunk) and fingerprinting both yields the same token. * Covers determinism + construction-independence. */ readonly stable: readonly SchemaFixture[]; @@ -775,13 +773,9 @@ export interface FingerprintFixtures { * Pairs of independently-built but structurally-IDENTICAL schemas (e.g. the * same object with permuted key order) that must share a fingerprint. */ - readonly equivalent?: readonly { - readonly label: string; - readonly a: () => unknown; - readonly b: () => unknown; - }[]; + readonly equivalent?: readonly EquivalentSchemaPair[]; /** - * Schemas that must all fingerprint to PAIRWISE-DISTINCT, non-null values — + * Schemas that must all fingerprint to PAIRWISE-DISTINCT, non-null tokens — * typically a base schema plus one mutation each (field added, type changed, * constraint changed, …). Proves sensitivity to real semantic changes. */ @@ -789,11 +783,11 @@ export interface FingerprintFixtures { /** * Schemas containing parts the strategy cannot soundly capture (opaque * `.refine`/`.transform`/`.brand`, unrepresentable types). The strategy MUST - * ABSTAIN (`value === null`) rather than emit a possibly-colliding token. + * ABSTAIN (`token === null`) rather than emit a possibly-colliding token. */ readonly abstain?: readonly SchemaFixture[]; /** - * Optional committed snapshots (`label` → expected `value`) for schemas in + * Optional committed snapshots (`label` → expected `token`) for schemas in * `stable`/`distinct`. Re-run under a new validator minor version in CI, a * drift means the introspection surface moved — the cross-version guard. */ @@ -818,13 +812,12 @@ function callFingerprint( throw new Error(`${label}: fingerprint() must be synchronous`); } const r = result as - | { token?: unknown; value?: unknown; strength?: unknown } + | { token?: unknown; strength?: unknown } | null | undefined; - // Accept the canonical `token` or the @deprecated `value` alias (CONTRACT.md P5), and normalize - // so downstream rules read `.token` regardless of which spelling the fingerprinter produced. A - // presence check (not `??`) preserves the `null` ABSTAIN sentinel — `null ?? value` would drop it. - const token = r?.token !== undefined ? r.token : r?.value; + // `null` is the ABSTAIN sentinel, so the shape check below is a presence check on `token` + // (missing/`undefined` fails, `null` passes) rather than a truthiness one. + const token = r?.token; if ( !r || (token !== null && typeof token !== 'string') || @@ -834,7 +827,7 @@ function callFingerprint( `${label}: expected { token: string|null, strength: 'strong'|'weak' }, got ${show(result)}`, ); } - return { token, value: token, strength: r.strength }; + return { token, strength: r.strength }; } /** @@ -1016,8 +1009,9 @@ export { export { gatedStream, sseStream, - type SseEvent, + type SseFixtureEvent, streamAdapter, + type StreamAdapterOptions, streamOf, streamThenError, } from './test-stream'; @@ -1030,6 +1024,7 @@ export { failStitch, stubStitch, type StubImpl, + type StubMatch, type StubSpy, type StubStitchOptions, } from './test-stub'; diff --git a/packages/core/src/trace.ts b/packages/core/src/trace.ts index 6d6b0d1b..3ae9f881 100644 --- a/packages/core/src/trace.ts +++ b/packages/core/src/trace.ts @@ -4,7 +4,7 @@ import { compact } from './compact'; import type { DriftLevel, StitchEvent, TraceContext, TraceSink } from './types'; import { dirnameOf, - isSecretQueryKey, + isSecretKey, nodeFs, readEnv, redactSecretsDeep, @@ -17,11 +17,11 @@ export interface TraceOptions { /** * Max length (in characters of the JSON encoding) of the request body and the * response value persisted to the JSONL sink. Anything larger is replaced with a - * `{ truncated, bytes, preview }` marker, so a multi-MB response never bloats the - * log or persists a payload in full. Default {@link DEFAULT_MAX_BODY_BYTES}; pass + * `{ truncated, chars, preview }` marker, so a multi-MB response never bloats the + * log or persists a payload in full. Default {@link DEFAULT_MAX_BODY_CHARS}; pass * `false` for full capture (the pre-1.0 behaviour); `0` keeps only the marker. */ - maxBodyBytes?: number | false; + maxBodyChars?: number | false; /** * Extra header names (case-insensitive) to redact on top of the built-in * denylist. Additive — you can widen the denylist but never shrink it, so a @@ -98,8 +98,8 @@ export function redactEventForTransport(event: StitchEvent): StitchEvent { // Default body/result truncation cap: large enough to keep a small JSON response or // error body fully readable, small enough to bound on-disk growth and limit how much -// payload is persisted by default. Opt into full capture with `maxBodyBytes: false`. -const DEFAULT_MAX_BODY_BYTES = 2048; +// payload is persisted by default. Opt into full capture with `maxBodyChars: false`. +const DEFAULT_MAX_BODY_CHARS = 2048; // Deep-clone `value`, replacing any object property whose key is in `denylist` // (lowercased secret header names) with '[REDACTED]'. Non-mutating: the engine keeps @@ -137,7 +137,7 @@ function capBody(value: unknown, max: number | false): unknown { if (json.length <= max) return value; return { truncated: true, - bytes: json.length, + chars: json.length, preview: json.slice(0, max), }; } catch { @@ -155,7 +155,7 @@ function redactSecretQuery( ): Record { const out: Record = {}; for (const [k, v] of Object.entries(query)) - out[k] = isSecretQueryKey(k) ? REDACTED : v; + out[k] = isSecretKey(k) ? REDACTED : v; return out; } @@ -189,12 +189,8 @@ function prepareRecord( } } else if (event.type === 'result') { record['data'] = capBody(record['data'], maxBody); - // The @deprecated `value` alias is co-emitted (CONTRACT.md P5); cap it too so the body is - // never written uncapped under either key. - if ('value' in record) - record['value'] = capBody(record['value'], maxBody); } else if (event.type === 'delta') { - // A streamed chunk is response-body data too — cap it like `result.value`. + // A streamed chunk is response-body data too — cap it like `result.data`. record['chunk'] = capBody(record['chunk'], maxBody); } return record; @@ -286,7 +282,7 @@ export interface LoggerSinkOptions { * `debug`, or dropping the happy-path lifecycle. Takes precedence over {@link levels}. `delta` is * dropped before this runs, so it is never called for one. */ - level?: ( + levelOf?: ( event: StitchEvent, ctx: TraceContext, ) => LogLevel | null | undefined; @@ -360,7 +356,7 @@ function summary(name: string, event: StitchEvent): string | null { * **never** logged — a streamed chunk is raw response data. Override any per-type level via * {@link LoggerSinkOptions.levels} (e.g. `{ result: 'debug' }`); `drift` still follows the finding * level unless you pin it there too. For conditional rules the per-type map can't express, pass a - * {@link LoggerSinkOptions.level} resolver (per-instance; `null` drops the event); for a different + * {@link LoggerSinkOptions.levelOf} resolver (per-instance; `null` drops the event); for a different * house style, pass a {@link LoggerSinkOptions.format} formatter (it then owns the payload-free * guarantee). `@stitchapi/nest`'s Nest-flavored `loggerSink` is built by delegating here with both. * @@ -386,7 +382,7 @@ export function loggerSink( opts?: LoggerSinkOptions, ): TraceSink { const overrides = opts?.levels; - const resolveLevel = opts?.level; + const resolveLevel = opts?.levelOf; const format = opts?.format; return { handle(event: StitchEvent, ctx: TraceContext): void { @@ -454,7 +450,7 @@ export function createTrace( ...SECRET_HEADERS, ...(opts?.redactHeaders ?? []).map((h) => h.toLowerCase()), ]); - const maxBody = opts?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; + const maxBody = opts?.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS; return { path, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3376e3a5..b499c3b3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -77,10 +77,11 @@ export interface DriftOptions { * The grammar mirrors the finding path: nested keys join with `.`, an **array element** is `[]` * (so `items[].meta` matches every element's `meta`), and a pattern matches by exact path, a * single-segment `*` wildcard, or as a prefix (`meta` ignores `meta` and everything beneath it). + * A bare string is shorthand for a one-element list (CONTRACT.md P7). * * @example `ignore: ['meta', '_links', 'debug.*']` */ - ignore?: string[]; + ignore?: string | string[]; /** * How soft drift is leveled / filtered. Three shapes (ADR 0015): * - a **single level** or a **bare list** of levels — an _allowlist_ of which severities to @@ -158,8 +159,6 @@ export interface ReconnectOptions { * ends/errors exactly as today. Default 3. */ attempts?: number; - /** @deprecated Renamed to {@link ReconnectOptions.attempts} (CONTRACT.md P4). Read until the 1.0 GA cut. */ - maxAttempts?: number; /** * Fallback reconnect backoff when the server has NOT sent a `retry:` field on the dropped * connection — `1000`, `'1s'`. When omitted, the stitch's `retry` (`RetryOptions` — @@ -167,21 +166,20 @@ export interface ReconnectOptions { * always wins over both. */ backoff?: number | string; - /** @deprecated Renamed to {@link ReconnectOptions.backoff} (CONTRACT.md P17). Read until the 1.0 GA cut. */ - backoffMs?: number; } /** * Resumable SSE (issue #71) — how the `sse` surface recovers from a dropped stream. **Off by * default**: with no `sse.reconnect` block the engine opens the body exactly once (today's * behaviour, byte-identical). When enabled the engine tracks the last `id:` seen and replays it as * `Last-Event-ID` on each reconnect, honours a server-sent `retry:` as the backoff (falling back to - * `reconnect.backoff` / the stitch's `retry` policy), and caps reconnects at `maxAttempts`. + * `reconnect.backoff` / the stitch's `retry` policy), and caps reconnects at `attempts`. * - * `true` = enabled with sane defaults; the object form tunes the cap / fallback backoff. Plain JSON - * (the contract gate). Only the `sse` surface acts on this; other surfaces ignore it. + * `true` = enabled with sane defaults; the object form tunes the cap / fallback backoff (the opaque + * `{}` is rejected — CONTRACT.md P20). Plain JSON (the contract gate). Only the `sse` surface acts + * on this; other surfaces ignore it. */ export interface SseOptions { - reconnect?: boolean | ReconnectOptions; + reconnect?: boolean | AtLeastOne; } /** * Byte-transfer progress for a single request (ADR 0005 Decision 9). Reported through @@ -271,18 +269,20 @@ export type Adapter = ((req: AdapterRequest) => Promise) & { }; // ---- Resilience options --------------------------------------------------- +/** + * A status-match field (CONTRACT.md P7): a single status, a list, or a predicate. Every reader + * normalizes through the shared `acceptsStatus` matcher, so the three spellings are equivalent + * (`on: 429` ≡ `on: [429]`); `compose` folds a bare number into its list form before `__config`. + */ +export type StatusMatch = number | number[] | ((status: number) => boolean); export interface RetryOptions { attempts?: number; // total attempts incl. the first (default 1 = no retry) - on?: number[] | ((status: number) => boolean); // statuses (or a predicate) that trigger a retry (default [429,502,503,504]) + on?: StatusMatch; // status(es) (or a predicate) that trigger a retry (default [429,502,503,504]) backoff?: 'expo' | 'expo-jitter' | 'fixed'; /** Base backoff delay before the first retry — `100`, `'100ms'`, `'1s'`. Default 100ms. */ baseDelay?: number | string; - /** @deprecated Renamed to {@link RetryOptions.baseDelay} (CONTRACT.md P17). Read until the 1.0 GA cut. */ - baseMs?: number; /** Backoff ceiling the computed delay is clamped to — `10_000`, `'10s'`. Default 10s. */ maxDelay?: number | string; - /** @deprecated Renamed to {@link RetryOptions.maxDelay} (CONTRACT.md P17). Read until the 1.0 GA cut. */ - maxMs?: number; respectRetryAfter?: boolean; } export interface ThrottleOptions { @@ -290,21 +290,18 @@ export interface ThrottleOptions { concurrency?: number; /** * Where the limiter's counter is pooled: `'stitch'` (default) keeps a per-stitch - * budget; `'host'` shares one budget across every stitch hitting the same host. Renamed - * from `scope` (CONTRACT.md P2) so `scope` only ever means principal/app tenancy. + * budget; `'host'` shares one budget across every stitch hitting the same host. */ pool?: 'stitch' | 'host'; - /** @deprecated Renamed to {@link ThrottleOptions.pool} (CONTRACT.md P2). Read until the 1.0 GA cut. */ - scope?: 'stitch' | 'host'; /** - * Delegate rate-limit handling to the host (folded in from the top-level `rateLimit`, - * CONTRACT.md P14). When `true`, a rate-limit response (status matched by `on`, default `[429]`) - * is **not** retried or throttled internally — self-pacing (`rate`/`concurrency`) is bypassed and - * the outcome surfaces as a {@link RateLimitError}. Use it when an OUTER gate owns the backoff. + * Delegate rate-limit handling to the host (CONTRACT.md P14). When `true`, a rate-limit + * response (status matched by `on`, default `[429]`) is **not** retried or throttled + * internally — self-pacing (`rate`/`concurrency`) is bypassed and the outcome surfaces as a + * {@link RateLimitError}. Use it when an OUTER gate owns the backoff. */ delegate?: boolean; - /** Statuses that count as a rate-limit signal under `delegate` — a list or a predicate. Default `[429]`. */ - on?: number[] | ((status: number) => boolean); + /** Status(es) that count as a rate-limit signal under `delegate` — a number, list, or predicate. Default `[429]`. */ + on?: StatusMatch; } /** * Options for one throttle `acquire`. `rateOnly` charges the rate limiter but takes NO concurrency @@ -321,25 +318,18 @@ export interface TimeoutOptions { export interface CircuitOptions { /** * Consecutive failures that trip the breaker OPEN. Required by design — a breaker with an - * invisible threshold fails silently (CONTRACT.md P15); `createCircuit` throws if neither - * `failures` nor the @deprecated `failureThreshold` is set. Optional at the type level only so - * the deprecated alias can stand in until the GA cut. + * invisible threshold fails silently (CONTRACT.md P15); `createCircuit` throws when `failures` + * or `cooldown` is missing. Optional at the type level only so the object form can be built up + * incrementally; the positional `[failures, cooldown]` shorthand supplies both. */ failures?: number; - /** @deprecated Renamed to {@link CircuitOptions.failures} (CONTRACT.md P4). Read until the 1.0 GA cut. */ - failureThreshold?: number; /** * Fast-fail window after opening, before a half-open trial — `30_000`, `'30s'`. Required by - * design (P15); `createCircuit` throws if neither `cooldown` nor the @deprecated `cooldownMs` - * is set. + * design (P15); `createCircuit` throws when it is missing. */ cooldown?: number | string; - /** @deprecated Renamed to {@link CircuitOptions.cooldown} (CONTRACT.md P17). Read until the 1.0 GA cut. */ - cooldownMs?: number; /** When to allow a half-open trial — `60_000`, `'1m'`. Default: `cooldown`. */ halfOpenAfter?: number | string; - /** @deprecated Renamed to {@link CircuitOptions.halfOpenAfter} (CONTRACT.md P17). Read until the 1.0 GA cut. */ - halfOpenAfterMs?: number; /** Store namespace to share a breaker across stitches (default: stitch/host key). */ key?: string; } @@ -367,10 +357,8 @@ export interface CircuitOptions { */ export interface IdempotencyOptions { header?: string; // header name (default 'Idempotency-Key') - /** Derive a stable key per logical call (default: a random uuid). Renamed from `key` (CONTRACT.md P6: `key` is a string, a derivation fn is `keyOf`). */ + /** Derive a stable key per logical call (default: a random uuid). CONTRACT.md P6: `key` would be a string; a derivation fn is `keyOf`. */ keyOf?: (input: StitchInput) => string; - /** @deprecated Renamed to {@link IdempotencyOptions.keyOf} (CONTRACT.md P6). Read until the 1.0 GA cut. */ - key?: (input: StitchInput) => string; /** false silences the "idempotency without retry" / "idempotency on a read" construction nudge. */ warn?: boolean; } @@ -382,7 +370,8 @@ export interface IdempotencyOptions { * what it names. Off by default: no `cache` block ⇒ no caching and no hot-path cost. The engine * ships behind its own `stitchapi/cache` subpath, so `import { stitch }` pulls none of it. * - * Every field round-trips as JSON; `key` is **sugar** (a function override) that does not. + * Every field round-trips as JSON; `keyOf` is **sugar** (a function override) that does not — it + * lives on the non-enumerable `__rawConfig` only (CONTRACT.md P0). */ export interface CacheOptions { /** Time-to-live for a cached entry — `30_000`, `'30s'`, `'5m'`. Bounds staleness/drift. */ @@ -394,22 +383,21 @@ export interface CacheOptions { */ scope?: 'principal' | 'app'; /** - * Request headers whose values vary the response and so must be part of the key (e.g. - * `['accept-language']`). An explicit allowlist **overrides** the default of honouring the - * response's `Vary`. Volatile/secret headers (authorization, cookie, traceparent, …) are - * never keyed. + * Request header(s) whose values vary the response and so must be part of the key (e.g. + * `'accept-language'` or a list). An explicit allowlist **overrides** the default of honouring + * the response's `Vary`. Volatile/secret headers (authorization, cookie, traceparent, …) are + * never keyed. A bare string is shorthand for a one-element list (CONTRACT.md P7). */ - vary?: string[]; + vary?: string | string[]; /** - * Cacheable HTTP methods. Default `['GET','HEAD']`. A GraphQL **query** opts in by listing - * its method (`['POST']`) — a POST's read-vs-mutate intent cannot be inferred, so it is - * explicit. Coalescing applies to exactly this set; mutations are never cached. + * Cacheable HTTP method(s). Default `['GET','HEAD']`. A GraphQL **query** opts in by listing + * its method (`'POST'`) — a POST's read-vs-mutate intent cannot be inferred, so it is + * explicit. Coalescing applies to exactly this set; mutations are never cached. A bare string + * is shorthand for a one-element list (CONTRACT.md P7). */ - methods?: string[]; + methods?: string | string[]; /** In-process LRU cap on live entries (the store stays dumb). Default 1000. */ entries?: number; - /** @deprecated Renamed to {@link CacheOptions.entries} (CONTRACT.md P4). Read until the 1.0 GA cut. */ - maxEntries?: number; /** * Request coalescing mode. `'process'` (v1 default) collapses concurrent identical in-flight * callers in one process onto a single shared run; `false` disables it. `'cluster'` is @@ -419,7 +407,7 @@ export interface CacheOptions { /** * Opaque schema/version tag — the **authoritative** rung of the fingerprint ladder (ADR 0004): * setting it pins the contract and takes the **no-revalidate** fast path (you promise the - * `output`/`transform`/`unwrap` are unchanged for this tag). Leaving it unset hands off to the + * `output`/`transform`/`pick` are unchanged for this tag). Leaving it unset hands off to the * automatic fingerprint: a registered `@stitchapi/fingerprint-*` strategy makes `output` * changes self-invalidate on the fast path; an un-fingerprintable schema falls to * {@link CacheOptions.onUnfingerprintable} (default **refuse-to-cache**, fail-closed). @@ -447,10 +435,8 @@ export interface CacheOptions { * only for pure validators with no coercion/transform inside the schema). */ onUnfingerprintable?: 'refuse' | 'revalidate'; - /** Sugar: author the key seed from the input instead of deriving it from the request. Renamed from `key` (CONTRACT.md P6). */ + /** Sugar: author the key seed from the input instead of deriving it from the request (CONTRACT.md P6). */ keyOf?: (input: StitchInput) => string; - /** @deprecated Renamed to {@link CacheOptions.keyOf} (CONTRACT.md P6). Read until the 1.0 GA cut. */ - key?: (input: StitchInput) => string; } // ---- Auth ----------------------------------------------------------------- @@ -481,7 +467,6 @@ export interface AuthContext { * during `apply`/`refresh` and yields them; outside a run it is a no-op. */ emit: (topic: string, detail?: string) => void; - runLogin?: () => Promise; // for cookieSession: invoke the login stitch } /** * A non-secret, declarative description of an auth strategy's wire shape — the OpenAPI 3.1 @@ -571,8 +556,6 @@ export type StitchEvent = detail?: string; /** How long the engine waited before this step (ms): throttle pacing or retry/reconnect backoff. */ waited?: number; - /** @deprecated Renamed to `waited` (CONTRACT.md P17). Set alongside `waited` until the 1.0 GA cut. */ - waitedMs?: number; at: number; } // A strategy-level announcement (auth decisions, inference). Non-progress; carries no secret. @@ -583,8 +566,6 @@ export type StitchEvent = type: 'result'; /** The terminal/aggregated result payload (aligns with `SafeResult.data`; CONTRACT.md P5/D1). */ data: T; - /** @deprecated Renamed to `data` (CONTRACT.md P5). Set alongside `data` until the 1.0 GA cut. */ - value?: T; status: number; attempts: number; at: number; @@ -599,8 +580,6 @@ export type StitchEvent = // structured backoff hint the awaited path gets off the thrown RateLimitError. Additive and // optional — every other `error` event omits it (issue #145). retryAfter?: number; - /** @deprecated Renamed to `retryAfter` (CONTRACT.md P17). Set alongside `retryAfter` until the 1.0 GA cut. */ - retryAfterMs?: number; attempts: number; at: number; } @@ -609,12 +588,20 @@ export type StitchEvent = ok: boolean; /** Total wall-clock time for the run (ms). */ elapsed: number; - /** @deprecated Renamed to `elapsed` (CONTRACT.md P17). Set alongside `elapsed` until the 1.0 GA cut. */ - ms?: number; attempts: number; at: number; }; +/** + * Anything that produces a stitch event stream: the event iterable itself (a `.stream()` + * generator), or anything that hands one back (a {@link StitchResult}, a stitch stub). The + * canonical intake for event-stream consumers — `collectStitchEvents` in `stitchapi/testing` + * accepts exactly this. + */ +export type StitchEventSource = + | AsyncIterable> + | { stream(): AsyncIterable> }; + // ---- Clock (injectable time, ADR 0010) ------------------------------------ /** An opaque timer handle returned by {@link Clock.setTimer}. */ export type TimerHandle = unknown; @@ -623,7 +610,7 @@ export type TimerHandle = unknown; * timeout, circuit cooldown, and `Retry-After` HTTP-dates — so a test can drive them deterministically * with no real waiting. Defaults to the system clock (wall-clock + global timers); inject a * `manualClock()` (from `stitchapi/testing`) to control time by hand. NOTE: `timeout.total` and the - * `at`/`ms` fields on events stay on wall-clock and are not driven by the clock. + * `at`/`elapsed` fields on events stay on wall-clock and are not driven by the clock. */ export interface Clock { /** Current time in epoch ms. */ @@ -660,12 +647,10 @@ export interface PaginateOptions { * over the original) for the next page, or `undefined` to stop. */ next: (prevBody: unknown, pagesFetched: number) => StitchInput | undefined; - /** Pull the array from each unwrapped page. Default: the value if it is an array. */ + /** Pull the array from each picked page. Default: the value if it is an array. */ items?: (value: unknown) => unknown[]; /** Safety cap on pages. Default 50. */ pages?: number; - /** @deprecated Renamed to `pages` (CONTRACT.md P4). Read until the 1.0 GA cut. */ - max?: number; } export interface StitchConfig { /** Label used in events and traces; defaults to `path` or `'stitch'`. */ @@ -682,26 +667,30 @@ export interface StitchConfig { bodyType?: 'json' | 'form' | 'multipart'; /** * Multipart serialisation options (ADR 0005 Decision 6) — how nested objects/arrays become - * field names. Only meaningful with `bodyType: 'multipart'`. Default nesting `'bracket'`. + * field names. Only meaningful with `bodyType: 'multipart'`. Default nesting `'bracket'`. A + * bare {@link MultipartNesting} string is shorthand for the object form — + * `multipart: 'dot'` ≡ `multipart: { nesting: 'dot' }` (CONTRACT.md P12). */ - multipart?: MultipartOptions; + multipart?: MultipartNesting | AtLeastOne; /** * Streaming options (ADR 0005 Decision 5) — how a `stream` surface decodes the live body * (`'bytes'` default / `'lines'` / `'ndjson'` / `'json'`). `'json'` is the structural, * unframed streaming-JSON decoder (issue #111): one `delta` per complete value / top-level * array element, tolerant of internal newlines and concatenated values. Only meaningful for - * the `stream` surface. + * the `stream` surface. A bare {@link StreamDecode} string is shorthand for the object form — + * `stream: 'ndjson'` ≡ `stream: { decode: 'ndjson' }` (CONTRACT.md P12). */ - stream?: StreamOptions; + stream?: StreamDecode | AtLeastOne; /** * Resumable-SSE options (issue #71) — sibling to {@link StitchConfig.stream}, but for the `sse` - * surface. **Off by default**: with no `sse.reconnect` the engine opens the live body once + * surface. **Off by default**: with no `sse` block the engine opens the live body once * (today's behaviour). When enabled, a dropped stream reconnects, replaying the last `id:` as * `Last-Event-ID` and honouring a server `retry:` (else `reconnect.backoff` / the `retry` - * policy), capped at `maxAttempts`. Plain JSON (the contract gate). Only the `sse` surface - * reads it. + * policy), capped at `reconnect.attempts`. Plain JSON (the contract gate). Only the `sse` + * surface reads it. `true` is shorthand for `{ reconnect: true }` (CONTRACT.md P13); the + * object form must set at least one field (P20). */ - sse?: SseOptions; + sse?: boolean | AtLeastOne; /** How to read the response body. Default: auto by content-type. */ responseType?: ResponseType; /** @@ -723,16 +712,19 @@ export interface StitchConfig { path?: string; /** Static default headers merged into every request. */ headers?: Record; - /** GraphQL query string (`kind: 'graphql'`). */ - query?: string; + /** GraphQL document string (`kind: 'graphql'`) — sent as the request body's `query` field. */ + document?: string; /** - * GraphQL `operationName` sent alongside `query` + `variables` (`kind: 'graphql'`). Omit to - * derive it from the first named operation in `query`; set it explicitly to override (e.g. a - * multi-operation document) or pass `''` to suppress the field entirely. + * GraphQL `operationName` sent alongside the document + `variables` (`kind: 'graphql'`). Omit + * to derive it from the first named operation in `document`; set it explicitly to override + * (e.g. a multi-operation document) or pass `''` to suppress the field entirely. */ operationName?: string; - /** Schemas validating params, query, body, headers, and (GraphQL) variables before the request. */ - input?: InputSchemas; + /** + * Schemas validating params, query, body, headers, and (GraphQL) variables before the request. + * At least one slot must be set — the opaque `input: {}` is rejected (CONTRACT.md P20). + */ + input?: AtLeastOne; /** * Response schema, or a {@link DriftSpec} for leveled drift detection. Accepts any * {@link SchemaLike} — a raw Zod schema, any Standard Schema (Valibot, ArkType), or a @@ -742,9 +734,9 @@ export interface StitchConfig { * @example output: z.object({ id: z.number(), name: z.string() }) */ output?: SchemaLike | DriftSpec; - /** Dot-path selecting the part of the response to return. */ - unwrap?: string; - /** Reshape the raw body before unwrap and validation (e.g. scrape HTML to structured data). */ + /** Dot-path picking the part of the response to return (e.g. `'data.items'`). */ + pick?: string; + /** Reshape the raw body before `pick` and validation (e.g. scrape HTML to structured data). */ transform?: (body: unknown) => unknown; /** Auto-loop pages, aggregating items, with auth/retry/throttle applied to every page. */ paginate?: PaginateOptions; @@ -752,56 +744,43 @@ export interface StitchConfig { auth?: AuthStrategy; /** * Retry-and-backoff policy. A bare number is shorthand for the attempt count — - * `retry: 3` ≡ `retry: { attempts: 3 }`. + * `retry: 3` ≡ `retry: { attempts: 3 }`; the opaque `retry: {}` is rejected (CONTRACT.md P20). */ - retry?: number | RetryOptions; + retry?: number | AtLeastOne; /** - * Statuses that are a NORMAL result rather than an error — a number list or a predicate. - * An accepted non-2xx flows through interpret → transform → unwrap → validate exactly like a - * 2xx (the response body becomes the result), instead of throwing a {@link StitchError}. Use - * this when an endpoint treats e.g. `404`/`400` as expected control flow (resource-gone → fall - * back to a broader call) so the happy path no longer runs through a `catch`. + * Status(es) that are a NORMAL result rather than an error — a number, a list, or a predicate + * (CONTRACT.md P7). An accepted non-2xx flows through interpret → transform → pick → validate + * exactly like a 2xx (the response body becomes the result), instead of throwing a + * {@link StitchError}. Use this when an endpoint treats e.g. `404`/`400` as expected control + * flow (resource-gone → fall back to a broader call) so the happy path no longer runs through + * a `catch`. * * `retry.on` still wins while attempts remain: a status listed in BOTH is retried until attempts * are exhausted, then accepted (returned) on the final attempt. Orthogonal to - * `rateLimit.delegate`, which surfaces a {@link RateLimitError} on rate-limit statuses earlier. + * `throttle.delegate`, which surfaces a {@link RateLimitError} on rate-limit statuses earlier. */ - acceptStatus?: number[] | ((status: number) => boolean); - /** Rate and concurrency limits. */ - throttle?: ThrottleOptions; + acceptStatus?: StatusMatch; + /** + * Rate and concurrency limits. A bare rate string is shorthand — + * `throttle: '2/s'` ≡ `throttle: { rate: '2/s' }` (CONTRACT.md P12); the opaque `throttle: {}` + * is rejected (P20). + */ + throttle?: string | AtLeastOne; /** * Total and per-attempt timeouts. A bare number (ms) or duration string is shorthand for the - * total — `timeout: '5s'` ≡ `timeout: { total: '5s' }`. + * total — `timeout: '5s'` ≡ `timeout: { total: '5s' }`; the opaque `timeout: {}` is rejected + * (CONTRACT.md P20). */ - timeout?: number | string | TimeoutOptions; + timeout?: number | string | AtLeastOne; /** * Circuit breaker that fast-fails a repeatedly failing dependency. `failures` + `cooldown` are - * required by design (P15), so the empty object is rejected (P20 — `AtLeastOne`). + * required by design (P15), so the empty object is rejected (P20 — `AtLeastOne`). The + * positional form names both at once — `circuit: [5, '30s']` ≡ + * `circuit: { failures: 5, cooldown: '30s' }`. */ - circuit?: AtLeastOne; - /** - * @deprecated Folded into `throttle` (CONTRACT.md P14): use `throttle.delegate` / `throttle.on`. - * Read until the 1.0 GA cut. - * - * Delegate backoff to the host (issue #145). When `delegate: true`, a rate-limit response - * (status in `on`, default `[429]`) is **not** retried internally and the built-in `throttle` - * is **bypassed** for the call — instead the outcome surfaces as a {@link RateLimitError} - * (carrying `status`, the `retryAfter` parsed from `Retry-After`, and the raw `response`) on - * the awaited path, and as an `error` event with `retryAfter` on `.stream()`. Use this when an - * OUTER gate/circuit owns the backoff (its own `Retry-After` hook, a DB-persisted budget) and - * StitchAPI's internal retry+throttle would double-count against it. - * - * ⚠️ In delegate mode the `throttle` config becomes **inert** for this stitch (the host owns the - * gate). A `circuit` block, if also set, still applies — the host may layer both. Non-rate-limit - * failures (5xx, etc.) behave exactly as today unless their status is listed in `on`. Validation, - * templating, transform/unwrap, and drift on the success path are unchanged. - */ - rateLimit?: { - /** Surface rate-limit outcomes instead of retrying/throttling them. Default `false`. */ - delegate?: boolean; - /** Statuses treated as a rate-limit signal. Default `[429]`. */ - on?: number[]; - }; + circuit?: + | [failures: number, cooldown: number | string] + | AtLeastOne; /** * Inject a stable Idempotency-Key header on writes so safe retries don't duplicate. * `true` enables it with defaults (header `Idempotency-Key`, a random uuid per call); the @@ -831,10 +810,17 @@ export interface StitchConfig { * - `'repeat'` — `ids=1&ids=2` */ arrayFormat?: 'indices' | 'brackets' | 'repeat'; - /** Request/response/error/retry lifecycle hooks. */ - hooks?: Hooks; - /** Fragments to deep-merge under this config — strings, partials, or other stitches. */ - extends?: (Partial | Stitch | string)[]; + /** Request/response/error/retry lifecycle hooks. At least one — the opaque `hooks: {}` is rejected (CONTRACT.md P20). */ + hooks?: AtLeastOne; + /** + * Fragment(s) to deep-merge under this config — strings, partials, or other stitches. A single + * fragment is shorthand for a one-element list (CONTRACT.md P7). + */ + extends?: + | Partial + | Stitch + | string + | (Partial | Stitch | string)[]; /** Test seam / custom transport. */ adapter?: Adapter; /** @@ -857,43 +843,100 @@ export interface StitchConfig { trace?: TraceSink | 'console' | false; } +/** {@link CacheOptions} after {@link compose}: the `T | T[]` list fields are always arrays. */ +export type ResolvedCacheOptions = Omit & { + vary?: string[]; + methods?: string[]; +}; + /** - * A {@link StitchConfig} after {@link compose} has run: every authoring shorthand is expanded, so - * the resilience fields are always their object form (a scalar `retry` / `timeout` / `cache` - * literal is normalised to `{ attempts }` / `{ total }` / `{ ttl }`). This is the shape the engine - * and {@link redactConfig} read — never the loose authoring union. + * A {@link StitchConfig} after {@link compose} has run: every authoring shorthand is expanded to + * its canonical envelope field (P0) — scalar `retry` / `timeout` / `cache` / `stream` / + * `multipart` / `sse` / `throttle` literals become `{ attempts }` / `{ total }` / `{ ttl }` / + * `{ decode }` / `{ nesting }` / `{ reconnect }` / `{ rate }`, the positional `circuit` tuple + * becomes `{ failures, cooldown }`, and every `T | T[]` list field is an array. This is the shape + * the engine and {@link redactConfig} read — never the loose authoring union. */ export type ResolvedStitchConfig = Omit< StitchConfig, - 'retry' | 'timeout' | 'cache' | 'idempotency' + | 'retry' + | 'timeout' + | 'cache' + | 'idempotency' + | 'stream' + | 'multipart' + | 'sse' + | 'throttle' + | 'circuit' + | 'hooks' + | 'input' + | 'acceptStatus' > & { retry?: RetryOptions; timeout?: TimeoutOptions; - cache?: CacheOptions; + cache?: ResolvedCacheOptions; idempotency?: IdempotencyOptions; + stream?: StreamOptions; + multipart?: MultipartOptions; + sse?: SseOptions; + throttle?: ThrottleOptions; + circuit?: CircuitOptions; + hooks?: Hooks; + input?: InputSchemas; + /** Post-compose a bare number is folded into its list form; predicates pass through. */ + acceptStatus?: number[] | ((status: number) => boolean); }; /** * The PUBLIC, redacted projection of a {@link StitchConfig} that a stitch exposes as `__config` * (and a seam as its shared `__config`). {@link redactConfig} produces it: the live, secret-bearing - * handles are stripped (`auth`, `store`, `adapter`), the surface is normalised to its `id` string - * (`kind`), and the auth's non-secret {@link SecurityScheme} is projected onto `authScheme`. It - * therefore round-trips as JSON (ADR 0005 Decision 11 — the contract gate) and is what `mcp` / - * `diagram` / `stitch export --openapi` read. + * handles are stripped (`auth`, `store`, `adapter`), **every function-valued field moves to the + * non-enumerable `__rawConfig`** (CONTRACT.md P0 — `url`/`baseUrl` thunks, `transform`, `hooks`, + * `paginate.next`/`items`, predicate forms of `retry.on`/`throttle.on`/`acceptStatus`, + * `idempotency.keyOf`, `cache.keyOf`), the surface is normalised to its `id` string (`kind`), and + * the auth's non-secret {@link SecurityScheme} is projected onto `authScheme`. It therefore + * round-trips as JSON (ADR 0005 Decision 11 — the contract gate) and is what `mcp` / `diagram` / + * `stitch export --openapi` read. * * This is the HONEST runtime shape: `__config.auth` / `.store` / `.adapter` are always absent, and * `__config.kind` is the surface's `id` string — never a live {@link Surface}. (The full, - * secret-bearing config lives on the non-enumerable `__rawConfig`, used only for fragment - * composition.) + * secret-bearing config lives on the non-enumerable `__rawConfig`, used by fragment composition + * and the engine.) */ export type RedactedStitchConfig = Omit< ResolvedStitchConfig, - 'auth' | 'store' | 'adapter' | 'clock' | 'kind' + | 'auth' + | 'store' + | 'adapter' + | 'clock' + | 'kind' + | 'url' + | 'baseUrl' + | 'transform' + | 'hooks' + | 'paginate' + | 'acceptStatus' + | 'retry' + | 'throttle' + | 'idempotency' + | 'cache' > & { /** The surface's `id` string (never the live {@link Surface}); absent for the default `http`. */ kind?: string; /** Non-secret auth scheme projected from the (stripped) live `auth`; feeds `export --openapi`. */ authScheme?: SecurityScheme; + /** The endpoint when statically known; a thunk (`() => string`) is redacted away (P0). */ + url?: string; + /** The base origin when statically known; a thunk is redacted away (P0). */ + baseUrl?: string; + /** Function-free projection: only the `pages` cap survives redaction (P0). */ + paginate?: { pages?: number }; + /** List form only; a predicate is redacted away (P0). */ + acceptStatus?: number[]; + retry?: Omit & { on?: number[] }; + throttle?: Omit & { on?: number[] }; + idempotency?: Omit; + cache?: Omit; }; /** @@ -961,7 +1004,7 @@ export interface InspectOptions { * undeclared field") is unimpaired. Set when you want to pipe `wrapper.raw` into a log or * support ticket and need the _known-secret_ fields removed first. * - * - `true` — apply the shared secret-key denylist (`isSecretKey` / `registerSecretQueryKey` + * - `true` — apply the shared secret-key denylist (`isSecretKey` / `registerSecretKey` * registrations) to every object key in `raw`, depth-first. Returns a deep clone. * - `string[]` — additionally scrub the listed key-name/path patterns on top of the shared * denylist (reuses the {@link matchPath} grammar: exact, `*` wildcard, or prefix). @@ -975,8 +1018,8 @@ export interface InspectOptions { /** * The result of {@link Stitch.inspect} (ADR 0016) — the validated value alongside the pre-validation * raw body and the drift {@link DriftFinding}s diffed between them, plus the response `status`. It - * **never throws**: a hard contract violation comes back as `{ value: null, error }` with `raw`, - * `findings`, and `status` still populated. `value` and `error` are **inverse** — `value` is `null` + * **never throws**: a hard contract violation comes back as `{ data: null, error }` with `raw`, + * `findings`, and `status` still populated. `data` and `error` are **inverse** — `data` is `null` * iff `error` is set. * * ⚠️ `raw` is the UNREDACTED pre-validation body, exposed on a **non-enumerable** field: `JSON.stringify`, @@ -987,8 +1030,6 @@ export interface InspectOptions { export interface Inspection { /** The validated result payload — coerced/defaulted/stripped per ADR 0015; `null` iff `error` is set. Aligns with `SafeResult.data` (CONTRACT.md P5). */ data: T | null; - /** @deprecated Renamed to `data` (CONTRACT.md P5). Set alongside `data` until the 1.0 GA cut. */ - value?: T | null; /** * The pre-validation body the findings are diffed against. Non-enumerable; `null` on * streaming/cache-hit. Unredacted by default — to scrub known-secret fields before @@ -1030,7 +1071,7 @@ export type CacheOutcome = /** * The result of {@link Stitch.report} (ADR 0019) — an {@link Inspection} **plus** run diagnostics: it - * _is_ an inspection (same `value` / `raw` / `findings` / `status` / `error` / `source`, same + * _is_ an inspection (same `data` / `raw` / `findings` / `status` / `error` / `source`, same * never-throws contract and the same non-enumerable `raw`) extended with how the run actually went. * Every added field is secret-free and enumerable — a report is safe to log _except_ don't expand * `raw` (inherited non-enumerable, ADR 0018's `redact` applies). Per-attempt latency is deliberately @@ -1040,10 +1081,11 @@ export interface RunReport extends Inspection { /** Total attempts made, including the first (1 = no retry). From the terminal event / `StitchError.attempts`. */ attempts: number; /** - * Wall-clock timing of the run. `ms` is the total (the `done` event's `ms`); `waited` is the - * summed backoff/throttle/reconnect wait (Σ `progress.waitedMs`), **omitted** when nothing waited. + * Wall-clock timing of the run. `elapsed` is the total (the `done` event's `elapsed`); `waited` + * is the summed backoff/throttle/reconnect wait (Σ `progress.waited`), **omitted** when nothing + * waited. */ - timing: { ms: number; waited?: number }; + timing: { elapsed: number; waited?: number }; /** * The **resolved, redacted** per-call config (ADR 0019 §5) — the stitch's existing redacted * `__config`, never the secret-bearing `__rawConfig`. Safe to echo into a log or support ticket. @@ -1086,34 +1128,34 @@ export interface Stitch { */ unwrap(...args: Args): Promise; /** - * Probe a fresh call and return an {@link Inspection} — `{ value, raw, findings, status, error }` — + * Probe a fresh call and return an {@link Inspection} — `{ data, raw, findings, status, error }` — * **without throwing** (ADR 0016). Use it after the fact to ask "the schema coerced/stripped this; * what did the server actually send?": `raw` is the pre-validation body, `findings` the soft + hard - * drift between it and `value`. + * drift between it and `data`. * * `.inspect()` **always hits the network and bypasses the cache by default**, so it is a fresh probe - * — *not* an observer of what your cached `await` call did. Pass `{ cache: true }` to honour the - * cache policy (then `raw` is `null` on a hit). On a streaming surface `raw` is `null` too (no single - * buffered body). ⚠️ `raw` is unredacted and non-enumerable — read `wrapper.raw` deliberately; never - * log the whole wrapper. + * — *not* an observer of what your cached `await` call did. Pass `true` (≡ `{ cache: true }`) to + * honour the cache policy (then `raw` is `null` on a hit). On a streaming surface `raw` is `null` + * too (no single buffered body). ⚠️ `raw` is unredacted and non-enumerable — read `wrapper.raw` + * deliberately; never log the whole wrapper. */ inspect( - ...args: [...Args, opts?: InspectOptions] + ...args: [...Args, opts?: boolean | AtLeastOne] ): Promise>; /** - * Probe a fresh call and return a {@link RunReport} — an {@link Inspection} (`{ value, raw, + * Probe a fresh call and return a {@link RunReport} — an {@link Inspection} (`{ data, raw, * findings, status, error, source }`) **plus** run diagnostics: `attempts`, `timing` - * (`{ ms, waited? }`), the resolved+redacted `config`, and the fine-grained `cache` outcome + * (`{ elapsed, waited? }`), the resolved+redacted `config`, and the fine-grained `cache` outcome * (ADR 0019). Like `.inspect()` it **never throws** (a hard contract violation comes back with * `error` set and the diagnostics populated) and is a **network probe**: it always hits the - * network and **bypasses the cache by default** — pass `{ cache: true }` to honour the cache - * policy (then `cache` reports the real `hit`/`miss` and `raw` is `null`/`source` is `'cache'` - * on a hit). Use `.report()` to ask "how did this run go?"; `.inspect()` stays the minimal - * "raw + drift" probe. ⚠️ `raw` is inherited unredacted and non-enumerable — the rest of the - * report is safe to log. + * network and **bypasses the cache by default** — pass `true` (≡ `{ cache: true }`) to honour + * the cache policy (then `cache` reports the real `hit`/`miss` and `raw` is `null`/`source` is + * `'cache'` on a hit). Use `.report()` to ask "how did this run go?"; `.inspect()` stays the + * minimal "raw + drift" probe. ⚠️ `raw` is inherited unredacted and non-enumerable — the rest + * of the report is safe to log. */ report( - ...args: [...Args, opts?: InspectOptions] + ...args: [...Args, opts?: boolean | AtLeastOne] ): Promise>; with>( partial: P, @@ -1123,12 +1165,12 @@ export interface Stitch { * - `invalidate(input)` — **exact** eviction of the one entry that `input` would hit. * - `cache.invalidate()` — **bulk** eviction of every entry this stitch produced (a * per-stitch generation bump; prior entries become unreachable and TTL out). - * - `cache.key(input)` — the derived opaque key, for introspection. + * - `cache.keyOf(input)` — the derived opaque key, for introspection (CONTRACT.md P6). */ invalidate(input?: StitchInput): Promise; readonly cache: { invalidate(): Promise; - key(input?: StitchInput): Promise; + keyOf(input?: StitchInput): Promise; }; readonly __config: RedactedStitchConfig; readonly __stitch: true; @@ -1190,8 +1232,10 @@ export interface TraceSink { // sessions persistent/shared across workers — see DESIGN.md §13. export interface StitchStore { get(key: string): Promise; + /** Set a value. `ttl` (ms) is optional on both verbs — absent means no expiry. */ set(key: string, value: unknown, ttl?: number): Promise; - incr(key: string, ttl: number): Promise; + /** Atomically increment a counter. Absent `ttl` means no window — the counter never expires. */ + incr(key: string, ttl?: number): Promise; /** * Release any resources (connections, timers) the store holds. Optional — the in-memory * default clears its map. A seam's `close()` calls this as the last lifecycle step. @@ -1202,16 +1246,23 @@ export interface StitchStore { // ---- Seam (a primitive stitches belong to) -------------------------------- /** * The config a seam shares with every member as a fragment — {@link StitchConfig} minus the keys - * that are intrinsically **per-endpoint**: the address (`path` / `url` / `method` / `query`) and - * the request/response shape (`name` / `input` / `output` / `kind`). Everything cross-cutting — - * `baseUrl`, `headers`, `auth`, `retry`, `throttle`, `timeout`, `circuit`, `idempotency`, - * `paginate`, `unwrap`, `transform`, `arrayFormat`, `hooks`, `trace`, `store`, `cache`, `adapter` + * that are intrinsically **per-endpoint**: the address (`path` / `url` / `method` / `document`) + * and the request/response shape (`name` / `input` / `output` / `kind`). Everything cross-cutting + * — `baseUrl`, `headers`, `auth`, `retry`, `throttle`, `timeout`, `circuit`, `idempotency`, + * `paginate`, `pick`, `transform`, `arrayFormat`, `hooks`, `trace`, `store`, `cache`, `adapter` * — belongs here, so the type itself answers "what belongs at the seam". Members set the endpoint * keys. */ export type SeamConfig = Omit< StitchConfig, - 'path' | 'url' | 'method' | 'query' | 'name' | 'input' | 'output' | 'kind' + | 'path' + | 'url' + | 'method' + | 'document' + | 'name' + | 'input' + | 'output' + | 'kind' >; /** @@ -1264,13 +1315,13 @@ export interface Seam { ): Stitch, InputOf>; /** Non-inferring fallback: a path string or a `string | Partial` value (see {@link StitchFn}). */ stitch(config: string | Partial): Stitch; - /** GraphQL-over-HTTP member stitch (POST `{ query, variables }`, unwrap `data`). */ + /** GraphQL-over-HTTP member stitch (POST `{ query, variables }`, picks `data`). */ graphql< TExplicit = never, const C extends Partial & { - query: string; + document: string; } = Partial & { - query: string; + document: string; }, >( config: C, @@ -1298,7 +1349,3 @@ export interface Seam { readonly __config: RedactedStitchConfig; readonly __seam: true; } - -// CONTRACT.md P3 — deprecated alias, removed at the 1.0 GA cut. -/** @deprecated Renamed to {@link CacheOptions} (CONTRACT.md P3). Imported name kept until the 1.0 GA cut. */ -export type CacheConfig = CacheOptions; diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index d5cf2879..ce523e01 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -67,16 +67,28 @@ export const systemClock: Clock = { }, }; -/** "30s" | "500ms" | "2m" | 1500 -> milliseconds. */ +/** + * Parse a duration into milliseconds. Grammar: a number (already ms), a numeric + * string (`"1500"` → 1500), or `` with unit `ms` | `s` | `m` | `h` | `d` + * (`"500ms"`, `"30s"`, `"2m"`, `"1h"`, `"2d"`; fractions like `"1.5s"` allowed). + * Anything else → `undefined`. + */ export function parseDuration( d: number | string | undefined, ): number | undefined { if (d == null) return undefined; if (typeof d === 'number') return d; - const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m)$/.exec(d.trim()); + const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/.exec(d.trim()); if (!m) return Number(d) || undefined; const n = parseFloat(m[1] ?? ''); - return m[2] === 'ms' ? n : m[2] === 's' ? n * 1000 : n * 60000; + const scale: Record = { + ms: 1, + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, + }; + return n * (scale[m[2] ?? ''] ?? 1); } /** "2/s" | "10/m" -> { count, per } (window length in ms). */ @@ -485,15 +497,16 @@ const URL_REDACTED = 'REDACTED'; // structured `input.query` via `redactSecretQuery`) without listing every vendor spelling. The // default `api_key` already matches a stem; this covers an arbitrary configured name too. // Lower-cased on insert so the membership test in `isSecretKey` stays case-insensitive. -const REGISTERED_SECRET_QUERY_KEYS = new Set(); +const REGISTERED_SECRET_KEYS = new Set(); /** - * Register an additional query-param name whose value is a secret, so the trace URL/query - * scrubbers redact it. Additive and process-wide (mirroring the built-in denylist): names can be - * widened but never un-redacted. Idempotent — registering the same name twice is a no-op. + * Register an additional key name whose value is a secret (a query-param name, a body + * field, …), so the trace URL/query scrubbers redact it. Additive and process-wide + * (mirroring the built-in denylist): names can be widened but never un-redacted. + * Idempotent — registering the same name twice is a no-op. */ -export function registerSecretQueryKey(name: string): void { - REGISTERED_SECRET_QUERY_KEYS.add(name.toLowerCase()); +export function registerSecretKey(name: string): void { + REGISTERED_SECRET_KEYS.add(name.toLowerCase()); } /** @@ -501,23 +514,17 @@ export function registerSecretQueryKey(name: string): void { * key in the same family — a `start` event's `input.query`) carries a secret value: * matched case-insensitively against the secret key set above, by containing one of * the secret stems, or because a caller registered it via - * {@link registerSecretQueryKey} (e.g. `apiKey({ in: 'query', name })`). + * {@link registerSecretKey} (e.g. `apiKey({ in: 'query', name })`). */ export function isSecretKey(key: string): boolean { const k = key.toLowerCase(); return ( SECRET_QUERY_KEYS.has(k) || - REGISTERED_SECRET_QUERY_KEYS.has(k) || + REGISTERED_SECRET_KEYS.has(k) || SECRET_QUERY_STEMS.some((s) => k.includes(s)) ); } -/** - * Backward-compatible alias for {@link isSecretKey} — the URL scrubbers and any - * external code that imported the original name keep working unchanged. - */ -export const isSecretQueryKey: (key: string) => boolean = isSecretKey; - /** * Deep-clone `value` and replace any object key that matches {@link isSecretKey} * (or the caller's `extra` name/path patterns via {@link matchPath}) with the @@ -531,33 +538,39 @@ export const isSecretQueryKey: (key: string) => boolean = isSecretKey; * grammar: exact, `*` wildcard, or prefix). Added on top of the shared denylist; * the denylist is always applied. */ -export function redactSecretsDeep( +export function redactSecretsDeep(value: unknown, extra?: string[]): unknown { + return redactSecretsAt(value, extra, undefined); +} + +// Recursive worker for redactSecretsDeep: `path` tracks where in the tree we are so the +// caller's `extra` patterns can match full paths, without that state leaking into the export. +function redactSecretsAt( value: unknown, - extra?: string[], - _path?: string, + extra: string[] | undefined, + path: string | undefined, ): unknown { if (Array.isArray(value)) { return value.map((item, i) => - redactSecretsDeep( + redactSecretsAt( item, extra, - _path !== undefined ? `${_path}[${i}]` : `[${i}]`, + path !== undefined ? `${path}[${i}]` : `[${i}]`, ), ); } if (value !== null && typeof value === 'object') { const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { - const childPath = _path !== undefined ? `${_path}.${k}` : k; + const childPath = path !== undefined ? `${path}.${k}` : k; const secret = isSecretKey(k) || (extra !== undefined && (extra.some((p) => matchPath(p, k)) || - (_path !== undefined && + (path !== undefined && extra.some((p) => matchPath(p, childPath))))); out[k] = secret ? URL_REDACTED - : redactSecretsDeep(v, extra, childPath); + : redactSecretsAt(v, extra, childPath); } return out; } diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index 982c1b1a..e9d8eef5 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -5,6 +5,7 @@ // which is a protocol-level detail, not an app-facing API (and whose raw result carries no `ok` // discriminant and can hold non-array issue paths — both normalised here). import type { InferOutput, SchemaLike } from './infer'; +import type { DriftSpec } from './types'; import { type ValidationResult, toValidator } from './validator'; const UNSUPPORTED = @@ -18,19 +19,26 @@ const UNSUPPORTED = * Reach for this when you validate many values against one schema: the coercion is paid here, not * on every call. For a one-off check, {@link validate} is the more direct spelling. * - * `schema` is any {@link SchemaLike} — a hand-written Zod / Valibot / ArkType schema, any Standard - * Schema (including one from `JsonSchema.adapt`), a `(value) => boolean` predicate, or a - * {@link Validator}. It is the identical set of schemas a stitch accepts. + * `schema` is anything a stitch's `output` slot accepts (P23) — a hand-written Zod / Valibot / + * ArkType schema, any Standard Schema (including one from `JsonSchema.adapt`), a + * `(value) => boolean` predicate, a {@link Validator}, or a `drift(...)` spec (validated against + * its wrapped schema; the drift options don't apply outside a call). * * @example * const check = compile(userSchema); * const result = await check(payload); * if (result.ok) use(result.value); // else result.issues → [{ message, path }] */ -export function compile( +export function compile( schema: S, ): (value: unknown) => Promise>> { - const validator = toValidator(schema); + // Unwrap a `drift(...)` spec to its schema, so the standalone verbs take the identical set the + // `output` slot takes (P23 — one schema intake). + const source = + (schema as Partial).__kind === 'drift' + ? (schema as DriftSpec).schema + : (schema as SchemaLike); + const validator = toValidator(source); if (!validator) throw new TypeError(UNSUPPORTED); return (value) => validator.validate(value) as Promise>>; @@ -48,7 +56,7 @@ export function compile( * const result = await validate(userSchema, payload); * if (result.ok) use(result.value); // else result.issues → [{ message, path }] */ -export function validate( +export function validate( schema: S, value: unknown, ): Promise>> { diff --git a/packages/core/test-d/graphql-variables.test-d.ts b/packages/core/test-d/graphql-variables.test-d.ts index 38d2e319..50ecbb77 100644 --- a/packages/core/test-d/graphql-variables.test-d.ts +++ b/packages/core/test-d/graphql-variables.test-d.ts @@ -11,7 +11,7 @@ import { z } from 'zod'; // 1) a declared `input.variables` schema types the call arg's `variables` and makes it required. const getThing = graphql({ - query: 'query($id: ID!) { thing(id: $id) { name } }', + document: 'query($id: ID!) { thing(id: $id) { name } }', input: { variables: z.object({ id: z.string() }) }, }); expectType<{ id: string }>( @@ -25,7 +25,7 @@ expectError(getThing({ variables: { id: 1 } })); // `id` is a string, not a numb // (so a no-arg call stays legal, asserted via `{}` below), yet a present `variables` is still // type-checked: a bad shape is rejected. const maybeVars = graphql({ - query: 'query { ok }', + document: 'query { ok }', input: { variables: z.object({ region: z.string() }).optional() }, }); expectAssignable>({}); // optional slot → empty arg is valid @@ -34,14 +34,14 @@ expectError(maybeVars({ variables: { region: 1 } })); // region must be a string // 3) a graphql stitch with NO variables schema keeps `variables` optional + a loose passthrough — // the pre-#75 behaviour, preserved now that `variables` is an InputSchemas slot. Any shape goes. -const loose = graphql({ query: 'query { ok }' }); +const loose = graphql({ document: 'query { ok }' }); expectAssignable>({ variables: { anything: 1 } }); expectAssignable>({}); // and the arg stays fully optional // 4) `seam.graphql(...)` infers `variables` identically (it routes through the same `InputOf`). const api = seam({ baseUrl: 'https://api.example.com' }); const memberTyped = api.graphql({ - query: 'query($id: ID!) { thing(id: $id) { name } }', + document: 'query($id: ID!) { thing(id: $id) { name } }', input: { variables: z.object({ id: z.string() }) }, }); expectType<{ id: string }>( @@ -51,5 +51,5 @@ expectError(memberTyped()); // required on a seam member too expectError(memberTyped({ variables: { id: 1 } })); // wrong type on a seam member too // a schema-less seam graphql member also keeps the loose passthrough. -const memberLoose = api.graphql({ query: 'query { ok }' }); +const memberLoose = api.graphql({ document: 'query { ok }' }); expectAssignable>({ variables: { anything: 1 } }); diff --git a/packages/core/test-d/input-inference.test-d.ts b/packages/core/test-d/input-inference.test-d.ts index b7a1e965..8cce2699 100644 --- a/packages/core/test-d/input-inference.test-d.ts +++ b/packages/core/test-d/input-inference.test-d.ts @@ -86,7 +86,7 @@ expectType<{ id: string }>( null as unknown as CallArg['params'], ); const gqlTyped = api.graphql({ - query: 'query { ok }', + document: 'query { ok }', input: { params: z.object({ region: z.string() }) }, }); expectType<{ region: string }>( @@ -95,7 +95,7 @@ expectType<{ region: string }>( // A graphql stitch with NO `input.variables` schema keeps `variables` as a loose untyped // passthrough. (Issue #75 makes a DECLARED `input.variables` schema type them — covered in // graphql-variables.test-d.ts; this case pins the schema-less behaviour stays unchanged.) -const gqlLoose = api.graphql({ query: 'query { ok }', output: userSchema }); +const gqlLoose = api.graphql({ document: 'query { ok }', output: userSchema }); expectAssignable>({ variables: { region: 'eu' } }); // 7) a non-schema `input` slot value is rejected at compile time. diff --git a/packages/core/test-d/overloads.test-d.ts b/packages/core/test-d/overloads.test-d.ts index 8967aad9..e8f529c9 100644 --- a/packages/core/test-d/overloads.test-d.ts +++ b/packages/core/test-d/overloads.test-d.ts @@ -26,10 +26,10 @@ expectType(output(makeApi('/x'))); const api = seam({ baseUrl: 'x' }); expectType(output(api.stitch(either))); -// 4) graphql is a single overload, so a union of query-configs already resolves (no fallback needed). +// 4) graphql is a single overload, so a union of document-configs already resolves (no fallback needed). declare const gqlEither: - | (Partial & { query: string }) - | (Partial & { query: string; method: string }); + | (Partial & { document: string }) + | (Partial & { document: string; method: string }); expectType(output(api.graphql(gqlEither))); // 5) REGRESSION GUARD: the fallback overload must NOT shadow inference for concrete literals. diff --git a/packages/core/test-d/path-vars.test-d.ts b/packages/core/test-d/path-vars.test-d.ts index 48992a11..f597b7c3 100644 --- a/packages/core/test-d/path-vars.test-d.ts +++ b/packages/core/test-d/path-vars.test-d.ts @@ -128,7 +128,7 @@ expectType<{ org: string | number }>( ); expectError(asMember()); const gqlMember = api.graphql({ - query: 'query { ok }', + document: 'query { ok }', path: '/gql/{region}', }); expectType<{ region: string | number }>( diff --git a/packages/core/test-d/postmessage.test-d.ts b/packages/core/test-d/postmessage.test-d.ts index 0feb7fe8..e4c41e08 100644 --- a/packages/core/test-d/postmessage.test-d.ts +++ b/packages/core/test-d/postmessage.test-d.ts @@ -23,8 +23,7 @@ const transport = null as unknown as MessageTransport; const ch = channel(transport, { allowedOrigins: ['https://x.test'] }); // 1) request: result inferred from `opts.output`, call argument from `opts.input`. -const sum = ch.request({ - type: 'sum', +const sum = ch.request('sum', { input: { body: z.object({ a: z.number(), b: z.number() }) }, output: z.object({ total: z.number() }), }); @@ -36,12 +35,11 @@ expectError(sum({ body: { a: 1 } })); // missing `b` expectError(sum({ body: { a: 1, b: 'two' } })); // `b` must be a number // 2) request with NO output schema → result is unknown (no contract pinned); arg stays optional. -const bare = ch.request({ type: 'ping' }); +const bare = ch.request('ping'); expectType(output(bare)); // 3) emit: result is void; call argument inferred from `opts.input`. -const log = ch.emit({ - type: 'log', +const log = ch.emit('log', { input: { body: z.object({ msg: z.string() }) }, }); expectType(null as unknown as Result); @@ -49,16 +47,15 @@ expectType<{ msg: string }>(null as unknown as CallArg['body']); expectError(log({ body: { msg: 123 } })); // msg must be a string // 4) events: result is the COLLECTED PAYLOAD ARRAY, typed from `opts.output`. -const ticks = ch.events({ - type: 'tick', +const ticks = ch.events('tick', { output: z.object({ n: z.number() }), }); expectType<{ n: number }[]>(output(ticks)); // await ⇒ payload[] (no required arg → output() is fine) // 5) events with NO output schema → unknown[] (the loose collected array). -const loose = ch.events({ type: 'evt' }); +const loose = ch.events('evt'); expectType(output(loose)); // 6) a no-input request keeps a fully-OPTIONAL call argument (backward-compatible with StitchInput). -const noInput = ch.request({ type: 'noop' }); +const noInput = ch.request('noop'); expectAssignable>(undefined); diff --git a/packages/core/test-d/seam.test-d.ts b/packages/core/test-d/seam.test-d.ts index 4b379d99..5b8a19a3 100644 --- a/packages/core/test-d/seam.test-d.ts +++ b/packages/core/test-d/seam.test-d.ts @@ -27,6 +27,9 @@ expectType( // graphql member infers from `output` expectType( output( - api.graphql({ query: 'query { u { id name } }', output: userSchema }), + api.graphql({ + document: 'query { u { id name } }', + output: userSchema, + }), ), ); diff --git a/packages/core/test-d/streaming-inference.test-d.ts b/packages/core/test-d/streaming-inference.test-d.ts index 4a55bb15..59484850 100644 --- a/packages/core/test-d/streaming-inference.test-d.ts +++ b/packages/core/test-d/streaming-inference.test-d.ts @@ -33,7 +33,7 @@ expectType(output(bare)[0]!.data); // pre-built `Seam`) so the seam is minted inside the same module family as `sse` — a `Seam` imported // from the root entry is a nominally distinct (built-`lib`) type from `src`'s here. const member = sse - .seam({ baseUrl: 'https://x' }) + .bind({ baseUrl: 'https://x' }) .stitch({ path: '/s', output: itemSchema }); expectType[]>(output(member)); @@ -83,6 +83,6 @@ expectType(output(outputNoDecode)); // 10) a seam-bound `stream` member is decoder-dependent too (covers `bindSeam`). const streamMember = stream - .seam({ baseUrl: 'https://x' }) + .bind({ baseUrl: 'https://x' }) .stitch({ path: '/s', stream: { decode: 'ndjson' }, output: itemSchema }); expectType(output(streamMember)); diff --git a/packages/core/test/HARNESS-API.md b/packages/core/test/HARNESS-API.md index a86a8501..c6bd83d5 100644 --- a/packages/core/test/HARNESS-API.md +++ b/packages/core/test/HARNESS-API.md @@ -27,11 +27,11 @@ import { z } from 'zod'; - `name`, `method` (default GET), `baseUrl` (string | `() => string`), `path` (may include `{param}` and `?predefined=query`) - `input`: `{ params?, query?, body?, headers? }` — each a Zod/Standard-Schema validator -- `output`: a schema **or** `drift(schema, opts)` — validated against the **unwrapped** value -- `unwrap`: dot-path string (e.g. `'data'`) +- `output`: a schema **or** `drift(schema, opts)` — validated against the **picked** value +- `pick`: dot-path string (e.g. `'data'`) - `auth`: an AuthStrategy (see below) -- `retry`: `{ attempts (total incl. first, default 1), on: number[] (default [429,502,503,504]), backoff: 'expo'|'expo-jitter'|'fixed', baseMs, maxMs, respectRetryAfter }` -- `throttle`: `{ rate: '2/s', concurrency: number, scope: 'stitch'|'host' }` +- `retry`: `{ attempts (total incl. first, default 1), on: number|number[]|((status)=>boolean) (default [429,502,503,504]), backoff: 'expo'|'expo-jitter'|'fixed', baseDelay, maxDelay, respectRetryAfter }` +- `throttle`: `{ rate: '2/s', concurrency: number, pool: 'stitch'|'host', delegate?: boolean, on?: StatusMatch }` - `timeout`: `{ total: number|string, perAttempt: number|string }` (ms or '30s') - `hooks`: `{ onRequest, onResponse, onError, onRetry }` — `(ctx) => void|Promise`, `ctx = { name, attempt, req?, res?, error? }` - `extends`: `Array` @@ -52,7 +52,7 @@ const s2 = s.with({ query: { role: 'admin' } }); // partial application -> new - `{ type:'start', name, method, url, input }` - `{ type:'progress', phase:'auth'|'request'|'throttled'|'retry'|'paginate', attempt, detail?, waited? }` - `{ type:'drift', finding:{ level:'error'|'warn'|'info'|'verbose', path, change:'invalid'|'undeclared'|'coerced'|'defaulted', detail? } }` -- `{ type:'result', value, status, attempts }` +- `{ type:'result', data, status, attempts }` - `{ type:'error', name, message, status?, attempts }` - `{ type:'done', ok, elapsed, attempts }` @@ -76,7 +76,7 @@ Drift is schema-anchored — no snapshot (ADR 0015). Each call validates the unw ## Auth -`bearer(secret)`, `apiKey({ header?, value })`, `basic({ user, pass })`, `cookieSession({ login: , cookie: 'sid', loginInput?: () => StitchInput, refreshOn?: [401] })`. Secrets: `env('VAR')` / `secretsFile('name')` return `() => string` resolved at call time. `cookieSession` auto-logs-in when no cookie is stored, replays the captured cookie, and re-logs-in when a response status is in `refreshOn`. +`bearer(secret)`, `apiKey({ in?, name?, value })`, `basic({ user, pass })`, `cookieSession({ login: , cookie: 'sid', loginInput?: () => StitchInput, refreshOn?: [401] })`. Secrets: `env('VAR')` / `secretsFile('name')` return `() => string` resolved at call time. `cookieSession` auto-logs-in when no cookie is stored, replays the captured cookie, and re-logs-in when a response status is in `refreshOn`. ## Mock server @@ -89,10 +89,10 @@ server.reset(); await server.close(); ``` -`behavior`: `{ statuses?: number[] (successive; last repeats), delayMs?: number|number[], body?: value | array(per-call sequence) | (callIndex, req) => body, requireCookie?: {name,value?}, requireHeader?: {name,value?}, setCookie?: {name,value}, retryAfter?: number(sec), headers? }`. +`behavior`: `{ statuses?: number[] (successive; last repeats), delay?: number|number[] (ms), body?: value | array(per-call sequence) | (callIndex, req) => body, requireCookie?: {name,value?}, requireHeader?: {name,value?}, setCookie?: {name,value}, retryAfterSeconds?: number, headers?, stream?: { chunks, chunkDelay? (ms) } }`. ## Test conventions - Put `process.env.STITCH_TRACE_FILE = join(tmpdir(), 'stitch--'+process.pid+'.jsonl')` at the **very top, before importing `../src`**, to capture/quiet the JSONL trace. - One `startMockServer()` per file in `beforeAll`; `server.reset()` in `beforeEach`; `server.close()` in `afterAll`. -- Keep retries fast: `retry: { baseMs: 5 }`. Backoff jitter is random — assert attempt COUNTS and event PRESENCE, not exact delays. Use `delayMs` to create timing for throttle/timeout assertions. +- Keep retries fast: `retry: { baseDelay: 5 }`. Backoff jitter is random — assert attempt COUNTS and event PRESENCE, not exact delays. Use `delay` to create timing for throttle/timeout assertions. diff --git a/packages/core/test/adapter-contract-fixture.spec.ts b/packages/core/test/adapter-contract-fixture.spec.ts index 45a619e0..1284a4a3 100644 --- a/packages/core/test/adapter-contract-fixture.spec.ts +++ b/packages/core/test/adapter-contract-fixture.spec.ts @@ -96,12 +96,10 @@ describe('adapterContractFixture', () => { }); }); - test('GET /slow carries delay (and the @deprecated delayMs alias)', () => { + test('GET /slow carries delay', () => { const res = adapterContractFixture(reqOf({ path: '/slow' })); expect(res.status).toBe(200); expect(res.delay).toBe(300); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- the alias is co-set until the GA cut (CONTRACT.md P17) - expect(res.delayMs).toBe(300); expect(bodyJson(res.body)).toEqual({ slow: true }); }); diff --git a/packages/core/test/auth-trace.spec.ts b/packages/core/test/auth-trace.spec.ts index e5caf7b4..8487df47 100644 --- a/packages/core/test/auth-trace.spec.ts +++ b/packages/core/test/auth-trace.spec.ts @@ -64,7 +64,7 @@ test('auth-wall: me() auto-logs-in and never asks the caller for a secret', asyn auth: cookieSession({ login: signIn, cookie: 'sid', - scope: 'app', + tenancy: 'app', loginInput: () => ({ body: { user: env('DEMO_USER')(), pass: env('DEMO_PASS')() }, }), @@ -103,8 +103,8 @@ test('refresh on the 401 wall re-logs-in and retries the request', async () => { auth: cookieSession({ login: signIn, cookie: 'sid', - refreshOn: [401], - scope: 'app', + refresh: [401], + tenancy: 'app', }), }); diff --git a/packages/core/test/body-encoding.spec.ts b/packages/core/test/body-encoding.spec.ts index a90cf904..76e850df 100644 --- a/packages/core/test/body-encoding.spec.ts +++ b/packages/core/test/body-encoding.spec.ts @@ -118,10 +118,13 @@ describe('cookieSession content-aware refresh (soft wall)', () => { auth: cookieSession({ login, cookie: 'SID', - scope: 'app', + tenancy: 'app', // status is 200, so only a content predicate can catch this wall: - refreshWhen: (res) => - typeof res.body === 'string' && /log in/i.test(res.body), + refresh: { + when: (res) => + typeof res.body === 'string' && + /log in/i.test(res.body), + }, loginInput: () => ({ body: { u: env('SOFT_USER')(), p: env('SOFT_PASS')() }, }), diff --git a/packages/core/test/cache-learned-vary.spec.ts b/packages/core/test/cache-learned-vary.spec.ts index 070acc1a..842f7fed 100644 --- a/packages/core/test/cache-learned-vary.spec.ts +++ b/packages/core/test/cache-learned-vary.spec.ts @@ -19,7 +19,7 @@ describe('createCache learned Vary (response-driven)', () => { const store = memoryStore(); const cache = createCache({ config: { ttl: 0 }, store, stitchId: 's' }); const d = desc(); - const baseKey = cache.key(d, {}); + const baseKey = cache.keyOf(d, {}); if (baseKey === undefined) throw new Error('expected a derived key'); await (await cache.open(baseKey, d)).set('VALUE', 200, '*'); expect(await (await cache.open(baseKey, d)).get()).toBeNull(); @@ -30,15 +30,15 @@ describe('createCache learned Vary (response-driven)', () => { const cache = createCache({ config: { ttl: 0 }, store, stitchId: 's' }); const en = desc({ 'accept-language': 'en' }); const de = desc({ 'accept-language': 'de' }); - const baseKey = cache.key(en, {}); + const baseKey = cache.keyOf(en, {}); if (baseKey === undefined) throw new Error('expected a derived key'); // Without an explicit vary allowlist, accept-language is NOT in the base key. - expect(cache.key(de, {})).toBe(baseKey); + expect(cache.keyOf(de, {})).toBe(baseKey); await (await cache.open(baseKey, en)).set('EN', 200, 'accept-language'); // same accept-language → hit - expect((await (await cache.open(baseKey, en)).get())?.value).toBe('EN'); + expect((await (await cache.open(baseKey, en)).get())?.data).toBe('EN'); // different accept-language → miss (a separate learned-vary entry) expect(await (await cache.open(baseKey, de)).get()).toBeNull(); }); diff --git a/packages/core/test/cache.spec.ts b/packages/core/test/cache.spec.ts index 800af947..f7bb042c 100644 --- a/packages/core/test/cache.spec.ts +++ b/packages/core/test/cache.spec.ts @@ -57,13 +57,13 @@ async function cacheTrace( function counting(opts?: { body?: (callNo: number, req: { url: string; method: string }) => unknown; headers?: Record; - delayMs?: number; + delay?: number; failCall?: number; }): { adapter: Adapter; calls: () => number } { let calls = 0; const adapter: Adapter = async (req) => { const n = (calls += 1); - if (opts?.delayMs) await sleep(opts.delayMs); + if (opts?.delay) await sleep(opts.delay); if (opts?.failCall === n) throw new Error(`origin failed on call ${n}`); return { status: 200, @@ -140,7 +140,7 @@ describe('cache — hit / miss', () => { describe('cache — in-process coalescing', () => { test('N concurrent identical callers collapse onto one origin call', async () => { - const { adapter, calls } = counting({ delayMs: 40 }); + const { adapter, calls } = counting({ delay: 40 }); const s = stitch({ url: URL, adapter, @@ -155,7 +155,7 @@ describe('cache — in-process coalescing', () => { test('a leader failure is NOT shared — each waiter re-runs independently', async () => { // Only the first origin call fails; the leader rejects, and the two followers proceed on // their own (each making its own call), so failure never fans out to the waiters. - const { adapter, calls } = counting({ delayMs: 30, failCall: 1 }); + const { adapter, calls } = counting({ delay: 30, failCall: 1 }); const s = stitch({ url: URL, adapter, @@ -169,7 +169,7 @@ describe('cache — in-process coalescing', () => { }); test('coalesce:false disables collapsing (still caches)', async () => { - const { adapter, calls } = counting({ delayMs: 40 }); + const { adapter, calls } = counting({ delay: 40 }); const s = stitch({ url: URL, adapter, @@ -265,7 +265,7 @@ describe('cache — invalidation', () => { expect(calls()).toBe(3); }); - test('cache.key(input) exposes the derived opaque key', async () => { + test('cache.keyOf(input) exposes the derived opaque key', async () => { const { adapter } = counting(); const s = stitch({ url: URL, @@ -273,9 +273,9 @@ describe('cache — invalidation', () => { trace: false, cache: { ttl: '60s', scope: 'app' }, }); - const k1 = await s.cache.key({ query: { id: 1 } }); - const k2 = await s.cache.key({ query: { id: 1 } }); - const k3 = await s.cache.key({ query: { id: 2 } }); + const k1 = await s.cache.keyOf({ query: { id: 1 } }); + const k2 = await s.cache.keyOf({ query: { id: 1 } }); + const k3 = await s.cache.keyOf({ query: { id: 2 } }); expect(k1).toMatch(/^[0-9a-f]{32}$/); expect(k1).toBe(k2); expect(k1).not.toBe(k3); @@ -347,7 +347,7 @@ describe('cache — LRU bound', () => { describe('cache — sensitive bypass', () => { test('sensitive:true never caches and never coalesces', async () => { - const { adapter, calls } = counting({ delayMs: 30 }); + const { adapter, calls } = counting({ delay: 30 }); const s = stitch({ url: URL, adapter, @@ -591,7 +591,7 @@ describe('cache — GraphQL opt-in', () => { }); const q = graphql({ url: 'https://api.test/graphql', - query: '{ me { id } }', + document: '{ me { id } }', adapter, trace: false, cache: { ttl: '60s', scope: 'app', methods: ['POST'] }, @@ -607,7 +607,7 @@ describe('cache — GraphQL opt-in', () => { }); const q = graphql({ url: 'https://api.test/graphql', - query: '{ me { id } }', + document: '{ me { id } }', adapter, trace: false, cache: { ttl: '60s', scope: 'app' }, // default methods GET/HEAD → POST excluded diff --git a/packages/core/test/circuit-breaker.spec.ts b/packages/core/test/circuit-breaker.spec.ts index 18c5c513..afb4e26d 100644 --- a/packages/core/test/circuit-breaker.spec.ts +++ b/packages/core/test/circuit-breaker.spec.ts @@ -94,7 +94,7 @@ test('a success resets the consecutive-failure count', async () => { expect(server.callCount('/svc')).toBe(5); // all 5 hit the server → breaker never opened }); -test('the @deprecated failureThreshold/cooldownMs aliases still trip the breaker (P4/P17)', async () => { +test('the positional [failures, cooldown] shorthand trips the breaker (P15)', async () => { server.route('GET', '/svc', { statuses: [500, 500, 200], body: { ok: true }, @@ -102,8 +102,8 @@ test('the @deprecated failureThreshold/cooldownMs aliases still trip the breaker const call = stitch({ baseUrl: server.url, path: '/svc', - // Pre-rename spelling — must behave identically to `failures`/`cooldown` until the GA cut. - circuit: { failureThreshold: 2, cooldownMs: 300 }, + // `[2, 300]` ≡ `{ failures: 2, cooldown: 300 }` — compose expands the tuple. + circuit: [2, 300], }); for (let i = 0; i < 2; i++) await expect(call()).rejects.toBeDefined(); diff --git a/packages/core/test/cli.spec.ts b/packages/core/test/cli.spec.ts index c1170c9f..18a3bc5b 100644 --- a/packages/core/test/cli.spec.ts +++ b/packages/core/test/cli.spec.ts @@ -197,7 +197,7 @@ describe('runStitch (arg → input → JSONL)', () => { const listWidgets = stitch({ baseUrl: server.url, path: '/widgets', - unwrap: 'data', + pick: 'data', output: asValidator( z.array(z.object({ id: z.number(), name: z.string() })), ), diff --git a/packages/core/test/clock-seam.spec.ts b/packages/core/test/clock-seam.spec.ts index 8189d818..74a94b5a 100644 --- a/packages/core/test/clock-seam.spec.ts +++ b/packages/core/test/clock-seam.spec.ts @@ -16,7 +16,12 @@ describe('manualClock drives retry backoff (ADR 0010)', () => { baseUrl: 'https://api.test', path: '/flaky', adapter: api, - retry: { attempts: 3, on: [503], backoff: 'fixed', baseMs: 10_000 }, + retry: { + attempts: 3, + on: [503], + backoff: 'fixed', + baseDelay: 10_000, + }, clock, }); @@ -47,7 +52,12 @@ describe('manualClock drives retry backoff (ADR 0010)', () => { baseUrl: 'https://api.test', path: '/flaky', adapter: api, - retry: { attempts: 2, on: [503], backoff: 'fixed', baseMs: 10_000 }, + retry: { + attempts: 2, + on: [503], + backoff: 'fixed', + baseDelay: 10_000, + }, clock, }); diff --git a/packages/core/test/composition.spec.ts b/packages/core/test/composition.spec.ts index 419fae3a..55cdeed3 100644 --- a/packages/core/test/composition.spec.ts +++ b/packages/core/test/composition.spec.ts @@ -48,7 +48,7 @@ test('extends facade produces the correct result', async () => { const schema = asValidator( z.array(z.object({ id: z.number(), name: z.string() })), ); - const base = { baseUrl: server.url, unwrap: 'data' }; + const base = { baseUrl: server.url, pick: 'data' }; const viaExtends = stitch({ extends: [base], @@ -71,7 +71,7 @@ test('a seam member is equivalent to the extends facade', async () => { const schema = asValidator( z.array(z.object({ id: z.number(), name: z.string() })), ); - const base = { baseUrl: server.url, unwrap: 'data' }; + const base = { baseUrl: server.url, pick: 'data' }; const viaExtends = stitch({ extends: [base], @@ -188,21 +188,21 @@ test('predefined query merges with call-time query (input wins on conflict)', as expect(call?.query).toEqual({ sort: 'name', type: 'user' }); }); -// 3) Objects deep-merge across layers: base retry {attempts,on} survives a child adding {baseMs}. -test('deep-merge keeps base retry.attempts/on when child adds retry.baseMs', async () => { +// 3) Objects deep-merge across layers: base retry {attempts,on} survives a child adding {baseDelay}. +test('deep-merge keeps base retry.attempts/on when child adds retry.baseDelay', async () => { server.route('GET', '/flaky', { statuses: [503, 503, 200], body: { ok: true }, }); const retryPreset = { retry: { attempts: 3, on: [503] } }; - // Child only sets baseMs; if merge replaced the object wholesale, attempts/on would be lost + // Child only sets baseDelay; if merge replaced the object wholesale, attempts/on would be lost // and the stitch would NOT retry the two 503s. const flaky = stitch({ baseUrl: server.url, path: '/flaky', extends: [retryPreset], - retry: { baseMs: 5 }, + retry: { baseDelay: 5 }, }); const events = await collect(flaky.stream()); diff --git a/packages/core/test/config-summary.spec.ts b/packages/core/test/config-summary.spec.ts index 3f6fc258..066301c5 100644 --- a/packages/core/test/config-summary.spec.ts +++ b/packages/core/test/config-summary.spec.ts @@ -29,16 +29,14 @@ describe('endpointLabel', () => { ); }); - it('marks a dynamic (function) url', () => { - expect(endpointLabel(cfg({ url: () => 'https://x' }))).toBe( - 'GET (dynamic url)', - ); + it('a redacted-away (dynamic) url reads as no endpoint', () => { + // A url thunk never survives redaction (P0 — no functions on `__config`), so the + // redacted view simply has no url and the label falls back. + expect(endpointLabel(cfg({}))).toBe('GET (no endpoint)'); }); - it('marks a dynamic baseUrl while keeping the path', () => { - expect( - endpointLabel(cfg({ baseUrl: () => 'https://x', path: '/y' })), - ).toBe('GET (dynamic)/y'); + it('keeps the path when a dynamic baseUrl was redacted away', () => { + expect(endpointLabel(cfg({ path: '/y' }))).toBe('GET /y'); }); it('falls back to "(no endpoint)" when nothing is configured', () => { @@ -63,20 +61,19 @@ describe('pipelineStages', () => { throttle: { concurrency: 2 }, retry: { attempts: 3 }, paginate: { pages: 7 }, - transform: () => undefined, - unwrap: 'data', + pick: 'data', output: () => true, - cache: '1m', + cache: { ttl: '1m' }, }); - // Post-response order is transform → unwrap → validate (engine.ts), bookended by call/result. + // Post-response order is pick → validate (engine.ts), bookended by call/result. (`transform` + // is a live closure and never survives redaction — P0 — so it cannot appear as a stage.) expect(pipelineStages(full, { detailed: true })).toEqual([ 'call', 'throttle', 'POST https://api.example.com/widgets', 'retry ×3', 'paginate (max 7)', - 'transform', - 'unwrap: data', + 'pick: data', 'validate', 'cache', 'result', diff --git a/packages/core/test/conformance-kit.spec.ts b/packages/core/test/conformance-kit.spec.ts index f768afca..b7a01bf9 100644 --- a/packages/core/test/conformance-kit.spec.ts +++ b/packages/core/test/conformance-kit.spec.ts @@ -91,7 +91,7 @@ function mountFixture(): Promise { // store contract // --------------------------------------------------------------------------- -// Deliberately broken store: set() ignores ttlMs (entries never expire) and +// Deliberately broken store: set() ignores ttl (entries never expire) and // incr() awaits between read and write (concurrent calls collide). function brokenStore(): StitchStore { const data = new Map(); @@ -129,8 +129,8 @@ describe('verifyStoreContract', () => { const report = await verifyStoreContract(brokenStore); expect(report.ok).toBe(false); const failed = report.violations.map((v) => v.rule); - expect(failed).toContain('set: a ttlMs entry expires'); - expect(failed).toContain('incr: the counter expires after ttlMs'); + expect(failed).toContain('set: a ttl entry expires'); + expect(failed).toContain('incr: the counter expires after its ttl'); expect(failed).toContain('incr: 20 concurrent calls net exactly +20'); // Independent rules: violations do not mask the healthy behaviors. expect(report.passed).toContain('set/get: round-trips a value'); @@ -155,9 +155,8 @@ describe('verifyAdapterContract', () => { }); test('fetchAdapter passes the adapter contract against adapterContractFixture', async () => { - const report = await verifyAdapterContract(fetchAdapter(), { - baseUrl: host.url, - }); + // A bare origin string is the `{ baseUrl }` shorthand. + const report = await verifyAdapterContract(fetchAdapter(), host.url); expect(report.seam).toBe('adapter'); expect(report.violations).toEqual([]); expect(report.ok).toBe(true); diff --git a/packages/core/test/contract-event-aliases.spec.ts b/packages/core/test/contract-event-aliases.spec.ts deleted file mode 100644 index 5511223f..00000000 --- a/packages/core/test/contract-event-aliases.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -// P17 parity: the engine CO-EMITS the @deprecated `*Ms` aliases alongside the canonical -// de-suffixed event fields, so a consumer still reading the old name keeps working until the -// GA cut (CONTRACT.md P17/P19). Covers the `done` (elapsed/ms) and throttled-`progress` -// (waited/waitedMs) events the engine constructs. -import { stitch } from '../src'; -import type { Adapter, StitchEvent } from '../src'; - -const okAdapter: Adapter = async () => ({ - status: 200, - headers: {}, - body: { ok: true }, -}); - -async function collect(s: { - stream(): AsyncGenerator; -}): Promise { - const out: StitchEvent[] = []; - for await (const e of s.stream()) out.push(e); - return out; -} - -test('done event carries both `elapsed` and the @deprecated `ms` alias', async () => { - const call = stitch({ url: 'https://x.test', adapter: okAdapter }); - const done = (await collect(call())).find((e) => e.type === 'done'); - const d = done as Extract; - expect(typeof d.elapsed).toBe('number'); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- asserting the alias is co-emitted for back-compat - expect(d.ms).toBe(d.elapsed); -}); - -test('throttled progress carries both `waited` and the @deprecated `waitedMs` alias', async () => { - let release!: () => void; - const gate = new Promise((r) => (release = r)); - let n = 0; - const adapter: Adapter = async () => { - // Hold the FIRST call open so the second blocks on the single concurrency slot. - if (++n === 1) await gate; - return { status: 200, headers: {}, body: { ok: true } }; - }; - const call = stitch({ - url: 'https://x.test', - adapter, - throttle: { concurrency: 1 }, - }); - - const first = collect(call()); // takes the only slot, parks on the gate - await new Promise((r) => setTimeout(r, 10)); // first acquires the slot before the second starts - const secondP = collect(call()); // blocks on concurrency → records waited > 0 - await new Promise((r) => setTimeout(r, 10)); // hold the block long enough to be measurable - release(); - const [, second] = await Promise.all([first, secondP]); - - const throttled = second.find( - (e) => e.type === 'progress' && e.phase === 'throttled', - ) as Extract | undefined; - expect(throttled).toBeDefined(); - const p = throttled as Extract; - expect(typeof p.waited).toBe('number'); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- asserting the alias is co-emitted for back-compat - expect(p.waitedMs).toBe(p.waited); -}); diff --git a/packages/core/test/contract-p0.spec.ts b/packages/core/test/contract-p0.spec.ts new file mode 100644 index 00000000..36a70f06 --- /dev/null +++ b/packages/core/test/contract-p0.spec.ts @@ -0,0 +1,143 @@ +// CONTRACT.md P0 regression: the enumerable `__config` is plain JSON data. Every function-valued +// field an author can write — endpoint thunks, `transform`, `paginate.next`/`items`, `hooks.*`, +// the predicate forms of `acceptStatus`/`retry.on`/`throttle.on`, `idempotency.keyOf`, +// `cache.keyOf` — must be stripped by redaction (the engine reads them off the non-enumerable +// `__rawConfig`), and every scalar shorthand must arrive on `__config` already normalized to its +// canonical envelope (P7/P12/P13/P15). +import { stitch } from '../src'; +import type { StitchConfig } from '../src'; + +// Recursively collect the paths of every function-valued field on an object graph. `__config` +// only carries own enumerable data, so a plain Object.entries walk is the honest P0 probe. +function fnPaths(value: unknown, path = '$'): string[] { + if (typeof value === 'function') return [path]; + if (value === null || typeof value !== 'object') return []; + const out: string[] = []; + for (const [k, v] of Object.entries(value as Record)) { + out.push(...fnPaths(v, `${path}.${k}`)); + } + return out; +} + +const rawConfigOf = (f: unknown): StitchConfig => + (f as { __rawConfig: StitchConfig }).__rawConfig; + +describe('CONTRACT.md P0 — __config is plain JSON data', () => { + // Every function-valued slot an author can populate, in one stitch. + const laden = stitch({ + name: 'p0-fn-laden', + url: () => 'https://api.example.test/items', + transform: (body) => body, + paginate: { + next: () => undefined, + items: () => [], + pages: 3, + }, + hooks: { + onRequest: () => undefined, + onResponse: () => undefined, + onError: () => undefined, + onRetry: () => undefined, + }, + acceptStatus: (status) => status === 404, + retry: { attempts: 2, on: (status) => status >= 500 }, + throttle: { rate: '2/s', on: (status) => status === 429 }, + idempotency: { keyOf: () => 'p0-idem' }, + cache: { + ttl: '1m', + keyOf: () => 'p0-cache', + // String→list P7 shorthands ride along here (the scalar-cache stitch below uses the + // bare-TTL spelling, so the object form lives on this one). + vary: 'accept', + methods: 'GET', + }, + }); + + // Every scalar shorthand, each normalized to its canonical envelope before `__config`. + const shorthand = stitch({ + name: 'p0-shorthand', + baseUrl: () => 'https://api.example.test', + path: '/things/{id}', + method: 'POST', + bodyType: 'multipart', + multipart: 'dot', + stream: 'ndjson', + sse: true, + retry: 3, + timeout: '5s', + throttle: '2/s', + idempotency: true, + circuit: [2, '30s'], + cache: '1m', + acceptStatus: 404, + // Single fragment ≡ one-element list (P7); folded into the merged config. + extends: { headers: { accept: 'application/json' } }, + }); + + test('the fn-laden stitch exposes zero function-valued paths on __config', () => { + expect(fnPaths(laden.__config)).toEqual([]); + }); + + test('the shorthand stitch exposes zero function-valued paths on __config', () => { + expect(fnPaths(shorthand.__config)).toEqual([]); + }); + + test('__config survives a JSON round-trip unchanged', () => { + for (const cfg of [laden.__config, shorthand.__config]) { + expect(JSON.parse(JSON.stringify(cfg))).toEqual(cfg); + } + }); + + test('function-valued sugar is redacted field by field', () => { + const cfg = laden.__config; + // A thunked endpoint has no static string — the slot is absent, not stringified. + expect(cfg.url).toBeUndefined(); + expect(cfg).not.toHaveProperty('transform'); + expect(cfg).not.toHaveProperty('hooks'); + // Only the fn-free `pages` cap survives on paginate. + expect(cfg.paginate).toEqual({ pages: 3 }); + // Predicate status-matches are redacted away; data fields stay. + expect(cfg.acceptStatus).toBeUndefined(); + expect(cfg.retry).toEqual({ attempts: 2 }); + expect(cfg.throttle).toEqual({ rate: '2/s' }); + expect(cfg.idempotency).toEqual({}); + expect(cfg.cache).toEqual({ + ttl: '1m', + vary: ['accept'], + methods: ['GET'], + }); + }); + + test('every scalar shorthand arrives as its canonical envelope', () => { + const cfg = shorthand.__config; + // The baseUrl thunk is redacted; the static path survives. + expect(cfg.baseUrl).toBeUndefined(); + expect(cfg.path).toBe('/things/{id}'); + expect(cfg.retry).toEqual({ attempts: 3 }); + expect(cfg.timeout).toEqual({ total: '5s' }); + expect(cfg.stream).toEqual({ decode: 'ndjson' }); + expect(cfg.multipart).toEqual({ nesting: 'dot' }); + expect(cfg.sse).toEqual({ reconnect: true }); + expect(cfg.throttle).toEqual({ rate: '2/s' }); + expect(cfg.idempotency).toEqual({}); + expect(cfg.circuit).toEqual({ failures: 2, cooldown: '30s' }); + expect(cfg.cache).toEqual({ ttl: '1m' }); + expect(cfg.acceptStatus).toEqual([404]); + // The single-fragment extends folded into the merged config (and the key itself is gone). + expect(cfg.headers).toEqual({ accept: 'application/json' }); + expect(cfg).not.toHaveProperty('extends'); + }); + + test('the engine-side sugar lives on the non-enumerable __rawConfig', () => { + const raw = rawConfigOf(laden); + expect(typeof raw.url).toBe('function'); + expect(typeof raw.transform).toBe('function'); + expect(typeof raw.paginate?.next).toBe('function'); + expect(typeof raw.hooks?.onRequest).toBe('function'); + expect(typeof raw.acceptStatus).toBe('function'); + expect(fnPaths(rawConfigOf(shorthand))).toContain('$.baseUrl'); + // Neither meta property is enumerable — a spread/JSON view of the stitch leaks nothing. + expect(Object.keys(laden)).not.toContain('__config'); + expect(Object.keys(laden)).not.toContain('__rawConfig'); + }); +}); diff --git a/packages/core/test/cookie-jar.spec.ts b/packages/core/test/cookie-jar.spec.ts index 9b5bcd7d..6495b599 100644 --- a/packages/core/test/cookie-jar.spec.ts +++ b/packages/core/test/cookie-jar.spec.ts @@ -2,7 +2,7 @@ // Set-Cookie set from the login and replays every cookie on subsequent requests — not just // one named cookie. A login that sets two cookies should make both ride along next time. // -// These standalone stitches share ONE session across all callers, so they pass `scope: 'app'` +// These standalone stitches share ONE session across all callers, so they pass `tenancy: 'app'` // explicitly — the fail-closed default `'principal'` would throw (no seam binds a principal). import { cookieSession, env, stitch } from '../src'; import { startMockServer } from './support/mock-server'; @@ -61,7 +61,7 @@ test('cookie: "*" captures and replays every cookie the login set', async () => login: loginStitch(), cookie: '*', loginInput, - scope: 'app', + tenancy: 'app', }), }); @@ -94,7 +94,7 @@ test('jar: true is equivalent to cookie: "*"', async () => { cookie: 'session', // only seeds the store key in jar mode jar: true, loginInput, - scope: 'app', + tenancy: 'app', }), }); @@ -124,7 +124,7 @@ test('a single named cookie still replays only that one (regression)', async () login: loginStitch(), cookie: 'sid', loginInput, - scope: 'app', + tenancy: 'app', }), }); diff --git a/packages/core/test/diagram.spec.ts b/packages/core/test/diagram.spec.ts index 67e4d6b7..749f3d29 100644 --- a/packages/core/test/diagram.spec.ts +++ b/packages/core/test/diagram.spec.ts @@ -21,7 +21,7 @@ const sampleRegistry = (): StitchRegistry => ({ path: '/users/{id}', retry: { attempts: 3 }, output: z.object({ id: z.number() }), - unwrap: 'data', + pick: 'data', }), ping: stitch('https://api.example.com/ping'), }); @@ -36,34 +36,34 @@ describe('toMermaid', () => { test('a stitch chains its configured pipeline stages in order', () => { const { diagram } = toMermaid(sampleRegistry()); - // getUser: call -> request -> retry -> unwrap -> validate -> result (no throttle/cache). + // getUser: call -> request -> retry -> pick -> validate -> result (no throttle/cache). expect(diagram).toContain('(["call"]) -->'); expect(diagram).toContain('GET https://api.example.com/users/{id}'); expect(diagram).toContain('retry'); expect(diagram).toContain('validate'); - expect(diagram).toContain('unwrap: data'); + expect(diagram).toContain('pick: data'); expect(diagram).toContain('(["result"])'); }); - test('post-response stages render in engine order: transform -> unwrap -> validate', () => { - // The engine processes the response body transform → unwrap → validate (engine.ts), and the - // diagram's contract is "the configured request pipeline … in engine order". So the diagram - // must place validate LAST of the three, not first. + test('post-response stages render in engine order: pick -> validate', () => { + // The engine reads the pick path then validates (engine.ts), and the diagram's contract is + // "the configured request pipeline … in engine order" — validate LAST of the two. The + // `transform` closure lives only on `__rawConfig` (P0), so the redacted view (and hence the + // diagram) never shows a transform stage. const { diagram } = toMermaid({ proc: stitch({ baseUrl: 'https://api.example.com', path: '/x', transform: (b) => b, - unwrap: 'data', + pick: 'data', output: z.object({ id: z.number() }), }), }); - const iTransform = diagram.indexOf('transform'); - const iUnwrap = diagram.indexOf('unwrap: data'); + expect(diagram).not.toContain('transform'); + const iPick = diagram.indexOf('pick: data'); const iValidate = diagram.indexOf('validate'); - expect(iTransform).toBeGreaterThanOrEqual(0); - expect(iUnwrap).toBeGreaterThan(iTransform); // unwrap after transform - expect(iValidate).toBeGreaterThan(iUnwrap); // validate after unwrap + expect(iPick).toBeGreaterThanOrEqual(0); + expect(iValidate).toBeGreaterThan(iPick); // validate after pick }); test('a bare stitch is just call -> request -> result', () => { diff --git a/packages/core/test/download.spec.ts b/packages/core/test/download.spec.ts index b905a153..f12d5ec6 100644 --- a/packages/core/test/download.spec.ts +++ b/packages/core/test/download.spec.ts @@ -265,9 +265,9 @@ describe('download authoring helpers (Decision 3)', () => { expect(a.__config.kind).toBe(b.__config.kind); }); - test('download.seam(existingSeam).stitch(...) creates a download member of that seam', async () => { + test('download.bind(existingSeam).stitch(...) creates a download member of that seam', async () => { const api = seam({ baseUrl: 'https://x.test' }); - const getThing = download.seam(api).stitch({ + const getThing = download.bind(api).stitch({ path: '/thing', adapter: blobAdapter('thing-bytes', { headers: { diff --git a/packages/core/test/fingerprint-resolve-edges.spec.ts b/packages/core/test/fingerprint-resolve-edges.spec.ts index 7a04b955..b964ac9a 100644 --- a/packages/core/test/fingerprint-resolve-edges.spec.ts +++ b/packages/core/test/fingerprint-resolve-edges.spec.ts @@ -1,6 +1,6 @@ // Edge rungs of resolveFingerprint (src/fingerprint.ts) that fingerprint.spec.ts leaves open. That // suite walks the ladder thoroughly, but three precedence/folding details go unpinned: -// - the no-output (rung 4) generation FOLDS `unwrap` and a versioned `transform`, so two +// - the no-output (rung 4) generation FOLDS `pick` and a versioned `transform`, so two // no-schema stitches that differ only in those get DISTINCT cache generations (the existing // test only checks the policy is fast + the generation is non-empty); // - an opaque transform with no output still REFUSES — the transform gate (rung 2) outranks the @@ -23,10 +23,10 @@ beforeEach(() => { clearFingerprinters(); }); -describe('resolveFingerprint: no-output (rung 4) folds unwrap + transform', () => { - it('distinct unwrap → distinct no-schema generation (still fast)', () => { - const a = resolveFingerprint({ unwrap: 'data' }); - const b = resolveFingerprint({ unwrap: 'meta' }); +describe('resolveFingerprint: no-output (rung 4) folds pick + transform', () => { + it('distinct pick → distinct no-schema generation (still fast)', () => { + const a = resolveFingerprint({ pick: 'data' }); + const b = resolveFingerprint({ pick: 'meta' }); expect(a.policy).toBe('fast'); expect(b.policy).toBe('fast'); expect(a.generation).not.toBe(b.generation); diff --git a/packages/core/test/fingerprint.spec.ts b/packages/core/test/fingerprint.spec.ts index 87fe3efd..524e7001 100644 --- a/packages/core/test/fingerprint.spec.ts +++ b/packages/core/test/fingerprint.spec.ts @@ -127,10 +127,10 @@ describe('resolveFingerprint — the fallback ladder', () => { expect(r.policy).toBe('fast'); }); - it('rung 1: different version or unwrap → different generation', () => { + it('rung 1: different version or pick → different generation', () => { const a = resolveFingerprint({ version: 'v1' }); const b = resolveFingerprint({ version: 'v2' }); - const c = resolveFingerprint({ version: 'v1', unwrap: 'data' }); + const c = resolveFingerprint({ version: 'v1', pick: 'data' }); expect(a.generation).not.toBe(b.generation); expect(a.generation).not.toBe(c.generation); }); @@ -148,9 +148,9 @@ describe('resolveFingerprint — the fallback ladder', () => { expect(changed.generation).not.toBe(r1.generation); // sensitive }); - it('rung 2: unwrap is folded into the generation', () => { + it('rung 2: pick is folded into the generation', () => { const a = resolveFingerprint({ output: userSchema() }); - const b = resolveFingerprint({ output: userSchema(), unwrap: 'data' }); + const b = resolveFingerprint({ output: userSchema(), pick: 'data' }); expect(b.policy).toBe('fast'); expect(b.generation).not.toBe(a.generation); }); diff --git a/packages/core/test/from-curl-edges.spec.ts b/packages/core/test/from-curl-edges.spec.ts index ae332c92..93eb3a57 100644 --- a/packages/core/test/from-curl-edges.spec.ts +++ b/packages/core/test/from-curl-edges.spec.ts @@ -135,7 +135,7 @@ describe('parseHar — body kind and entry selection', () => { }, }), ); - expect(req.bodyKind).toBe('form'); + expect(req.bodyType).toBe('form'); expect(req.body).toBe('a=1&b=2'); }); diff --git a/packages/core/test/from-curl.spec.ts b/packages/core/test/from-curl.spec.ts index 86a949ec..f3997726 100644 --- a/packages/core/test/from-curl.spec.ts +++ b/packages/core/test/from-curl.spec.ts @@ -54,15 +54,15 @@ describe('parseCurl', () => { expect(req.headers).toEqual([{ name: 'X-A', value: 'b' }]); }); - test('-d JSON → bodyKind json; --data-urlencode → form', () => { + test('-d JSON → bodyType json; --data-urlencode → form', () => { const json = parseCurl( `curl -d '{"name":"Ada"}' https://api.example.com/users`, ); - expect(json.bodyKind).toBe('json'); + expect(json.bodyType).toBe('json'); const form = parseCurl( 'curl --data-urlencode q=ada https://api.example.com/s', ); - expect(form.bodyKind).toBe('form'); + expect(form.bodyType).toBe('form'); }); test('an unknown flag warns and does not crash', () => { @@ -207,7 +207,7 @@ describe('parseHar', () => { expect(req.url).toBe('https://api.example.com/items'); // The HTTP/2 pseudo-header is dropped. expect(req.headers.some((h) => h.name.startsWith(':'))).toBe(false); - expect(req.bodyKind).toBe('json'); + expect(req.bodyType).toBe('json'); expect(req.body).toBe('{"sku":"abc"}'); }); diff --git a/packages/core/test/gaps/accept-status.spec.ts b/packages/core/test/gaps/accept-status.spec.ts index a877384d..85897ea8 100644 --- a/packages/core/test/gaps/accept-status.spec.ts +++ b/packages/core/test/gaps/accept-status.spec.ts @@ -67,7 +67,7 @@ test('an accepted non-2xx still runs transform, unwrap, and output validation', baseUrl: server.url, path: '/accepted-pipeline', acceptStatus: [404], - unwrap: 'data', + pick: 'data', transform: (body) => { const b = body as { data: { id: number; name: string } }; return { data: { id: b.data.id, label: b.data.name } }; @@ -124,7 +124,7 @@ test('a status in both retry.on and acceptStatus is retried, then accepted on th const call = stitch<{ ok: boolean; last: boolean }>({ baseUrl: server.url, path: '/retry-then-accept', - retry: { attempts: 3, on: [503], backoff: 'fixed', baseMs: 1 }, + retry: { attempts: 3, on: [503], backoff: 'fixed', baseDelay: 1 }, acceptStatus: [503], }); diff --git a/packages/core/test/gaps/cli-since-exit.spec.ts b/packages/core/test/gaps/cli-since-exit.spec.ts index 35be14a6..bdf052e3 100644 --- a/packages/core/test/gaps/cli-since-exit.spec.ts +++ b/packages/core/test/gaps/cli-since-exit.spec.ts @@ -25,7 +25,7 @@ function makeTraceFile(): string { name: 'test', type: 'done', ok: true, - ms: 5, + elapsed: 5, at: Date.now(), }) + '\n', ); @@ -44,14 +44,12 @@ describe('CLI trace --since with unparseable value', () => { }); /** - * CONTRACT (desired, not yet implemented): - * `stitch trace --since not-a-date` must: - * 1. exit with code 2 (not 0) - * 2. write a message to stderr that mentions "--since" + * CONTRACT: `stitch trace --since not-a-date` must: + * 1. exit with code 2 (not 0) + * 2. write a message to stderr that mentions "--since" * - * TODAY (bug): parseSince('not-a-date') returns undefined, the code falls - * through to `cutoff = io.now() - (undefined ?? 0) = now`, silently filters - * out all records, and exits 0 with an empty summary. + * Guards the fix: parseDuration('not-a-date') returns undefined and the + * command reports bad usage instead of silently filtering everything out. */ test('exits 2 and writes a --since error to stderr for unparseable input', async () => { const exitCode = await main( diff --git a/packages/core/test/gaps/cookie-session-hooks.spec.ts b/packages/core/test/gaps/cookie-session-hooks.spec.ts index 511bd418..84cf340c 100644 --- a/packages/core/test/gaps/cookie-session-hooks.spec.ts +++ b/packages/core/test/gaps/cookie-session-hooks.spec.ts @@ -8,7 +8,7 @@ // its own status. The hooks fire ONCE per actual login attempt (inside the single-flight-guarded // `doRefresh`), never per coalesced waiter, and a throwing hook never crashes the call. // -// These standalone stitches share ONE session across all callers, so they pass `scope: 'app'` +// These standalone stitches share ONE session across all callers, so they pass `tenancy: 'app'` // explicitly — the fail-closed default `'principal'` would throw (no seam binds a principal). import { type AuthFailureResult, @@ -73,7 +73,7 @@ test('onRefresh fires with { ok: true, status: 200 } after a successful cold log login: loginStitch(server.url), cookie: 'sid', loginInput, - scope: 'app', + tenancy: 'app', onRefresh: (r) => { refreshes.push(r); }, @@ -94,7 +94,7 @@ test('onRefresh fires ONCE (not per-waiter) under concurrent cold callers sharin // Hold the first login in flight long enough that every concurrent cold // caller has already taken the cache-miss branch — making the race // deterministic, the same trick the single-flight token test uses. - delayMs: 50, + delay: 50, setCookie: { name: 'sid', value: 'ABC' }, body: { ok: true }, }); @@ -112,7 +112,7 @@ test('onRefresh fires ONCE (not per-waiter) under concurrent cold callers sharin login: loginStitch(server.url), cookie: 'sid', loginInput, - scope: 'app', + tenancy: 'app', onRefresh: () => { refreshCount++; }, @@ -129,7 +129,7 @@ test('onRefresh fires ONCE (not per-waiter) under concurrent cold callers sharin test("onAuthFailure fires category 'unauthenticated' when the login returns 401 and sets no cookie", async () => { // No setCookie + a 401 status: the login responded but captured nothing, and 401 is a - // `refreshOn` status → the host hears "the creds were rejected". + // `refresh` status → the host hears "the creds were rejected". server.route('POST', '/login', { statuses: [401], body: { error: 'bad creds' }, @@ -149,7 +149,7 @@ test("onAuthFailure fires category 'unauthenticated' when the login returns 401 login: loginStitch(server.url), cookie: 'sid', loginInput, - scope: 'app', + tenancy: 'app', onAuthFailure: (f) => { failures.push(f); }, @@ -173,7 +173,7 @@ test("onAuthFailure fires category 'unauthenticated' when the login returns 401 test("onAuthFailure fires category 'rate-limited' + retryAfter when the login returns 429 with Retry-After", async () => { server.route('POST', '/login', { statuses: [429], - retryAfter: 7, // seconds → 7000ms + retryAfterSeconds: 7, // seconds → 7000ms body: { error: 'slow down' }, }); server.route('GET', '/data', { @@ -190,7 +190,7 @@ test("onAuthFailure fires category 'rate-limited' + retryAfter when the login re login: loginStitch(server.url), cookie: 'sid', loginInput, - scope: 'app', + tenancy: 'app', onAuthFailure: (f) => { failures.push(f); }, @@ -205,8 +205,6 @@ test("onAuthFailure fires category 'rate-limited' + retryAfter when the login re status: 429, category: 'rate-limited', retryAfter: 7000, - // The @deprecated `retryAfterMs` alias is co-set for back-compat (CONTRACT.md P17). - retryAfterMs: 7000, }, ]); }); @@ -231,7 +229,7 @@ test("onAuthFailure fires category 'network' + error when the login stitch throw login: loginStitch(deadUrl), cookie: 'sid', loginInput, - scope: 'app', + tenancy: 'app', onAuthFailure: (f) => { failures.push(f); }, @@ -270,7 +268,7 @@ test('a throwing hook does not crash the stitch call', async () => { login: loginStitch(server.url), cookie: 'sid', loginInput, - scope: 'app', + tenancy: 'app', onRefresh: () => { throw new Error('host bookkeeping blew up'); }, @@ -300,7 +298,7 @@ test('hooks are absent → cookieSession behaves exactly as before (additive, no login: loginStitch(server.url), cookie: 'sid', loginInput, - scope: 'app', + tenancy: 'app', }), }); diff --git a/packages/core/test/gaps/delegate-backoff.spec.ts b/packages/core/test/gaps/delegate-backoff.spec.ts index 45fb36a4..02216c1d 100644 --- a/packages/core/test/gaps/delegate-backoff.spec.ts +++ b/packages/core/test/gaps/delegate-backoff.spec.ts @@ -1,5 +1,5 @@ // Pins issue #145: an opt-in delegate-backoff mode that SURFACES a rate-limit outcome (status in -// `rateLimit.on`, default [429]) as a RateLimitError instead of retrying it internally, and bypasses +// `throttle.on`, default [429]) as a RateLimitError instead of retrying it internally, and bypasses // the built-in throttle so an OUTER gate (owned by the host) — not StitchAPI — paces the backoff. import { RateLimitError, type StitchEvent, stitch } from '../../src'; import { startMockServer } from '../support/mock-server'; @@ -44,7 +44,7 @@ async function rejectionOf( test('a 429 with Retry-After: 2 throws RateLimitError(retryAfter=2000) and is hit exactly once', async () => { server.route('GET', '/rl', { statuses: [429], - retryAfter: 2, // delta-seconds → Retry-After: 2 + retryAfterSeconds: 2, // delta-seconds → Retry-After: 2 body: { error: 'slow down' }, }); const call = stitch({ @@ -61,8 +61,6 @@ test('a 429 with Retry-After: 2 throws RateLimitError(retryAfter=2000) and is hi expect(err.name).toBe('RateLimitError'); expect(err.status).toBe(429); expect(err.retryAfter).toBe(2000); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- the @deprecated `retryAfterMs` alias is co-set until the GA cut (CONTRACT.md P17) - expect(err.retryAfterMs).toBe(2000); // back-compat alias parity // The raw response rides along so the host can read other rate headers. expect(err.response.status).toBe(429); expect(err.response.body).toEqual({ error: 'slow down' }); @@ -74,7 +72,7 @@ test('a 429 with Retry-After: 2 throws RateLimitError(retryAfter=2000) and is hi test('.stream() surfaces an error event with status 429 and retryAfter 2000', async () => { server.route('GET', '/rl-stream', { statuses: [429], - retryAfter: 2, + retryAfterSeconds: 2, body: { error: 'slow down' }, }); const call = stitch({ @@ -105,7 +103,7 @@ test('.stream() surfaces an error event with status 429 and retryAfter 2000', as test('the configured throttle does not pace the call and emits no throttled event', async () => { server.route('GET', '/rl-throttle', { statuses: [429, 429], - retryAfter: 1, + retryAfterSeconds: 1, body: { error: 'slow down' }, }); const call = stitch({ @@ -157,7 +155,7 @@ test('Retry-After as an HTTP-date parses to a positive retryAfter', async () => expect(err.retryAfter).toBeGreaterThan(3000); }); -// ── E. a missing Retry-After yields RateLimitError with retryAfterMs undefined ── +// ── E. a missing Retry-After yields RateLimitError with retryAfter undefined ── test('a 429 without Retry-After throws RateLimitError with retryAfter undefined', async () => { server.route('GET', '/rl-bare', { statuses: [429], @@ -182,7 +180,7 @@ test('a 429 without Retry-After throws RateLimitError with retryAfter undefined' test('throttle.on selects which statuses delegate (503 delegates, 429 retries)', async () => { server.route('GET', '/rl-503', { statuses: [503], - retryAfter: 1, + retryAfterSeconds: 1, body: { error: 'unavailable' }, }); const delegated = stitch({ @@ -206,18 +204,18 @@ test('throttle.on selects which statuses delegate (503 delegates, 429 retries)', const retried = stitch<{ ok: boolean }>({ baseUrl: server.url, path: '/retry-429', - retry: { attempts: 3, on: [429], backoff: 'fixed', baseMs: 1 }, + retry: { attempts: 3, on: [429], backoff: 'fixed', baseDelay: 1 }, throttle: { delegate: true, on: [503] }, }); await expect(retried()).resolves.toEqual({ ok: true }); expect(server.callCount('/retry-429')).toBe(3); }); -// ── G. the success path still runs transform → unwrap → drift under delegate mode ── -// Delegate mode only intercepts rate-limit statuses; a 200 must validate/transform/unwrap exactly as +// ── G. the success path still runs transform → pick → drift under delegate mode ── +// Delegate mode only intercepts rate-limit statuses; a 200 must validate/transform/pick exactly as // it would without the flag — proving the mode is "validate + template + transform + drift, but // delegate backoff", not a bare pass-through. -test('a success response still runs transform, unwrap, and output validation', async () => { +test('a success response still runs transform, pick, and output validation', async () => { server.route('GET', '/ok', { statuses: [200], body: { data: { id: 7, name: 'widget' } }, @@ -226,9 +224,9 @@ test('a success response still runs transform, unwrap, and output validation', a baseUrl: server.url, path: '/ok', throttle: { delegate: true }, - unwrap: 'data', + pick: 'data', transform: (body) => { - // raw body → reshape: rename `name` to `label`. Runs before unwrap. + // raw body → reshape: rename `name` to `label`. Runs before pick. const b = body as { data: { id: number; name: string } }; return { data: { id: b.data.id, label: b.data.name } }; }, @@ -247,7 +245,7 @@ test('a success response still runs transform, unwrap, and output validation', a test('.safe() surfaces the rate-limit status as a non-throwing StitchError', async () => { server.route('GET', '/rl-safe', { statuses: [429], - retryAfter: 2, + retryAfterSeconds: 2, body: { error: 'slow down' }, }); const call = stitch({ @@ -264,28 +262,11 @@ test('.safe() surfaces the rate-limit status as a non-throwing StitchError', asy expect((out.error?.cause as RateLimitError).retryAfter).toBe(2000); }); -// ── I. back-compat + predicate widening (CONTRACT.md P14 / P7) ── -test('the @deprecated top-level `rateLimit` still delegates identically (P14)', async () => { - server.route('GET', '/rl-legacy', { - statuses: [429], - retryAfter: 2, - body: { error: 'slow down' }, - }); - const call = stitch({ - baseUrl: server.url, - path: '/rl-legacy', - // Pre-fold spelling — must behave exactly like `throttle: { delegate: true }` until GA. - rateLimit: { delegate: true }, - }); - const err = await rejectionOf(call()); - expect(err).toBeInstanceOf(RateLimitError); - expect(err.status).toBe(429); -}); - +// ── I. predicate widening (CONTRACT.md P7) ── test('throttle.on accepts a predicate (P7): a 503 matched by the predicate delegates', async () => { server.route('GET', '/rl-pred', { statuses: [503], - retryAfter: 1, + retryAfterSeconds: 1, body: { error: 'busy' }, }); const call = stitch({ @@ -306,7 +287,7 @@ test('retry.on accepts a predicate (P7): retries while the predicate matches', a const call = stitch({ baseUrl: server.url, path: '/retry-pred', - retry: { attempts: 2, on: (s) => s === 503, baseMs: 1 }, + retry: { attempts: 2, on: (s) => s === 503, baseDelay: 1 }, }); await expect(call()).resolves.toEqual({ ok: true }); expect(server.calls('/retry-pred').length).toBe(2); // 503 retried, then 200 diff --git a/packages/core/test/gaps/drift-on-transform-output.spec.ts b/packages/core/test/gaps/drift-on-transform-output.spec.ts index d6fc4ef4..3e2d6a0f 100644 --- a/packages/core/test/gaps/drift-on-transform-output.spec.ts +++ b/packages/core/test/gaps/drift-on-transform-output.spec.ts @@ -102,7 +102,7 @@ test('drift fires on the TRANSFORM output: a required scraped field renamed away baseUrl: server.url, path: '/catalog', transform: scrape, // HTML string -> { items: [...] } - unwrap: 'items', + pick: 'items', // `score` is REQUIRED — losing it is a hard contract violation, validated on the // STRUCTURED shape (`[].score` exists only on the parsed object, not the raw HTML). output: drift(schema), diff --git a/packages/core/test/gaps/graphql-paginated-errors.spec.ts b/packages/core/test/gaps/graphql-paginated-errors.spec.ts index cc10e7e5..0ba186e0 100644 --- a/packages/core/test/gaps/graphql-paginated-errors.spec.ts +++ b/packages/core/test/gaps/graphql-paginated-errors.spec.ts @@ -75,7 +75,8 @@ test('paginated GraphQL rejects when a page body carries errors[]', async () => const query = graphql({ baseUrl: server.url, - query: 'query($after: String) { users(after: $after) { nodes { id name } pageInfo { endCursor hasNextPage } } }', + document: + 'query($after: String) { users(after: $after) { nodes { id name } pageInfo { endCursor hasNextPage } } }', ...paginateConfig, }); diff --git a/packages/core/test/gaps/resource-leaks.spec.ts b/packages/core/test/gaps/resource-leaks.spec.ts index 60610ec3..63078e83 100644 --- a/packages/core/test/gaps/resource-leaks.spec.ts +++ b/packages/core/test/gaps/resource-leaks.spec.ts @@ -2,7 +2,7 @@ // `open` map, and prompt abort during retry/reconnect backoff + throttle waits. These are leak // regressions, not behaviour changes — each test asserts a bound or a prompt cancellation that the // pre-fix code violated (an ever-growing Map, or a backoff sleep that ignored the caller signal). -import { otlpTrace, stitch } from '../../src'; +import { otlpSink, stitch } from '../../src'; import { OPEN_SPANS } from '../../src/otlp'; import { THROTTLE_STATES, createThrottle } from '../../src/resilience'; import { @@ -140,11 +140,11 @@ test('in-process throttle: a key with a queued waiter is not dropped early', asy }); // ── 2. OTLP sink drains its `open` map after a completed run ───────────────── -// otlpTrace stacks an in-flight span per run key; on `done` it popped the span but left the empty +// otlpSink stacks an in-flight span per run key; on `done` it popped the span but left the empty // stack as a Map entry, leaking one entry per unique run id. The fix deletes the entry when the // stack empties. test('otlp sink: the internal open-span map is empty after a completed run', () => { - const sink = otlpTrace({ + const sink = otlpSink({ exporter: { export() { /* drop spans — the test only inspects the internal map */ @@ -201,7 +201,7 @@ test('abort during a retry backoff rejects promptly (well under the backoff dela baseUrl: 'http://test', path: '/always-503', adapter: always503, - retry: { attempts: 5, on: [503], backoff: 'fixed', baseMs: 1000 }, + retry: { attempts: 5, on: [503], backoff: 'fixed', baseDelay: 1000 }, }); const ac = new AbortController(); @@ -235,7 +235,7 @@ test('an already-aborted signal rejects without sleeping the backoff', async () baseUrl: 'http://test', path: '/x', adapter: always503, - retry: { attempts: 5, on: [503], backoff: 'fixed', baseMs: 1000 }, + retry: { attempts: 5, on: [503], backoff: 'fixed', baseDelay: 1000 }, }); const ac = new AbortController(); diff --git a/packages/core/test/gaps/secrets-file-rename.spec.ts b/packages/core/test/gaps/secrets-file-rename.spec.ts index 26f41a4f..66a579bc 100644 --- a/packages/core/test/gaps/secrets-file-rename.spec.ts +++ b/packages/core/test/gaps/secrets-file-rename.spec.ts @@ -127,3 +127,18 @@ test('basic() sends Authorization: Basic header', async () = const expected = `Basic ${Buffer.from('alice:s3cret').toString('base64')}`; expect(req!.headers['authorization']).toBe(expected); }); + +test('positional basic(user, pass) is the P15 shorthand for basic({ user, pass })', async () => { + server.route('GET', '/secure', { body: { ok: true } }); + + const call = stitch({ + baseUrl: server.url, + path: '/secure', + auth: basic('alice', 's3cret'), + }); + + await expect(call()).resolves.toEqual({ ok: true }); + + const expected = `Basic ${Buffer.from('alice:s3cret').toString('base64')}`; + expect(server.calls('/secure')[0]!.headers['authorization']).toBe(expected); +}); diff --git a/packages/core/test/gaps/single-flight-refresh-failure.spec.ts b/packages/core/test/gaps/single-flight-refresh-failure.spec.ts index df66ede5..4a3eec43 100644 --- a/packages/core/test/gaps/single-flight-refresh-failure.spec.ts +++ b/packages/core/test/gaps/single-flight-refresh-failure.spec.ts @@ -62,11 +62,11 @@ afterEach(() => { */ test('a rejected single-flight token fetch does not poison the slot; recovery succeeds', async () => { server.route('POST', '/token', { - // First POST 500s; the `delayMs` holds it in flight long enough that + // First POST 500s; the `delay` holds it in flight long enough that // every concurrent cold caller takes the cache-miss branch before it // settles — making the coalescing deterministic, not timing-dependent. // Subsequent POSTs (after the burst rejects) clamp to status 200. - delayMs: 50, + delay: 50, statuses: [500, 200], body: [ { error: 'temporarily_unavailable' }, diff --git a/packages/core/test/gaps/single-flight-refresh.spec.ts b/packages/core/test/gaps/single-flight-refresh.spec.ts index 6c405676..db1ca852 100644 --- a/packages/core/test/gaps/single-flight-refresh.spec.ts +++ b/packages/core/test/gaps/single-flight-refresh.spec.ts @@ -52,7 +52,7 @@ test('5 concurrent cold calls coalesce into exactly ONE token-endpoint POST', as // The 50ms delay holds the first token fetch in flight long enough that // every concurrent cold caller has already taken the cache-miss branch — // making the race deterministic instead of timing-dependent. - delayMs: 50, + delay: 50, body: { access_token: 'T1', token_type: 'Bearer', expires_in: 3600 }, }); server.route('GET', '/data', { diff --git a/packages/core/test/gaps/throttle-host-pooling.spec.ts b/packages/core/test/gaps/throttle-host-pooling.spec.ts index 5bce755b..caf9169d 100644 --- a/packages/core/test/gaps/throttle-host-pooling.spec.ts +++ b/packages/core/test/gaps/throttle-host-pooling.spec.ts @@ -1,4 +1,4 @@ -// Pins docs/GAP-AUDIT.md §1.4: throttle scope:'host' must pool the budget across stitch instances in-process, as throttle.mdx documents +// Pins docs/GAP-AUDIT.md §1.4: throttle pool:'host' must pool the budget across stitch instances in-process, as throttle.mdx documents import { stitch } from '../../src'; import { startMockServer } from '../support/mock-server'; import type { MockServer } from '../support/mock-server'; @@ -22,7 +22,7 @@ beforeEach(() => { server.reset(); }); -describe('GAP-AUDIT §1.4 — throttle scope:"host" pools across instances', () => { +describe('GAP-AUDIT §1.4 — throttle pool:"host" pools across instances', () => { test('two separate stitches (no shared store) hitting the same host share one 2/s budget', async () => { // Record the server-side arrival time of each request. const hits: number[] = []; @@ -37,9 +37,6 @@ describe('GAP-AUDIT §1.4 — throttle scope:"host" pools across instances', () // declaring host pooling against the same origin. throttle.mdx says // 'host' "pools the budget across every stitch hitting the same host", // so the 2/s budget (500ms spacing) must apply across BOTH instances. - // `a` uses the canonical `pool` (CONTRACT.md P2); `b` uses the - // `@deprecated` `scope` alias — they MUST pool together, proving the - // alias is byte-equivalent to the new field. const a = stitch({ baseUrl: server.url, path: '/pooled', @@ -48,7 +45,7 @@ describe('GAP-AUDIT §1.4 — throttle scope:"host" pools across instances', () const b = stitch({ baseUrl: server.url, path: '/pooled', - throttle: { rate: '2/s', scope: 'host' }, + throttle: { rate: '2/s', pool: 'host' }, }); await Promise.all([a(), b()]); diff --git a/packages/core/test/gaps/timeout-total.spec.ts b/packages/core/test/gaps/timeout-total.spec.ts index d268d871..e5769a09 100644 --- a/packages/core/test/gaps/timeout-total.spec.ts +++ b/packages/core/test/gaps/timeout-total.spec.ts @@ -50,7 +50,7 @@ test('timeout.total caps the whole retry/backoff loop, not just one attempt', as const call = stitch({ baseUrl: server.url, path: '/always-503', - retry: { attempts: 5, on: [503], backoff: 'fixed', baseMs: 300 }, + retry: { attempts: 5, on: [503], backoff: 'fixed', baseDelay: 300 }, timeout: { total: 400 }, }); @@ -71,13 +71,13 @@ test('timeout.total caps the whole retry/backoff loop, not just one attempt', as // at ~400ms. test('timeout.total is enforced alongside timeout.perAttempt across retries', async () => { server.route('GET', '/glacial', { - delayMs: 5000, + delay: 5000, body: { ok: true }, }); const call = stitch({ baseUrl: server.url, path: '/glacial', - retry: { attempts: 5, backoff: 'fixed', baseMs: 50 }, + retry: { attempts: 5, backoff: 'fixed', baseDelay: 50 }, timeout: { total: 400, perAttempt: 350 }, }); @@ -123,7 +123,7 @@ test('timeout.total caps a throttle (rate) wait', async () => { // budget must cut it to a few pages instead of grinding through `max` (50) pages. test('timeout.total bounds a paginated call across pages', async () => { server.route('GET', '/feed', { - delayMs: 150, // each page costs ~150ms + delay: 150, // each page costs ~150ms body: (i: number) => ({ items: [i], cursor: i + 1 }), }); const call = stitch({ @@ -153,7 +153,7 @@ test('timeout.total bounds a paginated call across pages', async () => { // otherwise a hung login would block the whole session setup. A glacial endpoint // under a 300ms total must reject promptly, not hang for the server's 5s. test('timeout.total bounds executeRaw', async () => { - server.route('GET', '/raw-glacial', { delayMs: 5000, body: { ok: true } }); + server.route('GET', '/raw-glacial', { delay: 5000, body: { ok: true } }); const call = stitch({ baseUrl: server.url, path: '/raw-glacial', diff --git a/packages/core/test/gaps/trace-controls.spec.ts b/packages/core/test/gaps/trace-controls.spec.ts index 2615641b..c01c5c14 100644 --- a/packages/core/test/gaps/trace-controls.spec.ts +++ b/packages/core/test/gaps/trace-controls.spec.ts @@ -138,7 +138,7 @@ function readRecords(file: string): Record[] { const BIG = 'x'.repeat(5000); // (d) Default truncation: a request body / response value larger than the cap is -// replaced with a compact `{ truncated, bytes, preview }` marker — the multi-KB +// replaced with a compact `{ truncated, chars, preview }` marker — the multi-KB // payload never lands on disk in full. test('JSONL truncates request body and response value past the default cap', async () => { const traceFile = join(tmpdir(), `stitch-trace-trunc-${process.pid}.jsonl`); @@ -161,10 +161,10 @@ test('JSONL truncates request body and response value past the default cap', asy const reqBody = (start['input'] as { body: Record }).body; expect(reqBody['truncated']).toBe(true); - expect(reqBody['bytes']).toBeGreaterThanOrEqual(2048); + expect(reqBody['chars']).toBeGreaterThanOrEqual(2048); expect((reqBody['preview'] as string).length).toBeLessThanOrEqual(2048); - const value = result['value'] as Record; + const value = result['data'] as Record; expect(value['truncated']).toBe(true); expect(value['blob']).toBeUndefined(); // the original shape is gone @@ -186,12 +186,12 @@ test('STITCH_TRACE_MAX_BODY=full captures the whole body (no truncation)', async await expect(call()).resolves.toEqual({ blob: BIG }); const result = readRecords(traceFile).find((r) => r['type'] === 'result')!; - expect((result['value'] as { blob: string }).blob).toBe(BIG); + expect((result['data'] as { blob: string }).blob).toBe(BIG); }); -// (f) Opt-in full capture (code): fileSink(path, { maxBodyBytes: false }) is the +// (f) Opt-in full capture (code): fileSink(path, { maxBodyChars: false }) is the // in-code equivalent of the env switch. -test('fileSink({ maxBodyBytes: false }) captures the whole body', async () => { +test('fileSink({ maxBodyChars: false }) captures the whole body', async () => { const traceFile = join(tmpdir(), `stitch-trace-cap-${process.pid}.jsonl`); rmSync(traceFile, { force: true }); cleanupPaths.push(traceFile); @@ -201,12 +201,12 @@ test('fileSink({ maxBodyBytes: false }) captures the whole body', async () => { name: 'cap', baseUrl: server.url, path: '/cap', - trace: fileSink(traceFile, { maxBodyBytes: false }), + trace: fileSink(traceFile, { maxBodyChars: false }), }); await expect(call()).resolves.toEqual({ blob: BIG }); const result = readRecords(traceFile).find((r) => r['type'] === 'result')!; - expect((result['value'] as { blob: string }).blob).toBe(BIG); + expect((result['data'] as { blob: string }).blob).toBe(BIG); }); // (g) URL credential-scrub: a secret-bearing query param is REDACTED in the diff --git a/packages/core/test/graphql-and-headers.spec.ts b/packages/core/test/graphql-and-headers.spec.ts index 5acb27ee..3a5af78d 100644 --- a/packages/core/test/graphql-and-headers.spec.ts +++ b/packages/core/test/graphql-and-headers.spec.ts @@ -37,8 +37,8 @@ describe('GraphQL kind', () => { const query = graphql({ baseUrl: server.url, - query: 'query($id: ID) { thing(id: $id) { name } }', - auth: apiKey({ header: 'apikey', value: env('GQL_KEY') }), + document: 'query($id: ID) { thing(id: $id) { name } }', + auth: apiKey({ name: 'apikey', value: env('GQL_KEY') }), }); const out = await query({ variables: { id: 1 } }); @@ -56,7 +56,7 @@ describe('GraphQL kind', () => { server.route('POST', '/graphql', { body: { data: { ok: true } } }); const query = graphql({ baseUrl: server.url, - query: 'query($id: ID) { thing(id: $id) { name } }', + document: 'query($id: ID) { thing(id: $id) { name } }', }); // Partial application must preserve EVERY StitchInput field. `variables` is the primary @@ -74,7 +74,7 @@ describe('GraphQL kind', () => { server.route('POST', '/graphql', { body: { errors: [{ message: 'field "thing" not found' }] }, }); - const query = graphql({ baseUrl: server.url, query: '{ thing }' }); + const query = graphql({ baseUrl: server.url, document: '{ thing }' }); await expect(query()).rejects.toThrow(/thing.*not found/); }); }); @@ -86,7 +86,7 @@ describe('GraphQL input.variables validation', () => { server.route('POST', '/graphql', { body: { data: { ok: true } } }); const query = graphql({ baseUrl: server.url, - query: 'query($id: ID!) { thing(id: $id) { name } }', + document: 'query($id: ID!) { thing(id: $id) { name } }', input: { variables: z.object({ id: z.string() }) }, }); @@ -104,7 +104,7 @@ describe('GraphQL input.variables validation', () => { }); const query = graphql({ baseUrl: server.url, - query: 'query($id: ID!) { thing(id: $id) { name } }', + document: 'query($id: ID!) { thing(id: $id) { name } }', input: { variables: z.object({ id: z.string() }) }, }); @@ -120,7 +120,7 @@ describe('GraphQL input.variables validation', () => { server.route('POST', '/graphql', { body: { data: { ok: true } } }); const query = graphql({ baseUrl: server.url, - query: 'query($id: ID) { thing(id: $id) { name } }', + document: 'query($id: ID) { thing(id: $id) { name } }', }); // No `input.variables` → no validation; any variables flow straight through. @@ -157,7 +157,7 @@ describe('Surface dispatch — graphql is no longer special-cased', () => { id: 'upper', interpret: (res) => ({ ok: true, - value: (res.body as { msg: string }).msg.toUpperCase(), + data: (res.body as { msg: string }).msg.toUpperCase(), }), }; const s = stitch({ kind: upper, url: server.url + '/u' }); diff --git a/packages/core/test/idempotency.spec.ts b/packages/core/test/idempotency.spec.ts index aae562f7..6acb55a8 100644 --- a/packages/core/test/idempotency.spec.ts +++ b/packages/core/test/idempotency.spec.ts @@ -33,7 +33,7 @@ test('injects a stable Idempotency-Key on a write, unchanged across a retry', as method: 'POST', baseUrl: server.url, path: '/create', - retry: { attempts: 2, on: [503], baseMs: 5 }, + retry: { attempts: 2, on: [503], baseDelay: 5 }, idempotency: true, // default header + random per-call key }); @@ -55,7 +55,7 @@ test('uses a custom keyOf function derived from the input', async () => { method: 'POST', baseUrl: server.url, path: '/orders', - retry: { attempts: 2, on: [503], baseMs: 5 }, + retry: { attempts: 2, on: [503], baseDelay: 5 }, idempotency: { header: 'X-Idempotency-Key', keyOf: (input) => `order-${(input.body as { id: number }).id}`, @@ -69,26 +69,6 @@ test('uses a custom keyOf function derived from the input', async () => { expect(calls[1]!.headers['x-idempotency-key']).toBe('order-42'); // stable + deterministic }); -test('the @deprecated `key` alias still derives the same idempotency key (P6)', async () => { - server.route('POST', '/orders', { body: { ok: true } }); - const create = stitch({ - method: 'POST', - baseUrl: server.url, - path: '/orders', - idempotency: { - header: 'X-Idempotency-Key', - // The pre-rename spelling — must behave identically to `keyOf` until the GA cut. - key: (input) => `order-${(input.body as { id: number }).id}`, - }, - }); - - await create({ body: { id: 7 } }); - - expect(server.calls('/orders')[0]!.headers['x-idempotency-key']).toBe( - 'order-7', - ); -}); - test('separate logical calls get distinct generated keys', async () => { server.route('POST', '/c', { body: { ok: true } }); const create = stitch({ @@ -177,7 +157,7 @@ test('the nudge is a hint with an out — silenced, and never fired for the case // even though `method` is unset here. graphql({ baseUrl: server.url, - query: '{ me { id } }', + document: '{ me { id } }', idempotency: true, }); expect(warn).not.toHaveBeenCalled(); diff --git a/packages/core/test/inspect-redact.spec.ts b/packages/core/test/inspect-redact.spec.ts index c7762578..b4d22c27 100644 --- a/packages/core/test/inspect-redact.spec.ts +++ b/packages/core/test/inspect-redact.spec.ts @@ -4,15 +4,9 @@ // key, extra patterns, input-immutability) // - `.inspect({ redact: true })` redacts secret-named fields in `raw`; default off; findings // are unaffected; `redact: string[]` adds extra patterns -// - `isSecretQueryKey` alias still works import { drift, stitch } from '../src'; import type { Adapter } from '../src'; -import { - isSecretKey, - isSecretQueryKey, - redactSecretsDeep, - registerSecretQueryKey, -} from '../src/util'; +import { isSecretKey, redactSecretsDeep, registerSecretKey } from '../src/util'; import { z } from 'zod'; @@ -72,8 +66,8 @@ describe('redactSecretsDeep', () => { expect(result.users[0]?.['name']).toBe('bob'); }); - it('redacts a key registered via registerSecretQueryKey', () => { - registerSecretQueryKey('x_custom_cred'); + it('redacts a key registered via registerSecretKey', () => { + registerSecretKey('x_custom_cred'); const result = redactSecretsDeep({ x_custom_cred: 'val', other: 1, @@ -112,17 +106,13 @@ describe('redactSecretsDeep', () => { }); }); -// ---- isSecretQueryKey alias ----------------------------------------------- +// ---- isSecretKey ----------------------------------------------------------- -describe('isSecretQueryKey alias', () => { - it('is the same function as isSecretKey', () => { - expect(isSecretQueryKey).toBe(isSecretKey); - }); - - it('still matches secret stems correctly', () => { - expect(isSecretQueryKey('api_key')).toBe(true); - expect(isSecretQueryKey('access_token')).toBe(true); - expect(isSecretQueryKey('page')).toBe(false); +describe('isSecretKey', () => { + it('matches secret stems correctly', () => { + expect(isSecretKey('api_key')).toBe(true); + expect(isSecretKey('access_token')).toBe(true); + expect(isSecretKey('page')).toBe(false); }); }); diff --git a/packages/core/test/inspect.spec.ts b/packages/core/test/inspect.spec.ts index 09dc9b44..365f3fcd 100644 --- a/packages/core/test/inspect.spec.ts +++ b/packages/core/test/inspect.spec.ts @@ -55,8 +55,6 @@ test('primitive T: value + raw are the primitive, no findings', async () => { expect(r.status).toBe(200); expect(r.error).toBeNull(); expect(r.findings).toEqual([]); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- the @deprecated `value` alias is co-set with `data` until the GA cut (CONTRACT.md P5) - expect(r.value).toBe(42); // back-compat alias parity }); // --------------------------------------------------------------------------- @@ -139,7 +137,7 @@ test('raw is non-enumerable: JSON.stringify(wrapper) and { ...wrapper } both exc const json = JSON.parse(JSON.stringify(r)) as Record; expect('raw' in json).toBe(false); - expect(json['value']).toEqual({ n: 42 }); // the other fields DO serialise + expect(json['data']).toEqual({ n: 42 }); // the other fields DO serialise const spread = { ...r } as Record; expect('raw' in spread).toBe(false); diff --git a/packages/core/test/integration-patterns.spec.ts b/packages/core/test/integration-patterns.spec.ts index b8348b6a..0ff04526 100644 --- a/packages/core/test/integration-patterns.spec.ts +++ b/packages/core/test/integration-patterns.spec.ts @@ -69,9 +69,9 @@ describe('GraphQL-over-HTTP API (ApiKey header, 1 req/s bucket, retry on 429/5xx method: 'POST', baseUrl: server.url, path: '/graphql', - auth: apiKey({ header: 'apikey', value: env('METADATA_API_KEY') }), - retry: { attempts: 5, on: [429, 500, 502, 503], baseMs: 5 }, - unwrap: 'data', + auth: apiKey({ name: 'apikey', value: env('METADATA_API_KEY') }), + retry: { attempts: 5, on: [429, 500, 502, 503], baseDelay: 5 }, + pick: 'data', }); const out = await query({ @@ -90,7 +90,7 @@ describe('GraphQL-over-HTTP API (ApiKey header, 1 req/s bucket, retry on 429/5xx method: 'POST', baseUrl: server.url, path: '/graphql', - auth: apiKey({ header: 'apikey', value: () => 'sk' }), + auth: apiKey({ name: 'apikey', value: () => 'sk' }), throttle: { rate: '1/s' }, }); const start = Date.now(); @@ -127,8 +127,8 @@ describe('Session-cookie admin API (auto re-login on 403)', () => { auth: cookieSession({ login, cookie: 'SID', - scope: 'app', - refreshOn: [403], // this integration uses 403, not 401 + tenancy: 'app', + refresh: 403, // this integration uses 403, not 401 (bare status ≡ [403], CONTRACT.md P7) loginInput: () => ({ body: { username: env('CLIENT_USER')(), @@ -179,7 +179,7 @@ describe('HTML scrape provider — silent markup breakage becomes a loud drift e auth: cookieSession({ login, cookie: 'session_id', - scope: 'app', + tenancy: 'app', loginInput: () => ({ body: { username: env('SCRAPE_USER')(), @@ -189,7 +189,7 @@ describe('HTML scrape provider — silent markup breakage becomes a loud drift e }), throttle: { rate: '5/s' }, transform: scrapeListings, // HTML -> { items: [...] } - unwrap: 'items', + pick: 'items', // `score` is REQUIRED — the schema is the contract, so a markup rename that drops it // is a loud validation error, not a silent gap. No snapshot, no baseline call. output: drift( @@ -245,7 +245,7 @@ describe('Diverse co-located auth (three providers, three header formats)', () = baseUrl: server.url, path: '/system', auth: apiKey({ - header: 'authorization', + name: 'authorization', value: () => `MediaToken token="${env('MEDIA_A_TOKEN')()}"`, }), }); @@ -253,7 +253,7 @@ describe('Diverse co-located auth (three providers, three header formats)', () = baseUrl: server.url, path: '/node', auth: apiKey({ - header: 'x-media-token', + name: 'x-media-token', value: env('MEDIA_B_TOKEN'), }), hooks: { diff --git a/packages/core/test/llm.spec.ts b/packages/core/test/llm.spec.ts index 7a08a7dc..b6e2453f 100644 --- a/packages/core/test/llm.spec.ts +++ b/packages/core/test/llm.spec.ts @@ -53,7 +53,7 @@ test('anthropic: builds the Messages API body (system out of messages) and parse expect(body.messages).toEqual([{ role: 'user', content: 'hi' }]); expect(calls[0]!.headers['anthropic-version']).toBe('2023-06-01'); expect(calls[0]!.method).toBe('POST'); - expect(calls[0]!.url).toBe('https://api.anthropic.com/v1/messages'); // default endpoint + expect(calls[0]!.url).toBe('https://api.anthropic.com/v1/messages'); // provider default url expect(out).toMatchObject({ text: 'hello', @@ -97,7 +97,7 @@ test('llm requires a model (none on config, call, or provider default) — fail const { adapter } = captureAdapter({}); const bare: LlmProvider = { id: 'bare', - endpoint: 'https://example.test/v1', + url: 'https://example.test/v1', buildBody: anthropic.buildBody, parse: anthropic.parse, }; @@ -134,7 +134,7 @@ test('anthropic folds a system-role MESSAGE into the top-level `system` (never d expect(body.messages).toEqual([{ role: 'user', content: 'hi' }]); }); -test('llm honours an explicit endpoint over the provider default', async () => { +test('llm honours an explicit url over the provider default', async () => { const { adapter, calls } = captureAdapter({ content: [{ text: 'x' }] }); const chat = llm({ provider: anthropic, diff --git a/packages/core/test/logger-sink.spec.ts b/packages/core/test/logger-sink.spec.ts index 46ab1f05..c51aebc2 100644 --- a/packages/core/test/logger-sink.spec.ts +++ b/packages/core/test/logger-sink.spec.ts @@ -193,18 +193,18 @@ test('opts.levels can override the drift level too', () => { }); // --------------------------------------------------------------------------- -// opts.level resolver + opts.format — the per-instance hooks @stitchapi/nest +// opts.levelOf resolver + opts.format — the per-instance hooks @stitchapi/nest // delegates through (a conditional level rule the per-type map can't express, // and a different message house style), kept generic and tested here in core. // --------------------------------------------------------------------------- -test('opts.level resolves per instance and takes precedence over levels', () => { +test('opts.levelOf resolves per instance and takes precedence over levels', () => { const { logger, entries } = fakeLogger(); // A `retry`/`circuit` progress is surfaced at warn; a routine throttle defers // (undefined) to `levels` — which the per-type map alone could never split. const sink = loggerSink(logger, { levels: { progress: 'debug' }, - level: (event) => + levelOf: (event) => event.type === 'progress' && (event.phase === 'retry' || event.phase === 'circuit') ? 'warn' @@ -224,11 +224,11 @@ test('opts.level resolves per instance and takes precedence over levels', () => expect(levelsFor(entries, 'throttled#1')).toEqual(['debug']); // deferred to levels }); -test('opts.level returning null drops the event', () => { +test('opts.levelOf returning null drops the event', () => { const { logger, entries } = fakeLogger(); // Drop the happy-path lifecycle (start/result/done); keep everything else. const sink = loggerSink(logger, { - level: (event) => + levelOf: (event) => event.type === 'start' || event.type === 'result' || event.type === 'done' @@ -295,7 +295,7 @@ test('opts.format overrides the one-liner; null skips the event', () => { expect(entries).toEqual([{ level: 'debug', message: 'custom x' }]); }); -test('a delta event is dropped before opts.level/opts.format ever run', () => { +test('a delta event is dropped before opts.levelOf/opts.format ever run', () => { const { logger, entries } = fakeLogger(); let sawDelta = false; const note = () => { @@ -303,7 +303,7 @@ test('a delta event is dropped before opts.level/opts.format ever run', () => { return undefined; }; const sink = loggerSink(logger, { - level: note, + levelOf: note, format: () => { sawDelta = true; return 'should-never-log'; diff --git a/packages/core/test/mcp-e2e.spec.ts b/packages/core/test/mcp-e2e.spec.ts index a276c604..b436a1b6 100644 --- a/packages/core/test/mcp-e2e.spec.ts +++ b/packages/core/test/mcp-e2e.spec.ts @@ -139,7 +139,7 @@ const userSchema = { export const getUser = stitch({ baseUrl: ${JSON.stringify(baseUrl)}, path: '/users/{id}', - unwrap: 'data', + pick: 'data', output: userSchema, }); diff --git a/packages/core/test/mcp.spec.ts b/packages/core/test/mcp.spec.ts index 9eaa6217..df3e684d 100644 --- a/packages/core/test/mcp.spec.ts +++ b/packages/core/test/mcp.spec.ts @@ -50,7 +50,7 @@ beforeEach(() => { const getWidget = stitch({ baseUrl: api.url, path: '/widgets/{id}', - unwrap: 'data', + pick: 'data', auth: bearer('s3cr3t-token'), retry: { attempts: 3 }, timeout: { perAttempt: 1000 }, @@ -192,7 +192,7 @@ test('tools/call describe_stitch teaches a stitch shape without running it', asy endpoint: string; surface: string; input: { params: boolean; query: boolean; body: boolean }; - output: { validated: boolean; unwrap: string | null }; + output: { validated: boolean; pick: string | null }; auth: string | null; policies: Record; pipeline: string[]; @@ -207,11 +207,11 @@ test('tools/call describe_stitch teaches a stitch shape without running it', asy query: false, body: false, }); - expect(shape.output).toMatchObject({ validated: true, unwrap: 'data' }); - // the pipeline lists stages in engine order: the engine unwraps THEN validates, so the - // 'unwrap' stage must precede 'validate' (not the reverse). - expect(shape.pipeline.indexOf('unwrap: data')).toBeGreaterThanOrEqual(0); - expect(shape.pipeline.indexOf('unwrap: data')).toBeLessThan( + expect(shape.output).toMatchObject({ validated: true, pick: 'data' }); + // the pipeline lists stages in engine order: the engine picks THEN validates, so the + // 'pick' stage must precede 'validate' (not the reverse). + expect(shape.pipeline.indexOf('pick: data')).toBeGreaterThanOrEqual(0); + expect(shape.pipeline.indexOf('pick: data')).toBeLessThan( shape.pipeline.indexOf('validate'), ); // a Mermaid flowchart string is included for the diagram view diff --git a/packages/core/test/mock-adapter-extras.spec.ts b/packages/core/test/mock-adapter-extras.spec.ts index 424170c4..89a4e314 100644 --- a/packages/core/test/mock-adapter-extras.spec.ts +++ b/packages/core/test/mock-adapter-extras.spec.ts @@ -1,10 +1,10 @@ // mockAdapter behaviours (src/test-mock.ts) beyond testing-kit.spec.ts's coverage (method+path -// routing, status sequence, function responder, delayMs, unmatched). Driven at the Adapter layer +// routing, status sequence, function responder, delay, unmatched). Driven at the Adapter layer // directly — no stitch needed. Pins: // - match as a RegExp, a predicate, and the string-substring fallback (beyond exact pathname); // - first matching route wins; // - a response sequence repeats its LAST entry once exhausted; -// - build() defaults status to 200, lowercases headers, and maps retryAfter → retry-after; +// - build() defaults status to 200, lowercases headers, and maps retryAfterSeconds → retry-after; // - the spy: lastRequest(), filtered calls()/callCount(), and reset() (clears the log AND restarts // each route's per-call counter). import { mockAdapter } from '../src/test-mock'; @@ -72,15 +72,6 @@ describe('mockAdapter responses', () => { expect(res.headers['x-foo']).toBe('Bar'); // header key lowercased expect(res.headers['retry-after']).toBe('5'); // retryAfterSeconds → header }); - - test('the @deprecated `retryAfter` alias still maps to the header (P17)', async () => { - const api = mockAdapter({ - // Pre-rename spelling — still drives the `Retry-After` header until the GA cut. - respond: { retryAfter: 9 }, - }); - const res = await api(req('http://h/a')); - expect(res.headers['retry-after']).toBe('9'); - }); }); describe('mockAdapter spy', () => { diff --git a/packages/core/test/no-credential-leak.spec.ts b/packages/core/test/no-credential-leak.spec.ts index c601ad7c..00f7dbd7 100644 --- a/packages/core/test/no-credential-leak.spec.ts +++ b/packages/core/test/no-credential-leak.spec.ts @@ -10,7 +10,7 @@ // into output MUST be registered as a case below. A future config-exporter — `stitch gen` / // client publishing (ADR 0013/0014) — that forgets to scrub will fail THIS test instead of baking // a password into a shared document. Add the surface here in the same PR that adds the surface. -import { otlpTrace, stitch } from '../src'; +import { otlpSink, stitch } from '../src'; import type { OtelSpan, SpanExporter, StitchEvent } from '../src'; import { toOpenApi } from '../src/openapi'; import type { StitchRegistry } from '../src/registry'; @@ -63,7 +63,7 @@ function otlpSpans(): OtelSpan[] { spans.push(...batch); }, }; - const sink = otlpTrace({ exporter }); + const sink = otlpSink({ exporter }); const name = 'getUser'; sink.handle(startEvent(), { name }); sink.handle( @@ -110,7 +110,7 @@ const SURFACES: Surface[] = [ }, { // OTLP export — `url.full` / `server.address` on the exported CLIENT span. - name: 'otlpTrace → span url.full', + name: 'otlpSink → span url.full', serialize: () => JSON.stringify(otlpSpans()), emitsHost: true, }, diff --git a/packages/core/test/oauth2.spec.ts b/packages/core/test/oauth2.spec.ts index cb5e2ad9..cc4bf42e 100644 --- a/packages/core/test/oauth2.spec.ts +++ b/packages/core/test/oauth2.spec.ts @@ -105,7 +105,7 @@ test('refreshes the token after it expires', async () => { }); // skew 0 so the token stays usable until its real expiry, isolating the expiry path. - const data = protectedStitch('/data', { refreshSkewMs: 0 }); + const data = protectedStitch('/data', { refresh: { skew: 0 } }); await data(); await sleep(1100); // let the cached token (and its store TTL) expire await data(); @@ -116,7 +116,7 @@ test('refreshes the token after it expires', async () => { expect(calls[1]!.headers['authorization']).toBe('Bearer T2'); // the refreshed token }, 10000); -test('refreshes proactively BEFORE expiry using refreshSkewMs', async () => { +test('refreshes proactively BEFORE expiry using refresh.skew', async () => { server.route('POST', '/token', { body: (i: number) => ({ access_token: i === 0 ? 'T1' : 'T2', @@ -130,7 +130,7 @@ test('refreshes proactively BEFORE expiry using refreshSkewMs', async () => { }); // With a 1.5s skew the token is treated as stale ~0.5s in, well before the 2s expiry. - const data = protectedStitch('/data', { refreshSkewMs: 1500 }); + const data = protectedStitch('/data', { refresh: { skew: 1500 } }); await data(); await sleep(600); await data(); diff --git a/packages/core/test/openapi.spec.ts b/packages/core/test/openapi.spec.ts index 86043f05..2e952c43 100644 --- a/packages/core/test/openapi.spec.ts +++ b/packages/core/test/openapi.spec.ts @@ -348,7 +348,7 @@ describe('toOpenApi security schemes', () => { createThing: stitch({ method: 'POST', url: 'https://api.example.com/things', - auth: apiKey({ header: 'X-My-Key', value: 'zzz-apikey-cred' }), + auth: apiKey({ name: 'X-My-Key', value: 'zzz-apikey-cred' }), }), grant: stitch({ url: 'https://api.example.com/grant', @@ -422,7 +422,7 @@ describe('toOpenApi security schemes', () => { auth: cookieSession({ cookie: 'sid', login: stitch('https://api.example.com/login'), - scope: 'app', + tenancy: 'app', }), }), }; @@ -442,11 +442,11 @@ describe('toOpenApi security schemes', () => { const registry: StitchRegistry = { a: stitch({ url: 'https://api.example.com/a', - auth: apiKey({ header: 'X-Key-A', value: 'k' }), + auth: apiKey({ name: 'X-Key-A', value: 'k' }), }), b: stitch({ url: 'https://api.example.com/b', - auth: apiKey({ header: 'X-Key-B', value: 'k' }), + auth: apiKey({ name: 'X-Key-B', value: 'k' }), }), }; const { document } = toOpenApi(registry); diff --git a/packages/core/test/otlp-export.spec.ts b/packages/core/test/otlp-export.spec.ts index aad4f319..f9068fd2 100644 --- a/packages/core/test/otlp-export.spec.ts +++ b/packages/core/test/otlp-export.spec.ts @@ -1,7 +1,7 @@ // OTLP export: an opt-in trace sink maps the event stream to OpenTelemetry CLIENT spans (OTel // HTTP semconv attributes) and hands them to a SpanExporter. Tested with a STUB exporter that // captures spans in memory — no running collector, no network. -import { otlpTrace, stitch } from '../src'; +import { otlpSink, stitch } from '../src'; import type { OtelSpan, SpanExporter, StitchEvent } from '../src'; import { exportsFromEnv, multiplex } from '../src/trace'; import { scrubUrl } from '../src/util'; @@ -42,7 +42,7 @@ beforeEach(() => { test('maps a successful call to one CLIENT span with OTel HTTP semconv attributes', () => { const { exporter, spans } = stubExporter(); - const sink = otlpTrace({ exporter }); + const sink = otlpSink({ exporter }); const name = 'getThing'; const events: StitchEvent[] = [ { @@ -80,7 +80,7 @@ test('maps a successful call to one CLIENT span with OTel HTTP semconv attribute test('maps an error to an ERROR span with error.type and status_code', () => { const { exporter, spans } = stubExporter(); - const sink = otlpTrace({ exporter }); + const sink = otlpSink({ exporter }); const name = 'createThing'; const events: StitchEvent[] = [ { @@ -112,7 +112,7 @@ test('maps an error to an ERROR span with error.type and status_code', () => { test('end-to-end: a real stitch call exports one span to the stub exporter', async () => { const { exporter, spans } = stubExporter(); - const sink = otlpTrace({ exporter }); + const sink = otlpSink({ exporter }); server.route('GET', '/ping', { body: { ok: true } }); const ping = stitch({ name: 'ping', baseUrl: server.url, path: '/ping' }); @@ -149,7 +149,7 @@ test('STITCH_EXPORT parses to a list and multiplex fans out to every sink', () = // bodies), so it must be scrubbed before a span leaves for a collector. test('url.full strips userinfo and redacts secret query params before export', () => { const { exporter, spans } = stubExporter(); - const sink = otlpTrace({ exporter }); + const sink = otlpSink({ exporter }); const name = 'fetchThing'; const events: StitchEvent[] = [ { diff --git a/packages/core/test/otlp-json.spec.ts b/packages/core/test/otlp-json.spec.ts index b215c72d..a3b541e3 100644 --- a/packages/core/test/otlp-json.spec.ts +++ b/packages/core/test/otlp-json.spec.ts @@ -1,5 +1,5 @@ // Direct tests for toOtlpJson (src/otlp.ts) — the OTLP/JSON `ResourceSpans` serializer a collector -// ingests on /v1/traces. otlp-export.spec.ts asserts the SPAN objects (OtelSpan) an otlpTrace sink +// ingests on /v1/traces. otlp-export.spec.ts asserts the SPAN objects (OtelSpan) an otlpSink sink // produces; run-identity.spec.ts checks only that parentSpanId is serialized. The wire-shape itself // — the resource/scope envelope, attribute value TYPING (int vs double vs bool vs string), the // nanosecond timestamps, the status-code mapping, and event serialization — was unpinned. A drift diff --git a/packages/core/test/output-inference.spec.ts b/packages/core/test/output-inference.spec.ts index 2323d74a..1867f953 100644 --- a/packages/core/test/output-inference.spec.ts +++ b/packages/core/test/output-inference.spec.ts @@ -39,7 +39,7 @@ test('inferred element type flows through `unwrap`', async () => { const list = stitch({ baseUrl: server.url, path: '/list', - unwrap: 'data', + pick: 'data', output: z.array(userSchema), }); const users = await list(); diff --git a/packages/core/test/pagination.spec.ts b/packages/core/test/pagination.spec.ts index 63a568bd..d088d11e 100644 --- a/packages/core/test/pagination.spec.ts +++ b/packages/core/test/pagination.spec.ts @@ -37,7 +37,7 @@ const listAll = () => stitch({ baseUrl: server.url, path: '/list', - unwrap: 'data', + pick: 'data', paginate: { next: (body, fetched) => (body as { hasMore: boolean }).hasMore @@ -87,8 +87,9 @@ test('paginated GraphQL advances its cursor through the variables slot', async ( const listUsers = graphql({ baseUrl: server.url, path: '/gql', - query: 'query($after: String) { users(after: $after) { nodes pageInfo { endCursor hasNextPage } } }', - unwrap: 'data.users.nodes', + document: + 'query($after: String) { users(after: $after) { nodes pageInfo { endCursor hasNextPage } } }', + pick: 'data.users.nodes', paginate: { next: (body) => { const pi = ( @@ -120,7 +121,7 @@ test('max caps the page loop (guards a runaway paginator)', async () => { const list = stitch({ baseUrl: server.url, path: '/loop', - unwrap: 'data', + pick: 'data', paginate: { next: () => ({}), pages: 2 }, }); await list(); diff --git a/packages/core/test/postmessage-respond-fanout.spec.ts b/packages/core/test/postmessage-respond-fanout.spec.ts index 4f5889f4..17fc8e54 100644 --- a/packages/core/test/postmessage-respond-fanout.spec.ts +++ b/packages/core/test/postmessage-respond-fanout.spec.ts @@ -59,7 +59,7 @@ const ORIGIN_B = 'https://iframe.example.com'; function channelPair(): { parent: PostMessageChannel; iframe: PostMessageChannel; - closeBoth: () => void; + closeBoth: () => Promise; } { const { a, b } = linkedPair(ORIGIN_A, ORIGIN_B); const parent = channel(a, { allowedOrigins: [ORIGIN_B] }); @@ -67,9 +67,9 @@ function channelPair(): { return { parent, iframe, - closeBoth: () => { - parent.close(); - iframe.close(); + closeBoth: async () => { + await parent.close(); + await iframe.close(); }, }; } @@ -84,13 +84,12 @@ describe('respond() replacement', () => { iframe.respond('q', () => 'second', { reply: 'q-reply' }); // The first handle must NOT remove the now-active 'second' responder. off1(); - const ask = parent.request({ - type: 'q', + const ask = parent.request('q', { reply: 'q-reply', timeout: { perAttempt: 300 }, }); await expect(ask()).resolves.toBe('second'); - closeBoth(); + await closeBoth(); }); }); @@ -100,22 +99,21 @@ describe('responder error handling', () => { iframe.respond('boom', () => { throw new Error('handler blew up'); }); - const ask = parent.request({ - type: 'boom', + const ask = parent.request('boom', { timeout: { perAttempt: 40 }, }); await expect(ask({ body: { x: 1 } })).rejects.toMatchObject({ name: 'StitchError', }); - closeBoth(); + await closeBoth(); }); }); describe('events fan-out', () => { test('one inbound event reaches every matching subscription of that type', async () => { const { parent, iframe, closeBoth } = channelPair(); - const subA = parent.events<{ type: 'tick' }>({ type: 'tick' }); - const subB = parent.events<{ type: 'tick' }>({ type: 'tick' }); + const subA = parent.events('tick'); + const subB = parent.events('tick'); const ctlA = new AbortController(); const ctlB = new AbortController(); const gotA: unknown[] = []; @@ -130,8 +128,8 @@ describe('events fan-out', () => { })(); // Let both subscriptions register before emitting. await tick(10); - await iframe.emit({ type: 'tick' as const })({ body: { n: 1 } }); - await iframe.emit({ type: 'tick' as const })({ body: { n: 2 } }); + await iframe.emit('tick')({ body: { n: 1 } }); + await iframe.emit('tick')({ body: { n: 2 } }); await tick(30); ctlA.abort(); ctlB.abort(); @@ -141,6 +139,6 @@ describe('events fan-out', () => { ]); expect(gotA).toEqual([{ n: 1 }, { n: 2 }]); expect(gotB).toEqual([{ n: 1 }, { n: 2 }]); - closeBoth(); + await closeBoth(); }); }); diff --git a/packages/core/test/postmessage.spec.ts b/packages/core/test/postmessage.spec.ts index 820eaa76..7e99e666 100644 --- a/packages/core/test/postmessage.spec.ts +++ b/packages/core/test/postmessage.spec.ts @@ -74,17 +74,18 @@ const ORIGIN_B = 'https://iframe.example.com'; function channelPair(): { parent: PostMessageChannel; iframe: PostMessageChannel; - closeBoth: () => void; + closeBoth: () => Promise; } { const { a, b } = linkedPair(ORIGIN_A, ORIGIN_B); const parent = channel(a, { allowedOrigins: [ORIGIN_B] }); - const iframe = channel(b, { allowedOrigins: [ORIGIN_A] }); + // A single origin passes as a bare string (`T | T[]` — CONTRACT.md P7). + const iframe = channel(b, { allowedOrigins: ORIGIN_A }); return { parent, iframe, - closeBoth: () => { - parent.close(); - iframe.close(); + closeBoth: async () => { + await parent.close(); + await iframe.close(); }, }; } @@ -99,27 +100,27 @@ describe('postmessage surface identities (ADR 0005 Decision 11)', () => { expect(postMessageEventSurface.id).toBe('postmessage-event'); }); - test('request/emit kind round-trips through __config as "postmessage"', () => { + test('request/emit kind round-trips through __config as "postmessage"', async () => { const { parent, closeBoth } = channelPair(); - const req = parent.request({ type: 'focus' }); - const ev = parent.emit({ type: 'ping' }); + const req = parent.request('focus'); + const ev = parent.emit('ping'); for (const s of [req, ev]) { const json = JSON.parse(JSON.stringify(s.__config)) as { kind?: unknown; }; expect(json.kind).toBe('postmessage'); } - closeBoth(); + await closeBoth(); }); - test('events kind round-trips as "postmessage-event"', () => { + test('events kind round-trips as "postmessage-event"', async () => { const { parent, closeBoth } = channelPair(); - const sub = parent.events({ type: 'tick' }); + const sub = parent.events('tick'); const json = JSON.parse(JSON.stringify(sub.__config)) as { kind?: unknown; }; expect(json.kind).toBe('postmessage-event'); - closeBoth(); + await closeBoth(); }); }); @@ -139,8 +140,7 @@ describe('request → response (correlated RPC)', () => { output: asValidator(z.object({ total: z.number() })), }, ); - const sum = parent.request({ - type: 'sum', + const sum = parent.request('sum', { input: { body: z.object({ a: z.number(), b: z.number() }) }, output: asValidator(z.object({ total: z.number() })), }); @@ -148,18 +148,15 @@ describe('request → response (correlated RPC)', () => { total: 5, }); off(); - closeBoth(); + await closeBoth(); }); test('a custom `reply` type correlates the answer', async () => { const { parent, iframe, closeBoth } = channelPair(); iframe.respond('q', () => 'answered', { reply: 'q-answer' }); - const ask = parent.request<{ - type: 'q'; - reply: 'q-answer'; - }>({ type: 'q', reply: 'q-answer' }); + const ask = parent.request('q', { reply: 'q-answer' }); await expect(ask()).resolves.toBe('answered'); - closeBoth(); + await closeBoth(); }); test('the reply must match BOTH id and type — a same-type unsolicited message does not resolve it', async () => { @@ -168,18 +165,17 @@ describe('request → response (correlated RPC)', () => { const iframe = channel(b, { allowedOrigins: [ORIGIN_A] }); // The iframe emits an UNSOLICITED `sum-result` (no id) BEFORE any responder — it must NOT // resolve the pending request (whose reply correlates on the minted id too). - const sum = parent.request({ - type: 'sum', + const sum = parent.request('sum', { timeout: { perAttempt: 60 }, }); const p = sum({ body: { a: 1, b: 1 } }); // fire a bare {type:'sum-result'} with no id from the iframe side iframe - .emit({ type: 'sum-result' })() + .emit('sum-result')() .catch(() => undefined); await expect(p).rejects.toThrow(); // never correlated → times out - parent.close(); - iframe.close(); + await parent.close(); + await iframe.close(); }); }); @@ -190,14 +186,13 @@ describe('request → response (correlated RPC)', () => { describe('request timeout reuses the engine resilience chain', () => { test('no responder → the engine `timeout` rejects with a StitchError', async () => { const { parent, closeBoth } = channelPair(); - const lonely = parent.request({ - type: 'noone-home', + const lonely = parent.request('noone-home', { timeout: { perAttempt: 40 }, }); await expect(lonely({ body: { x: 1 } })).rejects.toMatchObject({ name: 'StitchError', }); - closeBoth(); + await closeBoth(); }); }); @@ -213,22 +208,21 @@ describe('origin gate (structural, gate-before-validation)', () => { const parent = channel(a, { allowedOrigins: [ORIGIN_B] }); // only the REAL iframe origin const evil = channel(b, { allowedOrigins: [ORIGIN_A] }); evil.respond('sum', () => ({ total: 999 })); - const sum = parent.request({ - type: 'sum', + const sum = parent.request('sum', { timeout: { perAttempt: 40 }, }); await expect(sum({ body: { a: 1, b: 2 } })).rejects.toMatchObject({ name: 'StitchError', }); - parent.close(); - evil.close(); + await parent.close(); + await evil.close(); }); test('an event from a disallowed origin is never delivered', async () => { const { a, b } = linkedPair(ORIGIN_A, 'https://evil.example.com'); const parent = channel(a, { allowedOrigins: [ORIGIN_B] }); const evil = channel(b, { allowedOrigins: [ORIGIN_A] }); - const events = parent.events({ type: 'tick' }); + const events = parent.events('tick'); const ctl = new AbortController(); const collected: unknown[] = []; const drain = (async () => { @@ -237,15 +231,13 @@ describe('origin gate (structural, gate-before-validation)', () => { } })(); // The evil frame emits a `tick`; the parent's gate drops it (wrong origin). - evil.emit({ type: 'tick' as const })({ body: { n: 1 } }).catch( - () => undefined, - ); + evil.emit('tick')({ body: { n: 1 } }).catch(() => undefined); await new Promise((r) => setTimeout(r, 30)); ctl.abort(); await drain.catch(() => undefined); expect(collected).toEqual([]); // nothing crossed the gate - parent.close(); - evil.close(); + await parent.close(); + await evil.close(); }); }); @@ -260,29 +252,27 @@ describe('validation on both boundaries', () => { input: asValidator(z.object({ n: z.number() })), }); // Send a string where a number is required → the responder validates-and-drops → no reply. - const call = parent.request({ - type: 'strict', + const call = parent.request('strict', { timeout: { perAttempt: 40 }, }); await expect( call({ body: { n: 'not-a-number' } }), ).rejects.toMatchObject({ name: 'StitchError' }); - closeBoth(); + await closeBoth(); }); test('a reply that fails the request `output` schema surfaces as drift/error', async () => { const { parent, iframe, closeBoth } = channelPair(); // The responder replies with the WRONG shape (no `output` guard on its side). iframe.respond('mismatch', () => ({ wrong: true })); - const call = parent.request({ - type: 'mismatch', + const call = parent.request('mismatch', { output: asValidator(z.object({ ok: z.boolean() })), }); // The reply correlates, but the buffered `output` contract rejects it → StitchError (drift). await expect(call({ body: {} })).rejects.toMatchObject({ name: 'StitchError', }); - closeBoth(); + await closeBoth(); }); // A plain `Validator` (from `toValidator(...)`) exposes `.validate()` but neither `~standard` @@ -302,14 +292,13 @@ describe('validation on both boundaries', () => { iframe.respond('dbl', (p: { n: number }) => ({ doubled: p.n * 2 }), { input: inputSchema, }); - const call = parent.request({ - type: 'dbl', + const call = parent.request('dbl', { timeout: { perAttempt: 200 }, }); await expect(call({ body: { n: 21 } })).resolves.toEqual({ doubled: 42, }); - closeBoth(); + await closeBoth(); }); test('a `toValidator(...)` input schema still DROPS an invalid payload (fail-closed preserved)', async () => { @@ -321,14 +310,13 @@ describe('validation on both boundaries', () => { iframe.respond('dbl2', (p: { n: number }) => ({ doubled: p.n * 2 }), { input: inputSchema, }); - const call = parent.request({ - type: 'dbl2', + const call = parent.request('dbl2', { timeout: { perAttempt: 40 }, }); await expect( call({ body: { n: 'not-a-number' } }), ).rejects.toMatchObject({ name: 'StitchError' }); - closeBoth(); + await closeBoth(); }); // `output` rides the same coercion — a `toValidator(...)` responder output that the result @@ -342,14 +330,13 @@ describe('validation on both boundaries', () => { iframe.respond('dbl3', (p: { n: number }) => ({ doubled: p.n * 2 }), { output: outputSchema, }); - const call = parent.request({ - type: 'dbl3', + const call = parent.request('dbl3', { timeout: { perAttempt: 200 }, }); await expect(call({ body: { n: 5 } })).resolves.toEqual({ doubled: 10, }); - closeBoth(); + await closeBoth(); }); }); @@ -360,8 +347,7 @@ describe('validation on both boundaries', () => { describe('events (a streaming surface, validated payloads)', () => { test('await collects the payloads; ordering is preserved over .stream()', async () => { const { parent, iframe, closeBoth } = channelPair(); - const events = parent.events({ - type: 'tick', + const events = parent.events('tick', { output: asValidator(z.object({ n: z.number() })), }); @@ -380,17 +366,17 @@ describe('events (a streaming surface, validated payloads)', () => { await new Promise((r) => setTimeout(r, 5)); for (const n of [1, 2, 3]) iframe - .emit({ type: 'tick' as const })({ body: { n } }) + .emit('tick')({ body: { n } }) .catch(() => undefined); await drain.catch(() => undefined); expect(seen).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]); // contractValue → the payload, in order - closeBoth(); + await closeBoth(); }); test('await resolves to the collected payload array when the channel closes the stream', async () => { const { parent, iframe } = channelPair(); - const events = parent.events({ type: 'evt' }); + const events = parent.events('evt'); // Kick off consumption NOW (`.then` starts the run and registers the subscription via // `execute`); the same shared run is what we assert on below. A bare `events()` is lazy. const settled = events().then((v) => v); @@ -398,36 +384,35 @@ describe('events (a streaming surface, validated payloads)', () => { await new Promise((r) => setTimeout(r, 5)); for (const v of ['x', 'y']) iframe - .emit({ type: 'evt' as const })({ body: v }) + .emit('evt')({ body: v }) .catch(() => undefined); await new Promise((r) => setTimeout(r, 20)); // Closing the parent ends its live event streams GRACEFULLY → the await resolves to the // payloads collected so far (vs. an abort, which errors the stream — tested separately). - parent.close(); - iframe.close(); + await parent.close(); + await iframe.close(); await expect(settled).resolves.toEqual(['x', 'y']); }); test('a payload failing the `output` schema fails the stream (the bad event is not delivered)', async () => { const { parent, iframe, closeBoth } = channelPair(); - const events = parent.events({ - type: 'num', + const events = parent.events('num', { output: asValidator(z.object({ n: z.number() })), }); const ev = collectEvents(events.stream()); await new Promise((r) => setTimeout(r, 5)); iframe - .emit({ type: 'num' as const })({ body: { n: 1 } }) + .emit('num')({ body: { n: 1 } }) .catch(() => undefined); iframe - .emit({ type: 'num' as const })({ body: { bad: true } }) + .emit('num')({ body: { bad: true } }) .catch(() => undefined); const collected = await ev; expect(collected.deltas).toEqual([{ n: 1 }]); expect(collected.drifts[0]?.level).toBe('error'); expect(collected.done?.ok).toBe(false); - closeBoth(); + await closeBoth(); }); }); @@ -443,17 +428,17 @@ describe('emit (fire-and-forget)', () => { received = p; return null; // a reply nobody is waiting for }); - const log = parent.emit({ type: 'log' }); + const log = parent.emit('log'); await expect(log({ body: { msg: 'hello' } })).resolves.toBeUndefined(); // give the microtask hop time to deliver to the iframe await new Promise((r) => setTimeout(r, 5)); expect(received).toEqual({ msg: 'hello' }); - closeBoth(); + await closeBoth(); }); test('emit is also observable as an inbound event on the peer', async () => { const { parent, iframe, closeBoth } = channelPair(); - const events = iframe.events({ type: 'beacon' }); + const events = iframe.events('beacon'); const ctl = new AbortController(); const seen: unknown[] = []; const drain = (async () => { @@ -465,10 +450,10 @@ describe('emit (fire-and-forget)', () => { } })(); await new Promise((r) => setTimeout(r, 5)); - await parent.emit({ type: 'beacon' })({ body: { hit: 1 } }); + await parent.emit('beacon')({ body: { hit: 1 } }); await drain.catch(() => undefined); expect(seen).toEqual([{ hit: 1 }]); - closeBoth(); + await closeBoth(); }); }); @@ -479,7 +464,7 @@ describe('emit (fire-and-forget)', () => { describe('abort + close', () => { test('aborting the call signal rejects the pending request and leaves no dangling entry', async () => { const { parent, closeBoth } = channelPair(); - const call = parent.request({ type: 'never-answered' }); + const call = parent.request('never-answered'); const ctl = new AbortController(); const p = call({ body: {}, signal: ctl.signal }); ctl.abort(); @@ -489,7 +474,7 @@ describe('abort + close', () => { const p2 = call({ body: {}, signal: ctl2.signal }); ctl2.abort(); await expect(p2).rejects.toMatchObject({ name: 'StitchError' }); - closeBoth(); + await closeBoth(); }); test('close() rejects in-flight pending requests and detaches the listener', async () => { @@ -506,14 +491,14 @@ describe('abort + close', () => { }, }; const ch = channel(transport, { allowedOrigins: [ORIGIN_B] }); - const call = ch.request({ type: 'pending' }); + const call = ch.request('pending'); // A stitch is lazy — start consuming so `execute` actually posts and registers the pending // entry, then let the resilience chain reach the transport before closing. const p = call({ body: {} }); p.catch(() => undefined); // begin the run await new Promise((r) => setTimeout(r, 10)); expect(delivered).toBe(1); // the request was posted once before close - ch.close(); + await ch.close(); await expect(p).rejects.toThrow(/closed/); expect(detached).toBe(true); // close() detached the demux listener }); @@ -550,11 +535,11 @@ describe('windowChannel guards', () => { targetOrigin: 'https://app.example.com', }); // Fire-and-forget so no reply is awaited; we only assert the post shape. - await ch.emit({ type: 'hi' })({ body: { a: 1 } }); + await ch.emit('hi')({ body: { a: 1 } }); expect(posts).toHaveLength(1); expect(posts[0]?.origin).toBe('https://app.example.com'); expect(posts[0]?.msg).toMatchObject({ type: 'hi', payload: { a: 1 } }); - ch.close(); + await ch.close(); }); }); @@ -566,10 +551,10 @@ describe('portChannel (origin gating bypassed for ports)', () => { worker.respond<{ x: number }, { y: number }>('double', ({ x }) => ({ y: x * 2, })); - const dbl = parent.request<{ type: 'double' }>({ type: 'double' }); + const dbl = parent.request('double'); await expect(dbl({ body: { x: 21 } })).resolves.toEqual({ y: 42 }); - parent.close(); - worker.close(); + await parent.close(); + await worker.close(); }); }); @@ -583,12 +568,12 @@ describe('channel() over an arbitrary transport', () => { const parent = channel(a, { allowedOrigins: [] }); // allow NOTHING const iframe = channel(b, { allowedOrigins: [ORIGIN_A] }); iframe.respond('x', () => 'ok'); - const call = parent.request({ type: 'x', timeout: { perAttempt: 40 } }); + const call = parent.request('x', { timeout: { perAttempt: 40 } }); // The iframe's reply carries origin ORIGIN_B, which is not in [] → dropped → times out. await expect(call({ body: {} })).rejects.toMatchObject({ name: 'StitchError', }); - parent.close(); - iframe.close(); + await parent.close(); + await iframe.close(); }); }); diff --git a/packages/core/test/public-api-surface.spec.ts b/packages/core/test/public-api-surface.spec.ts index 4dae8dba..b28556ab 100644 --- a/packages/core/test/public-api-surface.spec.ts +++ b/packages/core/test/public-api-surface.spec.ts @@ -28,7 +28,7 @@ const FUNCTIONS = [ 'fileSink', 'multiplex', 'loggerSink', - 'otlpTrace', + 'otlpSink', 'otlpHttpExporter', 'toOtlpJson', 'memoryStore', diff --git a/packages/core/test/report.spec.ts b/packages/core/test/report.spec.ts index c629f487..84c531bf 100644 --- a/packages/core/test/report.spec.ts +++ b/packages/core/test/report.spec.ts @@ -2,7 +2,7 @@ // Two surfaces under test: // - `source` on `Inspection` (also returned by `.inspect()`): 'live' for a default probe, // 'stream' for a streaming surface (raw null), 'cache' for a warmed cache hit (raw null). -// - `.report()`: a `RunReport` (an Inspection plus run diagnostics) — `attempts`, `timing.ms`, +// - `.report()`: a `RunReport` (an Inspection plus run diagnostics) — `attempts`, `timing.elapsed`, // the REDACTED `config` (never `__rawConfig`), and the fine-grained `cache` outcome // (bypass / miss / hit / disabled). Like `.inspect()` it never throws on a hard contract // violation — it returns with `error` set and the diagnostics populated. @@ -101,9 +101,9 @@ test('source: a cache hit is "cache" with raw null', async () => { // =========================================================================== // --------------------------------------------------------------------------- -// 4. A report carries the Inspection fields PLUS diagnostics; timing.ms is a number. +// 4. A report carries the Inspection fields PLUS diagnostics; timing.elapsed is a number. // --------------------------------------------------------------------------- -test('report: inspection fields + attempts + timing.ms (number) + source', async () => { +test('report: inspection fields + attempts + timing.elapsed (number) + source', async () => { const { adapter } = counting({ n: '42' }); const s = stitch({ url: URL, @@ -118,7 +118,7 @@ test('report: inspection fields + attempts + timing.ms (number) + source', async expect(r.error).toBeNull(); expect(r.source).toBe('live'); expect(r.attempts).toBe(1); - expect(typeof r.timing.ms).toBe('number'); + expect(typeof r.timing.elapsed).toBe('number'); }); // --------------------------------------------------------------------------- @@ -130,7 +130,7 @@ test('report: attempts reflects retries', async () => { url: URL, adapter, trace: false, - retry: { attempts: 3, baseMs: 0 }, + retry: { attempts: 3, baseDelay: 0 }, }); const r = await s.report(); expect(r.error).toBeNull(); @@ -228,7 +228,7 @@ test('report: a hard contract violation returns with error + diagnostics', async expect(r.status).toBe(200); expect(r.source).toBe('live'); expect(r.attempts).toBe(1); - expect(typeof r.timing.ms).toBe('number'); + expect(typeof r.timing.elapsed).toBe('number'); expect(r.cache).toBe('disabled'); expect( r.findings.some((f) => f.change === 'invalid' && f.level === 'error'), diff --git a/packages/core/test/resilience-backoff.spec.ts b/packages/core/test/resilience-backoff.spec.ts index 10560a31..21192187 100644 --- a/packages/core/test/resilience-backoff.spec.ts +++ b/packages/core/test/resilience-backoff.spec.ts @@ -1,8 +1,8 @@ // Direct unit tests for the pure retry helpers in src/resilience.ts. resilience.spec.ts exercises // retry/Retry-After through the ENGINE; the formula and the header parser themselves are never // asserted directly. These pin them: -// backoffDelay — fixed (constant), expo (2^(attempt-2), clamped at attempt 1), the maxMs cap, -// and expo-jitter (the default) staying within [0, computed) and under maxMs. +// backoffDelay — fixed (constant), expo (2^(attempt-2), clamped at attempt 1), the maxDelay cap, +// and expo-jitter (the default) staying within [0, computed) and under maxDelay. // parseRetryAfter— nullish/empty → undefined, delta-seconds → ms (whitespace tolerated), an // HTTP-date → ms-until-then against the clock, a past date clamped to 0, and an // unparseable value → undefined. @@ -44,12 +44,6 @@ describe('backoffDelay', () => { expect(backoffDelay(4, o)).toBe(3000); // 4000 clamped to '3s' }); - test('the @deprecated baseMs/maxMs aliases still pace the curve (P17)', () => { - const o: RetryOptions = { backoff: 'expo', baseMs: 100, maxMs: 1000 }; - expect(backoffDelay(2, o)).toBe(100); - expect(backoffDelay(20, o)).toBe(1000); - }); - test('expo-jitter (the default) stays within [0, computed) and under maxDelay', () => { for (let i = 0; i < 100; i++) { const d = backoffDelay(3); // default baseDelay 100 → computed 200 diff --git a/packages/core/test/resilience.spec.ts b/packages/core/test/resilience.spec.ts index 3c3820d5..29c92cf9 100644 --- a/packages/core/test/resilience.spec.ts +++ b/packages/core/test/resilience.spec.ts @@ -41,7 +41,7 @@ test('retries on 503 then succeeds, reporting attempts and retry progress', asyn const call = stitch({ baseUrl: server.url, path: '/flaky', - retry: { attempts: 3, on: [503], baseMs: 5 }, + retry: { attempts: 3, on: [503], baseDelay: 5 }, }); const events = await collect(call()); @@ -68,7 +68,7 @@ test('rejects with status 503 after exhausting all retry attempts', async () => const call = stitch({ baseUrl: server.url, path: '/down', - retry: { attempts: 3, on: [503], baseMs: 5 }, + retry: { attempts: 3, on: [503], baseDelay: 5 }, }); await expect(call()).rejects.toMatchObject({ status: 503 }); @@ -79,13 +79,18 @@ test('rejects with status 503 after exhausting all retry attempts', async () => test('honors the Retry-After header instead of the short backoff', async () => { server.route('GET', '/limited', { statuses: [429, 200], - retryAfter: 1, + retryAfterSeconds: 1, body: { ok: true }, }); const call = stitch({ baseUrl: server.url, path: '/limited', - retry: { attempts: 2, on: [429], respectRetryAfter: true, baseMs: 5 }, + retry: { + attempts: 2, + on: [429], + respectRetryAfter: true, + baseDelay: 5, + }, }); const t0 = Date.now(); @@ -127,7 +132,7 @@ test('throttle rate spaces sequential calls and emits throttled progress', async // ── 5. Throttle concurrency cap ──────────────────────────────────────────── test('throttle concurrency:1 serializes concurrent calls', async () => { - server.route('GET', '/serial', { delayMs: 60, body: { ok: true } }); + server.route('GET', '/serial', { delay: 60, body: { ok: true } }); const call = stitch({ baseUrl: server.url, path: '/serial', @@ -144,7 +149,7 @@ test('throttle concurrency:1 serializes concurrent calls', async () => { // ── 6. Timeout aborts ────────────────────────────────────────────────────── test('timeout aborts a slow request instead of waiting it out', async () => { - server.route('GET', '/slow', { delayMs: 200, body: { ok: true } }); + server.route('GET', '/slow', { delay: 200, body: { ok: true } }); const call = stitch({ baseUrl: server.url, path: '/slow', diff --git a/packages/core/test/run-identity.spec.ts b/packages/core/test/run-identity.spec.ts index b68dff1f..99396548 100644 --- a/packages/core/test/run-identity.spec.ts +++ b/packages/core/test/run-identity.spec.ts @@ -3,13 +3,7 @@ // The OTLP sink reads them to build a real span tree (shared traceId, parentSpanId) instead of // minting per-start and guessing by name; a child run inherits the parent's traceId and points // parentSpanId at the parent's spanId. Ids are engine-minted, never caller-supplied (ADR 0002 §2). -import { - cookieSession, - multiplex, - otlpTrace, - stitch, - toOtlpJson, -} from '../src'; +import { cookieSession, multiplex, otlpSink, stitch, toOtlpJson } from '../src'; import type { OtelSpan, SpanExporter, @@ -123,7 +117,7 @@ test('newRunContext: a root mints fresh ids; a child inherits traceId and sets p test('the OTLP sink builds a span tree from the ctx ids (traceId / spanId / parentSpanId)', () => { const { exporter, spans } = stubExporter(); - const sink = otlpTrace({ exporter }); + const sink = otlpSink({ exporter }); const parent = newRunContext(); const child = newRunContext(parent); // shares traceId, parentSpanId = parent.spanId @@ -187,7 +181,7 @@ test('end-to-end: a real call feeds the OTLP sink the same ids it stamped on `st name: 'ping', baseUrl: server.url, path: '/ping', - trace: multiplex(cap, otlpTrace({ exporter })), + trace: multiplex(cap, otlpSink({ exporter })), }); await ping(); @@ -200,7 +194,7 @@ test('end-to-end: a real call feeds the OTLP sink the same ids it stamped on `st test('a hand-fed OTLP sink (no ctx ids) still mints a valid span — back-compat', () => { const { exporter, spans } = stubExporter(); - const sink = otlpTrace({ exporter }); + const sink = otlpSink({ exporter }); const name = 'legacy'; sink.handle( { @@ -247,7 +241,7 @@ test('cookieSession runs its login as a traced CHILD of the call that triggered baseUrl: server.url, path: '/me', trace: sink, - auth: cookieSession({ login: signIn, cookie: 'sid', scope: 'app' }), + auth: cookieSession({ login: signIn, cookie: 'sid', tenancy: 'app' }), }); await expect(me()).resolves.toEqual({ user: 'ada' }); @@ -276,8 +270,8 @@ test('OTLP: a retried call emits flat per-attempt child spans parented to the ru name: 'flaky', baseUrl: server.url, path: '/flaky', - retry: { attempts: 3, on: [503], backoff: 'fixed', baseMs: 1 }, - trace: otlpTrace({ exporter }), + retry: { attempts: 3, on: [503], backoff: 'fixed', baseDelay: 1 }, + trace: otlpSink({ exporter }), }); await flaky(); @@ -307,7 +301,7 @@ test('OTLP: a paginated call emits flat per-page child spans parented to the run next: (_body, page) => page < 3 ? { query: { page: page + 1 } } : undefined, }, - trace: otlpTrace({ exporter }), + trace: otlpSink({ exporter }), }); await list(); diff --git a/packages/core/test/safe-unwrap.spec.ts b/packages/core/test/safe-unwrap.spec.ts index fcb8637c..1a2885f6 100644 --- a/packages/core/test/safe-unwrap.spec.ts +++ b/packages/core/test/safe-unwrap.spec.ts @@ -37,7 +37,7 @@ test('safe() returns { ok: false, data: null, error } instead of throwing', asyn const call = stitch({ baseUrl: server.url, path: '/down', - retry: { attempts: 3, on: [503], baseMs: 5 }, + retry: { attempts: 3, on: [503], baseDelay: 5 }, }); const res = await call.safe(); diff --git a/packages/core/test/seam-graphql.spec.ts b/packages/core/test/seam-graphql.spec.ts index bab5ba09..2ea3fea7 100644 --- a/packages/core/test/seam-graphql.spec.ts +++ b/packages/core/test/seam-graphql.spec.ts @@ -1,7 +1,7 @@ // seam.graphql() — the seam's GraphQL member builder (src/seam.ts, the makeBuild `isGql` branch). // seam.spec.ts covers regular `.stitch()` members (fragment inheritance, throttle pooling, principal // sessions, redaction, lifecycle) but NEVER exercises `.graphql()`. This pins the graphql member's -// defaults — the graphql surface, a default POST /graphql endpoint, `unwrap: 'data'` — plus that it +// defaults — the graphql surface, a default POST /graphql endpoint, `pick: 'data'` — plus that it // inherits the seam fragment, honours an explicit path, and is also buildable off a principal handle. import { seam } from '../src'; import { startMockServer } from './support/mock-server'; @@ -31,7 +31,7 @@ describe('seam.graphql() member', () => { server.route('POST', '/graphql', { body: { data: { thing: 42 } } }); const api = seam({ baseUrl: server.url, headers: { 'x-seam': 'yes' } }); - const q = api.graphql({ query: '{ thing }' }); + const q = api.graphql({ document: '{ thing }' }); // kind round-trips as the graphql surface id. const json = JSON.parse(JSON.stringify(q.__config)) as { @@ -52,7 +52,7 @@ describe('seam.graphql() member', () => { server.route('POST', '/gql', { body: { data: { ok: true } } }); const api = seam({ baseUrl: server.url }); - const q = api.graphql({ query: '{ ok }', path: '/gql' }); + const q = api.graphql({ document: '{ ok }', path: '/gql' }); await expect(q()).resolves.toEqual({ ok: true }); expect(server.callCount('/gql')).toBe(1); expect(server.callCount('/graphql')).toBe(0); @@ -62,7 +62,7 @@ describe('seam.graphql() member', () => { server.route('POST', '/graphql', { body: { data: { who: 'me' } } }); const api = seam({ baseUrl: server.url }); - const scoped = api.as('user-1').graphql({ query: '{ who }' }); + const scoped = api.as('user-1').graphql({ document: '{ who }' }); await expect(scoped()).resolves.toEqual({ who: 'me' }); expect(server.callCount('/graphql')).toBe(1); }); diff --git a/packages/core/test/seam.spec.ts b/packages/core/test/seam.spec.ts index f5c5e6fd..aea4856d 100644 --- a/packages/core/test/seam.spec.ts +++ b/packages/core/test/seam.spec.ts @@ -51,8 +51,8 @@ test('member stitches inherit the seam fragment (baseUrl + default headers)', as }); test('the seam pools ONE throttle bucket across its different member stitches', async () => { - server.route('GET', '/x', { delayMs: 150, body: { r: 'x' } }); - server.route('GET', '/y', { delayMs: 150, body: { r: 'y' } }); + server.route('GET', '/x', { delay: 150, body: { r: 'x' } }); + server.route('GET', '/y', { delay: 150, body: { r: 'y' } }); const api = seam({ baseUrl: server.url, throttle: { concurrency: 1 } }); const x = api.stitch('/x'); const y = api.stitch('/y'); @@ -66,7 +66,7 @@ test('the seam pools ONE throttle bucket across its different member stitches', }); test('a per-stitch throttle can only TIGHTEN — it cannot escape the seam budget', async () => { - server.route('GET', '/z', { delayMs: 150, body: { r: 'z' } }); + server.route('GET', '/z', { delay: 150, body: { r: 'z' } }); const api = seam({ baseUrl: server.url, throttle: { concurrency: 1 } }); // The member asks for a LOOSER limit (5); the seam's concurrency=1 still gates, so two // concurrent calls serialize — the local throttle stacks, it does not replace the shared one. @@ -109,7 +109,7 @@ test('seam.as(principal) gives each principal its OWN session — no cross-princ expect((logins[1]!.body as { u: string }).u).toBe('B'); }); -test('cookieSession fails closed: default scope throws when no principal is bound', async () => { +test('cookieSession fails closed: default tenancy throws when no principal is bound', async () => { server.route('POST', '/login', { setCookie: { name: 'sid', value: 'OK' }, body: { ok: true }, @@ -129,7 +129,7 @@ test('cookieSession fails closed: default scope throws when no principal is boun expect(server.callCount('/login')).toBe(0); // failed closed before any login }); -test("scope: 'app' is the explicit opt-in to ONE session shared across all callers", async () => { +test("tenancy: 'app' is the explicit opt-in to ONE session shared across all callers", async () => { server.route('POST', '/login', { setCookie: { name: 'sid', value: 'OK' }, body: { ok: true }, @@ -144,7 +144,7 @@ test("scope: 'app' is the explicit opt-in to ONE session shared across all calle auth: cookieSession({ login: loginStitch(), cookie: 'sid', - scope: 'app', + tenancy: 'app', loginInput: (principal) => ({ body: { u: principal ?? 'app' } }), }), }); diff --git a/packages/core/test/serve.spec.ts b/packages/core/test/serve.spec.ts index fe36144c..7696ea0a 100644 --- a/packages/core/test/serve.spec.ts +++ b/packages/core/test/serve.spec.ts @@ -46,7 +46,7 @@ beforeAll(async () => { const getWidget = stitch({ baseUrl: api.url, path: '/widgets/{id}', - unwrap: 'data', + pick: 'data', output: asValidator(z.object({ id: z.number() })), }); const ping = stitch({ baseUrl: api.url, path: '/ping' }); diff --git a/packages/core/test/smoke.spec.ts b/packages/core/test/smoke.spec.ts index 7fdd81e0..89352675 100644 --- a/packages/core/test/smoke.spec.ts +++ b/packages/core/test/smoke.spec.ts @@ -28,7 +28,7 @@ test('await sugar returns the unwrapped, validated result', async () => { const users = stitch({ baseUrl: server.url, path: '/users', - unwrap: 'data', + pick: 'data', // raw Zod schema — inference removes the old `asValidator()` cast. output: z.array(z.object({ id: z.number(), name: z.string() })), }); diff --git a/packages/core/test/sse-reconnect.spec.ts b/packages/core/test/sse-reconnect.spec.ts index 4debd917..02400147 100644 --- a/packages/core/test/sse-reconnect.spec.ts +++ b/packages/core/test/sse-reconnect.spec.ts @@ -1,6 +1,6 @@ // Resumable SSE (issue #71): the `sse` surface reconnects a dropped `text/event-stream`, replaying // the last `id:` as the `Last-Event-ID` request header and honouring a server-sent `retry:` as the -// reconnect backoff (falling back to `reconnect.backoffMs` / the stitch's `retry` policy). Off by +// reconnect backoff (falling back to `reconnect.backoff` / the stitch's `retry` policy). Off by // default — these specs prove both the unchanged default and the opt-in reconnect loop, driving // timing the way the repo's other backoff tests do: small distinct delays + real elapsed bounds // (no fake timers anywhere in this package). @@ -124,7 +124,7 @@ describe('sse reconnect replays Last-Event-ID (issue #71)', () => { ]); const s = sse({ url: 'https://x.test/e', - sse: { reconnect: { attempts: 2, backoffMs: 1 } }, + sse: { reconnect: { attempts: 2, backoff: 1 } }, adapter, }); @@ -156,7 +156,7 @@ describe('sse reconnect backoff: server retry: vs fallback (issue #71)', () => { ]); const s = sse({ url: 'https://x.test/e', - sse: { reconnect: { attempts: 1, backoffMs: 1 } }, + sse: { reconnect: { attempts: 1, backoff: 1 } }, adapter, }); @@ -168,13 +168,13 @@ describe('sse reconnect backoff: server retry: vs fallback (issue #71)', () => { expect(out.doneOk).toBe(true); }); - test('with no server retry:, the configured reconnect.backoffMs is used', async () => { + test('with no server retry:, the configured reconnect.backoff is used', async () => { const { adapter } = scriptedAdapter([ () => streamOf(['id: 1\ndata: a\n\n']), ]); const s = sse({ url: 'https://x.test/e', - sse: { reconnect: { attempts: 1, backoffMs: 90 } }, + sse: { reconnect: { attempts: 1, backoff: 90 } }, adapter, }); @@ -186,15 +186,15 @@ describe('sse reconnect backoff: server retry: vs fallback (issue #71)', () => { expect(out.doneOk).toBe(true); }); - test('with neither, the stitch retry backoff (fixed baseMs) supplies the delay', async () => { - // No server retry:, no reconnect.backoffMs → fall back to the `retry` policy: fixed 70ms. + test('with neither, the stitch retry backoff (fixed baseDelay) supplies the delay', async () => { + // No server retry:, no reconnect.backoff → fall back to the `retry` policy: fixed 70ms. const { adapter } = scriptedAdapter([ () => streamOf(['id: 1\ndata: a\n\n']), ]); const s = sse({ url: 'https://x.test/e', sse: { reconnect: { attempts: 1 } }, - retry: { backoff: 'fixed', baseMs: 70 }, + retry: { backoff: 'fixed', baseDelay: 70 }, adapter, }); @@ -216,7 +216,7 @@ describe('sse reconnect respects the attempts cap (issue #71)', () => { }); const s = sse({ url: 'https://x.test/e', - sse: { reconnect: { attempts: 2, backoffMs: 1 } }, + sse: { reconnect: { attempts: 2, backoff: 1 } }, adapter, }); @@ -238,7 +238,7 @@ describe('sse reconnect respects the attempts cap (issue #71)', () => { const s = sse({ url: 'https://x.test/e', sse: { reconnect: true }, - retry: { backoff: 'fixed', baseMs: 1 }, // keep the default-attempt fallback fast + retry: { backoff: 'fixed', baseDelay: 1 }, // keep the default-attempt fallback fast adapter, }); @@ -258,7 +258,7 @@ describe('sse reconnect resumes from the last id after a mid-stream ERROR (issue ]); const s = sse({ url: 'https://x.test/e', - sse: { reconnect: { attempts: 1, backoffMs: 1 } }, + sse: { reconnect: { attempts: 1, backoff: 1 } }, adapter, }); @@ -281,7 +281,7 @@ describe('sse reconnect resumes from the last id after a mid-stream ERROR (issue ]); const s = sse({ url: 'https://x.test/e', - sse: { reconnect: { attempts: 1, backoffMs: 1 } }, + sse: { reconnect: { attempts: 1, backoff: 1 } }, adapter, }); @@ -305,7 +305,7 @@ describe('sse per-delta output validation keeps firing across a reconnect (issue const s = sse({ url: 'https://x.test/e', output: asValidator(z.object({ tok: z.string() })), - sse: { reconnect: { attempts: 2, backoffMs: 1 } }, + sse: { reconnect: { attempts: 2, backoff: 1 } }, adapter, }); @@ -328,16 +328,16 @@ describe('sse reconnect config round-trips as JSON (contract-not-dependency gate expect(json.sse).toEqual({ reconnect: true }); }); - test('the object form (attempts + backoffMs) survives the round-trip intact', () => { + test('the object form (attempts + backoff) survives the round-trip intact', () => { const cfg = { url: 'https://x.test/e', - sse: { reconnect: { attempts: 5, backoffMs: 250 } }, + sse: { reconnect: { attempts: 5, backoff: 250 } }, }; const s = sse(cfg); const json = JSON.parse(JSON.stringify(s.__config)) as { - sse?: { reconnect?: { attempts?: number; backoffMs?: number } }; + sse?: { reconnect?: { attempts?: number; backoff?: number } }; }; - expect(json.sse?.reconnect).toEqual({ attempts: 5, backoffMs: 250 }); + expect(json.sse?.reconnect).toEqual({ attempts: 5, backoff: 250 }); }); }); diff --git a/packages/core/test/sse.spec.ts b/packages/core/test/sse.spec.ts index d5e24b2b..045bc36e 100644 --- a/packages/core/test/sse.spec.ts +++ b/packages/core/test/sse.spec.ts @@ -360,7 +360,7 @@ describe('sse over real fetch + Web Streams (browser-first gate)', () => { 'data: {"n":2}\n\n', 'data: {"n":3}\n\n', ], - chunkDelayMs: 15, + chunkDelay: 15, }, }); const events = sse({ baseUrl: server.url, path: '/ticks' }); @@ -383,7 +383,7 @@ describe('sse over real fetch + Web Streams (browser-first gate)', () => { 'data: {"n":2}\n\n', 'data: {"n":3}\n\n', ], - chunkDelayMs: 40, + chunkDelay: 40, }, }); const events = sse({ baseUrl: server.url, path: '/abortable' }); @@ -489,9 +489,9 @@ describe('sse authoring helpers (Decision 3)', () => { expect(a.__config.kind).toBe(b.__config.kind); }); - test('sse.seam(existingSeam).stitch(...) creates an sse member of that seam', async () => { + test('sse.bind(existingSeam).stitch(...) creates an sse member of that seam', async () => { const api = seam({ baseUrl: 'https://x.test' }); - const events = sse.seam(api).stitch({ + const events = sse.bind(api).stitch({ path: '/e', adapter: streamAdapter(streamOf(['data: ok\n\n'])), }); diff --git a/packages/core/test/store.spec.ts b/packages/core/test/store.spec.ts index 33ee4d61..24cd802f 100644 --- a/packages/core/test/store.spec.ts +++ b/packages/core/test/store.spec.ts @@ -78,7 +78,7 @@ describe('Pluggable store — sessions', () => { cookie: 'SID', key: 'svc', loginInput, - scope: 'app', // standalone shared session (no principal bound) + tenancy: 'app', // standalone shared session (no principal bound) }), }); const b = stitch({ @@ -90,7 +90,7 @@ describe('Pluggable store — sessions', () => { cookie: 'SID', key: 'svc', loginInput, - scope: 'app', // standalone shared session (no principal bound) + tenancy: 'app', // standalone shared session (no principal bound) }), }); @@ -129,7 +129,7 @@ describe('Pluggable store — sessions', () => { cookie: 'SID', key: 'svc', loginInput, - scope: 'app', // standalone shared session (no principal bound) + tenancy: 'app', // standalone shared session (no principal bound) }), }); const b = stitch({ @@ -140,7 +140,7 @@ describe('Pluggable store — sessions', () => { cookie: 'SID', key: 'svc', loginInput, - scope: 'app', // standalone shared session (no principal bound) + tenancy: 'app', // standalone shared session (no principal bound) }), }); @@ -158,13 +158,13 @@ describe('Pluggable store — throttle', () => { baseUrl: server.url, path: '/x', store, - throttle: { rate: '1/s', scope: 'host' }, + throttle: { rate: '1/s', pool: 'host' }, }); const s2 = stitch({ baseUrl: server.url, path: '/x', store, - throttle: { rate: '1/s', scope: 'host' }, + throttle: { rate: '1/s', pool: 'host' }, }); const [w1, w2] = await Promise.all([ @@ -174,7 +174,7 @@ describe('Pluggable store — throttle', () => { expect(w1 + w2).toBe(1); // 1/s shared → exactly one of two concurrent calls is paced }); - test('scope:"host" pools the rate budget in-process WITHOUT a shared store', async () => { + test('pool:"host" pools the rate budget in-process WITHOUT a shared store', async () => { // throttle.mdx: "'host' pools the budget across every stitch hitting the same host." // Host-scoped state lives in a module-level registry (resilience.ts), so two separate // stitches with no shared store still draw from one 1/s budget — one of two concurrent @@ -183,12 +183,12 @@ describe('Pluggable store — throttle', () => { const s1 = stitch({ baseUrl: server.url, path: '/y', - throttle: { rate: '1/s', scope: 'host' }, + throttle: { rate: '1/s', pool: 'host' }, }); const s2 = stitch({ baseUrl: server.url, path: '/y', - throttle: { rate: '1/s', scope: 'host' }, + throttle: { rate: '1/s', pool: 'host' }, }); const [w1, w2] = await Promise.all([ @@ -198,8 +198,8 @@ describe('Pluggable store — throttle', () => { expect(w1 + w2).toBe(1); // pooled 1/s → exactly one of two concurrent calls is paced }); - test('scope:"stitch" (default) keeps separate budgets per instance', async () => { - // The default scope is per-stitch: each instance keeps its own closure-local budget, + test('pool:"stitch" (default) keeps separate budgets per instance', async () => { + // The default pool is per-stitch: each instance keeps its own closure-local budget, // so two separate stitches (no shared store) on distinct names never pace each other. server.route('GET', '/z', { body: { ok: true } }); const s1 = stitch({ diff --git a/packages/core/test/stub-stitch-extras.spec.ts b/packages/core/test/stub-stitch-extras.spec.ts index 9764971d..46870b1a 100644 --- a/packages/core/test/stub-stitch-extras.spec.ts +++ b/packages/core/test/stub-stitch-extras.spec.ts @@ -42,11 +42,13 @@ describe('stubStitch call spy', () => { await s.unwrap({ params: { id: 1 } }); await s.safe(); await collect(s.stream()); - expect(s.callCount).toBe(4); - expect(s.calls[1]).toEqual({ params: { id: 1 } }); + expect(s.callCount()).toBe(4); + expect(s.calls()[1]).toEqual({ params: { id: 1 } }); + // The optional filter is a predicate over the call input, like mockAdapter's spy. + expect(s.callCount((i) => i.params !== undefined)).toBe(1); s.reset(); - expect(s.callCount).toBe(0); - expect(s.calls).toEqual([]); + expect(s.callCount()).toBe(0); + expect(s.calls()).toEqual([]); }); }); diff --git a/packages/core/test/support/mock-server.ts b/packages/core/test/support/mock-server.ts index 24558011..ad8baea9 100644 --- a/packages/core/test/support/mock-server.ts +++ b/packages/core/test/support/mock-server.ts @@ -12,22 +12,24 @@ export interface ReqInfo { export interface RouteBehavior { statuses?: number[]; - delayMs?: number | number[]; + /** Pause (ms) before responding — a number, or a per-call sequence (last repeats). */ + delay?: number | number[]; body?: unknown | unknown[] | ((callIndex: number, req: ReqInfo) => unknown); requireCookie?: { name: string; value?: string }; requireHeader?: { name: string; value?: string }; setCookie?: { name: string; value: string }; setCookies?: { name: string; value: string }[]; - retryAfter?: number; + /** Emit a `Retry-After` header with this many delta-seconds (the header's native unit). */ + retryAfterSeconds?: number; headers?: Record; /** * Stream the body as a real chunked HTTP response (no `content-length`) instead of one buffered * send — for the `sse`/`stream` surfaces. Each chunk is written separately, with an optional - * `chunkDelayMs` pause *before* each, so a test can observe cross-chunk boundaries over a real - * socket, abort mid-stream, or break early. Set `headers['content-type']` (e.g. + * `chunkDelay` pause (ms) *before* each, so a test can observe cross-chunk boundaries over a + * real socket, abort mid-stream, or break early. Set `headers['content-type']` (e.g. * `'text/event-stream'`); the loop stops as soon as the client goes away. */ - stream?: { chunks: (string | Uint8Array)[]; chunkDelayMs?: number }; + stream?: { chunks: (string | Uint8Array)[]; chunkDelay?: number }; } export interface MockServer { @@ -131,8 +133,8 @@ export function startMockServer(): Promise { out['Set-Cookie'] = extra.setCookies.map( (c) => `${c.name}=${c.value}`, ); - if (extra?.retryAfter !== undefined) - out['Retry-After'] = String(extra.retryAfter); + if (extra?.retryAfterSeconds !== undefined) + out['Retry-After'] = String(extra.retryAfterSeconds); return out; }; const send = ( @@ -174,7 +176,7 @@ export function startMockServer(): Promise { buildHeaders('application/octet-stream', extra), ); for (const chunk of spec.chunks) { - if (spec.chunkDelayMs) await sleep(spec.chunkDelayMs); + if (spec.chunkDelay) await sleep(spec.chunkDelay); if (res.writableEnded || res.destroyed) break; res.write( typeof chunk === 'string' @@ -237,9 +239,9 @@ export function startMockServer(): Promise { } let delay = 0; - if (Array.isArray(behavior.delayMs)) - delay = (at(behavior.delayMs, idx) as number) ?? 0; - else if (typeof behavior.delayMs === 'number') delay = behavior.delayMs; + if (Array.isArray(behavior.delay)) + delay = (at(behavior.delay, idx) as number) ?? 0; + else if (typeof behavior.delay === 'number') delay = behavior.delay; const respond = (): void => { send(status, body, behavior); diff --git a/packages/core/test/surface-execute.spec.ts b/packages/core/test/surface-execute.spec.ts index 6f4c2f28..2537d10d 100644 --- a/packages/core/test/surface-execute.spec.ts +++ b/packages/core/test/surface-execute.spec.ts @@ -47,7 +47,7 @@ test('the resilience chain wraps execute — a retry-on-status re-runs it', asyn name: 'flaky-exec', url: 'exec://x', kind: execSurface(execute), - retry: { attempts: 3, on: [503], backoff: 'fixed', baseMs: 1 }, + retry: { attempts: 3, on: [503], backoff: 'fixed', baseDelay: 1 }, }); await expect(s()).resolves.toEqual({ ok: true }); diff --git a/packages/core/test/surface-graphql-hooks.spec.ts b/packages/core/test/surface-graphql-hooks.spec.ts index 5f86cad6..bd570845 100644 --- a/packages/core/test/surface-graphql-hooks.spec.ts +++ b/packages/core/test/surface-graphql-hooks.spec.ts @@ -1,6 +1,6 @@ // Direct tests for graphqlSurface's pure hooks (src/surface.ts). surface.spec.ts and // graphql-and-headers.spec.ts exercise the graphql surface through the ENGINE (observable POST, -// unwrap data, 200-with-errors fails). The hooks' own branch contracts go unpinned: +// pick data, 200-with-errors fails). The hooks' own branch contracts go unpinned: // buildRequest — packs { query, variables } as a JSON POST over the base request; variables // precedence is input.variables → input.body → {}; query defaults to ''; a // configured method overrides POST and is upper-cased. @@ -16,7 +16,7 @@ import type { } from '../src/types'; const cfg = ( - o: { query?: string; method?: string; operationName?: string } = {}, + o: { document?: string; method?: string; operationName?: string } = {}, ): ResolvedStitchConfig => o; const base: AdapterRequest = { @@ -40,7 +40,7 @@ const res = (body: unknown, status = 200): AdapterResponse => ({ describe('graphqlSurface.buildRequest', () => { test('packs { query, variables } as a JSON POST, preserving the base request', () => { - const req = build(cfg({ query: '{ thing }' }), { + const req = build(cfg({ document: '{ thing }' }), { variables: { id: 1 }, }); expect(req.method).toBe('POST'); @@ -51,16 +51,16 @@ describe('graphqlSurface.buildRequest', () => { }); test('variables fall back to input.body, then to {}', () => { - const fromBody = build(cfg({ query: 'q' }), { body: { a: 1 } }); + const fromBody = build(cfg({ document: 'q' }), { body: { a: 1 } }); expect((fromBody.body as { variables: unknown }).variables).toEqual({ a: 1, }); - const none = build(cfg({ query: 'q' }), {}); + const none = build(cfg({ document: 'q' }), {}); expect((none.body as { variables: unknown }).variables).toEqual({}); }); test('input.variables wins over input.body', () => { - const req = build(cfg({ query: 'q' }), { + const req = build(cfg({ document: 'q' }), { variables: { v: 1 }, body: { b: 2 }, }); @@ -75,14 +75,14 @@ describe('graphqlSurface.buildRequest', () => { }); test('honours and upper-cases a configured method', () => { - const req = build(cfg({ query: 'q', method: 'put' }), {}); + const req = build(cfg({ document: 'q', method: 'put' }), {}); expect(req.method).toBe('PUT'); }); test('derives operationName from the first named operation in the query', () => { const req = build( cfg({ - query: 'query findScene($id: ID!) { scene(id: $id) { id } }', + document: 'query findScene($id: ID!) { scene(id: $id) { id } }', }), { variables: { id: 's1' } }, ); @@ -97,7 +97,9 @@ describe('graphqlSurface.buildRequest', () => { expect( ( build( - cfg({ query: 'mutation Login($p: P!) { login(p: $p) }' }), + cfg({ + document: 'mutation Login($p: P!) { login(p: $p) }', + }), {}, ).body as { operationName?: string; @@ -106,7 +108,7 @@ describe('graphqlSurface.buildRequest', () => { ).toBe('Login'); expect( ( - build(cfg({ query: 'subscription OnTick { tick }' }), {}) + build(cfg({ document: 'subscription OnTick { tick }' }), {}) .body as { operationName?: string; } @@ -115,11 +117,11 @@ describe('graphqlSurface.buildRequest', () => { }); test('omits operationName for an anonymous document (shorthand or unnamed query)', () => { - for (const query of [ + for (const document of [ '{ thing }', 'query($id: ID) { thing(id: $id) }', ]) { - const body = build(cfg({ query }), {}).body as Record< + const body = build(cfg({ document }), {}).body as Record< string, unknown >; @@ -129,7 +131,10 @@ describe('graphqlSurface.buildRequest', () => { test('an explicit cfg.operationName overrides the derived name', () => { const body = build( - cfg({ query: 'query A { a } query B { b }', operationName: 'B' }), + cfg({ + document: 'query A { a } query B { b }', + operationName: 'B', + }), {}, ).body as { operationName?: string }; expect(body.operationName).toBe('B'); @@ -137,7 +142,7 @@ describe('graphqlSurface.buildRequest', () => { test('cfg.operationName of "" suppresses the field even for a named query', () => { const body = build( - cfg({ query: 'query Named { x }', operationName: '' }), + cfg({ document: 'query Named { x }', operationName: '' }), {}, ).body as Record; expect('operationName' in body).toBe(false); @@ -146,7 +151,7 @@ describe('graphqlSurface.buildRequest', () => { test('does not mistake a field or type named like a keyword for the operation', () => { // `queryStatus` field + `query` keyword: only the real operation `Dash` is picked up. const body = build( - cfg({ query: 'query Dash { queryStatus mutationCount }' }), + cfg({ document: 'query Dash { queryStatus mutationCount }' }), {}, ).body as { operationName?: string }; expect(body.operationName).toBe('Dash'); @@ -157,7 +162,7 @@ describe('graphqlSurface.interpret', () => { test('a body without errors passes through as the value', () => { expect(interpret(res({ data: { x: 1 } }))).toEqual({ ok: true, - value: { data: { x: 1 } }, + data: { data: { x: 1 } }, }); }); @@ -178,11 +183,11 @@ describe('graphqlSurface.interpret', () => { test('an empty errors array is NOT a failure', () => { expect(interpret(res({ errors: [] }))).toEqual({ ok: true, - value: { errors: [] }, + data: { errors: [] }, }); }); test('a null body is not a failure', () => { - expect(interpret(res(null))).toEqual({ ok: true, value: null }); + expect(interpret(res(null))).toEqual({ ok: true, data: null }); }); }); diff --git a/packages/core/test/surface.spec.ts b/packages/core/test/surface.spec.ts index 5c6e8a2a..55dc2ea4 100644 --- a/packages/core/test/surface.spec.ts +++ b/packages/core/test/surface.spec.ts @@ -1,6 +1,6 @@ // The Surface plugin model (ADR 0005 Decisions 1-3, 10, 11): `kind` is a pluggable Surface // (not a closed string union), normalised to its id string in __config (JSON round-trip), and -// each surface exposes monomorphic `.stitch()` / `.seam()` helpers. The seam stays +// each surface exposes monomorphic `.stitch()` / `.bind()` helpers. The seam stays // surface-agnostic. graphql's behaviour still rides the engine's id-keyed handling here (it // moves behind the surface hooks in Stage 4). import { graphql, httpSurface, seam, stitch } from '../src'; @@ -26,7 +26,7 @@ describe('Surface model (ADR 0005 Decisions 1-2, 11)', () => { }); test('kind round-trips through __config as the id STRING, never the live object', () => { - const g = graphql({ baseUrl: 'https://x.test', query: '{ a }' }); + const g = graphql({ baseUrl: 'https://x.test', document: '{ a }' }); const json = JSON.parse(JSON.stringify(g.__config)) as { kind?: unknown; }; @@ -52,7 +52,7 @@ describe('generic stitch({ kind }) accepts a Surface (Decision 3)', () => { kind: graphqlSurface, baseUrl: server.url, path: '/gql', - query: '{ ok }', + document: '{ ok }', }); await q({ variables: { x: 1 } }); @@ -73,28 +73,28 @@ describe('graphql surface helper (Decision 3/10)', () => { const q = graphql.stitch({ baseUrl: server.url, path: '/g', - query: '{ me { id } }', - unwrap: 'data.me', + document: '{ me { id } }', + pick: 'data.me', }); expect(await q()).toEqual({ id: 7 }); }); - test('graphql.seam(existingSeam).stitch(...) creates a graphql member of that seam', async () => { + test('graphql.bind(existingSeam).stitch(...) creates a graphql member of that seam', async () => { server.route('POST', '/api', { body: { data: { ping: 'pong' } } }); const api = seam({ baseUrl: server.url }); - const ping = graphql.seam(api).stitch({ + const ping = graphql.bind(api).stitch({ path: '/api', - query: '{ ping }', - unwrap: 'data.ping', + document: '{ ping }', + pick: 'data.ping', }); expect(await ping()).toBe('pong'); }); - test('graphql.seam(options) makes a new seam whose members are graphql', async () => { + test('graphql.bind(options) makes a new seam whose members are graphql', async () => { server.route('POST', '/s', { body: { data: { v: 42 } } }); - const g = graphql.seam({ baseUrl: server.url }); + const g = graphql.bind({ baseUrl: server.url }); expect(g.seam.__seam).toBe(true); - const v = g.stitch({ path: '/s', query: '{ v }', unwrap: 'data.v' }); + const v = g.stitch({ path: '/s', document: '{ v }', pick: 'data.v' }); expect(await v()).toBe(42); }); }); diff --git a/packages/core/test/testing-kit.spec.ts b/packages/core/test/testing-kit.spec.ts index d67e5aee..76be3541 100644 --- a/packages/core/test/testing-kit.spec.ts +++ b/packages/core/test/testing-kit.spec.ts @@ -52,7 +52,7 @@ describe('mockAdapter — testing a stitch definition', () => { baseUrl: 'https://api.test', path: '/flaky', adapter: api, - retry: { attempts: 3, on: [503], baseMs: 1 }, + retry: { attempts: 3, on: [503], baseDelay: 1 }, }); await expect(call()).resolves.toEqual({ ok: true }); @@ -171,8 +171,8 @@ describe('stubStitch / failStitch — testing code that calls a stitch', () => { const user = await getUser({ params: { id: 42 } }); expect(user).toEqual({ id: 42, name: 'Ada' }); - expect(getUser.callCount).toBe(1); - expect(getUser.calls[0]).toEqual({ params: { id: 42 } }); + expect(getUser.callCount()).toBe(1); + expect(getUser.calls()[0]).toEqual({ params: { id: 42 } }); const safe = await getUser.safe({ params: { id: 42 } }); expect(safe).toEqual({ @@ -180,10 +180,10 @@ describe('stubStitch / failStitch — testing code that calls a stitch', () => { data: { id: 42, name: 'Ada' }, error: null, }); - expect(getUser.callCount).toBe(2); + expect(getUser.callCount()).toBe(2); getUser.reset(); - expect(getUser.callCount).toBe(0); + expect(getUser.callCount()).toBe(0); }); test('stubStitch synthesizes a start→result→done stream', async () => { diff --git a/packages/core/test/util-helpers.spec.ts b/packages/core/test/util-helpers.spec.ts index 25aeb0ad..0cba3b0f 100644 --- a/packages/core/test/util-helpers.spec.ts +++ b/packages/core/test/util-helpers.spec.ts @@ -25,10 +25,12 @@ describe('parseDuration', () => { expect(parseDuration(undefined)).toBeUndefined(); }); - it('parses ms / s / m suffixes', () => { + it('parses ms / s / m / h / d suffixes', () => { expect(parseDuration('500ms')).toBe(500); expect(parseDuration('30s')).toBe(30_000); expect(parseDuration('2m')).toBe(120_000); + expect(parseDuration('1h')).toBe(3_600_000); + expect(parseDuration('2d')).toBe(172_800_000); }); it('accepts a fractional value and surrounding whitespace', () => { diff --git a/packages/deno-kv/README.md b/packages/deno-kv/README.md index 7e8b40d6..4074b3f0 100644 --- a/packages/deno-kv/README.md +++ b/packages/deno-kv/README.md @@ -66,8 +66,11 @@ deadline. That keeps the window's expiry alive across every write instead of a later increment silently wiping it and leaking the key forever. (`get` unwraps this envelope back to the plain count, so nothing downstream sees the internal shape.) The TTL unit is **milliseconds** — the same unit as the contract's `ttl`, and Deno -KV's own `expireIn` unit, so there's no conversion at the seam. `maxIncrRetries` -(default `100`) bounds the loop under pathological contention. +KV's own `expireIn` unit, so there's no conversion at the seam. An `incr` without +a `ttl` (or with `ttl <= 0`) has **no window**: the counter accumulates forever +and the key never expires — the same "absent `ttl` = no expiry" rule `set` +follows. `incrRetries` (default `100`) bounds the loop under pathological +contention. The store owns no connection: `store.close()` delegates to the handle, so you decide when KV shuts down. diff --git a/packages/deno-kv/src/index.ts b/packages/deno-kv/src/index.ts index d5b17431..3b90d8e6 100644 --- a/packages/deno-kv/src/index.ts +++ b/packages/deno-kv/src/index.ts @@ -86,11 +86,13 @@ export interface DenoKvLike { /** * What `incr` stores under a counter key: the running count `n` plus the window's - * absolute `deadline` (epoch ms). We keep the deadline in the VALUE — rather than - * relying on the key's `expireIn` alone — because Deno KV's `set` replaces the - * whole entry (clearing any prior expiry) and `get` never exposes the remaining - * TTL. Storing the deadline lets each `incr` re-derive the correct `expireIn` on - * every write, so a fixed window's expiry survives later increments intact. + * absolute `deadline` (epoch ms; `0` = no window — the counter never expires, + * mirroring `memoryStore`'s "0 marks live forever"). We keep the deadline in the + * VALUE — rather than relying on the key's `expireIn` alone — because Deno KV's + * `set` replaces the whole entry (clearing any prior expiry) and `get` never + * exposes the remaining TTL. Storing the deadline lets each `incr` re-derive the + * correct `expireIn` on every write, so a fixed window's expiry survives later + * increments intact. */ interface CounterWindow { n: number; @@ -127,7 +129,7 @@ export interface DenoKvStoreOptions { * contention drains one caller at a time); the default `100` therefore * comfortably covers any realistic per-key concurrency on a single window. */ - maxIncrRetries?: number; + incrRetries?: number; } /** @@ -153,7 +155,7 @@ export function denoKvStore( opts: DenoKvStoreOptions = {}, ): StitchStore { const prefix = opts.keyPrefix; - const maxRetries = opts.maxIncrRetries ?? 100; + const retries = opts.incrRetries ?? 100; // String key → Deno KV array key. With a prefix it's a two-segment key so the // namespace is a real KV sub-range; without, a flat one-segment key. const k = (key: string): DenoKvKey => @@ -206,21 +208,33 @@ export function denoKvStore( // FIRST increment and never extended, and re-derive `expireIn` from // it on every commit so the fixed window always expires on time. const kk = k(key); - for (let attempt = 0; attempt <= maxRetries; attempt++) { + for (let attempt = 0; attempt <= retries; attempt++) { const entry = await kv.get(kk); const now = Date.now(); const prev = asWindow(entry.value); // A window past its deadline (or an absent / legacy bare-number // value) starts a fresh window at 1; an old bare counter thus - // self-heals into the envelope on its next incr. - const live = prev != null && prev.deadline > now; - const deadline = live ? prev.deadline : now + ttl; + // self-heals into the envelope on its next incr. A `deadline` of + // 0 marks a windowless counter — live forever. + const live = + prev != null && + (prev.deadline === 0 || prev.deadline > now); + // Absent (or non-positive) `ttl` = no window: the counter never + // expires — `deadline: 0`, the same "live forever" marker + // `memoryStore` uses. A live counter keeps its original + // deadline, windowed or not, regardless of this call's `ttl`. + const windowed = ttl != null && ttl > 0; + const deadline = live + ? prev.deadline + : windowed + ? now + ttl + : 0; const n = (live ? prev.n : 0) + 1; // Deno KV rejects `expireIn` of 0/negative; keep it ≥ 1 while the - // deadline is in the future. `ttl <= 0` means "no window" — write - // without an expiry (matching `set`'s no-TTL path). + // deadline is in the future. A windowless counter (deadline 0) + // writes without an expiry (matching `set`'s no-TTL path). const options = - ttl > 0 + deadline > 0 ? { expireIn: Math.max(1, deadline - now) } : undefined; const res = await kv @@ -231,7 +245,7 @@ export function denoKvStore( if (res.ok) return n; } throw new Error( - `@stitchapi/deno-kv: incr(${key}) lost ${maxRetries + 1} compare-and-set races`, + `@stitchapi/deno-kv: incr(${key}) lost ${retries + 1} compare-and-set races`, ); }, }; diff --git a/packages/deno-kv/test/store-behaviour.spec.ts b/packages/deno-kv/test/store-behaviour.spec.ts index 003efebc..9fee2888 100644 --- a/packages/deno-kv/test/store-behaviour.spec.ts +++ b/packages/deno-kv/test/store-behaviour.spec.ts @@ -2,6 +2,7 @@ // (`conformance.spec.ts`) does not pin down: the FIXED-window TTL rule (the // counter's window has an absolute deadline pinned at creation — later incrs in // the same window neither extend nor clear it, and a fresh window restarts at 1), +// the NO-window rule (an incr without a ttl — or with ttl <= 0 — never expires), // the compare-and-set retry EXHAUSTION error, the array-key shape (flat vs // prefixed), the cache-delete (`set(k, undefined)`), and `close()` delegation. // Driven by two fakes: a recording KV that captures every write's `expireIn`, and @@ -288,15 +289,62 @@ describe('denoKvStore — incr TTL window', () => { expect(await store.incr('legacy', 200)).toBe(2); }); - test('throws after exhausting maxIncrRetries lost compare-and-set races', async () => { + test('throws after exhausting incrRetries lost compare-and-set races', async () => { const rec = recordingKv({ failAtomic: true }); - const store = denoKvStore(rec.kv, { maxIncrRetries: 3 }); + const store = denoKvStore(rec.kv, { incrRetries: 3 }); await expect(store.incr('x', 1000)).rejects.toThrow( /lost 4 compare-and-set races/, ); }); }); +describe('denoKvStore — incr without a window', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test('absent ttl = no window: the counter accumulates and never expires', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const store = denoKvStore(expiryKv()); + + expect(await store.incr('count')).toBe(1); + // Arbitrarily far in the future — a windowless counter never resets. + vi.setSystemTime(1_000_000_000); + expect(await store.incr('count')).toBe(2); + expect(await store.incr('count')).toBe(3); + // `get` unwraps the envelope to the plain count, windowed or not. + expect(await store.get('count')).toBe(3); + }); + + test('ttl <= 0 unifies with absent: no window, and no commit carries an expireIn', async () => { + const rec = recordingKv(); + const store = denoKvStore(rec.kv); + + expect(await store.incr('count')).toBe(1); + expect(await store.incr('count', 0)).toBe(2); + expect(await store.incr('count', -5)).toBe(3); + + // A windowless counter is written WITHOUT an expiry, matching `set`'s + // no-TTL path — the key must never silently gain a window. + expect(rec.atomicSets).toHaveLength(3); + for (const s of rec.atomicSets) expect(s.expireIn).toBeUndefined(); + }); + + test('a live windowless counter keeps counting even when a later incr passes a ttl', async () => { + // Mirrors memoryStore: a LIVE counter keeps its original (absent) window; + // a later ttl neither expires it nor resets the count. + vi.useFakeTimers(); + vi.setSystemTime(0); + const store = denoKvStore(expiryKv()); + + expect(await store.incr('count')).toBe(1); // windowless + expect(await store.incr('count', 200)).toBe(2); // ttl ignored — still live + vi.setSystemTime(500); // past the ttl that was ignored + expect(await store.incr('count')).toBe(3); + }); +}); + describe('denoKvStore — keys & lifecycle', () => { test('maps a string key to a flat one-segment array key by default', async () => { const rec = recordingKv(); diff --git a/packages/docs-mcp/README.md b/packages/docs-mcp/README.md index e9a8532c..11957730 100644 --- a/packages/docs-mcp/README.md +++ b/packages/docs-mcp/README.md @@ -78,8 +78,10 @@ derivations: weights, and query-length cap **must** match `apps/docs/lib/search-index/*` exactly, or a query embeds into a different vector space than the bundled index was built in. -- `src/doc-path.ts` — a verbatim copy (that file has no fumadocs - dependency either, so it's a straight copy, not a re-derivation). +- `src/doc-path.ts` — a behavior-verbatim copy (that file has no fumadocs + dependency either, so it's a straight copy, not a re-derivation; the one + textual difference is the named `GetDocOptions` input interface, which + apps/docs keeps inline). - `src/server.ts`'s `SITE_URL`/`EXCERPT_LEN` mirror `apps/docs/lib/shared.ts`'s `siteUrl` and `apps/docs/app/api/mcp/route.ts`'s `EXCERPT_LEN`. @@ -99,6 +101,10 @@ intentional. of running the `stitchapi-docs-mcp` bin) - `searchDocs(query, options?)`, `getDoc({ url?, slug? })` — the underlying retrieval functions +- Option interfaces (all exported): `SearchOptions` — with `HybridWeights` + and `FieldBoost` naming its `hybridWeights`/`boost` slots (field names are + Orama's, passed through unchanged) — and `GetDocOptions`, the + `{ url?, slug? }` input shape `getDoc` takes ## Contributing diff --git a/packages/docs-mcp/src/doc-path.ts b/packages/docs-mcp/src/doc-path.ts index 51b5d29d..67b5c5ec 100644 --- a/packages/docs-mcp/src/doc-path.ts +++ b/packages/docs-mcp/src/doc-path.ts @@ -1,20 +1,34 @@ // Normalize a get_doc input (URL or slug) into page slug segments. Ported -// verbatim from apps/docs/lib/search-index/doc-path.ts (pure — no fumadocs -// import there either — so this is a straight copy, not a re-derivation) since -// this package can't depend on apps/docs's source. See README.md "Keeping this -// in sync" if that file's normalization rules ever change. +// from apps/docs/lib/search-index/doc-path.ts (pure — no fumadocs import +// there either — so this is a straight copy, not a re-derivation) since this +// package can't depend on apps/docs's source. Behavior is byte-identical; the +// only local difference is the named `GetDocOptions` interface (CONTRACT P14), +// which apps/docs keeps inline — the docs-mcp-config-parity tether measures +// behavior, not source text. See README.md "Keeping this in sync" if that +// file's normalization rules ever change. const DOCS_PREFIX = 'docs'; +/** + * A docs-page reference: an absolute URL + * (https://stitchapi.dev/docs/guides/throttle#x), a root-relative path + * (/docs/guides/throttle), or a bare slug (guides/throttle). When both fields + * are set, `slug` wins. `{}` is accepted and resolves to nothing (`getDoc` + * returns null, `parseDocPath` returns null). + * + * The input shape of both `getDoc` and `parseDocPath`. + */ +export interface GetDocOptions { + url?: string | undefined; + slug?: string | undefined; +} + /** * Accepts an absolute URL (https://stitchapi.dev/docs/guides/throttle#x), a * root-relative path (/docs/guides/throttle), or a bare slug (guides/throttle). * Returns null only when nothing usable was given; `[]` means the docs index. */ -export function parseDocPath(input: { - url?: string | undefined; - slug?: string | undefined; -}): string[] | null { +export function parseDocPath(input: GetDocOptions): string[] | null { let path = (input.slug ?? input.url)?.trim(); if (!path) return null; diff --git a/packages/docs-mcp/src/get-doc.ts b/packages/docs-mcp/src/get-doc.ts index 0be7687f..2ab6f5f6 100644 --- a/packages/docs-mcp/src/get-doc.ts +++ b/packages/docs-mcp/src/get-doc.ts @@ -5,7 +5,7 @@ // apps/docs/scripts/build-docs-pages.ts at build:mcp-bundle time), so it needs // no fumadocs machinery at runtime. import { DATA_DIR, PAGES_FILE } from './config'; -import { parseDocPath } from './doc-path'; +import { type GetDocOptions, parseDocPath } from './doc-path'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; @@ -63,10 +63,7 @@ function loadPages(): Map { return cached; } -export function getDoc(input: { - url?: string | undefined; - slug?: string | undefined; -}): DocResult | null { +export function getDoc(input: GetDocOptions): DocResult | null { const slug = parseDocPath(input); if (slug === null) return null; diff --git a/packages/docs-mcp/src/index.ts b/packages/docs-mcp/src/index.ts index 4caf9da6..23b1dab0 100644 --- a/packages/docs-mcp/src/index.ts +++ b/packages/docs-mcp/src/index.ts @@ -1,5 +1,12 @@ // Library entry — for embedding the local docs server in another process // (e.g. a custom MCP host) instead of running the `stitchapi-docs-mcp` bin. export { createServer } from './server'; +export type { GetDocOptions } from './doc-path'; export { type DocResult, getDoc } from './get-doc'; -export { type DocSearchHit, type SearchOptions, searchDocs } from './search'; +export { + type DocSearchHit, + type FieldBoost, + type HybridWeights, + type SearchOptions, + searchDocs, +} from './search'; diff --git a/packages/docs-mcp/src/search.ts b/packages/docs-mcp/src/search.ts index 54c136d4..9ad0287f 100644 --- a/packages/docs-mcp/src/search.ts +++ b/packages/docs-mcp/src/search.ts @@ -30,10 +30,30 @@ export interface DocSearchHit { score: number; } +/** + * Relative weight of each retrieval mode in hybrid scoring. Identity + * pass-through to Orama's `hybridWeights` slot, so the field names are + * Orama's, not house vocabulary (CONTRACT P18/P22). + */ +export interface HybridWeights { + text: number; + vector: number; +} + +/** + * Per-field full-text score boost. Identity pass-through to Orama's `boost` + * slot; the field names are the bundled index's schema fields (CONTRACT + * P18/P22). + */ +export interface FieldBoost { + pageTitle: number; + heading: number; +} + export interface SearchOptions { limit?: number; - hybridWeights?: { text: number; vector: number }; - boost?: { pageTitle: number; heading: number }; + hybridWeights?: HybridWeights; + boost?: FieldBoost; } let cached: Promise | undefined; @@ -98,7 +118,11 @@ export async function searchDocs( vector: { value: vector, property: VECTOR_FIELD }, properties: ['pageTitle', 'heading', 'text'], hybridWeights, - boost, + // Fresh object literal, not the interface value: named interfaces get + // no implicit index signature (unlike the anonymous type this replaced), + // so FieldBoost isn't directly assignable to Orama's + // Partial>. Same fields, identity pass-through. + boost: { ...boost }, similarity: 0, includeVectors: false, limit, diff --git a/packages/docs-mcp/test/index.spec.ts b/packages/docs-mcp/test/index.spec.ts index 5d281aba..397b1487 100644 --- a/packages/docs-mcp/test/index.spec.ts +++ b/packages/docs-mcp/test/index.spec.ts @@ -12,4 +12,35 @@ describe('package public API (src/index.ts barrel)', () => { expect(typeof docsMcp.getDoc).toBe('function'); expect(typeof docsMcp.searchDocs).toBe('function'); }); + + it('re-exports the named option interfaces (CONTRACT P14)', () => { + // Types are erased at runtime, so pin them at the type level: if the + // barrel drops any of these re-exports, this block stops compiling. + const getDocOptions: docsMcp.GetDocOptions = { + url: '/docs/guides/resilience/throttle', + slug: 'guides/resilience/throttle', + }; + const hybridWeights: docsMcp.HybridWeights = { text: 0.2, vector: 0.8 }; + const boost: docsMcp.FieldBoost = { pageTitle: 3, heading: 2 }; + const searchOptions: docsMcp.SearchOptions = { + limit: 8, + hybridWeights, + boost, + }; + const hit: docsMcp.DocSearchHit = { + pageUrl: '/docs/guides/x', + pageTitle: 'X Guide', + heading: 'Retries', + anchor: 'retries', + text: 'body text', + score: 1, + }; + const doc: docsMcp.DocResult = { + title: 'X Guide', + url: '/docs/guides/x', + markdown: '# X Guide', + }; + + expect({ getDocOptions, searchOptions, hit, doc }).toBeTruthy(); + }); }); diff --git a/packages/elysia/README.md b/packages/elysia/README.md index bca92395..d4dcd2ac 100644 --- a/packages/elysia/README.md +++ b/packages/elysia/README.md @@ -79,11 +79,25 @@ app.get('/chat', ({ stitch, query }) => { }); ``` -Each `delta` chunk becomes one SSE message; an `error` event ends the stream as a -named `event: error` message; stream end closes the response; and a client -disconnect cancels the body and aborts the upstream stitch stream rather than -leaving it running. Control events (`start`/`progress`/`result`/`done`/…) are not -forwarded. +Each `delta` chunk becomes one SSE message (label it with `event`, add a +last-event id with `id: (chunk, index) => string`); an `error` event ends the +stream as a named `event: error` message; stream end closes the response; and a +client disconnect cancels the body and aborts the upstream stitch stream rather +than leaving it running. Control events (`start`/`progress`/`result`/`done`/…) +are not forwarded. + +The error message's `data` is a **generic `error` token by default** — the raw +upstream message is withheld, because it can disclose internal network topology +(`getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status +(`HTTP 401`) to an untrusted client. Observe the real failure server-side with +`onError`, and opt in to shaping the client-facing frame with `errorData`: + +```ts +return streamStitchSse(chat.stream(), { + onError: (err) => log.error(err), // the raw failure, server-side only + // errorData: (e) => e.message, // opt-in: only when upstream messages are safe +}); +``` ## Error handling diff --git a/packages/elysia/src/context.ts b/packages/elysia/src/context.ts index 0a0b06b1..19c08f69 100644 --- a/packages/elysia/src/context.ts +++ b/packages/elysia/src/context.ts @@ -14,10 +14,11 @@ export interface PrincipalContext { } /** - * The slice of Elysia's error context the error bridge reads: the thrown `error`. Elysia's real - * context also carries `code` / `set` / `request`, but the StitchError bridge only needs `error`. + * The slice of Elysia's error context the error bridge reads: the thrown `error` (mirrors Elysia's + * `ErrorContext`, structurally). Elysia's real context also carries `code` / `set` / `request`, but + * the StitchError bridge only needs `error`. */ -export interface StitchEnvLike { +export interface ErrorContextLike { error: unknown; } diff --git a/packages/elysia/src/error.ts b/packages/elysia/src/error.ts index cf51e508..65a4f7f1 100644 --- a/packages/elysia/src/error.ts +++ b/packages/elysia/src/error.ts @@ -6,7 +6,7 @@ // // Web-standard: builds a plain `Response` (Fetch) — no `node:*`, so it runs under Bun, Node, // Deno and the edge alike. -import type { StitchEnvLike } from './context'; +import type { ErrorContextLike } from './context'; /** The error a stitch rejects with on failure: a branded `Error` carrying the upstream status. */ export type StitchErrorLike = Error & { status?: number }; @@ -96,9 +96,9 @@ export function stitchErrorResponse( */ export function stitchOnError( options: StitchErrorOptions = {}, -): (ctx: StitchEnvLike) => Response | undefined { +): (ctx: ErrorContextLike) => Response | undefined { // Elysia calls `.onError` with a rich context; we only read `.error`. Returning a value short- // circuits the response; returning `undefined` lets Elysia's default error handling proceed. - return (ctx: StitchEnvLike): Response | undefined => + return (ctx: ErrorContextLike): Response | undefined => stitchErrorResponse(ctx.error, options); } diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 107907f2..91e39425 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -19,10 +19,8 @@ export { stitch, type ElysiaRequestSeam, - // Deprecated alias (kept through 1.0.0-rc, removed at GA) — see ADR 0012. - type RequestSeam, + type ElysiaStitchPluginOptions, type StitchPlugin, - type StitchPluginOptions, } from './plugin'; export { @@ -39,4 +37,8 @@ export { type StitchErrorOptions, } from './error'; -export type { PrincipalContext, StitchContext, StitchEnvLike } from './context'; +export type { + ErrorContextLike, + PrincipalContext, + StitchContext, +} from './context'; diff --git a/packages/elysia/src/plugin.ts b/packages/elysia/src/plugin.ts index f63e7b97..8d5d15de 100644 --- a/packages/elysia/src/plugin.ts +++ b/packages/elysia/src/plugin.ts @@ -20,14 +20,6 @@ import type { PrincipalSeam, Seam } from 'stitchapi'; */ export type ElysiaRequestSeam = PrincipalSeam | Seam; -/** - * @deprecated Renamed to {@link ElysiaRequestSeam} so the public type is ecosystem-qualified (a bare - * `RequestSeam` would collide with any other host adapter's per-request seam type) — see - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). Kept through the `1.0.0-rc` - * line and removed at the 1.0 GA cut. - */ -export type RequestSeam = ElysiaRequestSeam; - /** * The Elysia instance {@link stitch} returns — a plugin you `.use()`. Its only public contract is * the global `derive` adding {@link StitchContext} (`{ stitch }`) to the context of the app that @@ -44,7 +36,7 @@ export type StitchPlugin = Elysia< } >; -export interface StitchPluginOptions { +export interface ElysiaStitchPluginOptions { /** * The seam this plugin shares across requests. **Borrowed, not owned** — build it once at * startup and `seam.close()` it on shutdown yourself; the plugin never closes it (the seam @@ -88,7 +80,7 @@ export interface StitchPluginOptions { * * The `stitch` context property is typed: a handler reads it off the destructured context. */ -export function stitch(options: StitchPluginOptions): StitchPlugin { +export function stitch(options: ElysiaStitchPluginOptions): StitchPlugin { const { seam, principal, errorHandler } = options; // `.derive` runs per request and merges its return into the context. The principal lives in this diff --git a/packages/elysia/src/sse.ts b/packages/elysia/src/sse.ts index 0c92dcfb..cf43ebd6 100644 --- a/packages/elysia/src/sse.ts +++ b/packages/elysia/src/sse.ts @@ -2,33 +2,56 @@ // (mirrors @stitchapi/hono's sse.ts, adapted to Elysia's Web-standard return model). A streaming/SSE // stitch's `.stream()` is an `AsyncIterable`; this forwards each `delta` chunk as one // SSE message, ends the stream cleanly on `done`, and turns an `error` event into a final -// `event: error` message. When the client disconnects the `ReadableStream` is cancelled — the helper -// calls the iterator's `return()` so the upstream stitch stream is torn down rather than left running. +// `event: error` message — a generic `data: error` by default, the raw message withheld (opt in via +// `errorData`). When the client disconnects the `ReadableStream` is cancelled — the helper calls the +// iterator's `return()` so the upstream stitch stream is torn down rather than left running. // // Web-standard: returns a plain `Response` whose body is a `ReadableStream` — no `node:*`, so it runs // under Bun, Node, Deno and the edge alike. Return it straight from an Elysia handler. -import type { StitchEvent } from 'stitchapi'; +import type { StitchEvent, StitchEventSource } from 'stitchapi'; -/** Anything `streamStitchSse` can drive: a stitch `.stream()` generator, or any event iterable. */ -export type StitchEventSource = - | AsyncIterable> - | AsyncGenerator, void>; +// The canonical event-source intake, from the core barrel: the event iterable itself (a `.stream()` +// generator), or anything that hands one back (a `StitchResult`, a stitch stub). +export type { StitchEventSource } from 'stitchapi'; + +/** The terminal `error` event a stitch stream emits — carries `message`, `status`, `attempts`. */ +type StitchErrorEvent = Extract; export interface StreamStitchSseOptions { /** * Map a `delta` chunk to the SSE message `data` string. The default JSON-stringifies the chunk * (a string chunk is sent verbatim). Pull text out of a structured chunk with, e.g., - * `data: (c) => c.choices[0].delta.content ?? ''`. + * `data: (c) => c.choices[0].delta.content ?? ''`. Receives the zero-based message index + * alongside the chunk. + */ + data?: (chunk: unknown, index: number) => string; + /** + * The SSE `event:` field for each delta message (default none): a fixed name, or a function + * of the chunk for per-message names. Set it to label the stream's messages on the client + * (`event: 'token'`). */ - data?: (chunk: unknown) => string; + event?: string | ((chunk: unknown) => string); /** - * The SSE `event:` field for each delta message (default none). Set it to label the stream's - * messages on the client (`event: 'token'`). + * Provide an `id:` line per delta message (the SSE last-event id), e.g. for resumable streams. + * Receives the chunk and the zero-based frame index. */ - event?: string; + id?: (chunk: unknown, index: number) => string; /** - * Called once if the underlying stream errors (a stitch `error` event, or a throw). After it - * runs, an `error`-typed SSE message carrying the error text is written and the stream closes. + * Shape the SSE `data` written for a terminal `error` event (or an uncaught throw mid-stream, + * normalised to an error event). **Default: a generic token (`data: error`)** — the raw + * `event.message` is deliberately *not* echoed, because it can disclose internal network + * topology (a transport failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or + * the upstream's status (`HTTP 401`) to an untrusted client. Opt in with `(e) => e.message` + * when the upstream messages are known safe, or return your own payload + * (e.g. `() => JSON.stringify({ error: 'stream failed' })`). A multi-line return gets one + * `data:` line each (SSE spec); the `event: error` name is fixed. + */ + errorData?: (event: StitchErrorEvent) => string; + /** + * Called once, server-side, if the underlying stream errors (a stitch `error` event, or a + * throw) — use it to observe/log the real failure. It does **not** shape the client-facing + * frame: the SSE `data` sent to the client is controlled by `errorData` (a generic token by + * default), so the raw message reaches your logs here but not the client. */ onError?: (err: unknown) => void; } @@ -39,13 +62,41 @@ function defaultData(chunk: unknown): string { // One SSE frame string. A multi-line payload is split so every line gets its own `data:` prefix // (the SSE spec joins them with `\n`); the frame ends on a blank line. -function frame(data: string, event?: string): string { +function frame(data: string, event?: string, id?: string): string { const lines: string[] = []; if (event !== undefined) lines.push(`event: ${event}`); + if (id !== undefined) lines.push(`id: ${id}`); for (const line of data.split('\n')) lines.push(`data: ${line}`); return `${lines.join('\n')}\n\n`; } +// The generic token written as an `error` message's `data` by default: the raw upstream message is +// withheld so an internal hostname (`getaddrinfo ENOTFOUND …`) or the upstream's status (`HTTP 401`) +// never reaches the client. Override with `options.errorData`. +const DEFAULT_ERROR_DATA = 'error'; + +// Normalise a thrown value into the terminal `error` event shape, so an `errorData` opt-in sees a +// consistent argument whether the failure arrived as a surfaced `error` event or an unexpected +// throw. `attempts`/`at` are best-effort placeholders — an `errorData` hook keys off `name`/`message`. +function toErrorEvent(err: unknown): StitchErrorEvent { + const e = err instanceof Error ? err : new Error(String(err)); + return { + type: 'error', + name: e.name, + message: e.message, + attempts: 0, + at: 0, + }; +} + +// Resolve the canonical intake to the event iterable itself: a `.stream()`-bearing source (a +// `StitchResult`, a stitch stub) is asked for its stream; an iterable is used as-is. +function toIterable( + source: StitchEventSource, +): AsyncIterable> { + return Symbol.asyncIterator in source ? source : source.stream(); +} + /** * Stream a stitch's events to the client as SSE. Returns a Web-standard {@link Response} with a * `text/event-stream` body, so an Elysia handler is one line: @@ -62,17 +113,28 @@ function frame(data: string, event?: string): string { * ``` * * Each `delta` becomes a `data:` message; a terminal `error` event (or a throw) becomes a final - * `event: error` message and ends the stream; every control event (`start`/`progress`/`result`/ - * `done`/…) is consumed but not forwarded. On client disconnect the stream is cancelled and the - * upstream iterator is `return()`-ed so the stitch stream is aborted. + * `event: error` message and ends the stream — a generic `data: error` by default, the raw message + * withheld to avoid disclosing internal topology (opt in via `errorData`); every control event + * (`start`/`progress`/`result`/`done`/…) is consumed but not forwarded. On client disconnect the + * stream is cancelled and the upstream iterator is `return()`-ed so the stitch stream is aborted. */ export function streamStitchSse( source: StitchEventSource, options: StreamStitchSseOptions = {}, ): Response { - const toData = options.data ?? defaultData; + const toData: (chunk: unknown, index: number) => string = + options.data ?? defaultData; const enc = new TextEncoder(); - const iterator = source[Symbol.asyncIterator](); + const iterator = toIterable(source)[Symbol.asyncIterator](); + let index = 0; + + // The terminal `error` frame: a generic token by default so a raw message + // (`getaddrinfo ENOTFOUND …` / `HTTP 401`) is never disclosed; `errorData` opts in. + const errorFrame = (event: StitchErrorEvent): string => + frame( + options.errorData ? options.errorData(event) : DEFAULT_ERROR_DATA, + 'error', + ); const body = new ReadableStream({ async pull(controller) { @@ -86,16 +148,23 @@ export function streamStitchSse( if (event.type === 'delta') { controller.enqueue( enc.encode( - frame(toData(event.chunk), options.event), + frame( + toData(event.chunk, index), + typeof options.event === 'function' + ? options.event(event.chunk) + : options.event, + options.id?.(event.chunk, index), + ), ), ); + index += 1; return; // one frame per pull → precise back-pressure } if (event.type === 'error') { + // `onError` gets the real failure server-side; the client frame is shaped + // by `errorData` (generic by default), never the raw `event.message`. options.onError?.(new Error(event.message)); - controller.enqueue( - enc.encode(frame(event.message, 'error')), - ); + controller.enqueue(enc.encode(errorFrame(event))); controller.close(); await iterator.return?.(undefined); return; @@ -104,15 +173,10 @@ export function streamStitchSse( // loop on to the next event without emitting a frame. } } catch (err) { + // A throw (not a surfaced `error` event): still withhold the raw message by + // default — normalise it to an error event so `errorData` sees a consistent shape. options.onError?.(err); - controller.enqueue( - enc.encode( - frame( - err instanceof Error ? err.message : String(err), - 'error', - ), - ), - ); + controller.enqueue(enc.encode(errorFrame(toErrorEvent(err)))); controller.close(); await iterator.return?.(undefined); } diff --git a/packages/elysia/test/elysia.spec.ts b/packages/elysia/test/elysia.spec.ts index 576c43ea..b0e6623f 100644 --- a/packages/elysia/test/elysia.spec.ts +++ b/packages/elysia/test/elysia.spec.ts @@ -157,7 +157,10 @@ describe('streamStitchSse bridges a stitch stream to an SSE body', () => { const res = await app.handle(GET('/events')); const body = await res.text(); - expect(body).toContain('event: error'); + // A generic `data: error` token — the raw upstream message (`HTTP 500`) is withheld + // so upstream status/topology is not disclosed; `errorData` is the opt-in. + expect(body).toContain('event: error\ndata: error'); + expect(body).not.toContain('HTTP 500'); await api.close(); }); }); diff --git a/packages/elysia/test/sse.spec.ts b/packages/elysia/test/sse.spec.ts index 05b0715c..e09b415c 100644 --- a/packages/elysia/test/sse.spec.ts +++ b/packages/elysia/test/sse.spec.ts @@ -3,7 +3,8 @@ // error-event frame. This drives the helper directly with hand-rolled // `StitchEvent` generators — it takes just the source and returns a Web-standard // `Response`, so no Elysia app is needed — to cover the untested surface: the -// DEFAULT data mapper, the `event:` label, the `onError` callback, control +// DEFAULT data mapper, the `event:` label, the `id:` line, the `onError` +// callback, the generic-by-default error frame (`errorData` opt-in), control // events not being forwarded, the throw-mid-stream catch path, the SSE-spec // multi-line `data:` framing, and the response headers. import { streamStitchSse } from '../src'; @@ -69,6 +70,67 @@ describe('streamStitchSse — delta mapping', () => { expect(body).toContain('data: pulled'); }); + test('the data mapper receives the zero-based message index', async () => { + const res = streamStitchSse( + gen([ + { type: 'delta', chunk: 'a', at: 0 }, + { type: 'delta', chunk: 'b', at: 0 }, + { type: 'done', ok: true, elapsed: 1, attempts: 1, at: 0 }, + ]), + { data: (chunk, index) => `${String(chunk)}#${index}` }, + ); + + const body = await res.text(); + expect(body).toContain('data: a#0'); + expect(body).toContain('data: b#1'); + }); + + test('event accepts a function of the chunk for per-message event names', async () => { + const res = streamStitchSse( + gen([ + { type: 'delta', chunk: { kind: 'token', text: 'a' }, at: 0 }, + { type: 'delta', chunk: { kind: 'usage', text: 'b' }, at: 0 }, + { type: 'done', ok: true, elapsed: 1, attempts: 1, at: 0 }, + ]), + { + event: (chunk) => (chunk as { kind: string }).kind, + data: (chunk) => (chunk as { text: string }).text, + }, + ); + + const body = await res.text(); + expect(body).toContain('event: token\ndata: a'); + expect(body).toContain('event: usage\ndata: b'); + }); + + test('the id option writes an id: line with the zero-based frame index', async () => { + const res = streamStitchSse( + gen([ + { type: 'delta', chunk: 'a', at: 0 }, + { type: 'delta', chunk: 'b', at: 0 }, + { type: 'done', ok: true, elapsed: 1, attempts: 1, at: 0 }, + ]), + { id: (_chunk, index) => String(index) }, + ); + + const body = await res.text(); + expect(body).toContain('id: 0\ndata: a'); + expect(body).toContain('id: 1\ndata: b'); + }); + + test('a {stream()} source (the core StitchEventSource arm) is driven too', async () => { + const res = streamStitchSse({ + stream: () => + gen([ + { type: 'delta', chunk: 'via-stream', at: 0 }, + { type: 'done', ok: true, elapsed: 1, attempts: 1, at: 0 }, + ]), + }); + + const body = await res.text(); + expect(body).toContain('data: via-stream'); + }); + test('a multi-line chunk gets one data: prefix per line (SSE spec)', async () => { const res = streamStitchSse( gen([ @@ -114,7 +176,7 @@ describe('streamStitchSse — control events', () => { }); describe('streamStitchSse — error paths', () => { - test('an error event writes an event: error frame, invokes onError, and stops forwarding', async () => { + test('an error event writes a GENERIC event: error frame, invokes onError, and stops forwarding', async () => { let captured: unknown; const res = streamStitchSse( gen([ @@ -137,13 +199,57 @@ describe('streamStitchSse — error paths', () => { const body = await res.text(); expect(body).toContain('data: a'); - expect(body).toContain('event: error'); - expect(body).toContain('upstream failed'); + expect(body).toContain('event: error\ndata: error'); + // The raw upstream message reaches onError (server-side) but NEVER the client frame. + expect(body).not.toContain('upstream failed'); expect(body).not.toContain('never'); expect((captured as Error).message).toBe('upstream failed'); }); - test('a throw mid-stream is caught: onError fires and a final event: error frame is written', async () => { + // Regression: the default error frame must not echo the raw message, which can disclose + // internal network topology (a transport failure names the host it failed to reach) or the + // upstream's status semantics to an untrusted client. + test('a transport failure with an internal hostname is not disclosed by default', async () => { + const res = streamStitchSse( + gen([ + { + type: 'error', + name: 'StitchError', + message: 'getaddrinfo ENOTFOUND payments.internal.corp', + attempts: 1, + at: 0, + }, + ]), + ); + + const body = await res.text(); + expect(body).toContain('event: error\ndata: error'); + expect(body).not.toContain('payments.internal.corp'); + expect(body).not.toContain('ENOTFOUND'); + }); + + test('errorData opts in to shaping the client-facing error frame', async () => { + const res = streamStitchSse( + gen([ + { + type: 'error', + name: 'StitchError', + message: 'upstream failed', + status: 503, + attempts: 2, + at: 0, + }, + ]), + { errorData: (e) => `${e.name}: ${e.message} (${e.status})` }, + ); + + const body = await res.text(); + expect(body).toContain( + 'event: error\ndata: StitchError: upstream failed (503)', + ); + }); + + test('a throw mid-stream is caught: onError fires and a generic event: error frame is written', async () => { let captured: unknown; async function* boom(): AsyncGenerator { yield { type: 'delta', chunk: 'a', at: 0 }; @@ -158,10 +264,26 @@ describe('streamStitchSse — error paths', () => { const body = await res.text(); expect(body).toContain('data: a'); - expect(body).toContain('event: error'); - expect(body).toContain('stream blew up'); + expect(body).toContain('event: error\ndata: error'); + expect(body).not.toContain('stream blew up'); expect((captured as Error).message).toBe('stream blew up'); }); + + test('a throw is normalised to an error event so errorData sees a consistent shape', async () => { + async function* boom(): AsyncGenerator { + yield { type: 'delta', chunk: 'a', at: 0 }; + throw new Error('stream blew up'); + } + + const res = streamStitchSse(boom(), { + errorData: (e) => `${e.type}/${e.name}: ${e.message}`, + }); + + const body = await res.text(); + expect(body).toContain( + 'event: error\ndata: error/Error: stream blew up', + ); + }); }); describe('streamStitchSse — response', () => { diff --git a/packages/eval-harness/README.md b/packages/eval-harness/README.md index 71100af9..ba10c0d9 100644 --- a/packages/eval-harness/README.md +++ b/packages/eval-harness/README.md @@ -26,7 +26,7 @@ Four seed tasks (plain data in `tasks/`): | --------------------------------- | ---------- | ---------------------------------------------------------------------------- | | `paginated-list-retries-validate` | pagination | `GET /paged/users` (cursor; flaky 429; validate each user) | | `oauth2-client-credentials` | auth | `POST /oauth/token` + `GET /auth/me` (bearer; credential held by the stitch) | -| `wrap-graphql-endpoint` | graphql | `POST /graphql` (unwrap `data`; `errors[]` ⇒ failure) | +| `wrap-graphql-endpoint` | graphql | `POST /graphql` (pick `data`; `errors[]` ⇒ failure) | | `stream-llm-completion` | streaming | `POST /v1/chat/completions` (SSE deltas, `[DONE]`-terminated) | Each task is a `{ id, title, family, prompt, expectedShape, endpointHint }` diff --git a/packages/eval-harness/runner/prebaked.ts b/packages/eval-harness/runner/prebaked.ts index 277ab2f1..05c38975 100644 --- a/packages/eval-harness/runner/prebaked.ts +++ b/packages/eval-harness/runner/prebaked.ts @@ -42,7 +42,7 @@ export async function run(fetchImpl: typeof fetch): Promise { path: '/paged/users', adapter: fetchAdapter({ fetch: fetchImpl }), // Transient 429s before success — retry with backoff. - retry: { attempts: 4, on: [429, 503], backoff: 'fixed', baseMs: 0 }, + retry: { attempts: 4, on: [429, 503], backoff: 'fixed', baseDelay: 0 }, // Follow the cursor until nextCursor is null, aggregating items. paginate: { next: (prev) => { @@ -50,7 +50,7 @@ export async function run(fetchImpl: typeof fetch): Promise { return cursor ? { query: { cursor } } : undefined; }, items: (page) => (page as Page).items, - max: 20, + pages: 20, }, // Validate every aggregated record is a real user. output: (v: unknown) => Array.isArray(v) && v.every(isUser), @@ -86,9 +86,9 @@ export async function run(fetchImpl: typeof fetch): Promise { baseUrl: '${SNIPPET_BASE}', path: '/graphql', adapter: fetchAdapter({ fetch: fetchImpl }), - query: 'query { user { id name email } }', - // The graphql surface unwraps 'data' and treats a non-empty errors[] as a failure. - unwrap: 'data.user', + document: 'query { user { id name email } }', + // The graphql surface picks from 'data' and treats a non-empty errors[] as a failure. + pick: 'data.user', output: (v: unknown): v is User => !!v && typeof v === 'object' && typeof (v as any).id === 'number' && diff --git a/packages/expo/README.md b/packages/expo/README.md index 0a90527d..96078ad8 100644 --- a/packages/expo/README.md +++ b/packages/expo/README.md @@ -67,7 +67,7 @@ import { const q = useStitch(getInbox, {}); useAppActiveRefetch(q); -useReconnectRefetch(q, { netInfo: NetInfo }); +useReconnectRefetch(q, NetInfo); ``` ## The secure store diff --git a/packages/express/README.md b/packages/express/README.md index 9eeaaee9..c79143e1 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -45,8 +45,9 @@ On each request the middleware sets `req.stitch` (and mirrors it on argument, so a handler can't impersonate another identity (ADR 0002 §2). - `principal` returns `undefined` (or is omitted) → the **root** seam, unbound. -`currentStitch(req)` reads the same handle back as a typed value (and throws if -the middleware never ran, so a missing `app.use(stitch(...))` fails loudly). +`currentStitch(req)` reads the same handle back as a typed value. It returns +`undefined` (it never throws) when the middleware never ran, so check for it — +or register `app.use(stitch(...))` ahead of the handler. **Borrow, don't own.** The middleware never calls `seam.close()` — the seam outlives any single request. You build it at startup and close it on shutdown, @@ -56,8 +57,8 @@ mirroring StitchAPI's borrow-don't-own rule. Stream a streaming/SSE stitch's `.stream()` to `res` as Server-Sent Events, by writing `text/event-stream` frames straight to the socket. Each `delta` becomes a -`data:` frame; a terminal `error` event becomes a final `event: error` frame (a -generic `data: error` by default — see below); control events +`data:` frame; a terminal `error` event (or a throw mid-stream) becomes a final +`event: error` frame (a generic `data: error` by default — see below); control events (`start`/`progress`/`result`/`done`/…) are consumed but not forwarded. On client disconnect (`res` — or `req`, when passed — emits `close`) the upstream stitch stream is aborted. @@ -88,11 +89,13 @@ By default the `error` frame carries a generic `data: error` token, **not** the error message — echoing it can disclose internal network topology (a transport failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`) to the client. Pass `errorData` to opt in when the upstream -messages are known safe to expose: +messages are known safe to expose, and `onError` to observe/log the real failure +server-side without exposing it: ```ts streamStitchSse(res, completion.stream({ body: { prompt: req.query.q } }), { errorData: (e) => e.message, // opt in to the raw upstream message + onError: (err) => logger.error(err), // the real failure, server-side only }); ``` diff --git a/packages/express/src/error.ts b/packages/express/src/error.ts index ad361e6f..29f63d7c 100644 --- a/packages/express/src/error.ts +++ b/packages/express/src/error.ts @@ -18,7 +18,7 @@ export function isStitchError(err: unknown): err is StitchErrorLike { return err instanceof Error && err.name === 'StitchError'; } -export interface StitchErrorHandlerOptions { +export interface StitchErrorOptions { /** * The HTTP status for a mapped stitch failure. Default `502 Bad Gateway` — **every** upstream * failure is reported as a gateway error, regardless of the upstream's own status. This is the @@ -50,7 +50,7 @@ const STATUS_TEXT: Record = { function resolveStatus( err: StitchErrorLike, - status: StitchErrorHandlerOptions['status'], + status: StitchErrorOptions['status'], ): number { if (status === undefined) return DEFAULT_STATUS; return typeof status === 'function' ? status(err) : status; @@ -58,7 +58,7 @@ function resolveStatus( /** * Build an Express error-handling middleware that maps a {@link StitchErrorLike} to a JSON response - * (status `502` by default; override via {@link StitchErrorHandlerOptions.status}) and **passes every + * (status `502` by default; override via {@link StitchErrorOptions.status}) and **passes every * other error to `next(err)`** so Express's default handler — and any error middleware registered * after it — stays in charge. Register it after your routes: * @@ -72,7 +72,7 @@ function resolveStatus( * though `req` is unused here, or Express treats it as a normal middleware. */ export function stitchErrorHandler( - options: StitchErrorHandlerOptions = {}, + options: StitchErrorOptions = {}, ): ErrorRequestHandler { return ( err: unknown, diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index 4046a289..88a4d443 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -18,9 +18,7 @@ export { stitch, currentStitch, type ExpressRequestSeam, - // Deprecated alias (kept through 1.0.0-rc, removed at GA) — see ADR 0012. - type RequestSeam, - type StitchMiddlewareOptions, + type ExpressStitchMiddlewareOptions, } from './middleware'; export { @@ -33,5 +31,5 @@ export { stitchErrorHandler, isStitchError, type StitchErrorLike, - type StitchErrorHandlerOptions, + type StitchErrorOptions, } from './error'; diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index 8dcf7728..0ec78be3 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -18,15 +18,7 @@ import type { PrincipalSeam, Seam } from 'stitchapi'; */ export type ExpressRequestSeam = PrincipalSeam | Seam; -/** - * @deprecated Renamed to {@link ExpressRequestSeam} so the public type is ecosystem-qualified (a bare - * `RequestSeam` would collide with any other host adapter's per-request seam type) — see - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). Kept through the `1.0.0-rc` - * line and removed at the 1.0 GA cut. - */ -export type RequestSeam = ExpressRequestSeam; - -export interface StitchMiddlewareOptions { +export interface ExpressStitchMiddlewareOptions { /** * The seam this middleware shares across requests. **Borrowed, not owned** — build it once at * startup and `seam.close()` it on shutdown yourself; the middleware never closes it (the seam @@ -60,7 +52,9 @@ export interface StitchMiddlewareOptions { * app.get('/me', async (req, res) => res.json(await req.stitch.stitch('/me')())); * ``` */ -export function stitch(options: StitchMiddlewareOptions): RequestHandler { +export function stitch( + options: ExpressStitchMiddlewareOptions, +): RequestHandler { const { seam, principal } = options; return (req: Request, res: Response, next: NextFunction): void => { const id = principal?.(req); @@ -76,22 +70,17 @@ export function stitch(options: StitchMiddlewareOptions): RequestHandler { /** * Read the request-scoped {@link ExpressRequestSeam} off a request as a typed value — the same handle * `stitch()` set on `req.stitch`. A convenience for code paths that hold a loosely-typed `Request` - * (e.g. a generic helper) and want the seam without re-declaring the augmentation. Throws if the - * middleware did not run for this request (so a missing `app.use(stitch(...))` fails loudly). + * (e.g. a generic helper) and want the seam without re-declaring the augmentation. Returns + * `undefined` (it never throws) when the middleware did not run for this request — check for it, or + * register `app.use(stitch({ seam }))` ahead of the handler. * * ```ts * import { currentStitch } from '@stitchapi/express'; - * const api = currentStitch(req); // the caller's principal-bound seam + * const api = currentStitch(req); // the caller's principal-bound seam, or undefined * ``` */ -export function currentStitch(req: Request): ExpressRequestSeam { - const host = req.stitch as ExpressRequestSeam | undefined; - if (host === undefined) { - throw new Error( - 'req.stitch is not set — register `app.use(stitch({ seam }))` before this handler', - ); - } - return host; +export function currentStitch(req: Request): ExpressRequestSeam | undefined { + return req.stitch as ExpressRequestSeam | undefined; } // Augment Express's `Request` so `req.stitch` is typed app-wide once this package is imported. diff --git a/packages/express/src/sse.ts b/packages/express/src/sse.ts index b655f527..d62f2dea 100644 --- a/packages/express/src/sse.ts +++ b/packages/express/src/sse.ts @@ -1,16 +1,16 @@ // stitch stream → Express SSE response. A stitch's `.stream()` (and a `StitchResult.stream()`) is an // `AsyncIterable`; an SSE endpoint wants `text/event-stream` frames. This adapts the // Fastify `sendStitchSse` bridge to Express's `res` (which is a Node `http.ServerResponse`): write -// frames straight to the socket. Each `delta` becomes one `data:` frame; an `error` event ends the -// stream with a named `event: error` frame; stream end closes it; and a client disconnect (`res` or -// `req` 'close') aborts the upstream iterator rather than leaving it running. +// frames straight to the socket. Each `delta` becomes one `data:` frame; an `error` event (or a +// throw mid-stream) ends the stream with a named `event: error` frame; stream end closes it; and a +// client disconnect (`res` or `req` 'close') aborts the upstream iterator rather than leaving it +// running. import type { Request, Response } from 'express'; -import type { StitchEvent } from 'stitchapi'; +import type { StitchEvent, StitchEventSource } from 'stitchapi'; -/** Anything `streamStitchSse` can drive: a stitch `.stream()` generator, or any event iterable. */ -export type StitchEventSource = - | AsyncIterable> - | AsyncGenerator, void>; +// The canonical event-source intake, from the core barrel: the event iterable itself (a `.stream()` +// generator), or anything that hands one back (a `StitchResult`, a stitch stub). +export type { StitchEventSource } from 'stitchapi'; /** The terminal `error` event a stitch stream emits — carries `message`, `status`, `attempts`. */ type StitchErrorEvent = Extract; @@ -19,33 +19,44 @@ export interface StreamStitchSseOptions { /** * Map a `delta` chunk to the SSE frame `data`. Default: the chunk itself (a string is sent * as-is; anything else is `JSON.stringify`-ed). Use this to pull the text out of a structured - * chunk, e.g. `data: (c) => c.choices[0].delta.content`. + * chunk, e.g. `data: (c) => c.choices[0].delta.content`. Receives the zero-based frame index + * alongside the chunk. */ - data?: (chunk: unknown) => string; + data?: (chunk: unknown, index: number) => string; /** - * Emit an `event:` line per delta frame (the SSE event name). Default: none (an unnamed - * `message` event, which `EventSource.onmessage` receives). + * Emit an `event:` line per delta frame (the SSE event name): a fixed name, or a function of + * the chunk for per-frame names. Default: none (an unnamed `message` event, which + * `EventSource.onmessage` receives). */ - event?: string; + event?: string | ((chunk: unknown) => string); /** * Provide an `id:` line per delta frame (the SSE last-event id), e.g. for resumable streams. * Receives the chunk and the zero-based frame index. */ id?: (chunk: unknown, index: number) => string; /** - * Shape the SSE `data` written for a terminal `error` event. **Default: a generic token - * (`data: error`)** — the raw `event.message` is deliberately *not* echoed, because it can - * disclose internal network topology (a transport failure reads like - * `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`) to an - * untrusted client. Opt in with `(e) => e.message` when the upstream messages are known safe, - * or return your own payload (e.g. `() => JSON.stringify({ error: 'stream failed' })`). A - * multi-line return gets one `data:` line each (SSE spec); the `event: error` name is fixed. + * Shape the SSE `data` written for a terminal `error` event (or an uncaught throw mid-stream, + * normalised to an error event). **Default: a generic token (`data: error`)** — the raw + * `event.message` is deliberately *not* echoed, because it can disclose internal network + * topology (a transport failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or + * the upstream's status (`HTTP 401`) to an untrusted client. Opt in with `(e) => e.message` + * when the upstream messages are known safe, or return your own payload (e.g. + * `() => JSON.stringify({ error: 'stream failed' })`). A multi-line return gets one `data:` + * line each (SSE spec); the `event: error` name is fixed. */ errorData?: (event: StitchErrorEvent) => string; /** - * The Express request, when available. Express normally fires `close` on the *response* on - * disconnect, but passing `req` lets the helper also listen on the request socket for - * environments/proxies that signal disconnect there — either fires the upstream teardown. + * Called once, server-side, if the underlying stream errors (a stitch `error` event, or a + * throw) — use it to observe/log the real failure. It does **not** shape the client-facing + * frame: the SSE `data` sent to the client is controlled by `errorData` (a generic token by + * default), so the raw message reaches your logs here but not the client. + */ + onError?: (err: unknown) => void; + /** + * **Express-specific.** The Express request, when available. Express normally fires `close` on + * the *response* on disconnect, but passing `req` lets the helper also listen on the request + * socket for environments/proxies that signal disconnect there — either fires the upstream + * teardown. */ req?: Request; } @@ -58,10 +69,16 @@ function frame( options: StreamStitchSseOptions, ): string { const raw = - options.data?.(chunk) ?? + options.data?.(chunk, index) ?? (typeof chunk === 'string' ? chunk : JSON.stringify(chunk)); const lines: string[] = []; - if (options.event) lines.push(`event: ${options.event}`); + if (options.event !== undefined) { + const name = + typeof options.event === 'function' + ? options.event(chunk) + : options.event; + lines.push(`event: ${name}`); + } if (options.id) lines.push(`id: ${options.id(chunk, index)}`); for (const dataLine of raw.split('\n')) lines.push(`data: ${dataLine}`); return `${lines.join('\n')}\n\n`; @@ -81,13 +98,29 @@ function errorFrame(data: string): string { return `${lines.join('\n')}\n\n`; } +// Normalise a thrown value into the terminal `error` event shape, so an `errorData` opt-in sees a +// consistent argument whether the failure arrived as a surfaced `error` event or an unexpected +// throw. `attempts`/`at` are best-effort placeholders — an `errorData` hook keys off `name`/`message`. +function toErrorEvent(err: unknown): StitchErrorEvent { + const e = err instanceof Error ? err : new Error(String(err)); + return { + type: 'error', + name: e.name, + message: e.message, + attempts: 0, + at: 0, + }; +} + /** * Stream a stitch's output to an Express {@link Response} as Server-Sent Events. Pass the stitch's - * `.stream()` generator (or any `AsyncIterable`): each `delta` becomes one SSE frame, an - * `error` event ends the stream with a named `event: error` frame (a generic `data: error` by - * default — the raw message is withheld to avoid disclosing internal topology; opt in via - * `errorData`), and stream end closes the response. The non-output events (`start` / `progress` / - * `drift` / `result` / `done`) are control signals and are not forwarded to the client. + * `.stream()` generator (or any `StitchEventSource` — an event iterable, or anything with a + * `.stream()` method such as a `StitchResult`): each `delta` becomes one SSE frame, an `error` event + * (or a throw mid-stream) ends the stream with a named `event: error` frame (a generic `data: error` + * by default — the raw message is withheld to avoid disclosing internal topology; opt in via + * `errorData`, observe the real failure server-side via `onError`), and stream end closes the + * response. The non-output events (`start` / `progress` / `drift` / `result` / `done`) are control + * signals and are not forwarded to the client. * * Writes raw frames straight to the socket, so do **not** also `res.send()`/`res.json()` from the * same handler. Resolves once the response is fully written (or the client disconnects). On @@ -117,7 +150,11 @@ export async function streamStitchSse( res.flushHeaders(); } - const iterator = source[Symbol.asyncIterator](); + // Accept both arms of the canonical `StitchEventSource`: the iterable itself, or a handle that + // hands one back (`StitchResult.stream()`, a stitch stub). + const iterable: AsyncIterable> = + Symbol.asyncIterator in source ? source : source.stream(); + const iterator = iterable[Symbol.asyncIterator](); let active = true; // Client disconnect: stop consuming and abort the upstream iterator. Listen on the response and, @@ -129,6 +166,18 @@ export async function streamStitchSse( res.on('close', onClose); options.req?.on('close', onClose); + // Write the terminal `error` frame: a generic token by default so a raw message + // (`getaddrinfo ENOTFOUND …` / `HTTP 401`) is never disclosed; `errorData` opts in. + const writeErrorFrame = (event: StitchErrorEvent): void => { + res.write( + errorFrame( + options.errorData + ? options.errorData(event) + : DEFAULT_ERROR_DATA, + ), + ); + }; + let index = 0; try { while (active) { @@ -138,21 +187,19 @@ export async function streamStitchSse( res.write(frame(event.chunk, index, options)); index += 1; } else if (event.type === 'error') { - // Surface the failure to the client as a named `error` SSE frame, then stop — but by - // default write a generic token, never the raw `event.message`, so an internal - // hostname (`getaddrinfo ENOTFOUND …`) or the upstream's status (`HTTP 401`) is not - // disclosed. Opt in to the real message via `options.errorData`. - res.write( - errorFrame( - options.errorData - ? options.errorData(event) - : DEFAULT_ERROR_DATA, - ), - ); + // `onError` gets the real failure server-side; the client frame is shaped by + // `errorData` (generic by default), never the raw `event.message`. + options.onError?.(new Error(event.message)); + writeErrorFrame(event); break; } // start / progress / info / drift / result / done are control signals: not forwarded. } + } catch (err) { + // A throw (not a surfaced `error` event): still withhold the raw message by default — + // normalise it to an error event so an `errorData` opt-in sees a consistent shape. + options.onError?.(err); + if (active) writeErrorFrame(toErrorEvent(err)); } finally { res.off('close', onClose); options.req?.off('close', onClose); diff --git a/packages/express/test/express.spec.ts b/packages/express/test/express.spec.ts index e45587af..c761a210 100644 --- a/packages/express/test/express.spec.ts +++ b/packages/express/test/express.spec.ts @@ -151,10 +151,8 @@ describe('stitch() middleware puts a seam on req', () => { await api.close(); }); - test('currentStitch throws when the middleware did not run', () => { - expect(() => currentStitch(mockReq())).toThrow( - /req\.stitch is not set/, - ); + test('currentStitch returns undefined (never throws) when the middleware did not run', () => { + expect(currentStitch(mockReq())).toBeUndefined(); }); }); @@ -246,6 +244,86 @@ describe('streamStitchSse writes SSE frames to res', () => { ); }); + test('the data mapper receives the zero-based frame index', async () => { + async function* events(): AsyncGenerator> { + yield { type: 'delta', chunk: 'a', at: 1 }; + yield { type: 'delta', chunk: 'b', at: 2 }; + } + const res = mockRes(); + await streamStitchSse(res as unknown as Response, events(), { + data: (c, index) => `${String(c)}#${index}`, + }); + expect(res.body()).toBe('data: a#0\n\ndata: b#1\n\n'); + }); + + test('event accepts a function of the chunk for per-frame event names', async () => { + async function* events(): AsyncGenerator> { + yield { type: 'delta', chunk: { kind: 'token', text: 'a' }, at: 1 }; + yield { type: 'delta', chunk: { kind: 'usage', text: 'b' }, at: 2 }; + } + const res = mockRes(); + await streamStitchSse(res as unknown as Response, events(), { + event: (c) => (c as { kind: string }).kind, + data: (c) => (c as { text: string }).text, + }); + expect(res.body()).toBe( + 'event: token\ndata: a\n\nevent: usage\ndata: b\n\n', + ); + }); + + test('accepts the { stream() } arm of StitchEventSource (e.g. a StitchResult)', async () => { + async function* events(): AsyncGenerator> { + yield { type: 'delta', chunk: 'via-stream', at: 1 }; + } + const res = mockRes(); + await streamStitchSse(res as unknown as Response, { + stream: () => events(), + }); + expect(res.body()).toBe('data: via-stream\n\n'); + expect(res.ended).toBe(true); + }); + + test('onError observes the real failure server-side while the client frame stays generic', async () => { + async function* events(): AsyncGenerator> { + yield { + type: 'error', + name: 'StitchError', + message: 'getaddrinfo ENOTFOUND payments.internal.corp', + attempts: 1, + at: 1, + }; + } + const res = mockRes(); + const seenErrors: unknown[] = []; + await streamStitchSse(res as unknown as Response, events(), { + onError: (err) => seenErrors.push(err), + }); + + expect(seenErrors).toHaveLength(1); + expect((seenErrors[0] as Error).message).toBe( + 'getaddrinfo ENOTFOUND payments.internal.corp', + ); + // The client still only sees the generic token. + expect(res.body()).toBe('event: error\ndata: error\n\n'); + }); + + test('a throw mid-stream becomes a generic error frame and reaches onError', async () => { + // eslint-disable-next-line require-yield + async function* events(): AsyncGenerator> { + throw new Error('boom from the iterator'); + } + const res = mockRes(); + const seenErrors: unknown[] = []; + await streamStitchSse(res as unknown as Response, events(), { + onError: (err) => seenErrors.push(err), + }); + + expect((seenErrors[0] as Error).message).toBe('boom from the iterator'); + expect(res.body()).toBe('event: error\ndata: error\n\n'); + expect(res.body()).not.toContain('boom'); + expect(res.ended).toBe(true); + }); + test('the default data mapper sends a string verbatim and JSON-stringifies an object', async () => { async function* events(): AsyncGenerator> { yield { type: 'delta', chunk: 'plain', at: 1 }; diff --git a/packages/fastify/README.md b/packages/fastify/README.md index 148238b3..a1708ff2 100644 --- a/packages/fastify/README.md +++ b/packages/fastify/README.md @@ -41,7 +41,7 @@ encapsulation and are visible app-wide. (default on). It logs **only metadata** (name, method, scrubbed URL, status, attempts, timing), never request/response bodies or headers, so it is safe on a secret-bearing seam. -- **SSE bridge.** `sendStitchSse(reply, stream)` streams a stitch's `.stream()` +- **SSE bridge.** `streamStitchSse(reply, stream)` streams a stitch's `.stream()` output to a `text/event-stream` reply. - **Error bridge.** A thrown `StitchError` is mapped to an HTTP response (`502` by default) so handlers need no try/catch. @@ -90,10 +90,10 @@ back to an explicit seam. ## SSE streaming ```ts -import { sendStitchSse } from '@stitchapi/fastify'; +import { streamStitchSse } from '@stitchapi/fastify'; app.get('/chat', (req, reply) => - sendStitchSse(reply, chat.stream({ query: { q: String(req.query.q) } }), { + streamStitchSse(reply, chat.stream({ query: { q: String(req.query.q) } }), { data: (c) => c.text, // pull text out of a structured chunk }), ); @@ -107,11 +107,13 @@ By default the `error` frame carries a generic `data: error` token, **not** the error message — echoing it can disclose internal network topology (a transport failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`) to the client. Pass `errorData` to opt in when the upstream -messages are known safe to expose: +messages are known safe to expose, and `onError` to observe/log the real failure +server-side without exposing it: ```ts -sendStitchSse(reply, chat.stream({ query: { q: String(req.query.q) } }), { +streamStitchSse(reply, chat.stream({ query: { q: String(req.query.q) } }), { errorData: (e) => e.message, // opt in to the raw upstream message + onError: (err) => req.log.error(err), // the real failure, server-side only }); ``` @@ -151,7 +153,7 @@ events and log only retries, drift, and errors). A seam built with its own | -------------------- | -------- | ------------------------------------------------- | | `stitchPlugin` | plugin | `fastify.register(stitchPlugin, options)` | | `currentStitch()` | function | The request's ambient principal-bound seam | -| `sendStitchSse` | function | Stream a stitch's `.stream()` to an SSE reply | +| `streamStitchSse` | function | Stream a stitch's `.stream()` to an SSE reply | | `stitchErrorHandler` | function | A `setErrorHandler`-compatible StitchError mapper | | `fastifyLoggerSink` | function | `fastify.log` → seam `TraceSink` bridge | | `isStitchError` | function | Narrow an unknown error to a `StitchError` | diff --git a/packages/fastify/src/error-handler.ts b/packages/fastify/src/error-handler.ts index e0a50eca..e80f01f4 100644 --- a/packages/fastify/src/error-handler.ts +++ b/packages/fastify/src/error-handler.ts @@ -13,7 +13,7 @@ export function isStitchError(err: unknown): err is StitchErrorLike { return err instanceof Error && err.name === 'StitchError'; } -export interface StitchErrorHandlerOptions { +export interface StitchErrorOptions { /** * The HTTP status for a mapped stitch failure. Default `502 Bad Gateway` — **every** * upstream failure is reported as a gateway error, regardless of the upstream's own @@ -46,7 +46,7 @@ const STATUS_TEXT: Record = { function resolveStatus( err: StitchErrorLike, - status: StitchErrorHandlerOptions['status'], + status: StitchErrorOptions['status'], ): number { if (status === undefined) return DEFAULT_STATUS; return typeof status === 'function' ? status(err) : status; @@ -54,7 +54,7 @@ function resolveStatus( /** * Build a `setErrorHandler`-compatible function that maps a {@link StitchErrorLike} to an HTTP - * response (status `502` by default; override via {@link StitchErrorHandlerOptions.status}) + * response (status `502` by default; override via {@link StitchErrorOptions.status}) * and **rethrows every other error** so Fastify's default handling — and any error handler * registered in an outer scope — stays in charge. Register it on the app or a plugin scope: * @@ -66,7 +66,7 @@ function resolveStatus( * only to register it yourself with custom options. */ export function stitchErrorHandler( - options: StitchErrorHandlerOptions = {}, + options: StitchErrorOptions = {}, ): (error: FastifyError, request: FastifyRequest, reply: FastifyReply) => void { return (error, _request, reply): void => { if (!isStitchError(error)) { diff --git a/packages/fastify/src/index.ts b/packages/fastify/src/index.ts index f8453fb1..3c6aefa8 100644 --- a/packages/fastify/src/index.ts +++ b/packages/fastify/src/index.ts @@ -1,19 +1,20 @@ export { stitchPlugin, currentStitch, - type StitchPluginOptions, - type StitchPluginSeamOptions, - type StitchPluginConfigOptions, + type FastifyStitchPluginOptions, + type FastifyStitchPluginSeamOptions, + type FastifyStitchPluginConfigOptions, type FastifyRequestSeam, - // Deprecated alias (kept through 1.0.0-rc, removed at GA) — see ADR 0012. - type StitchHost, } from './plugin'; -export { sendStitchSse, type SendStitchSseOptions } from './sse'; +export { streamStitchSse, type StreamStitchSseOptions } from './sse'; +// The canonical event-stream intake, re-exported from the core barrel (one shared shape +// across every host adapter — no local unions). +export type { StitchEventSource } from 'stitchapi'; export { stitchErrorHandler, isStitchError, type StitchErrorLike, - type StitchErrorHandlerOptions, + type StitchErrorOptions, } from './error-handler'; export { fastifyLoggerSink, diff --git a/packages/fastify/src/plugin.ts b/packages/fastify/src/plugin.ts index f8996bd6..bc711ba0 100644 --- a/packages/fastify/src/plugin.ts +++ b/packages/fastify/src/plugin.ts @@ -5,16 +5,14 @@ // genuine Node value-add over the browser-first core — makes that principal handle **ambient** // through `AsyncLocalStorage`, so handlers/services read it with `currentStitch()` instead of // threading `request.stitch` everywhere. -import { - type StitchErrorHandlerOptions, - stitchErrorHandler, -} from './error-handler'; +import { type StitchErrorOptions, stitchErrorHandler } from './error-handler'; import { type FastifyLoggerSinkOptions, fastifyLoggerSink } from './logger'; import type { FastifyPluginAsync, FastifyRequest } from 'fastify'; import fp from 'fastify-plugin'; import { AsyncLocalStorage } from 'node:async_hooks'; import { + type AtLeastOne, type PrincipalSeam, type Seam, type SeamConfig, @@ -25,16 +23,8 @@ import { // when a `principal` resolver is set, else the root seam (both create member stitches). export type FastifyRequestSeam = Seam | PrincipalSeam; -/** - * @deprecated Renamed to {@link FastifyRequestSeam} so the public type is ecosystem-qualified (a bare - * `StitchHost` would collide with any other host adapter's per-request seam type) — see - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). Kept through the `1.0.0-rc` - * line and removed at the 1.0 GA cut. - */ -export type StitchHost = FastifyRequestSeam; - -/** Common options shared by both `StitchPluginOptions` variants. */ -interface StitchPluginCommon { +/** Common options shared by both `FastifyStitchPluginOptions` variants. */ +interface FastifyStitchPluginCommon { /** * Derive the request's principal id (e.g. a tenant or user id) from the incoming request. * When set, each request gets a `seam.as(principal)` handle (a separate session/token over @@ -44,17 +34,18 @@ interface StitchPluginCommon { principal?: (req: FastifyRequest) => string | undefined; /** * Bridge `fastify.log` (Fastify's built-in Pino logger) into the seam as its `TraceSink`, - * so stitch events flow through Fastify's logger. Default `true`. Ignored when a prebuilt - * `seam` is passed *and* it already has its own `trace` — a borrowed seam keeps its sink. - * Set `false` to leave tracing as the seam configured it (off by default in core). + * so stitch events flow through Fastify's logger. Default `true` (the bridge is on) — the + * cross-host canonical default. Ignored when a prebuilt `seam` is passed *and* it already + * has its own `trace` — a borrowed seam keeps its sink. Set `false` to leave tracing as + * the seam configured it (off by default in core). */ - logger?: boolean | FastifyLoggerSinkOptions; + logger?: boolean | AtLeastOne; /** * Options for the error handler the plugin registers (see {@link stitchErrorHandler}). * Set to `false` to register **no** error handler (you wire your own). Default: register * with the `502`-by-default mapping. */ - errorHandler?: StitchErrorHandlerOptions | false; + errorHandler?: StitchErrorOptions | false; /** * Close the seam on `onClose`. Defaults to `true` **only when the plugin built the seam** * (from `seamConfig`); a borrowed seam (passed via `seam`) is never closed by the plugin — @@ -65,20 +56,22 @@ interface StitchPluginCommon { } /** Pass a prebuilt seam the app owns — the plugin borrows it and never closes it by default. */ -export interface StitchPluginSeamOptions extends StitchPluginCommon { +export interface FastifyStitchPluginSeamOptions + extends FastifyStitchPluginCommon { seam: Seam; seamConfig?: never; } /** Let the plugin build (and, by default, own + close) the seam from a {@link SeamConfig}. */ -export interface StitchPluginConfigOptions extends StitchPluginCommon { +export interface FastifyStitchPluginConfigOptions + extends FastifyStitchPluginCommon { seamConfig: SeamConfig; seam?: never; } -export type StitchPluginOptions = - | StitchPluginSeamOptions - | StitchPluginConfigOptions; +export type FastifyStitchPluginOptions = + | FastifyStitchPluginSeamOptions + | FastifyStitchPluginConfigOptions; // The ambient request-scoped host. `currentStitch()` reads it; the `onRequest` hook runs each // request inside `als.run(host, …)` so the value is the per-request principal handle. @@ -88,8 +81,9 @@ const als = new AsyncLocalStorage(); * The ambient request-scoped {@link FastifyRequestSeam} for the in-flight request — the principal-bound * `seam.as(principal)` handle (or the root seam when no principal resolver is set). Returns * `undefined` outside a request (no ambient context), so a caller can fall back to an explicit - * seam. Backed by Node's {@link AsyncLocalStorage}: a value-add a Node integration offers that - * the browser-first core cannot. + * seam. Never throws on a miss — `undefined` is the whole miss contract. Backed by Node's + * {@link AsyncLocalStorage}: a value-add a Node integration offers that the browser-first core + * cannot. * * ```ts * import { currentStitch } from '@stitchapi/fastify'; @@ -103,7 +97,7 @@ export function currentStitch(): FastifyRequestSeam | undefined { return als.getStore(); } -const pluginImpl: FastifyPluginAsync = async ( +const pluginImpl: FastifyPluginAsync = async ( fastify, options, ) => { diff --git a/packages/fastify/src/sse.ts b/packages/fastify/src/sse.ts index 7e408720..68d081ae 100644 --- a/packages/fastify/src/sse.ts +++ b/packages/fastify/src/sse.ts @@ -5,38 +5,48 @@ // becomes one `data:` frame; an `error` event ends the stream; stream end closes it; and a // client disconnect aborts the upstream generator rather than leaving it running. import type { FastifyReply } from 'fastify'; -import type { StitchEvent } from 'stitchapi'; +import type { StitchEvent, StitchEventSource } from 'stitchapi'; /** The terminal `error` event a stitch stream emits — carries `message`, `status`, `attempts`. */ type StitchErrorEvent = Extract; -export interface SendStitchSseOptions { +export interface StreamStitchSseOptions { /** * Map a `delta` chunk to the SSE frame `data`. Default: the chunk itself (a string is * sent as-is; anything else is `JSON.stringify`-ed). Use this to pull the text out of a - * structured chunk, e.g. `data: (c) => c.choices[0].delta.content`. + * structured chunk, e.g. `data: (c) => c.choices[0].delta.content`. Receives the + * zero-based frame index alongside the chunk. */ - data?: (chunk: unknown) => string; + data?: (chunk: unknown, index: number) => string; /** - * Emit an `event:` line per frame (the SSE event name). Default: none (an unnamed - * `message` event, which `EventSource.onmessage` receives). + * Emit an `event:` line per delta frame (the SSE event name): a fixed name, or a function + * of the chunk for per-frame names. Default: none (an unnamed `message` event, which + * `EventSource.onmessage` receives). */ - event?: string; + event?: string | ((chunk: unknown) => string); /** - * Provide an `id:` line per frame (the SSE last-event id), e.g. for resumable streams. - * Receives the chunk and the zero-based frame index. + * Provide an `id:` line per delta frame (the SSE last-event id), e.g. for resumable + * streams. Receives the chunk and the zero-based frame index. */ id?: (chunk: unknown, index: number) => string; /** - * Shape the SSE `data` written for a terminal `error` event. **Default: a generic token - * (`data: error`)** — the raw `event.message` is deliberately *not* echoed, because it can - * disclose internal network topology (a transport failure reads like + * Shape the SSE `data` written for a terminal `error` event (or an uncaught throw + * mid-stream, normalised to an error event). **Default: a generic token (`data: error`)** + * — the raw `event.message` is deliberately *not* echoed, because it can disclose internal + * network topology (a transport failure reads like * `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`) to * an untrusted client. Opt in with `(e) => e.message` when the upstream messages are known * safe, or return your own payload (e.g. `() => JSON.stringify({ error: 'stream failed' })`). * A multi-line return gets one `data:` line each (SSE spec); the `event: error` name is fixed. */ errorData?: (event: StitchErrorEvent) => string; + /** + * Called once, server-side, if the underlying stream errors (a stitch `error` event, or a + * throw) — use it to observe/log the real failure. It does **not** shape the client-facing + * frame: the SSE `data` sent to the client is controlled by `errorData` (a generic token by + * default), so the raw message reaches your logs here but not the client. + */ + onError?: (err: unknown) => void; } // One delta chunk → an SSE frame string. A multi-line payload is split so every line gets its @@ -44,13 +54,19 @@ export interface SendStitchSseOptions { function frame( chunk: unknown, index: number, - options: SendStitchSseOptions, + options: StreamStitchSseOptions, ): string { const raw = - options.data?.(chunk) ?? + options.data?.(chunk, index) ?? (typeof chunk === 'string' ? chunk : JSON.stringify(chunk)); const lines: string[] = []; - if (options.event) lines.push(`event: ${options.event}`); + if (options.event !== undefined) { + const name = + typeof options.event === 'function' + ? options.event(chunk) + : options.event; + lines.push(`event: ${name}`); + } if (options.id) lines.push(`id: ${options.id(chunk, index)}`); for (const dataLine of raw.split('\n')) lines.push(`data: ${dataLine}`); return `${lines.join('\n')}\n\n`; @@ -70,13 +86,29 @@ function errorFrame(data: string): string { return `${lines.join('\n')}\n\n`; } +// Normalise a thrown value into the terminal `error` event shape, so an `errorData` opt-in sees a +// consistent argument whether the failure arrived as a surfaced `error` event or an unexpected +// throw. `attempts`/`at` are best-effort placeholders — an `errorData` hook keys off `name`/`message`. +function toErrorEvent(err: unknown): StitchErrorEvent { + const e = err instanceof Error ? err : new Error(String(err)); + return { + type: 'error', + name: e.name, + message: e.message, + attempts: 0, + at: 0, + }; +} + /** * Stream a stitch's output to a Fastify {@link FastifyReply} as Server-Sent Events. Pass the - * stitch's `.stream()` generator (or any `AsyncIterable`): each `delta` becomes - * one SSE frame, an `error` event ends the stream with a named `event: error` frame (a generic + * stitch's `.stream()` generator (or any {@link StitchEventSource} — an event iterable, or + * anything with a `.stream()` that hands one back): each `delta` becomes one SSE frame, an + * `error` event (or a throw) ends the stream with a named `event: error` frame (a generic * `data: error` by default — the raw message is withheld to avoid disclosing internal topology; - * opt in via `errorData`), and stream end closes the response. The non-output events (`start` / - * `progress` / `drift` / `result` / `done`) are control signals and are not forwarded to the client. + * opt in via `errorData`, observe the real failure server-side via `onError`), and stream end + * closes the response. The non-output events (`start` / `progress` / `drift` / `result` / + * `done`) are control signals and are not forwarded to the client. * * Takes over the reply via `reply.hijack()` and writes raw frames, so do **not** also `send()` * from the same handler. Resolves once the response is fully written (or the client @@ -85,15 +117,15 @@ function errorFrame(data: string): string { * * ```ts * app.get('/chat', (req, reply) => - * sendStitchSse(reply, chat.stream({ query: { q: req.query.q } }), - * { data: (c: any) => c.text }), + * streamStitchSse(reply, chat.stream({ query: { q: req.query.q } }), + * { data: (c: any) => c.text }), * ); * ``` */ -export async function sendStitchSse( +export async function streamStitchSse( reply: FastifyReply, - stream: AsyncIterable>, - options: SendStitchSseOptions = {}, + source: StitchEventSource, + options: StreamStitchSseOptions = {}, ): Promise { const res = reply.raw; // Take control of the reply: we own the socket from here, Fastify won't try to send. @@ -106,7 +138,9 @@ export async function sendStitchSse( }); } - const iterator = stream[Symbol.asyncIterator](); + // Resolve the `{ stream() }` arm of StitchEventSource to its event iterable. + const iterable = Symbol.asyncIterator in source ? source : source.stream(); + const iterator = iterable[Symbol.asyncIterator](); let active = true; // Client disconnect: stop consuming and abort the upstream generator. @@ -116,6 +150,18 @@ export async function sendStitchSse( }; res.on('close', onClose); + // Write the terminal `error` frame: a generic token by default so a raw message + // (`getaddrinfo ENOTFOUND …` / `HTTP 401`) is never disclosed; `errorData` opts in. + const writeErrorFrame = (event: StitchErrorEvent): void => { + res.write( + errorFrame( + options.errorData + ? options.errorData(event) + : DEFAULT_ERROR_DATA, + ), + ); + }; + let index = 0; try { while (active) { @@ -125,20 +171,19 @@ export async function sendStitchSse( res.write(frame(event.chunk, index, options)); index += 1; } else if (event.type === 'error') { - // Surface the failure to the client as a named `error` SSE frame, then stop — but by - // default write a generic token, never the raw `event.message`, so an internal - // hostname (`getaddrinfo ENOTFOUND …`) or the upstream's status (`HTTP 401`) is not - // disclosed. Opt in to the real message via `options.errorData`. - res.write( - errorFrame( - options.errorData - ? options.errorData(event) - : DEFAULT_ERROR_DATA, - ), - ); + // `onError` gets the real failure server-side; the client frame is shaped by + // `errorData` (generic by default), never the raw `event.message`. + options.onError?.(new Error(event.message)); + writeErrorFrame(event); break; } + // start / progress / info / drift / result / done are control signals: not forwarded. } + } catch (err) { + // A throw (not a surfaced `error` event): still withhold the raw message by default — + // normalise it to an error event so an `errorData` opt-in sees a consistent shape. + options.onError?.(err); + if (active) writeErrorFrame(toErrorEvent(err)); } finally { res.off('close', onClose); if (active) { diff --git a/packages/fastify/test/fastify.spec.ts b/packages/fastify/test/fastify.spec.ts index a267157d..eb5e9cfd 100644 --- a/packages/fastify/test/fastify.spec.ts +++ b/packages/fastify/test/fastify.spec.ts @@ -2,13 +2,13 @@ // `adapter` (no network), so every stitch call resolves against canned responses. We drive the // app with `fastify.inject()` and assert the four contracts: the seam is decorated, the // request-scoped principal binds (a route reads `currentStitch()` and `request.stitch`), -// `sendStitchSse` streams events, and `stitchErrorHandler` maps a StitchError to a status. +// `streamStitchSse` streams events, and `stitchErrorHandler` maps a StitchError to a status. import { currentStitch, isStitchError, - sendStitchSse, stitchErrorHandler, stitchPlugin, + streamStitchSse, } from '../src'; import Fastify from 'fastify'; @@ -145,7 +145,7 @@ describe('stitchPlugin', () => { expect(res.json()).toEqual({ isRoot: true }); }); - test('sendStitchSse streams delta events to the reply', async () => { + test('streamStitchSse streams delta events to the reply', async () => { // A hand-built event stream — the SSE bridge consumes any AsyncIterable. async function* events(): AsyncGenerator> { yield { @@ -173,7 +173,7 @@ describe('stitchPlugin', () => { }, logger: false, }); - app.get('/sse', (_request, reply) => sendStitchSse(reply, events())); + app.get('/sse', (_request, reply) => streamStitchSse(reply, events())); await app.ready(); const res = await app.inject({ method: 'GET', url: '/sse' }); @@ -212,7 +212,7 @@ describe('stitchPlugin', () => { logger: false, }); app.get('/sse-err', (_request, reply) => - sendStitchSse(reply, events()), + streamStitchSse(reply, events()), ); await app.ready(); @@ -248,7 +248,7 @@ describe('stitchPlugin', () => { logger: false, }); app.get('/sse-err', (_request, reply) => - sendStitchSse(reply, events(), { errorData: (e) => e.message }), + streamStitchSse(reply, events(), { errorData: (e) => e.message }), ); await app.ready(); @@ -258,6 +258,172 @@ describe('stitchPlugin', () => { ); }); + test('onError observes the real failure server-side while the client frame stays generic', async () => { + async function* events(): AsyncGenerator> { + yield { + type: 'error', + name: 'StitchError', + message: 'getaddrinfo ENOTFOUND payments.internal.corp', + status: 502, + attempts: 1, + at: 1, + }; + } + const observed: unknown[] = []; + const app = Fastify(); + apps.push(app); + await app.register(stitchPlugin, { + seamConfig: { + baseUrl: 'https://api.test', + adapter: fakeAdapter(() => ({ + status: 200, + headers: {}, + body: {}, + })).adapter, + }, + logger: false, + }); + app.get('/sse-observe', (_request, reply) => + streamStitchSse(reply, events(), { + onError: (err) => observed.push(err), + }), + ); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/sse-observe' }); + // The server-side hook gets the raw failure… + expect(observed).toHaveLength(1); + expect((observed[0] as Error).message).toContain( + 'payments.internal.corp', + ); + // …the client still gets only the generic token. + expect(res.body).toBe('event: error\ndata: error\n\n'); + }); + + test('a throw mid-stream is normalised: onError fires and the frame stays generic', async () => { + // eslint-disable-next-line require-yield + async function* events(): AsyncGenerator> { + throw new Error('getaddrinfo ENOTFOUND payments.internal.corp'); + } + const observed: unknown[] = []; + const app = Fastify(); + apps.push(app); + await app.register(stitchPlugin, { + seamConfig: { + baseUrl: 'https://api.test', + adapter: fakeAdapter(() => ({ + status: 200, + headers: {}, + body: {}, + })).adapter, + }, + logger: false, + }); + app.get('/sse-throw', (_request, reply) => + streamStitchSse(reply, events(), { + onError: (err) => observed.push(err), + }), + ); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/sse-throw' }); + expect(observed).toHaveLength(1); + expect(res.body).toBe('event: error\ndata: error\n\n'); + expect(res.body).not.toContain('ENOTFOUND'); + }); + + test('accepts the { stream() } arm of StitchEventSource', async () => { + async function* events(): AsyncGenerator> { + yield { type: 'delta', chunk: 'via-stream()', at: 1 }; + yield { type: 'done', ok: true, elapsed: 1, attempts: 1, at: 2 }; + } + // Anything with a `.stream()` handing back the iterable — e.g. a StitchResult. + const source = { stream: () => events() }; + const app = Fastify(); + apps.push(app); + await app.register(stitchPlugin, { + seamConfig: { + baseUrl: 'https://api.test', + adapter: fakeAdapter(() => ({ + status: 200, + headers: {}, + body: {}, + })).adapter, + }, + logger: false, + }); + app.get('/sse-source', (_request, reply) => + streamStitchSse(reply, source), + ); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/sse-source' }); + expect(res.body).toBe('data: via-stream()\n\n'); + }); + + test('the data mapper receives the zero-based frame index', async () => { + async function* events(): AsyncGenerator> { + yield { type: 'delta', chunk: 'a', at: 1 }; + yield { type: 'delta', chunk: 'b', at: 2 }; + yield { type: 'done', ok: true, elapsed: 1, attempts: 1, at: 3 }; + } + const app = Fastify(); + apps.push(app); + await app.register(stitchPlugin, { + seamConfig: { + baseUrl: 'https://api.test', + adapter: fakeAdapter(() => ({ + status: 200, + headers: {}, + body: {}, + })).adapter, + }, + logger: false, + }); + app.get('/sse-index', (_request, reply) => + streamStitchSse(reply, events(), { + data: (c, index) => `${String(c)}#${index}`, + }), + ); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/sse-index' }); + expect(res.body).toBe('data: a#0\n\ndata: b#1\n\n'); + }); + + test('event accepts a function of the chunk for per-frame event names', async () => { + async function* events(): AsyncGenerator> { + yield { type: 'delta', chunk: { kind: 'token', text: 'a' }, at: 1 }; + yield { type: 'delta', chunk: { kind: 'usage', text: 'b' }, at: 2 }; + yield { type: 'done', ok: true, elapsed: 1, attempts: 1, at: 3 }; + } + const app = Fastify(); + apps.push(app); + await app.register(stitchPlugin, { + seamConfig: { + baseUrl: 'https://api.test', + adapter: fakeAdapter(() => ({ + status: 200, + headers: {}, + body: {}, + })).adapter, + }, + logger: false, + }); + app.get('/sse-event-fn', (_request, reply) => + streamStitchSse(reply, events(), { + event: (c) => (c as { kind: string }).kind, + data: (c) => (c as { text: string }).text, + }), + ); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/sse-event-fn' }); + expect(res.body).toBe( + 'event: token\ndata: a\n\nevent: usage\ndata: b\n\n', + ); + }); + test('the registered error handler maps a StitchError to 502 by default', async () => { // The fake adapter returns 503 → the stitch throws a StitchError with status 503. const { adapter } = fakeAdapter(() => ({ diff --git a/packages/fingerprint-arktype/README.md b/packages/fingerprint-arktype/README.md index 7efe1609..bf6ae5ac 100644 --- a/packages/fingerprint-arktype/README.md +++ b/packages/fingerprint-arktype/README.md @@ -8,7 +8,7 @@ Stable [Standard Schema](https://standardschema.dev) fingerprint strategy for It reads ArkType's canonical `t.json` representation and hashes a canonicalised form into an opaque, synchronous token. The token changes iff the schema's validation/shape semantics change. It is an **allowlist**: it abstains (returns -`value: null`, so the cache falls back to re-validate-on-hit) on anything it can't +`token: null`, so the cache falls back to re-validate-on-hit) on anything it can't soundly capture — morphs (`.pipe`) and narrows (`.narrow`), which ArkType emits as opaque, non-deterministic `$ark.fn` references, and property defaults. diff --git a/packages/fingerprint-arktype/src/index.ts b/packages/fingerprint-arktype/src/index.ts index c9264173..1d53034a 100644 --- a/packages/fingerprint-arktype/src/index.ts +++ b/packages/fingerprint-arktype/src/index.ts @@ -3,7 +3,7 @@ // ArkType exposes a canonical JSON representation of every type on `t.json`. It // already normalises the parts that matter for structural identity — object keys // are emitted in a stable order and union branches are pre-sorted — so the bulk -// of the work is reading that surface, then ABSTAINING (returning `value: null`) +// of the work is reading that surface, then ABSTAINING (returning `token: null`) // the moment it contains something that can't be soundly captured: // // - morphs (`.pipe`) surface as `morphs: ["$ark.fn10"]` and narrows (`.narrow`) @@ -120,10 +120,10 @@ export const arktypeFingerprinter: SchemaFingerprinter = { // `afp1` tags the descriptor format: bump it to force a one-time, // safe re-fingerprint if the descriptor scheme ever changes. const token = hash(`afp1|${describe(schema)}`); - return { token, value: token, strength: 'strong' }; + return { token, strength: 'strong' }; } catch { // ABSTAIN sentinel or any unexpected introspection failure → abstain. - return { token: null, value: null, strength: 'strong' }; + return { token: null, strength: 'strong' }; } }, }; diff --git a/packages/fingerprint-effect/README.md b/packages/fingerprint-effect/README.md index 7079c204..de176118 100644 --- a/packages/fingerprint-effect/README.md +++ b/packages/fingerprint-effect/README.md @@ -7,7 +7,7 @@ Stable [Standard Schema](https://standardschema.dev) fingerprint strategy for It walks Effect's `.ast` into a canonical structural descriptor hashed into an opaque, synchronous token. The token changes iff the schema's validation/shape -semantics change. It is an **allowlist**: it abstains (returns `value: null`, so +semantics change. It is an **allowlist**: it abstains (returns `token: null`, so the cache falls back to re-validate-on-hit) on anything it can't soundly capture — `Transformation`, `Refinement`, `Suspend`, and `Declaration` AST nodes. diff --git a/packages/fingerprint-effect/src/index.ts b/packages/fingerprint-effect/src/index.ts index 7bf20049..83c5a09e 100644 --- a/packages/fingerprint-effect/src/index.ts +++ b/packages/fingerprint-effect/src/index.ts @@ -8,7 +8,7 @@ // // The walker is an ALLOWLIST: it only emits a fingerprint for AST nodes whose // validation/shape semantics it fully captures, and ABSTAINS (returns -// `value: null`) on anything else — `Transformation` (`.transform`/applied +// `token: null`) on anything else — `Transformation` (`.transform`/applied // defaults), `Refinement` (an opaque predicate), `Suspend` (lazy/recursive), // `Declaration`, or any unknown `_tag`. Abstain is sound: the cache falls back to // re-validate-on-hit rather than trust a token that might collide across @@ -155,10 +155,10 @@ export const effectFingerprinter: SchemaFingerprinter = { // `efp1` tags the descriptor format: bump it to force a one-time, // safe re-fingerprint if the descriptor scheme ever changes. const token = hash(`efp1|${describeAst(ast)}`); - return { token, value: token, strength: 'strong' }; + return { token, strength: 'strong' }; } catch { // ABSTAIN sentinel or any unexpected introspection failure → abstain. - return { token: null, value: null, strength: 'strong' }; + return { token: null, strength: 'strong' }; } }, }; diff --git a/packages/fingerprint-typebox/README.md b/packages/fingerprint-typebox/README.md index 36eec9f4..481eb935 100644 --- a/packages/fingerprint-typebox/README.md +++ b/packages/fingerprint-typebox/README.md @@ -8,7 +8,7 @@ Stable [Standard Schema](https://standardschema.dev) fingerprint strategy for A TypeBox schema _is_ a JSON Schema object, so this strategy canonical-hashes it (recursively sorted keys; the `required` set sorted) into an opaque, synchronous token. The token changes iff the schema's validation/shape semantics change. It is -an **allowlist**: it abstains (returns `value: null`, so the cache falls back to +an **allowlist**: it abstains (returns `token: null`, so the cache falls back to re-validate-on-hit) on anything it can't soundly capture — a `Type.Transform` codec (detected via its symbol, recursively, since it is invisible to `JSON.stringify`) and opaque kinds (`Function`/`Constructor`/`Unsafe`/…). diff --git a/packages/fingerprint-typebox/src/index.ts b/packages/fingerprint-typebox/src/index.ts index b79e43e6..a12ee514 100644 --- a/packages/fingerprint-typebox/src/index.ts +++ b/packages/fingerprint-typebox/src/index.ts @@ -11,7 +11,7 @@ // opaque parts of a schema are INVISIBLE to a naive stringify: a // `Type.Transform(Type.String())...` serialises to exactly `{ type: 'string' }`, // indistinguishable from a plain string. We therefore walk every nested node and -// ABSTAIN (return `value: null`) the moment we hit a part we cannot soundly +// ABSTAIN (return `token: null`) the moment we hit a part we cannot soundly // capture: a transform codec (detected by `Symbol(TypeBox.Transform)`), an opaque // Kind (`Function`/`Constructor`/`Unsafe`/`Undefined`/`Void`), or a node that // carries no JSON-Schema discriminator at all (`Any`/`Unknown`/`Unsafe`, which all @@ -149,10 +149,10 @@ export const typeboxFingerprinter: SchemaFingerprinter = { // `tbfp1` tags the descriptor format: bump it to force a one-time, // safe re-fingerprint if the descriptor scheme ever changes. const token = hash(`tbfp1|${describeRoot(schema)}`); - return { token, value: token, strength: 'strong' }; + return { token, strength: 'strong' }; } catch { // ABSTAIN sentinel or any unexpected introspection failure → abstain. - return { token: null, value: null, strength: 'strong' }; + return { token: null, strength: 'strong' }; } }, }; diff --git a/packages/fingerprint-valibot/README.md b/packages/fingerprint-valibot/README.md index 42127a3e..abd2ec1f 100644 --- a/packages/fingerprint-valibot/README.md +++ b/packages/fingerprint-valibot/README.md @@ -8,7 +8,7 @@ Stable [Standard Schema](https://standardschema.dev) fingerprint strategy for It walks Valibot's plain-object representation — `.type`, `.entries`, and the `.pipe` action chain — into a canonical structural descriptor hashed into an opaque, synchronous token. The token changes iff the schema's validation/shape -semantics change. It is an **allowlist**: it abstains (returns `value: null`, so +semantics change. It is an **allowlist**: it abstains (returns `token: null`, so the cache falls back to re-validate-on-hit) on anything it can't soundly capture — `transformation` actions, `check`/`custom`/`raw_*` actions, function-valued requirements, and injected defaults. diff --git a/packages/fingerprint-valibot/src/index.ts b/packages/fingerprint-valibot/src/index.ts index b611f035..c88c33b1 100644 --- a/packages/fingerprint-valibot/src/index.ts +++ b/packages/fingerprint-valibot/src/index.ts @@ -7,7 +7,7 @@ // `[baseSchema, ...actions]`, each action a `{ kind, type, requirement }` record. // // The walker is an ALLOWLIST: it only emits a fingerprint for constructs whose -// validation/shape semantics it fully captures, and ABSTAINS (`value: null`) on +// validation/shape semantics it fully captures, and ABSTAINS (`token: null`) on // anything else — opaque `check`/`custom`/`transform`/`brand` pipe actions, any // action whose `requirement` is a function (hidden, unserialisable logic), an // injected `default`, or any unknown node. Abstain is sound: the cache falls @@ -245,10 +245,10 @@ export const valibotFingerprinter: SchemaFingerprinter = { // `vfp1` tags the descriptor format: bump it to force a one-time, // safe re-fingerprint if the descriptor scheme ever changes. const token = hash(`vfp1|${describe(schema)}`); - return { token, value: token, strength: 'strong' }; + return { token, strength: 'strong' }; } catch { // ABSTAIN sentinel or any unexpected introspection failure → abstain. - return { token: null, value: null, strength: 'strong' }; + return { token: null, strength: 'strong' }; } }, }; diff --git a/packages/fingerprint-valibot/test/conformance.spec.ts b/packages/fingerprint-valibot/test/conformance.spec.ts index 6e32ebd5..9613282e 100644 --- a/packages/fingerprint-valibot/test/conformance.spec.ts +++ b/packages/fingerprint-valibot/test/conformance.spec.ts @@ -217,6 +217,6 @@ describe('@stitchapi/fingerprint-valibot', () => { ) as never, ); expect(base.token).not.toBeNull(); - expect(checked.value).toBeNull(); + expect(checked.token).toBeNull(); }); }); diff --git a/packages/fingerprint-zod/README.md b/packages/fingerprint-zod/README.md index 1fc822fb..b697c937 100644 --- a/packages/fingerprint-zod/README.md +++ b/packages/fingerprint-zod/README.md @@ -8,7 +8,7 @@ Stable [Standard Schema](https://standardschema.dev) fingerprint strategy for It walks Zod's internal representation — `_def`/`typeName` on Zod 3, `_zod.def`/ `type` on Zod 4 — into a canonical structural descriptor hashed into an opaque, synchronous token. The token changes iff the schema's validation/shape semantics -change. It is an **allowlist**: it abstains (returns `value: null`, so the cache +change. It is an **allowlist**: it abstains (returns `token: null`, so the cache falls back to re-validate-on-hit) on anything it can't soundly capture — opaque `.refine`/`.transform`/`.default`/custom checks. diff --git a/packages/fingerprint-zod/src/index.ts b/packages/fingerprint-zod/src/index.ts index 066961b1..f03f57dd 100644 --- a/packages/fingerprint-zod/src/index.ts +++ b/packages/fingerprint-zod/src/index.ts @@ -4,7 +4,7 @@ // `type` on Zod 4 — building a canonical structural descriptor that is hashed // into an opaque token. The walker is an ALLOWLIST: it only emits a fingerprint // for constructs whose validation/shape semantics it fully captures, and ABSTAINS -// (returns `value: null`) on anything else — opaque `.refine`/`.transform`/ +// (returns `token: null`) on anything else — opaque `.refine`/`.transform`/ // `.default`/custom checks, unrepresentable literals, or any unknown node. Abstain // is sound: the cache falls back to re-validate-on-hit rather than trust a token // that might collide across semantically-different schemas. @@ -298,10 +298,10 @@ export const zodFingerprinter: SchemaFingerprinter = { // `zfp1` tags the descriptor format: bump it to force a one-time, // safe re-fingerprint if the descriptor scheme ever changes. const token = hash(`zfp1|${describe(schema)}`); - return { token, value: token, strength: 'strong' }; + return { token, strength: 'strong' }; } catch { // ABSTAIN sentinel or any unexpected introspection failure → abstain. - return { token: null, value: null, strength: 'strong' }; + return { token: null, strength: 'strong' }; } }, }; diff --git a/packages/hono/src/index.ts b/packages/hono/src/index.ts index 78658e62..37e9c420 100644 --- a/packages/hono/src/index.ts +++ b/packages/hono/src/index.ts @@ -18,9 +18,7 @@ export { STITCH_VAR, type HonoRequestSeam, type StitchEnv, - type StitchMiddlewareOptions, - // Deprecated alias (kept through 1.0.0-rc, removed at GA) — see ADR 0012. - type RequestSeam, + type HonoStitchMiddlewareOptions, } from './middleware'; export { diff --git a/packages/hono/src/middleware.ts b/packages/hono/src/middleware.ts index e15b6393..e0935da1 100644 --- a/packages/hono/src/middleware.ts +++ b/packages/hono/src/middleware.ts @@ -19,15 +19,10 @@ import type { PrincipalSeam, Seam } from 'stitchapi'; export type HonoRequestSeam = PrincipalSeam | Seam; /** - * @deprecated Renamed to {@link HonoRequestSeam} so the public type is - * ecosystem-qualified (a bare `RequestSeam` would collide with any other host - * adapter's per-request seam type) — see - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). Kept through the - * `1.0.0-rc` line and removed at the 1.0 GA cut. + * The key the seam is stored under on `c.var` / via `c.set` / `c.get`. Reading it before the + * `stitch()` middleware ran yields `undefined` (Hono's `c.get` never throws on an unset variable) + * — guard for it in routes mounted outside the middleware's path. */ -export type RequestSeam = HonoRequestSeam; - -/** The key the seam is stored under on `c.var` / via `c.set` / `c.get`. */ export const STITCH_VAR = 'stitch' as const; /** @@ -49,7 +44,7 @@ export interface StitchEnv extends Env { }; } -export interface StitchMiddlewareOptions { +export interface HonoStitchMiddlewareOptions { /** * The seam this middleware shares across requests. **Borrowed, not owned** — build it once at * startup and `seam.close()` it on shutdown yourself; the middleware never closes it (the seam @@ -83,7 +78,9 @@ export interface StitchMiddlewareOptions { * app.get('/me', (c) => c.json(c.get('stitch').stitch('/me')())); * ``` */ -export function stitch(options: StitchMiddlewareOptions): MiddlewareHandler { +export function stitch( + options: HonoStitchMiddlewareOptions, +): MiddlewareHandler { const { seam, principal } = options; return createMiddleware(async (c, next) => { const id = principal?.(c); diff --git a/packages/hono/src/sse.ts b/packages/hono/src/sse.ts index f0d3647b..a9ee6894 100644 --- a/packages/hono/src/sse.ts +++ b/packages/hono/src/sse.ts @@ -9,12 +9,11 @@ import type { Context } from 'hono'; import { streamSSE } from 'hono/streaming'; import type { SSEMessage, SSEStreamingApi } from 'hono/streaming'; -import type { StitchEvent } from 'stitchapi'; +import type { StitchEvent, StitchEventSource } from 'stitchapi'; -/** Anything `streamStitchSse` can drive: a stitch `.stream()` generator, or any event iterable. */ -export type StitchEventSource = - | AsyncIterable> - | AsyncGenerator, void>; +// The canonical event-stream intake (core's `StitchEventSource`): the event iterable itself (a +// `.stream()` generator), or anything that hands one back (a `StitchResult`, a stitch stub). +export type { StitchEventSource } from 'stitchapi'; /** The terminal `error` event a stitch stream emits — carries `message`, `status`, `attempts`. */ type StitchErrorEvent = Extract; @@ -23,14 +22,21 @@ export interface StreamStitchSseOptions { /** * Map a `delta` chunk to the SSE message `data` string. The default JSON-stringifies the chunk * (a string chunk is sent verbatim). Pull text out of a structured chunk with, e.g., - * `data: (c) => c.choices[0].delta.content ?? ''`. + * `data: (c) => c.choices[0].delta.content ?? ''`. Receives the zero-based message index + * alongside the chunk. */ - data?: (chunk: unknown) => string; + data?: (chunk: unknown, index: number) => string; /** - * The SSE `event:` field for each delta message (default none). Set it to label the stream's - * messages on the client (`event: 'token'`). + * The SSE `event:` field for each delta message (default none): a fixed name, or a function + * of the chunk for per-message names. Set it to label the stream's messages on the client + * (`event: 'token'`). */ - event?: string; + event?: string | ((chunk: unknown) => string); + /** + * Provide the SSE `id:` field per delta message (the last-event id), e.g. for resumable + * streams. Receives the chunk and the zero-based message index. + */ + id?: (chunk: unknown, index: number) => string; /** * Shape the SSE `data` written for a terminal `error` event (or an uncaught throw mid-stream, * normalised to an error event). **Default: a generic token (`data: error`)** — the raw @@ -99,9 +105,14 @@ export function streamStitchSse( source: StitchEventSource, options: StreamStitchSseOptions = {}, ): Response { - const toData = options.data ?? defaultData; + const toData: (chunk: unknown, index: number) => string = + options.data ?? defaultData; + // Core's `StitchEventSource` admits the iterable itself or a `{ stream() }` holder (a + // `StitchResult`, a stitch stub) — unwrap the holder once, up front. + const iterable: AsyncIterable> = + Symbol.asyncIterator in source ? source : source.stream(); return streamSSE(c, async (stream: SSEStreamingApi) => { - const iterator = source[Symbol.asyncIterator](); + const iterator = iterable[Symbol.asyncIterator](); // Write the terminal `error` message: a generic token by default so a raw message // (`getaddrinfo ENOTFOUND …` / `HTTP 401`) is never disclosed; `errorData` opts in. const writeErrorFrame = (event: StitchErrorEvent): Promise => @@ -115,14 +126,22 @@ export function streamStitchSse( stream.onAbort(() => { void iterator.return?.(undefined); }); + let index = 0; try { while (true) { const { value: event, done } = await iterator.next(); if (done) break; if (event.type === 'delta') { - const message: SSEMessage = { data: toData(event.chunk) }; + const message: SSEMessage = { + data: toData(event.chunk, index), + }; if (options.event !== undefined) - message.event = options.event; + message.event = + typeof options.event === 'function' + ? options.event(event.chunk) + : options.event; + if (options.id) message.id = options.id(event.chunk, index); + index += 1; await stream.writeSSE(message); } else if (event.type === 'error') { // `onError` gets the real failure server-side; the client frame is shaped by diff --git a/packages/hono/test/sse.spec.ts b/packages/hono/test/sse.spec.ts index 59b96636..302eb45e 100644 --- a/packages/hono/test/sse.spec.ts +++ b/packages/hono/test/sse.spec.ts @@ -69,6 +69,89 @@ describe('streamStitchSse — delta mapping', () => { expect(body).toContain('data: t2'); }); + test('the data mapper receives the zero-based message index', async () => { + const app = new Hono(); + app.get('/x', (c) => + streamStitchSse( + c, + gen([ + { type: 'delta', chunk: 'a', at: 0 }, + { type: 'delta', chunk: 'b', at: 0 }, + { type: 'done', ok: true, elapsed: 1, attempts: 1, at: 0 }, + ]), + { data: (chunk, index) => `${String(chunk)}#${index}` }, + ), + ); + + const body = await (await app.request('/x')).text(); + expect(body).toContain('data: a#0'); + expect(body).toContain('data: b#1'); + }); + + test('event accepts a function of the chunk for per-message event names', async () => { + const app = new Hono(); + app.get('/x', (c) => + streamStitchSse( + c, + gen([ + { + type: 'delta', + chunk: { kind: 'token', text: 'a' }, + at: 0, + }, + { + type: 'delta', + chunk: { kind: 'usage', text: 'b' }, + at: 0, + }, + { type: 'done', ok: true, elapsed: 1, attempts: 1, at: 0 }, + ]), + { + event: (chunk) => (chunk as { kind: string }).kind, + data: (chunk) => (chunk as { text: string }).text, + }, + ), + ); + + const body = await (await app.request('/x')).text(); + expect(body).toContain('event: token\ndata: a'); + expect(body).toContain('event: usage\ndata: b'); + }); + + test('the id option stamps each delta message with a last-event id (chunk + index)', async () => { + const app = new Hono(); + app.get('/x', (c) => + streamStitchSse( + c, + gen([ + { type: 'delta', chunk: 'a', at: 0 }, + { type: 'delta', chunk: 'b', at: 0 }, + { type: 'done', ok: true, elapsed: 1, attempts: 1, at: 0 }, + ]), + { id: (_chunk, index) => `msg-${index}` }, + ), + ); + + const body = await (await app.request('/x')).text(); + expect(body).toContain('id: msg-0'); + expect(body).toContain('id: msg-1'); + }); + + test('a { stream() } holder (StitchResult-shaped source) is unwrapped and driven', async () => { + const app = new Hono(); + const holder = { + stream: () => + gen([ + { type: 'delta', chunk: 'held', at: 0 }, + { type: 'done', ok: true, elapsed: 1, attempts: 1, at: 0 }, + ]), + }; + app.get('/x', (c) => streamStitchSse(c, holder)); + + const body = await (await app.request('/x')).text(); + expect(dataLines(body)).toEqual(['data: held']); + }); + test('a custom data mapper pulls text out of a structured chunk', async () => { const app = new Hono(); app.get('/x', (c) => diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index d4273479..ce072d60 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -23,14 +23,15 @@ usable anywhere a Standard Schema is — not only with StitchAPI. ```ts import Ajv from 'ajv'; +import { validate } from 'stitchapi'; import { JsonSchema } from '@stitchapi/json-schema'; const ajv = new Ajv(); // your app's configured engine — formats, keywords, $refs, draft // `discovered` is a JSON Schema you obtained at runtime. -const validator = JsonSchema.adapt(discovered, { ajv }); +const schema = JsonSchema.adapt(discovered, { ajv }); -const result = await validator['~standard'].validate(payload); -if (result.issues) { +const result = await validate(schema, payload); +if (!result.ok) { // Structured, per-path — hand it back to the sender to correct. // [{ message: 'must be <= 50', path: ['limit'] }] return respondWithErrors(result.issues); @@ -38,6 +39,9 @@ if (result.issues) { handle(result.value); // `unknown` — a runtime schema carries no static shape ``` +The adapted schema is a plain Standard Schema: pass it to a stitch's `input`/`output`, to +`validate`/`compile`, or to any other Standard-Schema consumer — they all treat it identically. + `JsonSchema.adapt()` takes an optional type argument. Leave it `unknown` for a runtime-obtained schema; pass `T` only when you already know the shape at authoring time. diff --git a/packages/json-schema/src/index.ts b/packages/json-schema/src/index.ts index 6c717dce..4f337dbd 100644 --- a/packages/json-schema/src/index.ts +++ b/packages/json-schema/src/index.ts @@ -36,9 +36,10 @@ export type JsonSchemaCheck = (value: unknown) => { /** * The slice of an Ajv instance we use — just `ajv.compile(schema)`. Declared structurally so this * package imports nothing from `ajv`: your configured instance satisfies it, and a `{ check }`-only - * consumer needs `ajv` neither installed nor in their type graph. + * consumer needs `ajv` neither installed nor in their type graph. Member spellings mirror Ajv's own + * (`compile`, `errors`, `instancePath`) so a real instance matches without translation. */ -export interface AjvInstance { +export interface AjvLike { compile(schema: JsonSchemaObject): AjvValidate; } interface AjvValidate { @@ -58,7 +59,7 @@ interface AjvError { * custom compiled `check` (a Workers-safe validator, a draft-2020-12 engine, a shared instance). */ export type JsonSchemaEngine = - | { readonly ajv: AjvInstance } + | { readonly ajv: AjvLike } | { readonly check: JsonSchemaCheck }; /** @@ -67,12 +68,13 @@ export type JsonSchemaEngine = * * ```ts * import Ajv from 'ajv'; + * import { validate } from 'stitchapi'; * import { JsonSchema } from '@stitchapi/json-schema'; * * const ajv = new Ajv(); // your app's configured engine - * const validator = JsonSchema.adapt(discovered, { ajv }); - * const result = await validator['~standard'].validate(payload); - * if (result.issues) repair(result.issues); // [{ message: 'must be <= 50', path: ['limit'] }] + * const schema = JsonSchema.adapt(discovered, { ajv }); + * const result = await validate(schema, payload); // { ok: true, value } | { ok: false, issues } + * if (!result.ok) repair(result.issues); // [{ message: 'must be <= 50', path: ['limit'] }] * ``` * * The output type is `T` (default `unknown`): a schema discovered at runtime carries no static @@ -122,7 +124,7 @@ function pointerToPath(pointer: string): (string | number)[] { }); } -function ajvCheck(ajv: AjvInstance, schema: JsonSchemaObject): JsonSchemaCheck { +function ajvCheck(ajv: AjvLike, schema: JsonSchemaObject): JsonSchemaCheck { const validate = ajv.compile(schema); return (value) => { if (validate(value)) return { valid: true, issues: [] }; diff --git a/packages/json-schema/test/json-schema.spec.ts b/packages/json-schema/test/json-schema.spec.ts index 4b5e3d16..468577af 100644 --- a/packages/json-schema/test/json-schema.spec.ts +++ b/packages/json-schema/test/json-schema.spec.ts @@ -1,6 +1,7 @@ import { JsonSchema, type JsonSchemaCheck } from '../src/index'; import Ajv from 'ajv'; +import { compile, validate } from 'stitchapi'; // A JSON Schema you obtained at runtime — the shape a discovery hands you. const toolSchema = { @@ -95,3 +96,31 @@ describe('JsonSchema.adapt (bring-your-own check)', () => { expect(result).toEqual({ value: { query: 'x' } }); }); }); + +// P23 — one schema intake: the adapted schema enters core through the same `SchemaLike` +// intake as any Standard Schema and yields core's `ValidationResult` shape +// (`{ ok: true, value } | { ok: false, issues }`), with issues nominally matching core's +// `Issue` (`{ message, path }`). Verified through the workspace dev-dependency; this +// package's runtime still depends on nothing from core. +describe('JsonSchema.adapt feeds core validate/compile (P23)', () => { + it('validate() returns { ok: true, value } for a conforming payload', async () => { + const schema = JsonSchema.adapt(toolSchema, { ajv }); + const result = await validate(schema, { query: 'shoes', limit: 5 }); + expect(result).toEqual({ + ok: true, + value: { query: 'shoes', limit: 5 }, + }); + }); + + it('compile() returns { ok: false, issues } with { message, path } per failure', async () => { + const check = compile(JsonSchema.adapt(toolSchema, { ajv })); + const result = await check({ query: 42 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues).toContainEqual({ + message: expect.any(String), + path: ['query'], + }); + } + }); +}); diff --git a/packages/nest/README.md b/packages/nest/README.md index 5685fbe7..f80e411f 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -25,10 +25,13 @@ pnpm add @stitchapi/nest@rc stitchapi@rc ## Configure the shared client — `forRoot` / `forRootAsync` -`forRoot` configures the app-wide shared infrastructure (one `store`, one `trace` +`forRoot` configures the app-wide shared infrastructure (one `store`, one trace sink) and a default seam carrying your `SeamConfig` defaults (`baseUrl`, `headers`, -`auth`, `retry`, `throttle`, `cache`, …). Tracing is **off by default**; opt in with the -`'logger'` sentinel. +`auth`, `retry`, `throttle`, `cache`, …). The **Nest `Logger` bridge is on by +default** (`logger: true` → `nestLoggerSink(new Logger('Stitch'))`, the same default +as `@stitchapi/fastify`'s plugin): pass sink options (`logger: { lifecycle: false }`), +set `logger: false` to leave tracing as core configures it (off), or set an explicit +`trace` — it wins over the bridge. ```ts import { StitchModule, fromNestConfig } from '@stitchapi/nest'; @@ -43,7 +46,7 @@ import { bearer } from 'stitchapi'; baseUrl: config.getOrThrow('API_BASE_URL'), store: new RedisStore(config.getOrThrow('REDIS_URL')), // any StitchStore auth: bearer(fromNestConfig(config)('API_TOKEN')), - trace: 'logger', // → nestLoggerSink(new Logger('Stitch')) + // logger: false, // opt out of the default Nest Logger trace bridge }), }), ], @@ -56,8 +59,8 @@ export class AppModule {} Declare stitches with `defineStitch(build)` — the injection **token is optional** (a unique `Symbol` is generated; pass `defineStitch(token, build)` only when you need a stable, well-known token). Register them per feature module, which may also own its -**own upstream seam** (its `baseUrl`/`auth`), built over the shared store + trace — omit -`seam` to attach the stitches to the default seam. +**own upstream seam** (a `seam.config` with its `baseUrl`/`auth`), built over the shared +store + trace — omit `seam` to attach the stitches to the default seam. ```ts // users.stitches.ts @@ -69,8 +72,10 @@ export const GetUser = defineStitch((s) => imports: [ StitchModule.forFeature({ seam: { - baseUrl: 'https://api.example.com', - auth: bearer(env('TOKEN')), + config: { + baseUrl: 'https://api.example.com', + auth: bearer(env('TOKEN')), + }, }, stitches: [GetUser], }), @@ -102,8 +107,10 @@ the request-scoped `seam.as(principal)` handle and the request-scoped stitches f imports: [ StitchModule.forFeatureScoped({ seam: { - baseUrl: 'https://api.example.com', - auth: bearer(env('TOKEN')), + config: { + baseUrl: 'https://api.example.com', + auth: bearer(env('TOKEN')), + }, }, stitches: [GetUser], principal: (req) => req.user?.tenantId ?? 'anonymous', @@ -138,10 +145,9 @@ seam and bind explicitly — `seam.as(job.data.tenantId)`. > [!NOTE] > > `nestLoggerSink` / `fromNestConfig` / `nestBorrowStore` / `NestConfigServiceLike` are -> the ecosystem-qualified names introduced by -> [ADR 0012](../../docs/adr/0012-integration-symbol-naming.md). The former bare names -> (`loggerSink`, `fromConfig`, `borrowStore`, `ConfigServiceLike`) remain as -> `@deprecated` aliases through the `1.0.0-rc` line and are removed at the 1.0 GA cut. +> the ecosystem-qualified names required by +> [ADR 0012](../../docs/adr/0012-integration-symbol-naming.md) — the former bare names +> were removed at the 1.0 GA cut. ## Errors → HTTP — `StitchExceptionFilter` @@ -158,39 +164,45 @@ app.useGlobalFilters(new StitchExceptionFilter(app.getHttpAdapter())); // …or as a provider: { provide: APP_FILTER, useClass: StitchExceptionFilter } ``` -`status` takes a fixed number or a `(err) => number` function. Outside a filter, use -`toHttpException(err, { status })` / `isStitchError(err)` directly. +The options envelope is `StitchErrorOptions` — the same `{ status?, body? }` shape as +`@stitchapi/hono`'s and `@stitchapi/elysia`'s error helpers. `status` takes a fixed number +or a `(err) => number` function. Outside a filter, use `toHttpException(err, { status })` / +`isStitchError(err)` directly. -The client-facing **message** is a fixed `'Upstream request failed'` by default — the raw -`err.message` is withheld, because it can disclose an internal hostname (a transport -failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status -(`HTTP 401`). The original error is always attached as the exception's `cause` for -server-side logging. Opt in to a message when you need one: `{ exposeMessage: true }` for -the raw message, or `{ message: 'Payment provider unavailable' }` / `{ message: (e) => … }` -for a curated one. +The client-facing **body** defaults to Nest's rendering of a fixed +`'Upstream request failed'` — the raw `err.message` is withheld, because it can disclose an +internal hostname (a transport failure reads like +`getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`). The +original error is always attached as the exception's `cause` for server-side logging. Shape +your own envelope with `body`: `{ body: (e, status) => ({ error: 'Payment provider unavailable' }) }`, +or opt in to the raw message with `{ body: (e) => ({ error: e.message }) }` when the +upstream messages are known safe. -## Streaming → SSE — `stitchSse` +## Streaming → SSE — `streamStitchSse` -Return a stitch's `stream()` from a Nest `@Sse()` endpoint: `delta` chunks become messages, -an `error` event errors the stream (a fixed, safe message by default — see below), and a -client disconnect aborts the upstream generator. +Return a stitch's `stream()` (or any `StitchEventSource`) from a Nest `@Sse()` endpoint: +`delta` chunks become messages, an `error` event errors the stream (a generic `data: error` +frame by default — see below), and a client disconnect aborts the upstream generator. The +options (`StreamStitchSseOptions`) are the canonical host set shared with +`@stitchapi/express` / `@stitchapi/hono`: `data`, `event`, `id`, `errorData`, `onError`. ```ts @Sse('chat') chat(@Query('q') q: string) { - return stitchSse(this.complete.stream({ body: { prompt: q } }), { + return streamStitchSse(this.complete.stream({ body: { prompt: q } }), { data: (c: any) => c.text, // map a delta chunk → message data }); } ``` Nest renders an errored `@Sse()` observable's message to the client as the final `event: error` -frame, so the client-facing **message** defaults to a fixed `'Upstream request failed'` — the raw +frame, so the client-facing **data** defaults to a generic `error` token — the raw `event.message` is withheld, since it can disclose an internal hostname (a transport failure reads -like `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`). The -original error is always attached as the errored observable's `cause` for server-side logging. Opt -in when you need a message: `{ exposeMessage: true }` for the raw message, or -`{ message: 'Stream unavailable' }` / `{ message: (e) => … }` for a curated one. +like `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`). Observe +the real failure server-side with `onError`; it is also attached as the errored observable's +`cause`. Shape the client frame with `errorData` when you need to: +`{ errorData: (e) => e.message }` opts in to the raw message, +`{ errorData: () => 'Stream unavailable' }` sets a curated one. ## Testing diff --git a/packages/nest/src/bridges.ts b/packages/nest/src/bridges.ts index 81819324..cfddd95b 100644 --- a/packages/nest/src/bridges.ts +++ b/packages/nest/src/bridges.ts @@ -67,27 +67,11 @@ export function nestLoggerSink( // per-instance level rules (`nestLevel`), and the glyph one-liners (`nestFormat`). The // resulting levels and messages are identical to the hand-rolled switch this replaced. return coreLoggerSink(toCoreLogger(logger), { - level: (event) => nestLevel(event, lifecycle), + levelOf: (event) => nestLevel(event, lifecycle), format: (event, ctx) => nestFormat(ctx.name, event), }); } -/** - * @deprecated Renamed to {@link nestLoggerSink}. The bare name collided with core's - * generic `loggerSink` (you had to alias one at every shared import site), so the - * cross-package logger-sink family is now ecosystem-qualified — see - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). This alias is kept - * through the `1.0.0-rc` line and removed at the 1.0 GA cut. - */ -export const loggerSink = nestLoggerSink; - -/** - * @deprecated Renamed to {@link NestLoggerLike} (it was indistinguishable from core's - * `LoggerLike`) — see [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). - * Kept through the `1.0.0-rc` line and removed at the 1.0 GA cut. - */ -export type LoggerLike = NestLoggerLike; - // Adapt a Nest `Logger` to core's `LoggerLike`. Core's level vocabulary is // error|warn|info|debug; Nest's is error|warn|log|debug|verbose. We route core `info` → // Nest `verbose` (the level `result` lands on) and guard `debug`/`verbose`, which a partial @@ -210,28 +194,3 @@ export function nestBorrowStore(store: StitchStore): StitchStore { incr: (key, ttlMs) => store.incr(key, ttlMs), }; } - -/** - * @deprecated Renamed to {@link fromNestConfig} so the adapter's secret-source - * constructor is ecosystem-qualified (a bare `fromConfig` would collide with any - * other framework's config bridge) — see - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). Kept through the - * `1.0.0-rc` line and removed at the 1.0 GA cut. - */ -export const fromConfig = fromNestConfig; - -/** - * @deprecated Renamed to {@link nestBorrowStore} so the helper is ecosystem-qualified - * (a bare `borrowStore` would collide with any other adapter's store wrapper) — see - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). Kept through the - * `1.0.0-rc` line and removed at the 1.0 GA cut. - */ -export const borrowStore = nestBorrowStore; - -/** - * @deprecated Renamed to {@link NestConfigServiceLike} so the duck-type is - * ecosystem-qualified — see - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). Kept through the - * `1.0.0-rc` line and removed at the 1.0 GA cut. - */ -export type ConfigServiceLike = NestConfigServiceLike; diff --git a/packages/nest/src/define-stitch.ts b/packages/nest/src/define-stitch.ts index 7e747320..49e4ce7c 100644 --- a/packages/nest/src/define-stitch.ts +++ b/packages/nest/src/define-stitch.ts @@ -8,14 +8,6 @@ import type { Seam, Stitch, StitchInput } from 'stitchapi'; /** Both `Seam` and `PrincipalSeam` satisfy this — the host a stitch is built from. */ export type NestRequestSeam = Pick; -/** - * @deprecated Renamed to {@link NestRequestSeam} so the public type is ecosystem-qualified (a bare - * `StitchHost` would collide with any other host adapter's per-request seam type) — see - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). Kept through the `1.0.0-rc` - * line and removed at the 1.0 GA cut. - */ -export type StitchHost = NestRequestSeam; - export interface StitchDef { token: InjectionToken; build: (host: NestRequestSeam) => Stitch; diff --git a/packages/nest/src/exception-filter.ts b/packages/nest/src/exception-filter.ts index 87d6ee5f..f63e820e 100644 --- a/packages/nest/src/exception-filter.ts +++ b/packages/nest/src/exception-filter.ts @@ -22,7 +22,7 @@ export function isStitchError(err: unknown): err is StitchErrorLike { /** The safe, fixed message the mapped exception carries by default. */ const SAFE_MESSAGE = 'Upstream request failed'; -export interface ToHttpExceptionOptions { +export interface StitchErrorOptions { /** * The HTTP status for the mapped exception. Default `502 Bad Gateway` — **every** * upstream failure is reported as a gateway error, regardless of the upstream's own @@ -33,50 +33,45 @@ export interface ToHttpExceptionOptions { */ status?: number | ((err: StitchErrorLike) => number); /** - * Set the client-facing message. **Default: a fixed `'Upstream request failed'`** — the - * raw `err.message` is deliberately *not* forwarded, because it can disclose internal - * network topology (a transport failure reads like - * `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`) - * to an untrusted client. The original error is always attached as the exception's - * `cause` for server-side logging. Provide a fixed string, or a function for a - * per-error message. Prefer this over {@link exposeMessage} when you want a specific, - * curated message. + * The response body for a mapped failure. **Default: Nest's rendering of a fixed, generic + * message** (`{ statusCode, message: 'Upstream request failed' }`) — the raw `err.message` + * is deliberately *not* echoed, because it can disclose internal network topology (a + * transport failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or the + * upstream's status (`HTTP 401`) to an untrusted client. Override to shape your own error + * envelope; pass `(e) => ({ error: e.message })` to opt in to the raw message when the + * upstream messages are known to be safe to expose. Receives the mapped status alongside + * the error. The original error is always attached as the exception's `cause` for + * server-side logging. */ - message?: string | ((err: StitchErrorLike) => string); - /** - * Opt in to forwarding the raw `err.message` as the client-facing message. **Default - * `false`** — see {@link message} for why the raw message is withheld by default. Ignored - * when {@link message} is set. Only enable this when the upstream messages are known to - * be safe to expose to your clients. - */ - exposeMessage?: boolean; + body?: (err: StitchErrorLike, status: number) => unknown; } /** * Map a thrown stitch failure to a Nest {@link HttpException}, or `undefined` when `err` * is not a {@link StitchErrorLike} (so a caller can rethrow it untouched). The status is - * `502` by default; override it via {@link ToHttpExceptionOptions.status}. + * `502` by default; override it via {@link StitchErrorOptions.status}. * - * The client-facing message defaults to a fixed `'Upstream request failed'` — the raw - * `err.message` is **not** forwarded, since it can leak internal hostnames or the - * upstream's status to an untrusted client. The original error is attached as the - * exception's `cause` for server-side logging. Opt in to the raw message with - * {@link ToHttpExceptionOptions.exposeMessage}, or set your own with - * {@link ToHttpExceptionOptions.message}. + * The client-facing body defaults to Nest's rendering of a fixed + * `'Upstream request failed'` — the raw `err.message` is **not** forwarded, since it can + * leak internal hostnames or the upstream's status to an untrusted client. The original + * error is attached as the exception's `cause` for server-side logging. Shape your own + * envelope (or opt in to the raw message) with {@link StitchErrorOptions.body}. */ export function toHttpException( err: unknown, - options: ToHttpExceptionOptions = {}, + options: StitchErrorOptions = {}, ): HttpException | undefined { if (!isStitchError(err)) return undefined; - const { status = HttpStatus.BAD_GATEWAY, message, exposeMessage } = options; + const { status = HttpStatus.BAD_GATEWAY } = options; const code = typeof status === 'function' ? status(err) : status; - const clientMessage = - typeof message === 'function' - ? message(err) - : (message ?? - (exposeMessage ? err.message || SAFE_MESSAGE : SAFE_MESSAGE)); - return new HttpException(clientMessage, code, { cause: err }); + // The default body is Nest's own rendering of the safe, fixed message — the raw + // `err.message` is deliberately withheld so an internal hostname (`getaddrinfo + // ENOTFOUND …`) or the upstream's status (`HTTP 401`) never reaches the client. + // Opt in / shape the envelope via `options.body`. + const response = options.body + ? (options.body(err, code) as string | Record) + : SAFE_MESSAGE; + return new HttpException(response, code, { cause: err }); } /** @@ -98,7 +93,7 @@ export function toHttpException( export class StitchExceptionFilter extends BaseExceptionFilter { constructor( applicationRef?: HttpServer, - private readonly options: ToHttpExceptionOptions = {}, + private readonly options: StitchErrorOptions = {}, ) { super(applicationRef); } diff --git a/packages/nest/src/index.ts b/packages/nest/src/index.ts index 4b7935af..bdafc3c5 100644 --- a/packages/nest/src/index.ts +++ b/packages/nest/src/index.ts @@ -5,6 +5,7 @@ export { type StitchModuleAsyncOptions, type StitchFeatureOptions, type StitchScopedFeatureOptions, + type NestFeatureSeamOptions, } from './module'; export { defineStitch, @@ -12,8 +13,6 @@ export { type StitchDef, type AnyStitchDef, type NestRequestSeam, - // Deprecated alias (kept through 1.0.0-rc, removed at GA) — see ADR 0012. - type StitchHost, type Injected, } from './define-stitch'; export { @@ -23,12 +22,6 @@ export { type NestConfigServiceLike, type NestLoggerLike, type NestLoggerSinkOptions, - // Deprecated aliases (kept through 1.0.0-rc, removed at GA) — see ADR 0012. - loggerSink, - fromConfig, - borrowStore, - type LoggerLike, - type ConfigServiceLike, } from './bridges'; export { STITCH_SEAM, STITCH_STORE, STITCH_TRACE } from './tokens'; export { @@ -36,6 +29,11 @@ export { toHttpException, isStitchError, type StitchErrorLike, - type ToHttpExceptionOptions, + type StitchErrorOptions, } from './exception-filter'; -export { stitchSse, type MessageEventLike, type StitchSseOptions } from './sse'; +export { + streamStitchSse, + type MessageEventLike, + type StitchEventSource, + type StreamStitchSseOptions, +} from './sse'; diff --git a/packages/nest/src/module.ts b/packages/nest/src/module.ts index 213a9e8c..69722d16 100644 --- a/packages/nest/src/module.ts +++ b/packages/nest/src/module.ts @@ -2,7 +2,11 @@ // configure shared infrastructure (store + trace) and a default seam; forFeature // registers injectable stitches and, optionally, a per-upstream feature seam built over // that shared infrastructure. `SeamRegistry` owns the shutdown lifecycle. -import { nestBorrowStore, nestLoggerSink } from './bridges'; +import { + type NestLoggerSinkOptions, + nestBorrowStore, + nestLoggerSink, +} from './bridges'; import type { AnyStitchDef, NestRequestSeam } from './define-stitch'; import { STITCH_SEAM, STITCH_STORE, STITCH_TRACE } from './tokens'; @@ -19,6 +23,7 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { + type AtLeastOne, type Seam, type SeamConfig, type SeamOptions, @@ -28,10 +33,17 @@ import { seam, } from 'stitchapi'; -/** forRoot options: the shared `SeamConfig` defaults + infra, plus the `'logger'` trace sentinel. */ -export interface StitchModuleOptions extends Omit { - /** A `TraceSink`, `'console'`, `false`, or the `'logger'` sentinel (→ Nest Logger). Default: off. */ - trace?: SeamOptions['trace'] | 'logger'; +/** forRoot options: the shared `SeamConfig` defaults + infra, plus the Nest `logger` bridge. */ +export interface StitchModuleOptions extends SeamOptions { + /** + * Bridge stitch events into a Nest `Logger` (via {@link nestLoggerSink}) as the seam's + * `TraceSink`. **Default `true`** — aligned with `@stitchapi/fastify`'s plugin `logger` + * default, so every host integration traces through its framework logger out of the box. + * Pass sink options to customise (`{ lifecycle: false }`), or `false` to leave tracing as + * core configures it (off). Ignored when `trace` is set — an explicit trace sink wins, + * exactly as a `trace` on a Fastify `seamConfig` wins over its `logger` bridge. + */ + logger?: boolean | AtLeastOne; /** Register as a global module (default `true`). */ isGlobal?: boolean; } @@ -47,16 +59,30 @@ export interface StitchModuleAsyncOptions { isGlobal?: boolean; } +/** + * The feature seam's build config and its DI exposure token — two facets of one capability + * folded into a single envelope (CONTRACT.md P24; `seamConfig`/`seamToken` shared the "seam" + * prefix, R8). Naming the envelope `seam` while it carries a `config` member is intentional: + * `config` is what carries the P1 clarity the original `seamConfig` rename was for, so nesting + * it here loses nothing. + */ +export interface NestFeatureSeamOptions { + /** The `SeamConfig` for this feature's own seam (its `baseUrl`/`auth`/…), built over the + * shared store + trace. Omit to attach the stitches to the root/default seam. At least one + * member is required — an empty `{}` is indistinguishable from omitting it (P20). */ + config?: AtLeastOne; + /** Token to expose the feature seam under, for `.as(principal)` multi-tenant. */ + token?: InjectionToken; +} + /** forFeature options — a feature's injectable stitches and, optionally, its own upstream seam. */ export interface StitchFeatureOptions { // Any-input element: a feature registry is heterogeneous, so it must admit templated-path defs // whose call argument *requires* `params` (see `AnyStitchDef`). Per-def inference is unaffected. stitches: AnyStitchDef[]; - /** This feature's own seam (its `baseUrl`/`auth`/…), built over the shared store + trace. - * Omit to attach the stitches to the root/default seam. */ - seam?: SeamConfig; - /** Token to expose the feature seam under, for `.as(principal)` multi-tenant. */ - seamToken?: InjectionToken; + /** The feature's own seam config and/or its DI exposure token. At least one member is + * required — an empty `{}` is indistinguishable from omitting it (P20). */ + seam?: AtLeastOne; } /** forFeatureScoped options — {@link StitchFeatureOptions} plus a `principal` derived @@ -74,19 +100,27 @@ interface Infra { } // Normalise module options into shared infra: borrow an app-provided store (else own a -// memoryStore), expand the `'logger'` trace sentinel, default tracing OFF. +// memoryStore), resolve the `logger` bridge (default ON, matching @stitchapi/fastify) — +// an explicit `trace` wins over it. function resolveInfra(options: StitchModuleOptions): Infra { - const { store, trace } = options; + const { store, trace, logger } = options; const defaults: Record = { ...options }; delete defaults['store']; delete defaults['trace']; + delete defaults['logger']; delete defaults['isGlobal']; + const sinkOptions: NestLoggerSinkOptions = + typeof logger === 'object' ? logger : {}; return { store: store ? nestBorrowStore(store) : memoryStore(), + // An explicit `trace` wins; otherwise bridge the Nest Logger unless `logger: false` + // (the same precedence as the Fastify plugin's `seamConfig.trace` vs `logger`). trace: - trace === 'logger' - ? nestLoggerSink(new Logger('Stitch')) - : (trace ?? false), + trace !== undefined + ? trace + : logger !== false + ? nestLoggerSink(new Logger('Stitch'), sinkOptions) + : false, defaults: defaults as Omit, }; } @@ -183,11 +217,11 @@ export class StitchModule { const norm: StitchFeatureOptions = Array.isArray(opts) ? { stitches: opts } : opts; - const cfg = norm.seam; + const cfg = norm.seam?.config; // A feature seam gets its own token (or the caller's, for multi-tenant `.as()`); // with no feature seam, stitches bind to the root/default seam. const token: InjectionToken = - norm.seamToken ?? + norm.seam?.token ?? (cfg ? Symbol('stitch-feature-seam') : STITCH_SEAM); const providers: Provider[] = []; const exported: InjectionToken[] = []; @@ -226,14 +260,14 @@ export class StitchModule { * singleton seam and bind explicitly instead: `seam.as(job.data.tenantId)`. */ static forFeatureScoped(opts: StitchScopedFeatureOptions): DynamicModule { - const cfg = opts.seam; + const cfg = opts.seam?.config; // The singleton base seam each per-request handle derives from: a feature seam over - // shared infra (if `seam` given) or the root/default seam. + // shared infra (if `seam.config` given) or the root/default seam. const baseToken: InjectionToken = cfg ? Symbol('stitch-scoped-base-seam') : STITCH_SEAM; const principalToken: InjectionToken = - opts.seamToken ?? Symbol('stitch-principal-seam'); + opts.seam?.token ?? Symbol('stitch-principal-seam'); const providers: Provider[] = []; const exported: InjectionToken[] = [principalToken]; diff --git a/packages/nest/src/sse.ts b/packages/nest/src/sse.ts index a5824d5d..3921e193 100644 --- a/packages/nest/src/sse.ts +++ b/packages/nest/src/sse.ts @@ -3,18 +3,26 @@ // This bridges the two so a streaming endpoint is one line, and aborts the upstream // generator when the client disconnects (the subscription tears down). import { Observable } from 'rxjs'; -import type { StitchEvent } from 'stitchapi'; +import type { StitchEvent, StitchEventSource } from 'stitchapi'; + +// The canonical intake for event-stream consumers, re-exported from the core barrel: the +// event iterable itself (a `.stream()` generator) or anything that hands one back (a +// `StitchResult`, a stitch stub). +export type { StitchEventSource } from 'stitchapi'; /** The terminal `error` event a stitch stream emits — carries `message`, `status`, `attempts`. */ type StitchErrorEvent = Extract; -/** The safe, fixed message the errored observable carries by default (mirrors the exception filter). */ -const SAFE_MESSAGE = 'Upstream request failed'; +// The generic token an `error` frame carries by default: the raw upstream message is withheld so an +// internal hostname (`getaddrinfo ENOTFOUND …`) or the upstream's status (`HTTP 401`) never reaches +// the client. Override with `options.errorData`. +const DEFAULT_ERROR_DATA = 'error'; /** * The SSE message shape Nest's `@Sse()` consumes — declared structurally so this * package does not import Nest's `MessageEvent` type (one less coupling); Nest's - * `MessageEvent` satisfies it. `data` is the only field the bridge sets. + * `MessageEvent` satisfies it. The bridge sets `data` (always), plus `type`/`id` + * when the `event`/`id` options are given. */ export interface MessageEventLike { data: string | object; @@ -23,37 +31,55 @@ export interface MessageEventLike { retry?: number; } -export interface StitchSseOptions { +export interface StreamStitchSseOptions { + /** + * Map a `delta` chunk to the SSE message `data` string. The default sends a string chunk + * as-is and `JSON.stringify`-s anything else. Use this to pull the text out of a structured + * chunk, e.g. `data: (c) => c.choices[0].delta.content`. Receives the zero-based message + * index alongside the chunk. + */ + data?: (chunk: unknown, index: number) => string; + /** + * Emit an `event:` line per delta message (the SSE event name — Nest's `MessageEvent.type`): + * a fixed name, or a function of the chunk for per-message names. Default: none (an unnamed + * `message` event, which `EventSource.onmessage` receives). + */ + event?: string | ((chunk: unknown) => string); /** - * Map a `delta` chunk to the SSE message `data`. Default: the chunk itself (a - * string is sent as-is; an object is JSON-serialised by Nest). Use this to pull - * the text out of a structured chunk, e.g. `data: (c) => c.choices[0].delta`. + * Provide an `id:` line per delta message (the SSE last-event id), e.g. for resumable + * streams. Receives the chunk and the zero-based message index. */ - data?: (chunk: unknown) => string | object; + id?: (chunk: unknown, index: number) => string; /** - * Set the client-facing message for a terminal `error` event. Nest renders an errored - * `@Sse()` observable's `message` to the client as the final `event: error` frame's data, - * so this controls what the browser's `EventSource` receives. **Default: a fixed - * `'Upstream request failed'`** — the raw `event.message` is deliberately *not* forwarded, - * because it can disclose internal network topology (a transport failure reads like - * `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`) to - * an untrusted client. The original error is always attached as the errored observable's - * `cause` for server-side logging. Provide a fixed string, or a function for a per-error - * message. Prefer this over {@link exposeMessage} when you want a specific, curated message. + * Shape the SSE `data` written for a terminal `error` event (or an uncaught throw + * mid-stream, normalised to an error event). Nest renders an errored `@Sse()` observable's + * `message` to the client as the final `event: error` frame's data, so this controls what + * the browser's `EventSource` receives. **Default: a generic token (`data: error`)** — the + * raw `event.message` is deliberately *not* echoed, because it can disclose internal network + * topology (a transport failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) + * or the upstream's status (`HTTP 401`) to an untrusted client. Opt in with + * `(e) => e.message` when the upstream messages are known safe, or return your own payload + * (e.g. `() => JSON.stringify({ error: 'stream failed' })`). */ - message?: string | ((event: StitchErrorEvent) => string); + errorData?: (event: StitchErrorEvent) => string; /** - * Opt in to forwarding the raw `event.message` as the client-facing message. **Default - * `false`** — see {@link message} for why the raw message is withheld by default. Ignored - * when {@link message} is set. Only enable this when the upstream messages are known to be - * safe to expose to your clients. + * Called once, server-side, if the underlying stream errors (a stitch `error` event, or a + * throw) — use it to observe/log the real failure. It does **not** shape the client-facing + * frame: the SSE `data` sent to the client is controlled by `errorData` (a generic token by + * default), so the raw message reaches your logs here but not the client. Host-specific + * extra: the original failure is *also* attached as the errored observable's `cause`. */ - exposeMessage?: boolean; + onError?: (err: unknown) => void; } -// Normalise a thrown value into the terminal `error` event shape, so a `message` opt-in sees a +// One delta chunk → the message `data` string: a string chunk as-is, anything else JSON. +function defaultData(chunk: unknown): string { + return typeof chunk === 'string' ? chunk : JSON.stringify(chunk); +} + +// Normalise a thrown value into the terminal `error` event shape, so an `errorData` opt-in sees a // consistent argument whether the failure arrived as a surfaced `error` event or an unexpected -// throw. `attempts`/`at` are best-effort placeholders — a `message` hook keys off `name`/`message`. +// throw. `attempts`/`at` are best-effort placeholders — an `errorData` hook keys off `name`/`message`. function toErrorEvent(err: unknown): StitchErrorEvent { const e = err instanceof Error ? err : new Error(String(err)); return { @@ -65,66 +91,86 @@ function toErrorEvent(err: unknown): StitchErrorEvent { }; } -// The Error the observable is errored with. By default it carries the safe, fixed message — the +// The Error the observable is errored with. By default it carries the generic `error` token — the // raw `event.message` is withheld, since Nest writes an errored observable's `message` straight to -// the client (an internal hostname / `HTTP 401` would leak). `exposeMessage`/`message` opt in; the -// original failure is always attached as `cause` for server-side logging. +// the client (an internal hostname / `HTTP 401` would leak). `errorData` opts in; the original +// failure is always attached as `cause` for server-side logging. function clientError( event: StitchErrorEvent, - options: StitchSseOptions, + options: StreamStitchSseOptions, cause: unknown, ): Error { - const { message, exposeMessage } = options; - const text = - typeof message === 'function' - ? message(event) - : (message ?? - (exposeMessage ? event.message || SAFE_MESSAGE : SAFE_MESSAGE)); + const text = options.errorData + ? options.errorData(event) + : DEFAULT_ERROR_DATA; return new Error(text, { cause }); } +// Accept the canonical `StitchEventSource` intake: the iterable itself, or anything that hands one +// back (a `StitchResult`, a stitch stub). +function toIterable( + source: StitchEventSource, +): AsyncIterable> { + return Symbol.asyncIterator in source ? source : source.stream(); +} + /** - * Adapt a stitch's `stream()` (or any `AsyncIterable`) into an + * Adapt a stitch's `stream()` (or any {@link StitchEventSource}) into an * `Observable` for an `@Sse()` endpoint: each `delta` becomes a * message, an `error` event errors the observable (Nest renders that to the client as a - * final `event: error` frame — a fixed, safe message by default, the raw message withheld to - * avoid disclosing internal topology; opt in via `exposeMessage`/`message`), and stream end - * completes it. The non-output events (`start` / `progress` / `drift` / `result` / `done`) are - * control signals and are not forwarded to the client. + * final `event: error` frame — a generic `data: error` by default, the raw message withheld to + * avoid disclosing internal topology; opt in via `errorData`, observe the real failure + * server-side via `onError`), and stream end completes it. The non-output events + * (`start` / `progress` / `drift` / `result` / `done`) are control signals and are not + * forwarded to the client. * * ```ts * @Sse('chat') * chat(@Query('q') q: string) { - * return stitchSse(this.complete.stream({ body: { prompt: q } }), - * { data: (c: any) => c.text }); + * return streamStitchSse(this.complete.stream({ body: { prompt: q } }), + * { data: (c: any) => c.text }); * } * ``` * * When the client disconnects, Nest unsubscribes; the teardown calls the iterator's * `return()` so the underlying stitch stream is aborted rather than left running. */ -export function stitchSse( - stream: AsyncIterable>, - options: StitchSseOptions = {}, +export function streamStitchSse( + source: StitchEventSource, + options: StreamStitchSseOptions = {}, ): Observable { - const toData = - options.data ?? ((chunk: unknown) => chunk as string | object); + const toData: (chunk: unknown, index: number) => string = + options.data ?? defaultData; return new Observable((subscriber) => { - const iterator = stream[Symbol.asyncIterator](); + const iterator = toIterable(source)[Symbol.asyncIterator](); let active = true; void (async () => { + let index = 0; try { while (active) { const { value: event, done } = await iterator.next(); if (done) break; if (event.type === 'delta') { - subscriber.next({ data: toData(event.chunk) }); + const message: MessageEventLike = { + data: toData(event.chunk, index), + }; + if (options.event !== undefined) + message.type = + typeof options.event === 'function' + ? options.event(event.chunk) + : options.event; + if (options.id) + message.id = options.id(event.chunk, index); + subscriber.next(message); + index += 1; } else if (event.type === 'error') { - // Error the observable — but by default with a safe, fixed message, never - // the raw `event.message`: Nest writes an errored `@Sse()` observable's - // `message` straight to the client, so echoing it would disclose an internal - // hostname (`getaddrinfo ENOTFOUND …`) or the upstream's status (`HTTP 401`). - // The event is attached as `cause`; opt in via `exposeMessage`/`message`. + // Error the observable — but by default with the generic token, never the + // raw `event.message`: Nest writes an errored `@Sse()` observable's + // `message` straight to the client, so echoing it would disclose an + // internal hostname (`getaddrinfo ENOTFOUND …`) or the upstream's status + // (`HTTP 401`). `onError` gets the real failure server-side; the event is + // also attached as `cause`; `errorData` shapes the client frame. + options.onError?.(new Error(event.message)); subscriber.error(clientError(event, options, event)); return; } @@ -132,8 +178,9 @@ export function stitchSse( subscriber.complete(); } catch (err) { // A throw (not a surfaced `error` event): withhold the raw message the same way — - // normalise it to an error event so a `message` opt-in sees a consistent shape, and - // attach the original as `cause`. + // normalise it to an error event so an `errorData` opt-in sees a consistent shape, + // and attach the original as `cause`. + options.onError?.(err); subscriber.error(clientError(toErrorEvent(err), options, err)); } })(); diff --git a/packages/nest/test/bridges.spec.ts b/packages/nest/test/bridges.spec.ts index e8545259..a54f1713 100644 --- a/packages/nest/test/bridges.spec.ts +++ b/packages/nest/test/bridges.spec.ts @@ -1,10 +1,10 @@ // Unit tests for @stitchapi/nest's three bridges (`bridges.ts`), which no spec -// touched: `loggerSink` (StitchEvent → Nest log level, delegating to core), the -// `fromConfig` ConfigService-backed secret resolver, and `borrowStore` (a store +// touched: `nestLoggerSink` (StitchEvent → Nest log level, delegating to core), the +// `fromNestConfig` ConfigService-backed secret resolver, and `nestBorrowStore` (a store // wrapper that deliberately omits `close`). Pure — no Nest app, no network: we // drive recording doubles directly. -import { borrowStore, fromConfig, loggerSink } from '../src'; -import type { ConfigServiceLike, LoggerLike } from '../src'; +import { fromNestConfig, nestBorrowStore, nestLoggerSink } from '../src'; +import type { NestConfigServiceLike, NestLoggerLike } from '../src'; import type { StitchEvent, StitchStore, TraceContext } from 'stitchapi'; import { describe, expect, test } from 'vitest'; @@ -16,7 +16,7 @@ type RecordedLevel = 'log' | 'warn' | 'error' | 'debug' | 'verbose'; // A recording Nest-logger double. `partial` omits debug/verbose to prove the // sink guards those optional methods. function recorder(partial = false): { - logger: LoggerLike; + logger: NestLoggerLike; calls: Array<{ level: RecordedLevel; message: string }>; } { const calls: Array<{ level: RecordedLevel; message: string }> = []; @@ -25,7 +25,7 @@ function recorder(partial = false): { (m: string): void => { calls.push({ level, message: m }); }; - const logger: LoggerLike = partial + const logger: NestLoggerLike = partial ? { log: push('log'), warn: push('warn'), error: push('error') } : { log: push('log'), @@ -48,10 +48,10 @@ const startEvent = (url: string, input = {}): StitchEvent => ({ at: 0, }); -describe('loggerSink — level mapping', () => { +describe('nestLoggerSink — level mapping', () => { test('maps each event type to a Nest level (result → verbose, lifecycle → debug)', () => { const { logger, calls } = recorder(); - const sink = loggerSink(logger); + const sink = nestLoggerSink(logger); const events: StitchEvent[] = [ startEvent('https://api.test/u'), @@ -90,7 +90,7 @@ describe('loggerSink — level mapping', () => { test('drift follows its finding level, pinning info-drift to debug', () => { const { logger, calls } = recorder(); - const sink = loggerSink(logger); + const sink = nestLoggerSink(logger); const drift = (level: 'error' | 'warn' | 'info'): StitchEvent => ({ type: 'drift', finding: { level, path: 'data.id', change: 'coerced' }, @@ -106,7 +106,7 @@ describe('loggerSink — level mapping', () => { test('delta chunks and info announcements are never logged', () => { const { logger, calls } = recorder(); - const sink = loggerSink(logger); + const sink = nestLoggerSink(logger); sink.handle({ type: 'info', topic: 'auth', detail: 'x', at: 0 }, ctx); sink.handle({ type: 'delta', chunk: 'raw-data', at: 0 }, ctx); @@ -116,7 +116,7 @@ describe('loggerSink — level mapping', () => { test('lifecycle:false drops start/result/done but keeps errors and retries', () => { const { logger, calls } = recorder(); - const sink = loggerSink(logger, { lifecycle: false }); + const sink = nestLoggerSink(logger, { lifecycle: false }); sink.handle(startEvent('https://api.test/u'), ctx); sink.handle( @@ -141,7 +141,7 @@ describe('loggerSink — level mapping', () => { test('a partial logger (no debug/verbose) does not throw — those events are skipped', () => { const { logger, calls } = recorder(true); - const sink = loggerSink(logger); + const sink = nestLoggerSink(logger); sink.handle(startEvent('https://api.test/u'), ctx); // debug → no-op sink.handle( @@ -157,10 +157,10 @@ describe('loggerSink — level mapping', () => { }); }); -describe('loggerSink — messages & security', () => { +describe('nestLoggerSink — messages & security', () => { test('uses the trace-context name in the message', () => { const { logger, calls } = recorder(); - const sink = loggerSink(logger); + const sink = nestLoggerSink(logger); sink.handle( { type: 'result', data: 1, status: 201, attempts: 2, at: 0 }, @@ -172,7 +172,7 @@ describe('loggerSink — messages & security', () => { test('strips the URL query and logs only metadata — never raw input/value/delta', () => { const { logger, calls } = recorder(); - const sink = loggerSink(logger); + const sink = nestLoggerSink(logger); sink.handle( startEvent('https://api.test/login?api_key=SECRET', { @@ -207,11 +207,11 @@ describe('loggerSink — messages & security', () => { // --- fromConfig ------------------------------------------------------------ function fakeConfig(map: Record): { - config: ConfigServiceLike; + config: NestConfigServiceLike; keys: string[]; } { const keys: string[] = []; - const config: ConfigServiceLike = { + const config: NestConfigServiceLike = { getOrThrow(key: string): T { keys.push(key); if (!(key in map)) { @@ -223,10 +223,10 @@ function fakeConfig(map: Record): { return { config, keys }; } -describe('fromConfig', () => { +describe('fromNestConfig', () => { test('resolves lazily at call time, then returns the value', () => { const { config, keys } = fakeConfig({ API_TOKEN: 'tok' }); - const thunk = fromConfig(config)('API_TOKEN'); + const thunk = fromNestConfig(config)('API_TOKEN'); // The thunk holds the key but has not touched the config yet — the secret // never lands on __config or in a trace. @@ -237,20 +237,20 @@ describe('fromConfig', () => { test('propagates a missing-key error from getOrThrow', () => { const { config } = fakeConfig({}); - const resolve = fromConfig(config)('MISSING'); + const resolve = fromNestConfig(config)('MISSING'); expect(() => resolve()).toThrow(/Configuration key "MISSING"/); }); test('rejects an empty value so a blank credential can never ride along', () => { const { config } = fakeConfig({ BLANK: '' }); - const resolve = fromConfig(config)('BLANK'); + const resolve = fromNestConfig(config)('BLANK'); expect(() => resolve()).toThrow(/missing secret BLANK/); }); }); // --- borrowStore ----------------------------------------------------------- -describe('borrowStore', () => { +describe('nestBorrowStore', () => { test('delegates get/set/incr but omits close, so a seam cannot dispose the app store', async () => { const seen: string[] = []; let closed = false; @@ -271,7 +271,7 @@ describe('borrowStore', () => { }, }; - const borrowed = borrowStore(store); + const borrowed = nestBorrowStore(store); // The borrowed wrapper exposes no close (ADR 0006 Decision 8). expect(borrowed.close).toBeUndefined(); diff --git a/packages/nest/test/exception-filter.spec.ts b/packages/nest/test/exception-filter.spec.ts index 7f7d5759..98b5c597 100644 --- a/packages/nest/test/exception-filter.spec.ts +++ b/packages/nest/test/exception-filter.spec.ts @@ -95,23 +95,26 @@ describe('toHttpException', () => { expect(ex.cause).toBe(original); }); - it('opt-in `exposeMessage: true` includes the raw message', () => { + it('opt-in `body` can echo the raw message when the caller chooses to', () => { const ex = toHttpException( stitchError('getaddrinfo ENOTFOUND payments.internal.corp'), - { exposeMessage: true }, + { body: (e) => ({ error: e.message }) }, )!; expect(bodyOf(ex)).toContain('payments.internal.corp'); }); - it('opt-in `message` override sets a caller-chosen message', () => { - const fixed = toHttpException(stitchError('HTTP 401', 401), { - message: 'Payment provider unavailable', + it('`body` shapes a caller-chosen error envelope, receiving the mapped status', () => { + const ex = toHttpException(stitchError('HTTP 401', 401), { + body: (_e, status) => ({ + error: 'Payment provider unavailable', + status, + }), })!; - expect(fixed.message).toBe('Payment provider unavailable'); - const fromFn = toHttpException(stitchError('HTTP 429', 429), { - message: (e) => `upstream said ${e.status}`, - })!; - expect(fromFn.message).toBe('upstream said 429'); + expect(ex.getResponse()).toEqual({ + error: 'Payment provider unavailable', + status: 502, // the mapped status, not the upstream 401 + }); + expect(bodyOf(ex)).not.toContain('HTTP 401'); }); }); }); diff --git a/packages/nest/test/module.spec.ts b/packages/nest/test/module.spec.ts index 03e949d9..31a83993 100644 --- a/packages/nest/test/module.spec.ts +++ b/packages/nest/test/module.spec.ts @@ -2,9 +2,6 @@ // directly (no Nest DI container needed) and run the resulting stitches against a mock // adapter, asserting wire-level effects — the same style as core's tests. import { - // Deprecated aliases (ADR 0012) — exercised by the alias-guard test below. - type ConfigServiceLike, - type LoggerLike, type NestConfigServiceLike, type NestLoggerLike, STITCH_SEAM, @@ -12,11 +9,8 @@ import { STITCH_TRACE, SeamRegistry, StitchModule, - borrowStore, defineStitch, - fromConfig, fromNestConfig, - loggerSink, nestBorrowStore, nestLoggerSink, } from '../src'; @@ -49,6 +43,7 @@ describe('StitchModule.forRoot', () => { const mod = StitchModule.forRoot({ baseUrl: 'https://api.test', adapter: recordingAdapter(calls), + logger: false, // keep the default Nest Logger bridge out of the test output }); expect(mod.global).toBe(true); @@ -66,20 +61,38 @@ describe('StitchModule.forRoot', () => { await reg.closeAll(); // must not throw }); - it('defaults tracing OFF (no STITCH_TRACE sink)', () => { + // The `logger` bridge defaults ON — aligned with @stitchapi/fastify's plugin default. + it('defaults the Nest Logger bridge ON (STITCH_TRACE is a sink)', () => { const providers = StitchModule.forRoot().providers as FProv[]; const traceProv = providers.find((p) => p.provide === STITCH_TRACE); - expect(traceProv?.useValue).toBe(false); + expect( + typeof (traceProv?.useValue as { handle?: unknown }).handle, + ).toBe('function'); }); - it("expands the 'logger' trace sentinel to a sink", () => { - const providers = StitchModule.forRoot({ trace: 'logger' }) + it('logger: false turns the bridge off (tracing falls back to core’s off default)', () => { + const providers = StitchModule.forRoot({ logger: false }) .providers as FProv[]; const traceProv = providers.find((p) => p.provide === STITCH_TRACE); + expect(traceProv?.useValue).toBe(false); + }); + + it('logger accepts sink options (AtLeastOne envelope) and still yields a sink', () => { + const providers = StitchModule.forRoot({ + logger: { lifecycle: false }, + }).providers as FProv[]; + const traceProv = providers.find((p) => p.provide === STITCH_TRACE); expect( typeof (traceProv?.useValue as { handle?: unknown }).handle, ).toBe('function'); }); + + it('an explicit trace wins over the logger bridge', () => { + const providers = StitchModule.forRoot({ trace: false, logger: true }) + .providers as FProv[]; + const traceProv = providers.find((p) => p.provide === STITCH_TRACE); + expect(traceProv?.useValue).toBe(false); + }); }); describe('StitchModule.forFeature', () => { @@ -90,8 +103,10 @@ describe('StitchModule.forFeature', () => { ); const mod = StitchModule.forFeature({ seam: { - baseUrl: 'https://feat.test', - adapter: recordingAdapter(calls), + config: { + baseUrl: 'https://feat.test', + adapter: recordingAdapter(calls), + }, }, stitches: [GetThing], }); @@ -130,6 +145,39 @@ describe('StitchModule.forFeature', () => { expect(providers[0]?.inject).toEqual([STITCH_SEAM]); }); + // P20/P24: `seam` is `AtLeastOne` — an empty `{}` is + // indistinguishable from omitting the envelope entirely, so it must not compile. + // `config`/`token` compose freely (each independently satisfies AtLeastOne). + it('rejects an empty seam envelope at compile time, but config + token compose', () => { + const GetThing = defineStitch('GET_THING_3', (h) => + h.stitch({ path: '/thing' }), + ); + const TOKEN = Symbol('feature-seam-token'); + + // @ts-expect-error — `seam: {}` satisfies neither `config` nor `token`; P20/P24. + StitchModule.forFeature({ seam: {}, stitches: [GetThing] }); + + // Both facets together — config builds the seam, token exposes it under a + // caller-chosen DI token (not just the root/default XOR one-of-them cases above). + const providers = StitchModule.forFeature({ + seam: { + config: { + baseUrl: 'https://feat.test', + adapter: recordingAdapter([]), + }, + token: TOKEN, + }, + stitches: [GetThing], + }).providers as FProv[]; + const seamProv = providers.find((p) => p.provide === TOKEN); + expect(seamProv).toBeDefined(); + expect(seamProv?.inject).toEqual([ + STITCH_STORE, + STITCH_TRACE, + SeamRegistry, + ]); + }); + // Regression (path-vars fallout, #114): a templated-path def's call argument now *requires* // `params`, so its `StitchDef` has a narrower (contravariant) input than the loose default. // The feature registry must still admit it — `stitches` is bound to the any-input @@ -145,8 +193,10 @@ describe('StitchModule.forFeature', () => { ]; const providers = StitchModule.forFeature({ seam: { - baseUrl: 'https://feat.test', - adapter: recordingAdapter(calls), + config: { + baseUrl: 'https://feat.test', + adapter: recordingAdapter(calls), + }, }, stitches: mixed, }).providers as FProv[]; @@ -175,12 +225,16 @@ describe('StitchModule.forFeatureScoped', () => { const TENANT = Symbol('tenant'); const GetThing = defineStitch((h) => h.stitch({ path: '/thing' })); const mod = StitchModule.forFeatureScoped({ + // Both facets of the envelope together: config builds the feature seam, token + // exposes it under a caller-chosen DI token — proving they compose (P24). seam: { - baseUrl: 'https://feat.test', - adapter: recordingAdapter(calls), + config: { + baseUrl: 'https://feat.test', + adapter: recordingAdapter(calls), + }, + token: TENANT, }, stitches: [GetThing], - seamToken: TENANT, principal: (req: { tenantId: string }) => req.tenantId, }); const providers = mod.providers as FProv[]; @@ -246,8 +300,10 @@ describe('defineStitch token', () => { const GetThing = defineStitch((h) => h.stitch({ path: '/thing' })); const providers = StitchModule.forFeature({ seam: { - baseUrl: 'https://feat.test', - adapter: recordingAdapter(calls), + config: { + baseUrl: 'https://feat.test', + adapter: recordingAdapter(calls), + }, }, stitches: [GetThing], }).providers as FProv[]; @@ -315,28 +371,6 @@ describe('bridges', () => { return { rec, logger }; }; - it('keeps the pre-ADR-0012 names as deprecated aliases of the ecosystem-qualified ones', () => { - // Runtime: each deprecated function export is the very same function object. - expect(loggerSink).toBe(nestLoggerSink); - expect(fromConfig).toBe(fromNestConfig); - expect(borrowStore).toBe(nestBorrowStore); - // Type-level: the deprecated type aliases stay interchangeable with the canonical ones. - const loggerViaDeprecated: LoggerLike = { - log() {}, - warn() {}, - error() {}, - }; - const loggerViaCanonical: NestLoggerLike = loggerViaDeprecated; - expect(typeof loggerViaCanonical.log).toBe('function'); - const cfgViaDeprecated: ConfigServiceLike = { - getOrThrow(key: string): T { - return key as T; - }, - }; - const cfgViaCanonical: NestConfigServiceLike = cfgViaDeprecated; - expect(typeof cfgViaCanonical.getOrThrow).toBe('function'); - }); - it('nestLoggerSink maps each event to the right level, payload-free, query redacted', () => { const { rec, logger } = recordingLogger(); const sink = nestLoggerSink(logger); diff --git a/packages/nest/test/sse.spec.ts b/packages/nest/test/sse.spec.ts index b62ebb11..3efd886c 100644 --- a/packages/nest/test/sse.spec.ts +++ b/packages/nest/test/sse.spec.ts @@ -1,31 +1,35 @@ -import { stitchSse } from '../src'; +import { streamStitchSse } from '../src'; +import type { MessageEventLike } from '../src'; import type { StitchEvent } from 'stitchapi'; import { describe, expect, it } from 'vitest'; -// Subscribe and collect message data until the observable terminates. +// Subscribe and collect messages until the observable terminates. const collect = ( - obs: ReturnType, -): Promise<{ data: unknown[]; error?: Error }> => + obs: ReturnType, +): Promise<{ messages: MessageEventLike[]; error?: Error }> => new Promise((resolve) => { - const data: unknown[] = []; + const messages: MessageEventLike[] = []; obs.subscribe({ - next: (m) => data.push(m.data), - error: (error: Error) => resolve({ data, error }), - complete: () => resolve({ data }), + next: (m) => messages.push(m), + error: (error: Error) => resolve({ messages, error }), + complete: () => resolve({ messages }), }); }); +const dataOf = (messages: MessageEventLike[]): unknown[] => + messages.map((m) => m.data); + async function* events( ...evs: StitchEvent[] ): AsyncGenerator { for (const e of evs) yield e; } -describe('stitchSse', () => { +describe('streamStitchSse', () => { it('forwards delta chunks as messages and completes at stream end', async () => { - const { data, error } = await collect( - stitchSse( + const { messages, error } = await collect( + streamStitchSse( events( { type: 'start', @@ -42,12 +46,21 @@ describe('stitchSse', () => { ), ); expect(error).toBeUndefined(); - expect(data).toEqual(['a', 'b']); // control events not forwarded + expect(dataOf(messages)).toEqual(['a', 'b']); // control events not forwarded + }); + + it('accepts a { stream() } source (the core StitchEventSource intake)', async () => { + const source = { + stream: () => events({ type: 'delta', chunk: 'a', at: 0 }), + }; + const { messages, error } = await collect(streamStitchSse(source)); + expect(error).toBeUndefined(); + expect(dataOf(messages)).toEqual(['a']); }); - it('by default errors the observable with a safe message, withholding the raw upstream message', async () => { - const { data, error } = await collect( - stitchSse( + it('by default errors the observable with the generic token, withholding the raw upstream message', async () => { + const { messages, error } = await collect( + streamStitchSse( events( { type: 'delta', chunk: 'a', at: 0 }, { @@ -63,9 +76,10 @@ describe('stitchSse', () => { ), ), ); - expect(data).toEqual(['a']); // prior deltas are still delivered - // Nest renders an errored observable's `message` to the client, so it must be the safe token. - expect(error?.message).toBe('Upstream request failed'); + expect(dataOf(messages)).toEqual(['a']); // prior deltas are still delivered + // Nest renders an errored observable's `message` to the client, so it must be the + // generic token — the same default `data: error` the express/hono helpers write. + expect(error?.message).toBe('error'); expect(error?.message).not.toContain('payments.internal.corp'); // …but the raw failure is preserved server-side as the error's `cause`. expect( @@ -73,82 +87,150 @@ describe('stitchSse', () => { ).toBe('getaddrinfo ENOTFOUND payments.internal.corp'); }); - it('exposeMessage opts in to forwarding the raw upstream message', async () => { - const { data, error } = await collect( - stitchSse( - events( - { type: 'delta', chunk: 'a', at: 0 }, - { - type: 'error', - name: 'StitchError', - message: 'upstream blew up', - attempts: 1, - at: 0, - }, - ), - { exposeMessage: true }, + it('errorData shapes the client-facing error frame (raw message opt-in)', async () => { + const raw = await collect( + streamStitchSse( + events({ + type: 'error', + name: 'StitchError', + message: 'upstream blew up', + attempts: 1, + at: 0, + }), + { errorData: (e) => e.message }, ), ); - expect(data).toEqual(['a']); - expect(error?.message).toBe('upstream blew up'); - }); + expect(raw.error?.message).toBe('upstream blew up'); - it('message sets a curated client-facing message (string or function), overriding exposeMessage', async () => { - const fixed = await collect( - stitchSse( + const curated = await collect( + streamStitchSse( events({ type: 'error', name: 'StitchError', - message: 'getaddrinfo ENOTFOUND payments.internal.corp', + message: 'boom', + status: 503, attempts: 1, at: 0, }), - { - message: 'Payment provider unavailable', - exposeMessage: true, - }, + { errorData: (e) => `upstream ${e.status ?? '???'}` }, ), ); - expect(fixed.error?.message).toBe('Payment provider unavailable'); - expect(fixed.error?.message).not.toContain('payments.internal.corp'); + expect(curated.error?.message).toBe('upstream 503'); + }); - const dynamic = await collect( - stitchSse( + it('onError observes the real failure server-side without shaping the client frame', async () => { + const seen: unknown[] = []; + const { error } = await collect( + streamStitchSse( events({ type: 'error', name: 'StitchError', - message: 'boom', - status: 503, + message: 'getaddrinfo ENOTFOUND payments.internal.corp', attempts: 1, at: 0, }), - { message: (e) => `upstream ${e.status ?? '???'}` }, + { onError: (err) => seen.push(err) }, ), ); - expect(dynamic.error?.message).toBe('upstream 503'); + expect(seen).toHaveLength(1); + expect((seen[0] as Error).message).toBe( + 'getaddrinfo ENOTFOUND payments.internal.corp', + ); + // The client frame stays generic — onError is observation only. + expect(error?.message).toBe('error'); }); - it('by default withholds the raw message on a thrown error too, preserving it as cause', async () => { + it('by default withholds the raw message on a thrown error too, preserving it as cause and reporting via onError', async () => { async function* boom(): AsyncGenerator { yield { type: 'delta', chunk: 'a', at: 0 }; throw new Error('getaddrinfo ENOTFOUND payments.internal.corp'); } - const { data, error } = await collect(stitchSse(boom())); - expect(data).toEqual(['a']); - expect(error?.message).toBe('Upstream request failed'); + const seen: unknown[] = []; + const { messages, error } = await collect( + streamStitchSse(boom(), { onError: (err) => seen.push(err) }), + ); + expect(dataOf(messages)).toEqual(['a']); + expect(error?.message).toBe('error'); expect(error?.message).not.toContain('payments.internal.corp'); expect((error?.cause as Error | undefined)?.message).toBe( 'getaddrinfo ENOTFOUND payments.internal.corp', ); + expect((seen[0] as Error).message).toBe( + 'getaddrinfo ENOTFOUND payments.internal.corp', + ); }); - it('applies the data mapper to each chunk', async () => { - const { data } = await collect( - stitchSse(events({ type: 'delta', chunk: { text: 'hi' }, at: 0 }), { - data: (c) => (c as { text: string }).text, - }), + it('applies the data mapper to each chunk; the default JSON-stringifies non-strings', async () => { + const mapped = await collect( + streamStitchSse( + events({ type: 'delta', chunk: { text: 'hi' }, at: 0 }), + { data: (c) => (c as { text: string }).text }, + ), + ); + expect(dataOf(mapped.messages)).toEqual(['hi']); + + const stringified = await collect( + streamStitchSse( + events({ type: 'delta', chunk: { text: 'hi' }, at: 0 }), + ), + ); + expect(dataOf(stringified.messages)).toEqual(['{"text":"hi"}']); + }); + + it('the data mapper receives the zero-based message index', async () => { + const { messages } = await collect( + streamStitchSse( + events( + { type: 'delta', chunk: 'a', at: 0 }, + { type: 'delta', chunk: 'b', at: 0 }, + ), + { data: (c, index) => `${String(c)}#${index}` }, + ), + ); + expect(dataOf(messages)).toEqual(['a#0', 'b#1']); + }); + + it('event accepts a function of the chunk for per-message event names', async () => { + const { messages } = await collect( + streamStitchSse( + events( + { + type: 'delta', + chunk: { kind: 'token', text: 'a' }, + at: 0, + }, + { + type: 'delta', + chunk: { kind: 'usage', text: 'b' }, + at: 0, + }, + ), + { + event: (c) => (c as { kind: string }).kind, + data: (c) => (c as { text: string }).text, + }, + ), + ); + expect(messages).toEqual([ + { data: 'a', type: 'token' }, + { data: 'b', type: 'usage' }, + ]); + }); + + it('event names each message and id stamps the (chunk, index) last-event id', async () => { + const { messages } = await collect( + streamStitchSse( + events( + { type: 'delta', chunk: 'a', at: 0 }, + { type: 'delta', chunk: 'b', at: 0 }, + ), + { event: 'token', id: (_chunk, index) => `#${index}` }, + ), ); - expect(data).toEqual(['hi']); + expect(messages).toEqual([ + { data: 'a', type: 'token', id: '#0' }, + { data: 'b', type: 'token', id: '#1' }, + ]); }); it('aborts the upstream generator when the subscription tears down', async () => { @@ -170,7 +252,7 @@ describe('stitchSse', () => { }; }, }; - const sub = stitchSse(gen).subscribe({ next: () => {} }); + const sub = streamStitchSse(gen).subscribe({ next: () => {} }); sub.unsubscribe(); expect(returned).toBe(true); // teardown called iterator.return() }); diff --git a/packages/next/README.md b/packages/next/README.md index fbc205d1..2fcb194e 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -6,8 +6,8 @@ Next App Router route handlers are Web-standard — they take a `Request` and return a `Response` — so a stitch already runs in one directly: define a `seam` once and call it in the handler. What's worth a helper is the two bits you'd otherwise hand-roll on the Web platform: -- **`sseResponse(stitch.stream())`** — turn a streaming stitch into a `text/event-stream` `Response`. -- **`stitchErrorResponse(err)`** — map a thrown `StitchError` to a `Response` with a safe status. +- **`streamStitchSse(stitch.stream())`** — turn a streaming stitch into a `text/event-stream` `Response`. +- **`stitchErrorResponse(err)`** — map a thrown `StitchError` to a `Response` with a safe status, or `undefined` for anything else so you can rethrow it. Built on Web standards only (`Response`, `ReadableStream`, `TextEncoder`) — **no `next` import** — so the same helpers also work in Remix, SvelteKit endpoints, Bun, Deno, and Workers. @@ -27,7 +27,7 @@ A non-streaming endpoint is just the stitch plus the error helper: // app/api/users/[id]/route.ts import { getUser } from '@/lib/api'; -import { isStitchError, stitchErrorResponse } from '@stitchapi/next'; +import { stitchErrorResponse } from '@stitchapi/next'; export async function GET( _req: Request, @@ -37,27 +37,28 @@ export async function GET( try { return Response.json(await getUser({ params: { id } })); } catch (err) { - if (isStitchError(err)) return stitchErrorResponse(err); - throw err; + const mapped = stitchErrorResponse(err); + if (mapped) return mapped; // a Stitch failure → safe JSON Response + throw err; // anything else falls through untouched } } ``` -`stitchErrorResponse` maps a `StitchError` to `502` by default (never leaking the upstream's status); pass `{ status: (e) => e.status ?? 502 }` to propagate it. The body is a generic, status-tied message (`{ error: 'Bad Gateway' }`) — the raw `err.message` is withheld, since it can leak an internal hostname (`getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`). Opt in with `{ body: (e) => ({ error: e.message }) }` when the upstream messages are safe to expose. +`stitchErrorResponse` maps a `StitchError` to `502` by default (never leaking the upstream's status) and returns `undefined` for any other error, so the map-or-rethrow composition stays one branch; pass `{ status: (e) => e.status ?? 502 }` to propagate the upstream status. The body is a generic, status-tied message (`{ error: 'Bad Gateway' }`) — the raw `err.message` is withheld, since it can leak an internal hostname (`getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`). Opt in with `{ body: (e) => ({ error: e.message }) }` when the upstream messages are safe to expose. ## Streaming with SSE -`sseResponse` streams a stitch's events as `text/event-stream`. Each `delta` becomes one frame; an `error` event ends with a named `event: error` frame (a generic `data: error` by default — see below): +`streamStitchSse` streams a stitch's events as `text/event-stream`. Each `delta` becomes one frame; an `error` event ends with a named `event: error` frame (a generic `data: error` by default — see below): ```ts // app/api/chat/route.ts import { chat } from '@/lib/api'; -import { sseResponse } from '@stitchapi/next'; +import { streamStitchSse } from '@stitchapi/next'; export async function POST(request: Request) { const { prompt } = await request.json(); - return sseResponse(chat({ body: { prompt } }).stream(), { + return streamStitchSse(chat({ body: { prompt } }).stream(), { data: (c) => String(c), // pull text out of each chunk signal: request.signal, // abort the upstream if the client leaves }); @@ -66,11 +67,12 @@ export async function POST(request: Request) { Pass `request.signal` so a client disconnect tears the stitch down rather than leaving it running. -By default the `error` frame carries a generic `data: error` token, **not** the raw error message — echoing it can disclose internal network topology (a transport failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`) to the client. Pass `errorData` to opt in when the upstream messages are known safe to expose: +By default the `error` frame carries a generic `data: error` token, **not** the raw error message — echoing it can disclose internal network topology (a transport failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`) to the client. Pass `errorData` to opt in when the upstream messages are known safe to expose, and `onError` to observe/log the real failure server-side: ```ts -return sseResponse(chat({ body: { prompt } }).stream(), { +return streamStitchSse(chat({ body: { prompt } }).stream(), { errorData: (e) => e.message, // opt in to the raw upstream message + onError: (err) => console.error(err), // the real failure, server-side only }); ``` diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 868822b9..711e137c 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -5,40 +5,50 @@ // then call it in the handler. What's worth a helper is the two bits you'd otherwise // hand-roll on the Web platform: // -// - `sseResponse(stitch.stream())` — turn a streaming stitch into a `text/event-stream` -// `Response` (the Web-standard twin of `@stitchapi/express`'s `streamStitchSse`, -// which targets a Node `ServerResponse`). +// - `streamStitchSse(stitch.stream())` — turn a streaming stitch into a +// `text/event-stream` `Response` (the Web-standard twin of `@stitchapi/express`'s +// `streamStitchSse`, which targets a Node `ServerResponse`). // - `stitchErrorResponse(err)` — map a thrown `StitchError` to a `Response` with a -// safe status (default 502), so a route handler needs no bespoke error shaping. +// safe status (default 502), or `undefined` for anything else so the caller can +// rethrow it untouched. // // Built on Web standards (`Response`, `ReadableStream`, `TextEncoder`) only — no // `next` import — so the same helpers work in Next route handlers, Remix, SvelteKit // endpoints, Bun, Deno, and Workers. `stitchapi` is the only peer dependency. -import type { StitchEvent } from 'stitchapi'; +import type { StitchEvent, StitchEventSource } from 'stitchapi'; // --------------------------------------------------------------------------- // SSE Response // --------------------------------------------------------------------------- -/** Anything `sseResponse` can drive: a stitch `.stream()` generator, or any event - * iterable. */ -export type StitchEventSource = - | AsyncIterable> - | AsyncGenerator, void>; +/** + * Anything `streamStitchSse` can drive: the canonical event-stream intake from the + * `stitchapi` barrel — an event iterable (a `.stream()` generator), or anything that + * hands one back (a `StitchResult`, a stitch stub). + */ +export type { StitchEventSource } from 'stitchapi'; /** The terminal `error` event a stitch stream emits — carries `message`, `status`, `attempts`. */ type StitchErrorEvent = Extract; -export interface SseResponseOptions { +export interface StreamStitchSseOptions { /** * Map a `delta` chunk to the SSE frame `data`. Default: the chunk itself (a * string as-is; anything else `JSON.stringify`-ed). Use it to pull text out of a - * structured chunk, e.g. `data: (c) => c.choices[0].delta.content`. + * structured chunk, e.g. `data: (c) => c.choices[0].delta.content`. Receives the + * zero-based frame index alongside the chunk. + */ + data?: (chunk: unknown, index: number) => string; + /** + * Emit an `event:` line per delta frame (the SSE event name): a fixed name, or a + * function of the chunk for per-frame names. Default: none (an unnamed `message` + * event, which `EventSource.onmessage` receives). + */ + event?: string | ((chunk: unknown) => string); + /** + * Provide an `id:` line per delta frame (the SSE last-event id), e.g. for + * resumable streams. Receives the chunk and the zero-based frame index. */ - data?: (chunk: unknown) => string; - /** Emit an `event:` line per frame (the SSE event name). Default: unnamed. */ - event?: string; - /** Provide an `id:` line per frame (the SSE last-event id), for resumable streams. */ id?: (chunk: unknown, index: number) => string; /** * Shape the SSE `data` written for a terminal `error` event (or an uncaught throw @@ -51,10 +61,25 @@ export interface SseResponseOptions { * A multi-line return gets one `data:` line each (SSE spec); the `event: error` name is fixed. */ errorData?: (event: StitchErrorEvent) => string; - /** Extra response headers (merged over the SSE defaults). */ + /** + * Called once, server-side, if the underlying stream errors (a stitch `error` event, or a + * throw) — use it to observe/log the real failure. It does **not** shape the client-facing + * frame: the SSE `data` sent to the client is controlled by `errorData` (a generic token by + * default), so the raw message reaches your logs here but not the client. + */ + onError?: (err: unknown) => void; + /** + * Extra response headers (merged over the SSE defaults). Host-specific: this helper + * *builds* the Web `Response`, so header shaping happens here — hosts that write to a + * live response (Express, Fastify) set headers on it directly instead. + */ headers?: Record; - /** Abort the upstream iterator when this fires — pass the route handler's - * `request.signal` so a client disconnect tears the stitch down. */ + /** + * Abort the upstream iterator when this fires — pass the route handler's + * `request.signal` so a client disconnect tears the stitch down. Host-specific: + * Web-standard hosts signal disconnect via `AbortSignal`, where Node hosts use the + * response's `close` event. + */ signal?: AbortSignal; } @@ -63,15 +88,21 @@ export interface SseResponseOptions { function frame( chunk: unknown, index: number, - options: SseResponseOptions, + options: StreamStitchSseOptions, ): string { const payload = options.data - ? options.data(chunk) + ? options.data(chunk, index) : typeof chunk === 'string' ? chunk : JSON.stringify(chunk); let out = ''; - if (options.event) out += `event: ${options.event}\n`; + if (options.event !== undefined) { + const name = + typeof options.event === 'function' + ? options.event(chunk) + : options.event; + out += `event: ${name}\n`; + } if (options.id) out += `id: ${options.id(chunk, index)}\n`; for (const line of payload.split('\n')) out += `data: ${line}\n`; return `${out}\n`; @@ -105,6 +136,16 @@ function toErrorEvent(reason: unknown): StitchErrorEvent { }; } +// Resolve the canonical intake to the event iterable: an iterable is used as-is; anything +// carrying a `.stream()` (a `StitchResult`, a stitch stub) hands its stream over. +function toIterable( + source: StitchEventSource, +): AsyncIterable> { + return typeof (source as { stream?: unknown }).stream === 'function' + ? (source as { stream(): AsyncIterable> }).stream() + : (source as AsyncIterable>); +} + /** * Stream a stitch's events as a `text/event-stream` `Response`. Each `delta` becomes * one frame; an `error` event ends the stream with a named `event: error` frame (a generic @@ -113,27 +154,27 @@ function toErrorEvent(reason: unknown): StitchErrorEvent { * * ```ts * // app/api/chat/route.ts - * import { sseResponse } from '@stitchapi/next'; + * import { streamStitchSse } from '@stitchapi/next'; * * export async function POST(request: Request) { * const { prompt } = await request.json(); - * return sseResponse(chat({ body: { prompt } }).stream(), { + * return streamStitchSse(chat({ body: { prompt } }).stream(), { * data: (c) => String(c), * signal: request.signal, // abort the upstream if the client leaves * }); * } * ``` */ -export function sseResponse( +export function streamStitchSse( source: StitchEventSource, - options: SseResponseOptions = {}, + options: StreamStitchSseOptions = {}, ): Response { const encoder = new TextEncoder(); let index = 0; const stream = new ReadableStream({ async start(controller) { try { - for await (const event of source) { + for await (const event of toIterable(source)) { if (options.signal?.aborted) break; if (event.type === 'delta') { controller.enqueue( @@ -145,7 +186,9 @@ export function sseResponse( // Surface the failure as a named `error` frame, then stop — but by default // write a generic token, never the raw `event.message`, so an internal // hostname (`getaddrinfo ENOTFOUND …`) or the upstream's status (`HTTP 401`) - // is not disclosed. Opt in to the real message via `options.errorData`. + // is not disclosed. `onError` gets the real failure server-side; opt the + // client in to the real message via `options.errorData`. + options.onError?.(new Error(event.message)); controller.enqueue( encoder.encode( errorFrame( @@ -163,7 +206,8 @@ export function sseResponse( } catch (reason) { // A throw (not a surfaced `error` event): still withhold the raw message by // default — normalise it to an error event so an `errorData` opt-in sees a - // consistent shape. + // consistent shape. `onError` observes the original thrown value. + options.onError?.(reason); controller.enqueue( encoder.encode( errorFrame( @@ -193,14 +237,16 @@ export function sseResponse( // Error Response // --------------------------------------------------------------------------- -/** The error a stitch throws on failure: a branded `Error` with the upstream status. */ +/** The error a stitch rejects with on failure: a branded `Error` carrying the upstream status. */ export type StitchErrorLike = Error & { status?: number }; -/** True when `err` is the error a stitch throws on failure (`name === 'StitchError'`). */ +/** True when `err` is the error a stitch rejects with on failure (`name === 'StitchError'`). */ export function isStitchError(err: unknown): err is StitchErrorLike { return err instanceof Error && err.name === 'StitchError'; } +const BAD_GATEWAY = 502; + // A small map of the statuses this helper emits → their generic reason phrase, used for // the default body so the raw error message is never echoed to the client. const STATUS_TEXT: Record = { @@ -208,34 +254,38 @@ const STATUS_TEXT: Record = { 502: 'Bad Gateway', }; -export interface ErrorResponseOptions { +export interface StitchErrorOptions { /** - * The HTTP status for the mapped failure. Default `502` for a `StitchError` (an - * upstream gateway failure) and `500` otherwise — the safe default never leaks an - * upstream's `401`/`404` semantics to your client. Override with a number, or a - * function: propagate the upstream status with `(e) => e.status ?? 502`. + * The HTTP status for the mapped response. Default `502 Bad Gateway` — **every** upstream + * failure is reported as a gateway error, regardless of the upstream's own status. This is the + * safe default: it never leaks an upstream's `401`/`404`/etc. semantics to your client. Override + * per call — a fixed number, or a function for full control: propagate the upstream status with + * `(e) => e.status ?? 502`, or remap specific codes (`(e) => (e.status === 429 ? 429 : 502)`). */ status?: number | ((err: StitchErrorLike) => number); /** - * Shape the JSON body. **Default: a generic, status-tied message** - * (`{ error: 'Bad Gateway' }`) — the raw `err.message` is deliberately *not* echoed, - * because it can disclose internal network topology (a transport failure reads like - * `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`) - * to an untrusted client. Provide this to shape the body yourself; pass - * `(e) => ({ error: e.message })` to opt in to the raw message when the upstream - * messages are known to be safe to expose. + * The JSON body for a mapped failure. **Default: a generic, status-tied message** + * (`{ error: 'Bad Gateway' }`) — the raw `err.message` is deliberately *not* echoed, because it + * can disclose internal network topology (a transport failure reads like + * `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`) to an + * untrusted client. Override to shape your own error envelope; pass `(e) => ({ error: e.message })` + * to opt in to the raw message when the upstream messages are known to be safe to expose. + * Receives the mapped status alongside the error. */ body?: (err: StitchErrorLike, status: number) => unknown; } /** - * Map a thrown error to a JSON `Response`. Use it in a route handler's `catch`: + * Map a thrown stitch failure to a JSON {@link Response}, or `undefined` when `err` is not a + * Stitch error (so a caller can rethrow / fall through). The status is `502` by default; override + * it via {@link StitchErrorOptions.status}. Use it in a route handler's `catch`: * * ```ts * try { * return Response.json(await getUser({ params: { id } })); * } catch (err) { - * if (isStitchError(err)) return stitchErrorResponse(err); + * const mapped = stitchErrorResponse(err); + * if (mapped) return mapped; // or: return stitchErrorResponse(err) ?? throwAgain(err) * throw err; * } * ``` @@ -243,21 +293,20 @@ export interface ErrorResponseOptions { * The default body is a generic, status-tied message (`{ error: 'Bad Gateway' }`) — the * raw `err.message` is **not** echoed, since it can leak internal hostnames or the * upstream's status to an untrusted client. Opt in to a custom (or the raw) message with - * {@link ErrorResponseOptions.body}. + * {@link StitchErrorOptions.body}. */ export function stitchErrorResponse( err: unknown, - options: ErrorResponseOptions = {}, -): Response { - const e: StitchErrorLike = - err instanceof Error ? err : new Error(String(err)); - const fallback = isStitchError(err) ? 502 : 500; - const status = - typeof options.status === 'function' - ? options.status(e) - : (options.status ?? fallback); + options: StitchErrorOptions = {}, +): Response | undefined { + if (!isStitchError(err)) return undefined; + const { status = BAD_GATEWAY } = options; + const code = typeof status === 'function' ? status(err) : status; + // Default body is a generic, status-tied message — the raw `err.message` is deliberately + // withheld so an internal hostname (`getaddrinfo ENOTFOUND …`) or the upstream's status + // (`HTTP 401`) never reaches the client. Opt in via `options.body`. const body = options.body - ? options.body(e, status) - : { error: STATUS_TEXT[status] ?? 'Error' }; - return Response.json(body, { status }); + ? options.body(err, code) + : { error: STATUS_TEXT[code] ?? 'Error' }; + return Response.json(body, { status: code }); } diff --git a/packages/next/test/next.spec.ts b/packages/next/test/next.spec.ts index 32ab7407..89342bf3 100644 --- a/packages/next/test/next.spec.ts +++ b/packages/next/test/next.spec.ts @@ -1,9 +1,9 @@ // @stitchapi/next behaviour — Web-standard Response helpers driven with fake event // sources and errors. No engine, no Next. -import { isStitchError, sseResponse, stitchErrorResponse } from '../src'; +import { isStitchError, stitchErrorResponse, streamStitchSse } from '../src'; import type { StitchEvent } from 'stitchapi'; -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; async function* events( ...evs: StitchEvent[] @@ -41,11 +41,11 @@ function stitchError(message: string, status?: number): Error { return e; } -// --- sseResponse ----------------------------------------------------------- +// --- streamStitchSse --------------------------------------------------------- -describe('sseResponse', () => { +describe('streamStitchSse', () => { test('streams each delta as an SSE frame and sets the content type', async () => { - const res = sseResponse( + const res = streamStitchSse( events(delta('a'), delta('b'), result(['a', 'b']), done), ); expect(res.headers.get('content-type')).toBe( @@ -55,15 +55,45 @@ describe('sseResponse', () => { expect(body).toBe('data: a\n\ndata: b\n\n'); }); + test('accepts anything with a .stream() (the core StitchEventSource intake)', async () => { + const source = { stream: () => events(delta('a'), done) }; + const res = streamStitchSse(source); + expect(await res.text()).toBe('data: a\n\n'); + }); + test('a `data` mapper pulls text out of a structured chunk', async () => { - const res = sseResponse(events(delta({ text: 'hi' }), done), { + const res = streamStitchSse(events(delta({ text: 'hi' }), done), { data: (c) => (c as { text: string }).text, }); expect(await res.text()).toBe('data: hi\n\n'); }); + test('the data mapper receives the zero-based frame index', async () => { + const res = streamStitchSse(events(delta('a'), delta('b'), done), { + data: (c, index) => `${String(c)}#${index}`, + }); + expect(await res.text()).toBe('data: a#0\n\ndata: b#1\n\n'); + }); + + test('event accepts a function of the chunk for per-frame event names', async () => { + const res = streamStitchSse( + events( + delta({ kind: 'token', text: 'a' }), + delta({ kind: 'usage', text: 'b' }), + done, + ), + { + event: (c) => (c as { kind: string }).kind, + data: (c) => (c as { text: string }).text, + }, + ); + expect(await res.text()).toBe( + 'event: token\ndata: a\n\nevent: usage\ndata: b\n\n', + ); + }); + test('by default an error event yields a named event: error frame with a generic token, never the raw message', async () => { - const res = sseResponse( + const res = streamStitchSse( events(delta('a'), { type: 'error', name: 'StitchError', @@ -84,7 +114,7 @@ describe('sseResponse', () => { }); test('errorData opts in to the raw message on the error frame', async () => { - const res = sseResponse( + const res = streamStitchSse( events(delta('a'), { type: 'error', name: 'StitchError', @@ -100,13 +130,36 @@ describe('sseResponse', () => { ); }); + test('onError observes the real failure server-side while the client frame stays generic', async () => { + const onError = vi.fn(); + const res = streamStitchSse( + events(delta('a'), { + type: 'error', + name: 'StitchError', + message: 'getaddrinfo ENOTFOUND payments.internal.corp', + status: 502, + attempts: 1, + at: 0, + }), + { onError }, + ); + const body = await res.text(); + expect(body).toBe('data: a\n\nevent: error\ndata: error\n\n'); + expect(onError).toHaveBeenCalledTimes(1); + expect((onError.mock.calls[0]?.[0] as Error).message).toBe( + 'getaddrinfo ENOTFOUND payments.internal.corp', + ); + }); + test('the event option labels each frame', async () => { - const res = sseResponse(events(delta('a'), done), { event: 'token' }); + const res = streamStitchSse(events(delta('a'), done), { + event: 'token', + }); expect(await res.text()).toBe('event: token\ndata: a\n\n'); }); test('the id option emits an id: line per frame with the zero-based index', async () => { - const res = sseResponse(events(delta('a'), delta('b'), done), { + const res = streamStitchSse(events(delta('a'), delta('b'), done), { id: (chunk, i) => `${String(chunk)}-${i}`, }); expect(await res.text()).toBe( @@ -115,12 +168,12 @@ describe('sseResponse', () => { }); test('a multi-line chunk gets one data: prefix per line (SSE spec)', async () => { - const res = sseResponse(events(delta('l1\nl2'), done)); + const res = streamStitchSse(events(delta('l1\nl2'), done)); expect(await res.text()).toBe('data: l1\ndata: l2\n\n'); }); test('extra headers merge over the SSE defaults, with overrides winning', async () => { - const res = sseResponse(events(delta('a'), done), { + const res = streamStitchSse(events(delta('a'), done), { headers: { 'x-custom': '1', 'cache-control': 'no-store' }, }); expect(res.headers.get('x-custom')).toBe('1'); @@ -134,7 +187,7 @@ describe('sseResponse', () => { }); test('a pre-aborted signal yields no frames', async () => { - const res = sseResponse(events(delta('a'), delta('b'), done), { + const res = streamStitchSse(events(delta('a'), delta('b'), done), { signal: AbortSignal.abort(), }); expect(await res.text()).toBe(''); @@ -146,7 +199,7 @@ describe('sseResponse', () => { // A transport failure disclosing an internal hostname must not reach the client. throw new Error('getaddrinfo ENOTFOUND payments.internal.corp'); } - const res = sseResponse(boom()); + const res = streamStitchSse(boom()); const body = await res.text(); expect(body).toBe('data: a\n\nevent: error\ndata: error\n\n'); expect(body).not.toContain('payments.internal.corp'); @@ -158,10 +211,23 @@ describe('sseResponse', () => { yield delta('a'); throw new Error('stream blew up'); } - const res = sseResponse(boom(), { errorData: (e) => e.message }); + const res = streamStitchSse(boom(), { errorData: (e) => e.message }); const body = await res.text(); expect(body).toBe('data: a\n\nevent: error\ndata: stream blew up\n\n'); }); + + test('onError observes the original thrown value on the throw path', async () => { + const thrown = new Error('stream blew up'); + async function* boom(): AsyncGenerator { + yield delta('a'); + throw thrown; + } + const onError = vi.fn(); + const res = streamStitchSse(boom(), { onError }); + await res.text(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[0]).toBe(thrown); + }); }); // --- stitchErrorResponse --------------------------------------------------- @@ -169,22 +235,35 @@ describe('sseResponse', () => { describe('stitchErrorResponse', () => { test('a StitchError maps to 502 by default with a generic JSON body', async () => { const res = stitchErrorResponse(stitchError('upstream down', 503)); - expect(res.status).toBe(502); - expect(res.headers.get('content-type')).toContain('application/json'); + expect(res).toBeDefined(); + expect(res?.status).toBe(502); + expect(res?.headers.get('content-type')).toContain('application/json'); // the raw message is withheld by default (see the leak-regression block below) - expect(await res.json()).toEqual({ error: 'Bad Gateway' }); + expect(await res?.json()).toEqual({ error: 'Bad Gateway' }); }); test('status can propagate the upstream status', async () => { const res = stitchErrorResponse(stitchError('rate limited', 429), { status: (e) => e.status ?? 502, }); - expect(res.status).toBe(429); + expect(res?.status).toBe(429); + }); + + test('a non-StitchError returns undefined so the caller can rethrow it', () => { + expect(stitchErrorResponse(new Error('oops'))).toBeUndefined(); + expect(stitchErrorResponse('oops')).toBeUndefined(); + expect(stitchErrorResponse(undefined)).toBeUndefined(); }); - test('a non-StitchError defaults to 500', async () => { - const res = stitchErrorResponse(new Error('oops')); - expect(res.status).toBe(500); + test('composes with ?? for the map-or-rethrow pattern', () => { + const fallthrough = new Error('not a stitch failure'); + const attempt = (err: unknown): Response => { + const mapped = stitchErrorResponse(err); + if (mapped) return mapped; + throw err; + }; + expect(attempt(stitchError('upstream down'))).toBeInstanceOf(Response); + expect(() => attempt(fallthrough)).toThrow(fallthrough); }); // Regression: the default body must not echo the raw upstream/transport message, @@ -195,16 +274,16 @@ describe('stitchErrorResponse', () => { const res = stitchErrorResponse( stitchError('getaddrinfo ENOTFOUND payments.internal.corp'), ); - expect(res.status).toBe(502); - const body = await res.text(); + expect(res?.status).toBe(502); + const body = await res?.text(); expect(body).not.toContain('payments.internal.corp'); expect(body).not.toContain('ENOTFOUND'); }); test("an upstream 401 does not surface as 'HTTP 401' in the body", async () => { const res = stitchErrorResponse(stitchError('HTTP 401', 401)); - expect(res.status).toBe(502); - expect(await res.text()).not.toContain('HTTP 401'); + expect(res?.status).toBe(502); + expect(await res?.text()).not.toContain('HTTP 401'); }); test('the `body` opt-in can still include the raw message', async () => { @@ -212,7 +291,7 @@ describe('stitchErrorResponse', () => { stitchError('getaddrinfo ENOTFOUND payments.internal.corp'), { body: (e) => ({ error: e.message }) }, ); - expect(await res.text()).toContain('payments.internal.corp'); + expect(await res?.text()).toContain('payments.internal.corp'); }); }); }); diff --git a/packages/openapi/README.md b/packages/openapi/README.md index 1c795e2a..4a1ab27d 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -79,7 +79,7 @@ The generator is also a pure library: ```ts import { type OpenApiDoc, planGen } from '@stitchapi/openapi'; -const result = planGen(doc as OpenApiDoc, { tags: ['pet'] }); +const result = planGen(doc as OpenApiDoc, { tags: 'pet' }); // or tags: ['pet', 'store'] for (const f of result.files) console.log(f.path, f.contents); ``` diff --git a/packages/openapi/src/gen-openapi.ts b/packages/openapi/src/gen-openapi.ts index 39e89e0c..5b5b9880 100644 --- a/packages/openapi/src/gen-openapi.ts +++ b/packages/openapi/src/gen-openapi.ts @@ -100,9 +100,12 @@ export interface GenOptions { validator?: 'types-only' | 'valibot' | 'zod'; /** ADR 0013 Q3: default `dir`. `single` is not implemented in v1. */ layout?: 'dir' | 'flat'; - /** Filters (ADR 0013 Decision 2). `all` overrides the others. */ - only?: string[]; // operationIds (or derived names) - tags?: string[]; + /** + * Filters (ADR 0013 Decision 2). `all` overrides the others. The list-shaped filters take a + * bare value too — `tags: 'pets'` ≡ `tags: ['pets']` (CONTRACT.md P7). + */ + only?: string | string[]; // operationIds (or derived names) + tags?: string | string[]; grep?: string; // substring match on the path all?: boolean; } @@ -660,19 +663,21 @@ export function planGen(doc: OpenApiDoc, opts: GenOptions = {}): GenResult { // ---- selection ------------------------------------------------------------ +// P7 widening: `tags`/`only` accept a bare string or a list — normalize once here. +function toList(v: string | string[] | undefined): string[] { + return v === undefined ? [] : Array.isArray(v) ? v : [v]; +} + function matches(o: SelectedOp, opts: GenOptions): boolean { if (opts.all) return true; + const tags = toList(opts.tags); + const only = toList(opts.only); const hasFilter = - (opts.tags?.length ?? 0) > 0 || - (opts.only?.length ?? 0) > 0 || - (opts.grep?.length ?? 0) > 0; + tags.length > 0 || only.length > 0 || (opts.grep?.length ?? 0) > 0; if (!hasFilter) return false; // selective by default: require an explicit selector - if (opts.tags?.length && o.tag && opts.tags.includes(o.tag)) return true; - if (opts.only?.length) { - if (opts.only.includes(o.name)) return true; - if (o.op.operationId && opts.only.includes(o.op.operationId)) - return true; - } + if (o.tag && tags.includes(o.tag)) return true; + if (only.includes(o.name)) return true; + if (o.op.operationId && only.includes(o.op.operationId)) return true; if (opts.grep && o.path.includes(opts.grep)) return true; return false; } @@ -757,7 +762,7 @@ function emitClient(baseUrl: string | undefined, auth: DerivedAuth): GenFile { if (auth.expr) lines.push(` auth: ${auth.expr},`); lines.push(' // TODO: tune shared resilience, e.g.'); lines.push(' // retry: { attempts: 3, on: [429, 502, 503] },'); - lines.push(" // throttle: { rate: '10/s', scope: 'host' },"); + lines.push(" // throttle: { rate: '10/s', pool: 'host' },"); lines.push('});'); return { path: 'client.ts', contents: `${lines.join('\n')}\n` }; } @@ -916,16 +921,28 @@ function deriveAuth( expr: `basic({ user: env('API_USER'), pass: env('API_PASSWORD') })`, imports: ['basic', 'env'], }; - if (scheme.type === 'apiKey') { + if ( + scheme.type === 'apiKey' && + (scheme.in === 'header' || + scheme.in === 'query' || + scheme.in === undefined) + ) { + // Core's `ApiKeyOptions` is a union discriminated on `in`, with `name` naming the key's + // location in BOTH arms: `{ in?: 'header', name?, value }` | `{ in: 'query', name?, value }`. + // Header is the default arm, so `in: 'header'` is never emitted; a query scheme gets the + // `in: 'query'` discriminant. `in: 'cookie'` has no arm — it falls through to the + // not-auto-mapped warning below instead of silently becoming a header key. const where = scheme.in === 'query' ? `in: 'query', ` : ''; - const nm = scheme.name ? `name: ${JSON.stringify(scheme.name)}, ` : ''; + const nm = scheme.name ? `name: ${q(scheme.name)}, ` : ''; return { expr: `apiKey({ ${where}${nm}value: env('API_KEY') })`, imports: ['apiKey', 'env'], }; } warnings.push( - `security scheme "${schemeName}" (type ${scheme.type ?? '?'}) not auto-mapped — set client.ts auth manually`, + `security scheme "${schemeName}" (type ${scheme.type ?? '?'}${ + scheme.type === 'apiKey' ? `, in ${scheme.in ?? '?'}` : '' + }) not auto-mapped — set client.ts auth manually`, ); return { imports: [] }; } diff --git a/packages/openapi/test/gen-openapi.spec.ts b/packages/openapi/test/gen-openapi.spec.ts index e11205fc..0304806c 100644 --- a/packages/openapi/test/gen-openapi.spec.ts +++ b/packages/openapi/test/gen-openapi.spec.ts @@ -143,6 +143,21 @@ describe('planGen — selection', () => { const r = planGen(doc, { all: true }); expect(r.selected).toHaveLength(4); }); + + // CONTRACT.md P7: list-shaped filters take a bare value too. + test('P7: bare-string tags ≡ one-element list', () => { + const r = planGen(doc, { tags: 'users' }); + expect(r.selected.map((s) => s.name).sort()).toEqual([ + 'createUser', + 'getUser', + 'listUsers', + ]); + }); + + test('P7: bare-string only ≡ one-element list', () => { + const r = planGen(doc, { only: 'getUser' }); + expect(r.selected.map((s) => s.name)).toEqual(['getUser']); + }); }); describe('planGen — ownership (fan-in over the transitive closure)', () => { @@ -195,6 +210,13 @@ describe('planGen — naming, typing, auth, notice', () => { expect(c).toMatch(/import \{ seam, bearer, env \}/); }); + test('emitted throttle TODO uses the canonical `pool` (ThrottleOptions.scope is gone)', () => { + const r = planGen(doc, { all: true }); + const c = file(r, 'client.ts') as string; + expect(c).toMatch(/pool: 'host'/); + expect(c).not.toMatch(/\bscope\b/); + }); + test('types-only emits the validation-off notice', () => { const r = planGen(doc, { all: true }); expect(r.notices.join('\n')).toMatch( @@ -203,6 +225,69 @@ describe('planGen — naming, typing, auth, notice', () => { }); }); +// Core's `ApiKeyOptions` is a union discriminated on `in`, with `name` naming the key's location +// in BOTH arms: `{ in?: 'header', name?, value }` | `{ in: 'query', name?, value }`. The emitted +// call must match that union exactly — header is the default arm (no `in` emitted), query carries +// the `in: 'query'` discriminant, and cookie has no arm at all. +describe('planGen — apiKey security-scheme mapping', () => { + const authDoc = ( + scheme: NonNullable< + NonNullable['securitySchemes'] + >[string], + ): OpenApiDoc => ({ + openapi: '3.0.0', + info: { title: 'T', version: '1' }, + security: [{ keyAuth: [] }], + components: { securitySchemes: { keyAuth: scheme } }, + paths: { + '/x': { get: { operationId: 'getX', responses: { '200': {} } } }, + }, + }); + + test("header scheme → apiKey({ name, value }) — the default arm, no `in: 'header'`", () => { + const r = planGen( + authDoc({ type: 'apiKey', in: 'header', name: 'X-Api-Key' }), + { all: true }, + ); + const c = file(r, 'client.ts') as string; + expect(c).toContain( + "auth: apiKey({ name: 'X-Api-Key', value: env('API_KEY') })", + ); + expect(c).not.toContain("in: 'header'"); + expect(c).toMatch(/import \{ seam, apiKey, env \} from 'stitchapi';/); + }); + + test("query scheme → apiKey({ in: 'query', name, value })", () => { + const r = planGen( + authDoc({ type: 'apiKey', in: 'query', name: 'api-key' }), + { all: true }, + ); + const c = file(r, 'client.ts') as string; + expect(c).toContain( + "auth: apiKey({ in: 'query', name: 'api-key', value: env('API_KEY') })", + ); + }); + + test('nameless scheme (defensive) still typechecks — name defaults inside core', () => { + const r = planGen(authDoc({ type: 'apiKey' }), { all: true }); + const c = file(r, 'client.ts') as string; + expect(c).toContain("auth: apiKey({ value: env('API_KEY') })"); + }); + + test('cookie scheme has no ApiKeyOptions arm → warned, not silently emitted as a header key', () => { + const r = planGen( + authDoc({ type: 'apiKey', in: 'cookie', name: 'sid' }), + { all: true }, + ); + const c = file(r, 'client.ts') as string; + expect(c).not.toContain('apiKey('); + expect(c).not.toContain('auth:'); + expect(r.warnings.join('\n')).toMatch( + /security scheme "keyAuth" \(type apiKey, in cookie\) not auto-mapped/, + ); + }); +}); + // The codegen turns an UNTRUSTED OpenAPI document into TS source the developer compiles. Spec text // (summary, path, param names, server URL, operationId) is attacker-controlled input. These guard // against injection / credential-leak / crash-the-build regressions (review-sweep on merged #328). diff --git a/packages/pino/README.md b/packages/pino/README.md index df106d65..e5b5e8a8 100644 --- a/packages/pino/README.md +++ b/packages/pino/README.md @@ -45,6 +45,11 @@ retries, drift findings, and errors: pinoSink(pino(), { lifecycle: false }); ``` +> [!NOTE] +> The same `lifecycle` option in `@stitchapi/sentry` defaults to `false` — a +> **deliberate** divergence: Pino log lines are cheap and level-filtered, while +> Sentry breadcrumbs/events cost quota. + ## Bring your own logger This package **imports no logger** — it runs on a small structural @@ -63,7 +68,7 @@ v8 and v9. Concretely, it logs the stitch name, method, **redacted URL** (the query string is stripped — it can carry `?api_key=…`), status, attempt counts, drift path/level/change, progress phase, and timing. It **never** logs `event.input` -(headers like `authorization` / `cookie` stay raw on the event), `event.value` +(headers like `authorization` / `cookie` stay raw on the event), `event.data` (the response body), a `delta` chunk, or `JSON.stringify(event)`. That keeps it safe on a secret-bearing seam regardless of core's trace redaction — proven by a test that feeds a `start` event whose `input.headers.authorization` is set and diff --git a/packages/pino/src/index.ts b/packages/pino/src/index.ts index c570a8d4..f6034bb0 100644 --- a/packages/pino/src/index.ts +++ b/packages/pino/src/index.ts @@ -52,6 +52,11 @@ export interface PinoSinkOptions { * `done` → `debug`. Default `true`. `start`/`done` sit at `debug` so a production * pino level (`info`) hides them by default; set `false` to drop the lifecycle * entirely and log only retries, drift findings, and errors. + * + * DELIBERATE DIVERGENCE (contract P8): `@stitchapi/sentry`'s same-named + * `lifecycle` defaults to `false`. Pino log lines are cheap and level-filtered, + * so the lifecycle is on by default here; Sentry breadcrumbs/events cost quota, + * so it is off by default there. */ lifecycle?: boolean; } diff --git a/packages/pino/test/pino.spec.ts b/packages/pino/test/pino.spec.ts index 4f53b3bd..74269152 100644 --- a/packages/pino/test/pino.spec.ts +++ b/packages/pino/test/pino.spec.ts @@ -293,7 +293,7 @@ describe('pinoSink — secret safety', () => { expect(JSON.stringify(calls[0]!.obj)).not.toContain('headers'); }); - it('never logs the `result.value` response body', () => { + it('never logs the `result.data` response body', () => { const calls = run({ type: 'result', data: { ssn: '123-45-6789', token: 'leak-me' }, diff --git a/packages/query-core/README.md b/packages/query-core/README.md index 436991b3..e048450b 100644 --- a/packages/query-core/README.md +++ b/packages/query-core/README.md @@ -16,7 +16,7 @@ A `stitch` is a typed declarative call: invoking it returns a `StitchResult` pnpm add @stitchapi/query-core@rc stitchapi@rc ``` -`stitchapi` is a peer dependency (`>=0.7.0`). +`stitchapi` is a peer dependency (`^1.0.0-rc.1`). ## API @@ -27,17 +27,17 @@ Returns a `StitchQuery`: ```ts interface StitchQuery { subscribe(listener: () => void): () => void; - getSnapshot(): StitchQueryState; + getSnapshot(): StitchQueryResult; refetch(): void; cancel(): void; destroy(): void; } ``` -### `StitchQueryState` +### `StitchQueryResult` ```ts -interface StitchQueryState { +interface StitchQueryResult { status: 'idle' | 'pending' | 'streaming' | 'success' | 'error'; data: T | undefined; error: unknown; @@ -55,12 +55,31 @@ Snapshots are **identity-stable** between real changes — the store only hands | Option | Default | Meaning | | ----------- | ---------- | --------------------------------------------------------------------------------------------------------- | -| `stream` | `false` | Drive the call through `.stream()` and update state per `delta` (for `sse`/`stream` surfaces). | +| `streaming` | `false` | Drive the call through `.stream()` and update state per `delta` (for `sse`/`stream` surfaces). | | `mode` | `'append'` | Streaming fold: `'append'` collects chunks into `data`/`chunks`; `'replace'` keeps only the latest chunk. | | `enabled` | `true` | Run immediately on creation. `false` starts `idle`; fetch lazily via `refetch()`. | | `onSuccess` | — | Called with the validated value on success. | | `onError` | — | Called with the thrown reason on failure. | +### TanStack Query interop + +`@stitchapi/query-core` also owns the one shared implementation behind every framework binding's TanStack adapter — the key format cannot drift between frameworks: + +```ts +import { deriveQueryKey, stitchQueryOptions } from '@stitchapi/query-core'; +import type { StitchQueryOptions } from '@stitchapi/query-core'; + +// [name, sanitised input] — signal/onProgress dropped, secret header values redacted +const key = deriveQueryKey(getUser, { params: { id: '1' } }); + +// A TanStack-compatible `{ queryKey, queryFn }` POJO — no @tanstack/* dependency +const options = stitchQueryOptions(getUser, { params: { id: '1' } }); +``` + +- **`stitchQueryOptions(stitch, input)`** returns a `StitchQueryOptions` (`{ queryKey, queryFn }` — TanStack's own field names, kept verbatim as a standards-interop carve-out). Pass it straight to `useQuery` / `createQuery` / `injectQuery`. +- **`deriveQueryKey(stitch, input)`** builds the canonical cache key: a stable stitch name plus a sanitised input. +- **`nameOf(stitch)`** and **`keyInputFor(input)`** expose the two key segments for bindings that compose keys themselves. `keyInputFor` never puts the raw input in a key: it drops runtime-only `signal`/`onProgress` and redacts the values of secret-bearing headers (`authorization`, `cookie`, `*-token`, `*-api-key`, plus core's `isSecretKey` names — including anything widened via `registerSecretKey`). + ## Example ```ts @@ -87,7 +106,7 @@ import { createStitchQuery } from '@stitchapi/query-core'; import { sse } from 'stitchapi'; const tokens = sse({ url: 'https://api.example.com/chat' }); -const q = createStitchQuery(tokens, undefined, { stream: true }); +const q = createStitchQuery(tokens, undefined, { streaming: true }); // q.getSnapshot().chunks grows as each `delta` arrives; status is 'streaming' // until the terminal `result`, then 'success'. ``` diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 45f2ef81..7dce41ec 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -8,13 +8,15 @@ // that one-shot call into a SUBSCRIBABLE store: a `getSnapshot()` / `subscribe()` // handle that drives React's `useSyncExternalStore` (see `@stitchapi/react`) and, // later, the equivalent primitives in Vue / Svelte / Solid. It imports NO -// framework and NO `node:*`, so it is browser- and edge-safe. +// framework and NO `node:*` — its only runtime import is its `stitchapi` peer +// (for the shared secret-key predicate) — so it is browser- and edge-safe. // // The store owns the lifecycle the engine deliberately leaves to a host: it runs // the call under an `AbortController`, publishes status transitions to listeners, // `cancel()`s by aborting, and `refetch()`es by re-running. For a streaming // surface it consumes `.stream()` and pushes a state update as each `delta` // arrives — the reactive differentiator over plain request/response query libs. +import { isSecretKey } from 'stitchapi'; import type { Stitch, StitchEvent, StitchResult } from 'stitchapi'; // --------------------------------------------------------------------------- @@ -36,14 +38,14 @@ export type StitchQueryStatus = * actually changed, so `useSyncExternalStore` (and `===` checks elsewhere) never * tear or loop. */ -export interface StitchQueryState { +export interface StitchQueryResult { readonly status: StitchQueryStatus; /** The validated output (unary) or the latest streamed value (streaming). */ readonly data: T | undefined; /** The thrown reason on failure (a `StitchError` from core, or any throw). */ readonly error: unknown; /** Accumulated `delta` chunks, in arrival order. Empty for a unary call, or - * when `accumulate: false` (only the latest chunk is kept on `data`). */ + * when `mode: 'replace'` (only the latest chunk is kept on `data`). */ readonly chunks: readonly unknown[]; /** `status === 'pending'` — a convenience flag for the common render branch. */ readonly isPending: boolean; @@ -63,7 +65,7 @@ export interface CreateStitchQueryOptions { * a streaming surface (`sse` / `stream`), set this so chunks render as they * arrive. */ - readonly stream?: boolean; + readonly streaming?: boolean; /** * For a streaming query, how `data` reflects the stream. `'append'` (default) * collects every chunk into `chunks` and sets `data` to the running array. @@ -85,7 +87,7 @@ export interface StitchQuery { /** Register a listener; returns an unsubscribe function. */ subscribe(listener: () => void): () => void; /** The current immutable state. Identity-stable between real changes. */ - getSnapshot(): StitchQueryState; + getSnapshot(): StitchQueryResult; /** Abort the in-flight run (if any) and re-run from scratch. */ refetch(): void; /** Abort the in-flight run, if any. Leaves the last state in place; the run @@ -152,16 +154,16 @@ type Core = { chunks: readonly unknown[]; }; -const IDLE: StitchQueryState = freeze({ +const IDLE: StitchQueryResult = freeze({ status: 'idle', data: undefined, error: undefined, chunks: [], }); -function freeze(partial: Core): StitchQueryState { +function freeze(partial: Core): StitchQueryResult { const { status } = partial; - const state: StitchQueryState = { + const state: StitchQueryResult = { status, data: partial.data, error: partial.error, @@ -197,7 +199,7 @@ export function createStitchQuery( options: CreateStitchQueryOptions = {}, ): StitchQuery { const { - stream = false, + streaming = false, mode = 'append', enabled = true, onSuccess, @@ -205,7 +207,7 @@ export function createStitchQuery( } = options; const listeners = new Set<() => void>(); - let state = IDLE as StitchQueryState; + let state = IDLE as StitchQueryResult; let controller: AbortController | undefined; // A token guards against a stale run resolving after a newer refetch/cancel: // every run captures the token live at start, and only the current run may @@ -213,7 +215,7 @@ export function createStitchQuery( let runToken = 0; let destroyed = false; - function setState(next: StitchQueryState): void { + function setState(next: StitchQueryResult): void { state = next; for (const l of listeners) l(); } @@ -328,7 +330,7 @@ export function createStitchQuery( }), ); // `void` the promise: errors are folded into state, never unhandled. - void (stream ? runStream(token) : runUnary(token)); + void (streaming ? runStream(token) : runUnary(token)); } if (enabled) start(); @@ -368,6 +370,165 @@ class DOMExceptionLike extends Error { } } +// --------------------------------------------------------------------------- +// TanStack Query interop — the canonical options shape + key derivation +// --------------------------------------------------------------------------- +// The one shared implementation behind every TanStack binding's +// `stitchQueryOptions` (react / vue / svelte / solid / angular) and the swr +// binding's key builder. Bindings import from here instead of carrying private +// copies, so the key format — and its secret-redaction guarantees — cannot +// drift between frameworks (CONTRACT.md P9). + +/** + * The canonical options object a binding's `stitchQueryOptions(...)` returns — + * structurally what TanStack Query's `useQuery` / `createQuery` / `injectQuery` + * consume, WITHOUT importing the library. + * + * `queryKey` / `queryFn` — and the `*Options` name itself, which TanStack uses + * for exactly this shape — are TanStack Query's own vocabulary, kept verbatim + * as a deliberate standards-interop carve-out (CONTRACT.md P22): this type + * exists solely to be handed to TanStack, so renaming any part of it would buy + * a translation seam and nothing else. The name is blessed; do not rename. + */ +export interface StitchQueryOptions { + queryKey: readonly unknown[]; + queryFn: (ctx?: { signal?: AbortSignal }) => Promise; +} + +/** The `__config` slice a key derives from. Mirrors core's `nameOf` + * (`name ?? path ?? 'stitch'`) plus a `url` fallback for URL-configured stitches. */ +type KeyConfig = { name?: string; path?: string; url?: string }; + +/** A stable, human-meaningful name for the stitch — the first segment of a + * derived query key. Mirrors core's `nameOf` (`packages/core/src/engine.ts`) — + * `name ?? path ?? url ?? 'stitch'` — so two DISTINCT nameless stitches + * (`/users/{id}` vs `/orders/{id}`) don't collapse to the literal `'stitch'` + * and collide on one cache entry. */ +export function nameOf(stitch: unknown): string { + const cfg = (stitch as { __config?: KeyConfig }).__config; + return cfg?.name ?? cfg?.path ?? cfg?.url ?? 'stitch'; +} + +// Header names whose VALUES are secrets — the header-specific denylist on top of +// core's `isSecretKey` predicate (which contributes the secret stems — `token`, +// `secret`, `apikey`, … — and any caller-registered names via +// `registerSecretKey`). We redact the value (rather than dropping the header) so +// the key stays stable per token AND callers who legitimately vary a response by +// a non-secret header (e.g. `accept-language`) keep separate cache entries. +// Compared case-insensitively; the `*-token` / `*-api-key` suffix rules catch +// vendor spellings the stems miss (dashes defeat the `api_key`/`apikey` stems). +const SECRET_HEADERS = new Set([ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', +]); +const REDACTED = '[redacted]'; + +function isSecretHeader(name: string): boolean { + const k = name.toLowerCase(); + return ( + SECRET_HEADERS.has(k) || + k.endsWith('-token') || + k.endsWith('-api-key') || + isSecretKey(k) + ); +} + +/** + * Build the value that goes into a cache/query key from a stitch's per-call + * input — the second segment of a derived query key. Never puts the raw input + * in the key: + * + * - drops `signal` / `onProgress` — runtime-only, never-serialised (CONTRACT.md); + * `onProgress` in particular churns identity every render, which would refetch + * forever if it entered the key; + * - redacts the VALUES of secret-bearing headers (`authorization`, `cookie`, …) + * so a bearer token can't leak into a persisted / devtools-visible key, while + * keeping non-secret headers so they still vary the cache; + * - keeps every other field (`params` / `query` / `body` / `variables` / …) as-is. + * + * `null` / `undefined` inputs stay `null`; a primitive input is returned unchanged. + */ +export function keyInputFor(input: unknown): unknown { + if (input === null || input === undefined) return null; + if (typeof input !== 'object') return input; + + const { + signal: _signal, + onProgress: _onProgress, + ...rest + } = input as { + signal?: unknown; + onProgress?: unknown; + headers?: Record; + } & Record; + + if (rest.headers && typeof rest.headers === 'object') { + const headers: Record = {}; + for (const [k, v] of Object.entries(rest.headers)) { + headers[k] = isSecretHeader(k) ? REDACTED : v; + } + rest.headers = headers; + } + return rest; +} + +/** + * Derive the canonical cache/query key for a stitch call: a stable name for the + * stitch (see {@link nameOf}) plus a sanitised copy of the input (see + * {@link keyInputFor}). Every binding — the five TanStack adapters' query keys + * and the swr key builder — derives from here, so a stitch keys identically no + * matter which framework reads it. + */ +export function deriveQueryKey( + stitch: unknown, + input: unknown, +): readonly [string, unknown] { + return [nameOf(stitch), keyInputFor(input)]; +} + +/** + * Build a TanStack-Query-compatible options object for a stitch, WITHOUT a hard + * dependency on any `@tanstack/*-query` package — it just returns a POJO. The + * framework bindings re-export this; pass it straight to `useQuery` / + * `createQuery` / `injectQuery`: + * + * ```ts + * import { useQuery } from '@tanstack/react-query'; + * import { stitchQueryOptions } from '@stitchapi/react'; + * + * const { data } = useQuery(stitchQueryOptions(getUser, { params: { id } })); + * ``` + * + * The `queryFn` awaits the stitch (the validated output); the `queryKey` is + * {@link deriveQueryKey}'s stable, secret-redacted key, so TanStack caches per + * call without leaking a bearer token into the key or refetching every render. + * + * Named `stitchQueryOptions` (not a bare `queryOptions`) because TanStack Query + * itself exports a `queryOptions` — the bare name would clash on import. See + * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). + */ +export function stitchQueryOptions>( + stitch: S, + input: QueryInput, +): StitchQueryOptions>; +export function stitchQueryOptions( + stitch: StitchLike, + input: Input, +): StitchQueryOptions; +export function stitchQueryOptions( + stitch: StitchLike, + input: unknown, +): StitchQueryOptions { + return { + queryKey: deriveQueryKey(stitch, input), + queryFn: () => Promise.resolve(stitch(input)), + }; +} + // --------------------------------------------------------------------------- // Re-exports for binding authors // --------------------------------------------------------------------------- diff --git a/packages/query-core/test/query.spec.ts b/packages/query-core/test/query.spec.ts index 08fce9e5..50c8c243 100644 --- a/packages/query-core/test/query.spec.ts +++ b/packages/query-core/test/query.spec.ts @@ -1,9 +1,16 @@ // @stitchapi/query-core behaviour. Driven by FAKE stitches (plain callables that // return a `StitchResult`-shaped value) — no engine, no network. We exercise the // unary lifecycle, cancel/refetch, and the streaming `delta` accumulation. -import { createStitchQuery } from '../src'; +import { + createStitchQuery, + deriveQueryKey, + keyInputFor, + nameOf, + stitchQueryOptions, +} from '../src'; import type { StitchCallResult, StitchLike } from '../src'; +import { registerSecretKey } from 'stitchapi'; import type { StitchEvent } from 'stitchapi'; import { describe, expect, test, vi } from 'vitest'; @@ -15,7 +22,7 @@ function unaryStitch(settle: (input: unknown) => Promise): StitchLike { const promise = settle(input); return { then: (onf, onr) => promise.then(onf, onr), - // A unary fake never streams; the store only calls this in stream mode. + // A unary fake never streams; the store only calls this when `streaming`. stream() { async function* gen(): AsyncGenerator> { const value = await promise; @@ -253,7 +260,7 @@ describe('streaming query', () => { { type: 'done', ok: true, elapsed: 1, attempts: 1, at: 0 }, ]; const q = createStitchQuery(streamStitch(events), undefined, { - stream: true, + streaming: true, }); const seen: number[][] = []; @@ -282,7 +289,7 @@ describe('streaming query', () => { { type: 'result', data: 20, status: 200, attempts: 1, at: 0 }, ]; const q = createStitchQuery(streamStitch(events), undefined, { - stream: true, + streaming: true, mode: 'replace', }); await new Promise((r) => setTimeout(r, 10)); @@ -304,7 +311,7 @@ describe('streaming query', () => { }, ]; const q = createStitchQuery(streamStitch(events), undefined, { - stream: true, + streaming: true, }); await new Promise((r) => setTimeout(r, 10)); const s = q.getSnapshot(); @@ -319,7 +326,7 @@ describe('streaming query', () => { ]; const statuses: string[] = []; const q = createStitchQuery(streamStitch(events), undefined, { - stream: true, + streaming: true, mode: 'replace', }); q.subscribe(() => statuses.push(q.getSnapshot().status)); @@ -394,7 +401,7 @@ describe('onSuccess / onError callbacks', () => { { type: 'result', data: 'final', status: 200, attempts: 1, at: 0 }, ]; const q = createStitchQuery(streamStitch(events), undefined, { - stream: true, + streaming: true, onSuccess, }); await tick(); @@ -415,7 +422,7 @@ describe('onSuccess / onError callbacks', () => { }, ]; const q = createStitchQuery(streamStitch(events), undefined, { - stream: true, + streaming: true, onError, }); await tick(); @@ -439,3 +446,99 @@ describe('onSuccess / onError callbacks', () => { q.destroy(); }); }); + +// --- key derivation (the one shared implementation behind every binding) ---- + +describe('nameOf()', () => { + const withConfig = (cfg: Record): StitchLike => + Object.assign( + unaryStitch(async () => 'x'), + { __config: cfg }, + ); + + test('prefers name, then path, then url, then the literal fallback', () => { + expect(nameOf(withConfig({ name: 'getUser', path: '/u/{id}' }))).toBe( + 'getUser', + ); + expect(nameOf(withConfig({ path: '/users/{id}' }))).toBe('/users/{id}'); + expect(nameOf(withConfig({ url: 'https://x.dev/feed' }))).toBe( + 'https://x.dev/feed', + ); + expect(nameOf(withConfig({}))).toBe('stitch'); + }); + + test('a bare callable without __config falls back to the literal', () => { + expect(nameOf(unaryStitch(async () => 1))).toBe('stitch'); + }); +}); + +describe('keyInputFor()', () => { + test('null / undefined stay null; primitives pass through', () => { + expect(keyInputFor(null)).toBeNull(); + expect(keyInputFor(undefined)).toBeNull(); + expect(keyInputFor(7)).toBe(7); + expect(keyInputFor('q')).toBe('q'); + }); + + test('drops runtime-only signal / onProgress, keeps everything else', () => { + const out = keyInputFor({ + params: { id: '1' }, + signal: new AbortController().signal, + onProgress: () => {}, + }); + expect(out).toEqual({ params: { id: '1' } }); + }); + + test('redacts secret header VALUES but keeps benign headers varying the key', () => { + const out = keyInputFor({ + headers: { + Authorization: 'Bearer tok', + 'x-csrf-token': 'abc', + 'x-goog-api-key': 'k', + 'accept-language': 'uk', + }, + }) as { headers: Record }; + expect(out.headers['Authorization']).toBe('[redacted]'); + expect(out.headers['x-csrf-token']).toBe('[redacted]'); + expect(out.headers['x-goog-api-key']).toBe('[redacted]'); + expect(out.headers['accept-language']).toBe('uk'); + }); + + test("reuses core's isSecretKey: registerSecretKey widens header redaction", () => { + registerSecretKey('x-querycore-spec-credential'); + const out = keyInputFor({ + headers: { 'x-querycore-spec-credential': 'v' }, + }) as { headers: Record }; + expect(out.headers['x-querycore-spec-credential']).toBe('[redacted]'); + }); +}); + +describe('deriveQueryKey() / stitchQueryOptions()', () => { + test('the key is [name, sanitised input]', () => { + const stitch = Object.assign( + unaryStitch(async () => 'x'), + { __config: { path: '/users/{id}' } }, + ); + expect( + deriveQueryKey(stitch, { + params: { id: '1' }, + headers: { authorization: 'Bearer t' }, + }), + ).toEqual([ + '/users/{id}', + { params: { id: '1' }, headers: { authorization: '[redacted]' } }, + ]); + }); + + test('stitchQueryOptions returns the derived key and an awaiting queryFn', async () => { + const stitch = Object.assign( + unaryStitch(async (input) => ({ echoed: input })), + { __config: { name: 'echo' } }, + ); + const options = stitchQueryOptions(stitch, { body: { a: 1 } }); + expect(options.queryKey).toEqual(['echo', { body: { a: 1 } }]); + await expect(options.queryFn()).resolves.toEqual({ + echoed: { body: { a: 1 } }, + }); + }); +}); diff --git a/packages/react-native/README.md b/packages/react-native/README.md index bdb22ec9..495581c1 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -64,7 +64,7 @@ function Chat({ prompt }: { prompt: string }) { } ``` -`useStitch` / `useStitchStream` / `queryOptions` and their types are re-exported here, so you import everything from `@stitchapi/react-native`. +`useStitch` / `useStitchStream` / `stitchQueryOptions` and their types are re-exported here, so you import everything from `@stitchapi/react-native`. ## Refetch on foreground / reconnect @@ -79,16 +79,16 @@ import { function Inbox() { const q = useStitch(getInbox, {}); useAppActiveRefetch(q); // refetch when the app returns to the foreground - useReconnectRefetch(q, { netInfo: NetInfo }); // refetch when connectivity returns + useReconnectRefetch(q, NetInfo); // refetch when connectivity returns // ... } ``` -Both subscriptions are also available as plain functions — `onAppActive(appState, cb)` and `onReconnect(netInfo, cb)` — for use outside React. +`useReconnectRefetch` takes the NetInfo module positionally; pass the options envelope (`{ netInfo, enabled }`) when you also need `enabled`. Both subscriptions are also available as plain functions — `onAppActive(appState, cb)` and `onReconnect(netInfo, cb)` — for use outside React. ## The persistent store -`asyncStorageStore(storage, options?)` accepts any client matching `{ getItem, setItem, removeItem }` (the community AsyncStorage module, an MMKV shim, a test double). Values ride in a JSON envelope with an absolute expiry (AsyncStorage has no native TTL); `incr` is serialized so concurrent increments stay atomic — the throttle counter behaves exactly as it does on Redis. Pass `keyPrefix` to namespace, `now` to inject a clock in tests. +`asyncStorageStore(storage, options?)` accepts any client matching `{ getItem, setItem, removeItem }` (the community AsyncStorage module, an MMKV shim, a test double). Values ride in a JSON envelope with an absolute expiry (AsyncStorage has no native TTL); `incr` is serialized so concurrent increments stay atomic — the throttle counter behaves exactly as it does on Redis. Pass `keyPrefix` to namespace (default `'stitch:'` — AsyncStorage is the app's one shared bucket, so the store namespaces by default), `now` to inject a clock in tests. ## License diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 3945db29..f2d6976b 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -1,6 +1,6 @@ // @stitchapi/react-native — React Native bindings for StitchAPI. // -// The hooks (`useStitch` / `useStitchStream` / `queryOptions`) are re-exported +// The hooks (`useStitch` / `useStitchStream` / `stitchQueryOptions`) are re-exported // verbatim from `@stitchapi/react`: they are pure `useSyncExternalStore` over the // shared `@stitchapi/query-core` store and run unchanged on React Native. What this // package ADDS is the platform glue bare RN needs: diff --git a/packages/react-native/src/lifecycle.ts b/packages/react-native/src/lifecycle.ts index 97d02aa3..c4a5a16e 100644 --- a/packages/react-native/src/lifecycle.ts +++ b/packages/react-native/src/lifecycle.ts @@ -109,25 +109,34 @@ export interface ReconnectRefetchOptions { /** Turn the subscription on/off without unmounting (default `true`). */ enabled?: boolean; /** - * The NetInfo module — required, because `@react-native-community/netinfo` is - * an optional peer this package does not bundle. Pass the imported module. + * The NetInfo module — required (P15), because `@react-native-community/netinfo` + * is an optional peer this package does not bundle. Pass the imported module; + * or skip the envelope and pass the module positionally. */ netInfo: NetInfoLike; } /** - * Refetch a stitch query whenever connectivity returns. + * Refetch a stitch query whenever connectivity returns. The NetInfo module is the + * one required value, so it can be passed positionally (P15 shorthand); use the + * options envelope when you also need `enabled`. * * ```tsx * import NetInfo from '@react-native-community/netinfo'; - * useReconnectRefetch(q, { netInfo: NetInfo }); + * useReconnectRefetch(q, NetInfo); + * // or, with the envelope: + * useReconnectRefetch(q, { netInfo: NetInfo, enabled: isLoggedIn }); * ``` */ export function useReconnectRefetch( handle: Refetchable, - options: ReconnectRefetchOptions, + options: NetInfoLike | ReconnectRefetchOptions, ): void { - const { enabled = true, netInfo } = options; + // A NetInfoLike is distinguishable by its `addEventListener`; the envelope + // carries the module under `netInfo` instead. + const opts: ReconnectRefetchOptions = + 'addEventListener' in options ? { netInfo: options } : options; + const { enabled = true, netInfo } = opts; const ref = useRef(handle); ref.current = handle; useEffect(() => { diff --git a/packages/react-native/src/store.ts b/packages/react-native/src/store.ts index 3edb586e..a5ff4877 100644 --- a/packages/react-native/src/store.ts +++ b/packages/react-native/src/store.ts @@ -28,6 +28,11 @@ export interface AsyncStorageStoreOptions { * Prefix applied to every key, for sharing one AsyncStorage with other data. * Applied on read and write so the store stays self-consistent. Default * `'stitch:'`. + * + * Deliberate P8 divergence from `redisStore`'s `''` default: AsyncStorage is + * the app's single shared device-wide bucket — the app's own data lives right + * next to the store's keys — so namespacing by default prevents collisions. + * A Redis deployment typically dedicates a database/namespace instead. */ keyPrefix?: string; /** Injectable clock (ms epoch) for deterministic TTL tests. Default `Date.now`. */ @@ -103,7 +108,7 @@ export function asyncStorageStore( async get(key) { return (await readEnvelope(key))?.v; }, - async set(key, value, ttlMs) { + async set(key, value, ttl) { // `set(key, undefined)` is the cache's delete (ADR 0003 §8). if (value === undefined) { await storage.removeItem(k(key)); @@ -111,19 +116,23 @@ export function asyncStorageStore( } await write( key, - ttlMs === undefined - ? { v: value } - : { v: value, e: now() + ttlMs }, + ttl === undefined ? { v: value } : { v: value, e: now() + ttl }, ); }, - incr(key, ttlMs) { + incr(key, ttl) { return serialize(async () => { const env = await readEnvelope(key); const current = typeof env?.v === 'number' ? env.v : 0; const next = current + 1; // Set the expiry only when CREATING the counter, never extending it, // so a busy window still resets once it lapses (matches redisStore). - const expiry = env === undefined ? now() + ttlMs : env.e; + // An absent `ttl` means no window — the counter never expires. + const expiry = + env === undefined + ? ttl === undefined + ? undefined + : now() + ttl + : env.e; await write( key, expiry === undefined ? { v: next } : { v: next, e: expiry }, diff --git a/packages/react-native/test/lifecycle.spec.ts b/packages/react-native/test/lifecycle.spec.ts index eacff30f..65a52c3c 100644 --- a/packages/react-native/test/lifecycle.spec.ts +++ b/packages/react-native/test/lifecycle.spec.ts @@ -1,7 +1,11 @@ // The lifecycle subscription logic lives in two pure functions (onAppActive / // onReconnect) that take the platform module by argument; the hooks are thin // useEffect wrappers over them. Test the logic directly with fake emitters. -import { onAppActive, onReconnect } from '../src/lifecycle'; +import { + onAppActive, + onReconnect, + useReconnectRefetch, +} from '../src/lifecycle'; import type { AppStateLike, NetInfoLike } from '../src/lifecycle'; import { describe, expect, test } from 'vitest'; @@ -68,6 +72,17 @@ describe('onAppActive', () => { }); }); +describe('useReconnectRefetch', () => { + test('accepts the NetInfo module positionally or in the envelope (P15, type-level)', () => { + const { netInfo } = fakeNetInfo(); + type Second = Parameters[1]; + const positional: Second = netInfo; + const envelope: Second = { netInfo, enabled: false }; + expect(positional).toBeDefined(); + expect(envelope).toBeDefined(); + }); +}); + describe('onReconnect', () => { test('fires on disconnected→connected transitions, and stops after unsubscribe', () => { const { netInfo, emit } = fakeNetInfo(); diff --git a/packages/react-native/test/store.spec.ts b/packages/react-native/test/store.spec.ts index bcc95875..6890f355 100644 --- a/packages/react-native/test/store.spec.ts +++ b/packages/react-native/test/store.spec.ts @@ -51,6 +51,14 @@ describe('asyncStorageStore', () => { expect(await store.get('k')).toBe('v'); }); + test('incr without a ttl never expires (no window)', async () => { + let t = 0; + const store = asyncStorageStore(fakeAsyncStorage(), { now: () => t }); + expect(await store.incr('c')).toBe(1); + t = 10_000_000; + expect(await store.incr('c')).toBe(2); + }); + test('incr resets to 1 once its TTL window lapses', async () => { let t = 0; const store = asyncStorageStore(fakeAsyncStorage(), { now: () => t }); diff --git a/packages/react/README.md b/packages/react/README.md index 18718863..a9d1191d 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -14,7 +14,7 @@ These hooks are a thin layer over [`@stitchapi/query-core`](../query-core), the pnpm add @stitchapi/react@rc @stitchapi/query-core@rc stitchapi@rc react ``` -`stitchapi` (`>=0.7.0`) and `react` (`^18 || ^19`) are peer dependencies. `@tanstack/react-query` is an **optional** peer — only needed if you use `stitchQueryOptions`. +`stitchapi` (`^1.0.0-rc.1`), `@stitchapi/query-core` (`^1.0.0-rc.2`) and `react` (`^18 || ^19`) are peer dependencies. `@tanstack/react-query` is an **optional** peer — only needed if you use `stitchQueryOptions`. ## `useStitch` — request / response @@ -91,12 +91,13 @@ import { useQuery } from '@tanstack/react-query'; const { data } = useQuery(stitchQueryOptions(getUser, { params: { id } })); ``` +The adapter (and its key derivation — `deriveQueryKey`, `nameOf`, `keyInputFor`) is the single shared implementation from [`@stitchapi/query-core`](../query-core), re-exported here, so a stitch keys identically across every framework binding and secret header values never enter the key. + > [!NOTE] > > It is `stitchQueryOptions`, not a bare `queryOptions`, because TanStack Query > exports its own `queryOptions` — the bare name would clash on import -> ([ADR 0012](../../docs/adr/0012-integration-symbol-naming.md)). `queryOptions` -> remains a `@deprecated` alias through `1.0.0-rc`, removed at the 1.0 GA cut. +> ([ADR 0012](../../docs/adr/0012-integration-symbol-naming.md)). ## License diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 03873fc4..5cd11c1c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -10,17 +10,21 @@ // - `useStitchStream` — the streaming hook: re-renders as `delta` chunks arrive. // This is the differentiator over plain request/response // query libraries. -// - `stitchQueryOptions` — an OPTIONAL TanStack Query adapter (returns a plain POJO, -// so it needs no import of `@tanstack/react-query`; named with the -// `stitch` prefix because TanStack exports its own `queryOptions`). +// - `stitchQueryOptions` — an OPTIONAL TanStack Query adapter, re-exported from +// `@stitchapi/query-core` (returns a plain POJO, so it needs +// no import of `@tanstack/react-query`; named with the +// `stitch` prefix because TanStack exports its own +// `queryOptions`, see ADR 0012). import { type CreateStitchQueryOptions, type QueryInput, type QueryOutput, type StitchLike, type StitchQuery, - type StitchQueryState, + type StitchQueryResult, createStitchQuery, + keyInputFor, + nameOf, } from '@stitchapi/query-core'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useSyncExternalStore } from 'react'; @@ -32,16 +36,44 @@ export type { QueryOutput, StitchLike, StitchQuery, - StitchQueryState, + StitchQueryOptions, + StitchQueryResult, +} from '@stitchapi/query-core'; + +// The TanStack Query adapter and its key derivation live in +// `@stitchapi/query-core` — ONE shared implementation across every framework +// binding, so the key format (and its secret-redaction guarantees) cannot drift +// between frameworks. Re-exported here so React apps import everything from +// `@stitchapi/react`. +export { + deriveQueryKey, + keyInputFor, + nameOf, + stitchQueryOptions, } from '@stitchapi/query-core'; // --------------------------------------------------------------------------- -// Hook result +// Hook options & result // --------------------------------------------------------------------------- +/** + * Options accepted by {@link useStitch} / {@link useStitchStream}. + * + * The store's `streaming` flag is deliberately OMITTED: each hook hard-sets it + * (`useStitch` → unary, `useStitchStream` → streaming), so passing it would be + * silently ignored — the type forbids it instead. + */ +export interface UseStitchOptions + extends Omit, 'streaming'> { + /** Explicit re-create trigger. When provided, the handle is re-created only + * when one of these changes (by `Object.is`), instead of the default + * structural key of `input`. */ + deps?: readonly unknown[]; +} + /** What `useStitch` / `useStitchStream` return: the reactive state plus the * imperative `refetch` / `cancel` handles. */ -export interface UseStitchResult extends StitchQueryState { +export interface UseStitchResult extends StitchQueryResult { /** Abort the in-flight run and re-run from scratch. */ refetch: () => void; /** Abort the in-flight run, if any. */ @@ -52,94 +84,16 @@ export interface UseStitchResult extends StitchQueryState { // Shared driver // --------------------------------------------------------------------------- -// --- key derivation (shared logic; duplicated in @stitchapi/swr) ----------- -// These helpers are intentionally copied verbatim into `@stitchapi/swr`'s -// `swrKey`: they are separate published packages, so a cross-package import would -// add a runtime dependency. Keep the two copies in lock-step. - -/** The `__config` slice a key derives from. Mirrors core's `nameOf` - * (`name ?? path ?? 'stitch'`) plus a `url` fallback for URL-configured stitches. */ -type KeyConfig = { name?: string; path?: string; url?: string }; - -/** A stable, human-meaningful name for the stitch. Mirrors core's `nameOf` - * (`packages/core/src/engine.ts`) — `name ?? path ?? url ?? 'stitch'` — so two - * DISTINCT nameless stitches (`/users/{id}` vs `/orders/{id}`) don't collapse to - * the literal `'stitch'` and collide on one cache entry. */ -function nameOf(stitch: unknown): string { - const cfg = (stitch as { __config?: KeyConfig }).__config; - return cfg?.name ?? cfg?.path ?? cfg?.url ?? 'stitch'; -} - -// Header names whose VALUES are secrets — mirrors core's private `SECRET_HEADERS` -// trace denylist (`packages/core/src/trace.ts`), which is not exported. We redact -// the value (rather than dropping the header) so the key stays stable per token -// AND callers who legitimately vary a response by a non-secret header (e.g. -// `accept-language`) keep separate cache entries. Compared case-insensitively; the -// `*-token` / `*-api-key` suffix rules catch vendor spellings without enumerating. -const SECRET_HEADERS = new Set([ - 'authorization', - 'proxy-authorization', - 'cookie', - 'set-cookie', - 'x-api-key', - 'x-auth-token', -]); -const REDACTED = '[redacted]'; - -function isSecretHeader(name: string): boolean { - const k = name.toLowerCase(); - return ( - SECRET_HEADERS.has(k) || k.endsWith('-token') || k.endsWith('-api-key') - ); -} - -/** - * Build the value that goes into a cache/query key from a stitch's per-call input. - * Never puts the raw input in the key: - * - * - drops `signal` / `onProgress` — runtime-only, never-serialised (CONTRACT.md); - * `onProgress` in particular churns identity every render, which would refetch - * forever if it entered the key; - * - redacts the VALUES of secret-bearing headers (`authorization`, `cookie`, …) - * so a bearer token can't leak into a persisted / devtools-visible key, while - * keeping non-secret headers so they still vary the cache; - * - keeps every other field (`params` / `query` / `body` / `variables` / …) as-is. - * - * `null` / `undefined` inputs stay `null`; a primitive input is returned unchanged. - */ -function keyInputFor(input: unknown): unknown { - if (input === null || input === undefined) return null; - if (typeof input !== 'object') return input; - - const { - signal: _signal, - onProgress: _onProgress, - ...rest - } = input as { - signal?: unknown; - onProgress?: unknown; - headers?: Record; - } & Record; - - if (rest.headers && typeof rest.headers === 'object') { - const headers: Record = {}; - for (const [k, v] of Object.entries(rest.headers)) { - headers[k] = isSecretHeader(k) ? REDACTED : v; - } - rest.headers = headers; - } - return rest; -} - // `deps` lets the caller control when the query handle is re-created. By default // we derive a stable identity from the stitch + a structural key of the input, so // `{ id: 1 }` !== `{ id: 2 }` re-fetches but a re-render with an equal-shaped // literal does not. A caller who keys differently passes explicit `deps`. function defaultKey(input: unknown): string { try { - // Sanitise first: an inline `onProgress` (fresh identity per render) would - // otherwise churn the structural key and loop; a per-call `signal` would - // add non-deterministic noise. Both are runtime-only, so drop them. + // Sanitise first (via query-core's `keyInputFor`): an inline `onProgress` + // (fresh identity per render) would otherwise churn the structural key + // and loop; a per-call `signal` would add non-deterministic noise. Both + // are runtime-only, so they are dropped. return JSON.stringify(keyInputFor(input)); } catch { // Non-serialisable input (a function, a cyclic object) → opt out of @@ -148,19 +102,12 @@ function defaultKey(input: unknown): string { } } -interface UseStitchOptions extends CreateStitchQueryOptions { - /** Explicit re-create trigger. When provided, the handle is re-created only - * when one of these changes (by `Object.is`), instead of the default - * structural key of `input`. */ - deps?: readonly unknown[]; -} - function useStitchInternal( stitch: StitchLike, input: unknown, - options: UseStitchOptions & { stream: boolean }, + options: UseStitchOptions & { streaming: boolean }, ): UseStitchResult { - const { deps, stream, mode, enabled, onSuccess, onError } = options; + const { deps, streaming, mode, enabled, onSuccess, onError } = options; // Keep the latest callbacks in a ref so changing them does not re-create the // handle (and so the store never holds a stale closure). @@ -184,11 +131,12 @@ function useStitchInternal( // The dependency list that triggers a fresh handle (and re-fetch). Default: a // structural key of the input + a stable name for the stitch (NOT its function - // identity; via `nameOf`, so two nameless stitches on different paths don't - // share a dep key) + the streaming flags. Pass `options.deps` to override. + // identity; via query-core's `nameOf`, so two nameless stitches on different + // paths don't share a dep key) + the streaming flags. Pass `options.deps` to + // override. const depKey = deps ? deps - : [nameOf(stitch), defaultKey(input), stream, mode, enabled]; + : [nameOf(stitch), defaultKey(input), streaming, mode, enabled]; const query: StitchQuery = useMemo( () => @@ -196,7 +144,7 @@ function useStitchInternal( stableStitch, input, compact({ - stream, + streaming, mode, enabled, onSuccess: (d: T) => cbRef.current.onSuccess?.(d), @@ -262,7 +210,10 @@ export function useStitch( input: unknown, options: UseStitchOptions = {}, ): UseStitchResult { - return useStitchInternal(stitch, input, { ...options, stream: false }); + return useStitchInternal(stitch, input, { + ...options, + streaming: false, + }); } // --------------------------------------------------------------------------- @@ -297,63 +248,5 @@ export function useStitchStream( input: unknown, options: UseStitchOptions = {}, ): UseStitchResult { - return useStitchInternal(stitch, input, { ...options, stream: true }); + return useStitchInternal(stitch, input, { ...options, streaming: true }); } - -// --------------------------------------------------------------------------- -// stitchQueryOptions — optional TanStack Query adapter -// --------------------------------------------------------------------------- - -/** The plain object {@link stitchQueryOptions} returns — structurally compatible with - * TanStack Query's `useQuery(options)` without importing the library. */ -export interface StitchQueryOptions { - queryKey: readonly unknown[]; - queryFn: (ctx?: { signal?: AbortSignal }) => Promise; -} - -/** - * Build a TanStack-Query-compatible options object for a stitch, WITHOUT a hard - * dependency on `@tanstack/react-query` — it just returns a POJO. Pass it - * straight to `useQuery`: - * - * ```tsx - * import { useQuery } from '@tanstack/react-query'; - * import { stitchQueryOptions } from '@stitchapi/react'; - * - * const { data } = useQuery(stitchQueryOptions(getUser, { params: { id } })); - * ``` - * - * The `queryFn` awaits the stitch (the validated output); the `queryKey` is a - * stable name for the stitch plus a sanitised copy of the input (secret header - * values redacted, runtime-only `signal`/`onProgress` dropped), so TanStack caches - * per call without leaking a bearer token into the key or refetching every render. - * - * Named `stitchQueryOptions` (not a bare `queryOptions`) because TanStack Query - * itself exports a `queryOptions` — the bare name would clash on import. See - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). - */ -export function stitchQueryOptions>( - stitch: S, - input: QueryInput, -): StitchQueryOptions>; -export function stitchQueryOptions( - stitch: StitchLike, - input: Input, -): StitchQueryOptions; -export function stitchQueryOptions( - stitch: StitchLike, - input: unknown, -): StitchQueryOptions { - return { - queryKey: [nameOf(stitch), keyInputFor(input)], - queryFn: () => Promise.resolve(stitch(input)), - }; -} - -/** - * @deprecated Renamed to {@link stitchQueryOptions} — a bare `queryOptions` collides - * with TanStack Query's own `queryOptions` export when both are imported. See - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). Kept through the - * `1.0.0-rc` line and removed at the 1.0 GA cut. - */ -export const queryOptions = stitchQueryOptions; diff --git a/packages/react/test/hooks.spec.tsx b/packages/react/test/hooks.spec.tsx index c898f0d4..60f33abe 100644 --- a/packages/react/test/hooks.spec.tsx +++ b/packages/react/test/hooks.spec.tsx @@ -1,12 +1,6 @@ // @stitchapi/react hook behaviour, rendered into jsdom with @testing-library. // Driven by FAKE stitches — no engine, no network. -import { - // Deprecated alias (ADR 0012) — exercised by the alias-guard test below. - queryOptions, - stitchQueryOptions, - useStitch, - useStitchStream, -} from '../src'; +import { stitchQueryOptions, useStitch, useStitchStream } from '../src'; import type { StitchCallResult, StitchLike } from '@stitchapi/query-core'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; @@ -288,6 +282,24 @@ describe('useStitchStream', () => { }); }); +// --- UseStitchOptions surface ------------------------------------------------ + +describe('UseStitchOptions', () => { + test("the store's 'streaming' flag cannot be passed to the hooks", () => { + const stitch = unaryStitch(async () => 1); + // Never executed — compile-time assertions only: each hook hard-sets + // `streaming`, so passing it must be a TYPE ERROR rather than being + // silently ignored. + void function TypeOnly(): void { + // @ts-expect-error — 'streaming' is omitted from UseStitchOptions + useStitch(stitch, {}, { streaming: true }); + // @ts-expect-error — 'streaming' is omitted from UseStitchOptions + useStitchStream(stitch, {}, { streaming: false }); + }; + expect(true).toBe(true); + }); +}); + // --- stitchQueryOptions ---------------------------------------------------------- describe('stitchQueryOptions', () => { @@ -305,15 +317,13 @@ describe('stitchQueryOptions', () => { const opts = stitchQueryOptions(stitch, null); expect(opts.queryKey[0]).toBe('stitch'); }); - - test('queryOptions stays a deprecated alias of stitchQueryOptions (ADR 0012)', () => { - expect(queryOptions).toBe(stitchQueryOptions); - }); }); // --- stitchQueryOptions: cache-key derivation regressions ------------------ -// The `queryKey` derivation shared the same three bugs as `@stitchapi/swr`'s -// `swrKey`. Each of these FAILED before the fix. +// The derivation now lives in `@stitchapi/query-core` (`deriveQueryKey`) and is +// re-exported here; these regressions stay to guard the re-export wiring. Each +// of these FAILED before the original fix (the local copy shared the same three +// bugs as `@stitchapi/swr`'s `swrKey`). describe('stitchQueryOptions — no cache collision between nameless stitches', () => { // Bug 1 (correctness): `name ?? 'stitch'` keyed every nameless stitch as the diff --git a/packages/redis/README.md b/packages/redis/README.md index 00ee935d..f951f294 100644 --- a/packages/redis/README.md +++ b/packages/redis/README.md @@ -65,16 +65,17 @@ import { redisStore } from '@stitchapi/redis'; const store = redisStore({ get: (k) => myClient.get(k), - set: (k, v, ttlMs) => myClient.set(k, v, ttlMs), - del: (k) => myClient.del(k), - incr: (k, ttlMs) => myClient.incrWithTtl(k, ttlMs), // atomic INCR + first-time PEXPIRE + set: (k, v, ttl) => myClient.set(k, v, ttl), + delete: (k) => myClient.del(k), + incr: (k, ttl) => myClient.incrWithWindow(k, ttl), // atomic INCR + first-time PEXPIRE }); ``` -`incr` must be **atomic** and set the key's TTL **only when it creates the -counter** — `fromIoredis` / `fromNodeRedis` do this with a single Lua `EVAL` -(`INCR`, then `PEXPIRE` only when the value is `1`), so a window can't slide -forever and a crash can't strand an immortal counter. +`ttl` (ms) is optional on both `set` and `incr` — absent means no expiry / no +window. When a `ttl` is given, `incr` must be **atomic** and set it **only when +it creates the counter** — `fromIoredis` / `fromNodeRedis` do this with a single +Lua `EVAL` (`INCR`, then `PEXPIRE` only when the value is `1`), so a window +can't slide forever and a crash can't strand an immortal counter. The store owns no connection: `store.close()` delegates to the driver, so you decide when the client shuts down. diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index ea7ad4ca..dbf1137a 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -33,11 +33,12 @@ import type { StitchStore } from 'stitchapi'; * primitives only. `redisStore` layers the JSON envelope and key prefixing on * top; a driver only moves opaque strings and one atomic counter. * - * `incr(key, ttl)` MUST be atomic and set the key's expiry **only when it - * creates the counter** (the first increment), never extending it afterwards — - * otherwise a busy rate window would slide forever and never reset. {@link - * fromIoredis} / {@link fromNodeRedis} guarantee this with a Lua `EVAL`; a custom - * driver must do the same. + * `incr(key, ttl)` MUST be atomic and, when `ttl` is given, set the key's + * expiry **only when it creates the counter** (the first increment), never + * extending it afterwards — otherwise a busy rate window would slide forever + * and never reset. An absent `ttl` means **no window**: a plain atomic `INCR`, + * the counter never expires. {@link fromIoredis} / {@link fromNodeRedis} + * guarantee this with a Lua `EVAL`; a custom driver must do the same. */ export interface RedisDriver { /** `GET key` — the raw stored string, or `null` when absent. */ @@ -45,20 +46,27 @@ export interface RedisDriver { /** `SET key value` (no TTL) or `SET key value PX ttl` when `ttl` is set. */ set(key: string, value: string, ttl?: number): Promise; /** `DEL key`. */ - del(key: string): Promise; - /** Atomic `INCR key` + first-time `PEXPIRE key ttl`; resolves to the new count. */ - incr(key: string, ttl: number): Promise; + delete(key: string): Promise; + /** + * Atomic `INCR key`; resolves to the new count. When `ttl` (ms) is set, a + * first-time `PEXPIRE key ttl` is bound to the creating increment; absent + * `ttl` = no expiry. + */ + incr(key: string, ttl?: number): Promise; /** Release the connection (optional — `redisStore().close()` delegates here). */ close?(): Promise; } -// Atomic counter-with-window. INCR is atomic on its own; the EXPIRE is bound to -// it in one server-side script so the TTL is set exactly once — on the increment -// that creates the window (`v == 1`) — and a crash can never leave an immortal -// counter. Redis caches the script body after the first EVAL, so re-sending it is -// cheap; EVALSHA would shave the bytes but isn't worth the dialect surface here. -const INCR_WITH_TTL = `local v = redis.call('INCR', KEYS[1]) -if v == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end +// Atomic counter-with-optional-window. INCR is atomic on its own; the EXPIRE is +// bound to it in one server-side script so the TTL is set exactly once — on the +// increment that creates the window (`v == 1`) — and a crash can never leave an +// immortal counter. ARGV[1] = 0 encodes an absent `ttl` (a deliberate no-window +// counter: plain INCR, no expiry — the adapters send `ttl ?? 0`). Redis caches +// the script body after the first EVAL, so re-sending it is cheap; EVALSHA would +// shave the bytes but isn't worth the dialect surface here. +const INCR_SCRIPT = `local v = redis.call('INCR', KEYS[1]) +local ttl = tonumber(ARGV[1]) or 0 +if v == 1 and ttl > 0 then redis.call('PEXPIRE', KEYS[1], ttl) end return v`; // --------------------------------------------------------------------------- @@ -142,12 +150,12 @@ export function fromIoredis(client: IoredisLike): RedisDriver { if (ttl == null) await client.set(key, value); else await client.set(key, value, 'PX', ttl); }, - async del(key) { + async delete(key) { await client.del(key); }, async incr(key, ttl) { - // ioredis: eval(script, numKeys, ...keysThenArgs). - return Number(await client.eval(INCR_WITH_TTL, 1, key, ttl)); + // ioredis: eval(script, numKeys, ...keysThenArgs). 0 = no window. + return Number(await client.eval(INCR_SCRIPT, 1, key, ttl ?? 0)); }, async close() { await client.quit?.(); @@ -175,15 +183,16 @@ export function fromNodeRedis(client: NodeRedisLike): RedisDriver { if (ttl == null) await client.set(key, value); else await client.set(key, value, { PX: ttl }); }, - async del(key) { + async delete(key) { await client.del(key); }, async incr(key, ttl) { // node-redis: eval(script, { keys, arguments }); ARGV are strings. + // '0' = no window. return Number( - await client.eval(INCR_WITH_TTL, { + await client.eval(INCR_SCRIPT, { keys: [key], - arguments: [String(ttl)], + arguments: [String(ttl ?? 0)], }), ); }, @@ -228,13 +237,13 @@ export function fromUpstash(client: UpstashLike): RedisDriver { if (ttl == null) await client.set(key, value); else await client.set(key, value, { px: ttl }); }, - async del(key) { + async delete(key) { await client.del(key); }, async incr(key, ttl) { - // Upstash: eval(script, keys[], args[]); ARGV are strings. + // Upstash: eval(script, keys[], args[]); ARGV are strings. '0' = no window. return Number( - await client.eval(INCR_WITH_TTL, [key], [String(ttl)]), + await client.eval(INCR_SCRIPT, [key], [String(ttl ?? 0)]), ); }, }; @@ -291,7 +300,7 @@ export function redisStore( async set(key, value, ttl) { // `set(key, undefined)` is the cache's delete (ADR 0003 §8) — drop the key. if (value === undefined) { - await driver.del(k(key)); + await driver.delete(k(key)); return; } await driver.set(k(key), JSON.stringify(value), ttl); diff --git a/packages/redis/test/conformance.spec.ts b/packages/redis/test/conformance.spec.ts index 5c45a9fd..874c73bc 100644 --- a/packages/redis/test/conformance.spec.ts +++ b/packages/redis/test/conformance.spec.ts @@ -16,7 +16,7 @@ import { fromIoredis, fromNodeRedis, fromUpstash, redisStore } from '../src'; import type { IoredisLike, NodeRedisLike, UpstashLike } from '../src'; import { assertConformance, verifyStoreContract } from 'stitchapi/testing'; -import { describe, test } from 'vitest'; +import { describe, expect, test } from 'vitest'; // --- a faithful in-memory Redis engine ------------------------------------ @@ -57,10 +57,15 @@ class FakeRedisEngine { // The INCR + first-time-PEXPIRE script, run atomically (no await between read // and write — the JS event loop can't interleave it, just as Redis can't). - incrWithTtl(key: string, ttlMs: number): number { + // Mirrors the Lua exactly: ARGV[1] = 0 means no window — the counter is + // created without an expiry (the adapters send `ttl ?? 0`). + incrScript(key: string, ttlMs: number): number { const e = this.live(key); if (!e) { - this.data.set(key, { value: '1', expiresAt: Date.now() + ttlMs }); + this.data.set(key, { + value: '1', + expiresAt: ttlMs > 0 ? Date.now() + ttlMs : Infinity, + }); return 1; } const v = Number(e.value) + 1; @@ -91,7 +96,7 @@ function ioredisFacade(engine: FakeRedisEngine): IoredisLike { }, async eval(_script, _numKeys, ...args) { const [key, ttlMs] = args; - return engine.incrWithTtl(String(key), Number(ttlMs)); + return engine.incrScript(String(key), Number(ttlMs)); }, async quit() { return 'OK'; @@ -115,7 +120,7 @@ function nodeRedisFacade(engine: FakeRedisEngine): NodeRedisLike { async eval(_script, options) { const key = options.keys[0] ?? ''; const ttlMs = Number(options.arguments[0]); - return engine.incrWithTtl(key, ttlMs); + return engine.incrScript(key, ttlMs); }, async quit() { return 'OK'; @@ -150,7 +155,7 @@ function upstashFacade(engine: FakeRedisEngine): UpstashLike { async eval(_script, keys, args) { const key = keys[0] ?? ''; const ttlMs = Number(args[0]); - return engine.incrWithTtl(key, ttlMs); + return engine.incrScript(key, ttlMs); }, }; } @@ -185,6 +190,45 @@ describe('@stitchapi/redis store contract', () => { }); }); +// --- no-window incr (absent ttl) ------------------------------------------- +// +// `StitchStore.incr(key)` without a ttl means "no window": the counter never +// expires. The adapters encode the absent ttl as ARGV[1] = 0 and the script +// skips the PEXPIRE; the fake engine mirrors that (0 → no expiry), so this +// pins each dialect's translation of the sentinel end to end. + +describe('incr without a ttl never expires (no window)', () => { + const stores = [ + [ + 'fromIoredis', + (): ReturnType => + redisStore(fromIoredis(ioredisFacade(new FakeRedisEngine()))), + ], + [ + 'fromNodeRedis', + (): ReturnType => + redisStore( + fromNodeRedis(nodeRedisFacade(new FakeRedisEngine())), + ), + ], + [ + 'fromUpstash', + (): ReturnType => + redisStore(fromUpstash(upstashFacade(new FakeRedisEngine()))), + ], + ] as const; + + test.each(stores)('%s', async (_name, makeStore) => { + const store = makeStore(); + // A windowed counter alongside proves the wait outlives a real window. + await store.incr('windowed', 40); + expect(await store.incr('unwindowed')).toBe(1); + await new Promise((resolve) => setTimeout(resolve, 90)); + expect(await store.incr('windowed', 40)).toBe(1); // window expired → restart + expect(await store.incr('unwindowed')).toBe(2); // no window → still counting + }); +}); + // --- opt-in: against a real Redis ----------------------------------------- const REDIS_URL = process.env['REDIS_URL']; @@ -209,4 +253,23 @@ describe.skipIf(!REDIS_URL)('against a real Redis (REDIS_URL)', () => { await client.quit(); } }); + + test('incr without a ttl never expires (the real Lua skips PEXPIRE on 0)', async () => { + const mod = (await import('ioredis')) as unknown as { + default: new (url: string) => IoredisLike & { + quit(): Promise; + }; + }; + const client = new mod.default(REDIS_URL as string); + const store = redisStore(fromIoredis(client)); + const key = `stitch-conformance:no-window-${Date.now().toString(36)}`; + try { + await store.incr(key); + await new Promise((resolve) => setTimeout(resolve, 90)); + expect(await store.incr(key)).toBe(2); // no window → still counting + } finally { + await store.set(key, undefined); // drop the immortal counter + await client.quit(); + } + }); }); diff --git a/packages/redis/test/store.spec.ts b/packages/redis/test/store.spec.ts index 33959d43..53a647bc 100644 --- a/packages/redis/test/store.spec.ts +++ b/packages/redis/test/store.spec.ts @@ -19,16 +19,16 @@ function recordingDriver(over: { close?: () => Promise } = {}): { calls.push(['get', key]); return data.get(key) ?? null; }, - async set(key, value, ttlMs) { - calls.push(['set', key, value, ttlMs]); + async set(key, value, ttl) { + calls.push(['set', key, value, ttl]); data.set(key, value); }, - async del(key) { - calls.push(['del', key]); + async delete(key) { + calls.push(['delete', key]); data.delete(key); }, - async incr(key, ttlMs) { - calls.push(['incr', key, ttlMs]); + async incr(key, ttl) { + calls.push(['incr', key, ttl]); return 1; }, ...(over.close ? { close: over.close } : {}), @@ -57,6 +57,19 @@ describe('redisStore — key mapping', () => { await redisStore(driver).set('k', 'v'); expect(calls[0]).toEqual(['set', 'k', JSON.stringify('v'), undefined]); }); + + test('an absent ttl passes through as absent on set and incr (no expiry / no window)', async () => { + const { driver, calls } = recordingDriver(); + const store = redisStore(driver); + + await store.set('k', 'v'); + await store.incr('c'); + + expect(calls).toEqual([ + ['set', 'k', JSON.stringify('v'), undefined], + ['incr', 'c', undefined], + ]); + }); }); describe('redisStore — values & delete', () => { @@ -71,7 +84,7 @@ describe('redisStore — values & delete', () => { test('set(key, undefined) deletes the prefixed key (cache delete) and writes nothing', async () => { const { driver, calls } = recordingDriver(); await redisStore(driver, { keyPrefix: 'app:' }).set('k', undefined); - expect(calls).toEqual([['del', 'app:k']]); + expect(calls).toEqual([['delete', 'app:k']]); }); }); diff --git a/packages/rtk-query/README.md b/packages/rtk-query/README.md index 074ab2f5..e60f86d6 100644 --- a/packages/rtk-query/README.md +++ b/packages/rtk-query/README.md @@ -16,7 +16,7 @@ pnpm add @stitchapi/rtk-query@rc stitchapi@rc @reduxjs/toolkit ## `stitchQueryFn` — a stitch as an endpoint -`stitchQueryFn(stitch)` returns an endpoint `queryFn`: on success `{ data }` with the validated output, on a throw `{ error }` with a **serialisable** error (so Redux holds no non-serialisable value). +`stitchQueryFn(stitch)` returns an endpoint `queryFn`: on success `{ data }` with the validated output, on a throw `{ error }` with a **serialisable** error (so Redux holds no non-serialisable value). The error keeps the thrown error's `name` (the discriminator), `message`, and every JSON-survivable own field — for a `StitchError` / `RateLimitError` that is the full `status` / `attempts` / `body` / `url` set, so you can branch on a stored error exactly as on the thrown one. Fields that would not survive `JSON.stringify` (functions, class instances, the raw `response` carrier) are dropped. ```ts import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query/react'; @@ -52,7 +52,7 @@ chat: build.query({ }), ``` -The cached data is the accumulated chunks (`mode: 'append'`, default) or the latest chunk (`mode: 'replace'`). Streaming stops when the cache entry is removed (the last component unsubscribes). +The cached data is the accumulated chunks (`'append'`, default) or the latest chunk — pass the mode directly: `stitchStreamUpdater(chat, 'replace')` (≡ `{ mode: 'replace' }`). Streaming stops when the cache entry is removed (the last component unsubscribes). ## License diff --git a/packages/rtk-query/src/index.ts b/packages/rtk-query/src/index.ts index 227f9665..e52a082b 100644 --- a/packages/rtk-query/src/index.ts +++ b/packages/rtk-query/src/index.ts @@ -11,7 +11,7 @@ // - `stitchStreamUpdater` — fold a streaming stitch's `delta` chunks into the cache // via an endpoint `onCacheEntryAdded` — RTK Query is the one // cache lib here that models streaming. -import type { Stitch, StitchEvent } from 'stitchapi'; +import type { AtLeastOne, Stitch, StitchEvent } from 'stitchapi'; // --------------------------------------------------------------------------- // The structural call contract @@ -24,6 +24,11 @@ import type { Stitch, StitchEvent } from 'stitchapi'; export type StitchLike = (input?: Input) => PromiseLike; /** A callable whose result is also streamable (`sse` / `stream` surfaces). */ +// The INTENTIONAL rich tier, restated locally (CONTRACT.md P9 de-list): this is the same +// awaitable-plus-`stream()` shape as `@stitchapi/query-core`'s canonical `StitchLike`, deliberately +// under a DISTINCT name — two named tiers, not a unique-by-shape clash. It is restated rather than +// imported because this package takes no `@stitchapi/query-core` dependency by design (RTK Query is +// the store); a real stitch satisfies both tiers. export type StreamableStitchLike = ( input?: Input, ) => PromiseLike & { stream(): AsyncIterable> }; @@ -49,11 +54,21 @@ export type QueryInput = // --------------------------------------------------------------------------- /** A serialisable representation of a thrown reason, safe to hold in Redux state: - * the error's `name` and `message` plus any primitive own fields (e.g. a - * `StitchError`'s `status`). */ + * the error's `name` (the stable discriminator) and `message` plus every + * JSON-survivable own field. For a `StitchError` / `RateLimitError` that means the + * whole CONTRACT.md P10 field set — `status?`, `attempts`, `body?`, `url?` — survives + * into Redux state (`body` is the parsed response payload, plain JSON data), so a + * consumer can branch on a stored error exactly as on the thrown one. Fields that + * would not survive `JSON.stringify` intact (functions, class instances, cycles) are + * dropped, as is `RateLimitError.response`: core documents the raw `AdapterResponse` + * as riding on the live instance only, never a serialized surface. */ export interface StitchQueryFnError { readonly name: string; readonly message: string; + readonly status?: number; + readonly attempts?: number; + readonly body?: unknown; + readonly url?: string; readonly [key: string]: unknown; } @@ -63,14 +78,32 @@ export type QueryFnResult = | { data: Data; error?: undefined } | { data?: undefined; error: StitchQueryFnError }; -function isPrimitive(v: unknown): boolean { - return ( - v === null || - v === undefined || - typeof v === 'string' || - typeof v === 'number' || - typeof v === 'boolean' - ); +/** Does `value` come back from a `JSON.stringify`/`parse` round trip intact? Plain + * data only: primitives (finite numbers — `NaN`/`Infinity` degrade to `null`), + * arrays, and plain objects. Class instances (a raw `Response`, a nested `Error`), + * functions, symbols, bigints, and cyclic structures do not survive, so their + * fields are dropped rather than mangled. `undefined` INSIDE a container is fine + * (JSON omits the key / nulls the array slot without throwing). */ +function isJsonSurvivable(value: unknown, seen: Set): boolean { + if (value === null || value === undefined) return true; + const t = typeof value; + if (t === 'string' || t === 'boolean') return true; + if (t === 'number') return Number.isFinite(value as number); + if (t !== 'object') return false; // function | symbol | bigint + const obj = value as object; + if (seen.has(obj)) return false; // cycle — JSON.stringify would throw + seen.add(obj); + let ok: boolean; + if (Array.isArray(obj)) { + ok = obj.every((v) => isJsonSurvivable(v, seen)); + } else { + const proto: unknown = Object.getPrototypeOf(obj); + ok = + (proto === Object.prototype || proto === null) && + Object.values(obj).every((v) => isJsonSurvivable(v, seen)); + } + seen.delete(obj); // path-scoped: shared (DAG) substructure is not a cycle + return ok; } function serializeError(reason: unknown): StitchQueryFnError { @@ -78,8 +111,16 @@ function serializeError(reason: unknown): StitchQueryFnError { const extra: Record = {}; const own = reason as unknown as Record; for (const key of Object.keys(reason)) { + // The raw response carrier stays behind: core documents + // `RateLimitError.response` (the full `AdapterResponse`, headers and all) + // as living on the thrown instance ONLY — it must never serialise into a + // sink, and Redux state (devtools, persistence) is exactly such a sink. + // The P10 projection of it (`status`/`body`/`url`) is already lifted onto + // the error's own fields and survives below. + if (key === 'response') continue; const value = own[key]; - if (isPrimitive(value)) extra[key] = value; + if (value !== undefined && isJsonSurvivable(value, new Set())) + extra[key] = value; } return { name: reason.name, message: reason.message, ...extra }; } @@ -127,10 +168,18 @@ export function stitchQueryFn( // stitchStreamUpdater // --------------------------------------------------------------------------- -/** How a streaming endpoint folds `delta` chunks into its cached array. */ +/** How a streaming endpoint folds `delta` chunks into its cached array: + * `'append'` (default) pushes every chunk; `'replace'` keeps only the latest. */ +export type StreamUpdaterMode = 'append' | 'replace'; + +/** The {@link stitchStreamUpdater} options envelope. At the parameter the mode is + * the dominant field, so its scalar is accepted directly (`'replace'` ≡ + * `{ mode: 'replace' }`, CONTRACT.md P12) and the object form requires at least one + * field — `{}` is a compile error, the all-defaults case is omitting the argument + * (P20). */ export interface StreamUpdaterOptions { /** `'append'` (default) pushes every chunk; `'replace'` keeps only the latest. */ - readonly mode?: 'append' | 'replace'; + readonly mode?: StreamUpdaterMode; } /** The slice of RTK Query's cache-lifecycle API that {@link stitchStreamUpdater} @@ -153,18 +202,21 @@ export interface CacheLifecycleApi { * }), * ``` * - * The cached data is the accumulated chunks (`mode: 'append'`, default) or the - * latest chunk (`mode: 'replace'`). Streaming stops when the cache entry is removed. + * The cached data is the accumulated chunks (`'append'`, default) or the latest + * chunk (`'replace'`): pass the mode scalar — `stitchStreamUpdater(chat, 'replace')` + * ≡ `{ mode: 'replace' }`. Streaming stops when the cache entry is removed. */ export function stitchStreamUpdater( stitch: StreamableStitchLike, - options?: StreamUpdaterOptions, + options?: StreamUpdaterMode | AtLeastOne, ): (input: Input, api: CacheLifecycleApi) => Promise; export function stitchStreamUpdater( stitch: StreamableStitchLike, - options: StreamUpdaterOptions = {}, + options?: StreamUpdaterMode | AtLeastOne, ): (input: unknown, api: CacheLifecycleApi) => Promise { - const mode = options.mode ?? 'append'; + // The scalar shorthand normalises to the canonical `mode` field (CONTRACT.md P0/P12). + const mode: StreamUpdaterMode = + typeof options === 'string' ? options : (options?.mode ?? 'append'); return async ( input: unknown, api: CacheLifecycleApi, diff --git a/packages/rtk-query/test/rtk-query.spec.ts b/packages/rtk-query/test/rtk-query.spec.ts index f2f4e187..68b68e78 100644 --- a/packages/rtk-query/test/rtk-query.spec.ts +++ b/packages/rtk-query/test/rtk-query.spec.ts @@ -87,11 +87,13 @@ describe('stitchQueryFn', () => { expect(result.error && typeof result.error).toBe('object'); }); - test('keeps primitive own fields but DROPS non-primitive ones (Redux-serialisable)', async () => { + test('preserves the P10 field set — body included — across serialisation', async () => { class RichError extends Error { override name = 'StitchError'; - status = 502; // primitive → carried - response = { headers: { secret: 'x' } }; // object → dropped + status = 502; + attempts = 3; + body = { error: 'bad gateway', upstream: ['a', 'b'] }; // parsed JSON payload → carried + url = 'https://api.example.com/users/1'; } const qfn = stitchQueryFn( unaryStitch(async () => { @@ -104,8 +106,45 @@ describe('stitchQueryFn', () => { name: 'StitchError', message: 'bad gateway', status: 502, + attempts: 3, + body: { error: 'bad gateway', upstream: ['a', 'b'] }, + url: 'https://api.example.com/users/1', + }); + // The whole error survives a real JSON round trip intact (Redux persistence). + expect(JSON.parse(JSON.stringify(result.error))).toEqual(result.error); + }); + + test('drops non-JSON-survivable fields and the raw response carrier', async () => { + const cyclic: Record = {}; + cyclic['self'] = cyclic; + class LeakyError extends Error { + override name = 'RateLimitError'; + status = 429; + retryAfter = 1000; // plain data → carried + // The full AdapterResponse lives on the thrown instance ONLY (core's rule) — + // its headers (set-cookie & co.) must never reach Redux state. + response = { + status: 429, + headers: { 'set-cookie': 'secret' }, + body: {}, + }; + onRetry = (): void => {}; // function → dropped + inner = new RangeError('nested'); // class instance → dropped + loop = cyclic; // cycle → dropped (JSON.stringify would throw) + } + const qfn = stitchQueryFn( + unaryStitch(async () => { + throw new LeakyError('rate limited (HTTP 429)'); + }), + ); + const result = await qfn({}); + + expect(result.error).toEqual({ + name: 'RateLimitError', + message: 'rate limited (HTTP 429)', + status: 429, + retryAfter: 1000, }); - // A non-serialisable object field must never reach Redux state. expect(result.error && 'response' in result.error).toBe(false); }); @@ -149,6 +188,19 @@ describe('stitchStreamUpdater', () => { expect(data).toEqual([3]); }); + test("scalar shorthand: 'replace' ≡ { mode: 'replace' } (P12), and {} is rejected (P20)", async () => { + const { data, api } = mockCache([]); + await stitchStreamUpdater(streamStitch(events), 'replace')( + undefined, + api, + ); + expect(data).toEqual([3]); + + // @ts-expect-error — P20: the empty object is not a valid options value; + // the all-defaults case is omitting the argument. + stitchStreamUpdater(streamStitch(events), {}); + }); + test('stops when the cache entry is removed, even if the stream never ends', async () => { const data: number[] = []; const api: CacheLifecycleApi = { diff --git a/packages/sentry/README.md b/packages/sentry/README.md index b5768274..5c17defb 100644 --- a/packages/sentry/README.md +++ b/packages/sentry/README.md @@ -33,20 +33,25 @@ The same sink works on a single stitch — `stitch({ trace: sentrySink(Sentry) } ## What it sends -| Event | Sentry | -| ------------------------------ | -------------------------------------------------------------------- | -| `error` | `captureMessage` (level `error`) + an error breadcrumb | -| `progress` (`retry`/`circuit`) | breadcrumb, level `warning` | -| `progress` (throttle/paginate) | breadcrumb, level `debug` | -| `drift` | breadcrumb (level follows the finding); captured with `captureDrift` | -| `start` / `result` / `done` | breadcrumb only when `lifecycle: true` (off by default) | -| `delta` / `info` | **never sent** (raw response data / strategy announcements) | - -Options: `{ lifecycle?, captureErrors?, captureDrift? }`. +| Event | Sentry | +| ------------------------------ | -------------------------------------------------------------------------------- | +| `error` | `captureMessage` (level `error`) + an error breadcrumb | +| `progress` (`retry`/`circuit`) | breadcrumb, level `warning` | +| `progress` (throttle/paginate) | breadcrumb, level `debug` | +| `drift` | breadcrumb (level follows the finding); captured with `capture: { drift: true }` | +| `start` / `result` / `done` | breadcrumb only when `lifecycle: true` (off by default) | +| `delta` / `info` | **never sent** (raw response data / strategy announcements) | + +Options: `{ lifecycle?, capture?: boolean | { errors?, drift? } }`. `capture: false` disables both `errors` and `drift` capture (breadcrumbs still flow); `capture: true`/omitted resolves to the documented defaults (`errors: true`, `drift: false`); pass the envelope to set them independently. + +> [!NOTE] +> The same `lifecycle` option in `@stitchapi/pino` defaults to `true` — a +> **deliberate** divergence: Sentry breadcrumbs/events cost quota, while Pino log +> lines are cheap and level-filtered. ## Safe on a secret-bearing seam -> A custom `TraceSink` receives the **raw** event — core only redacts inside its own built-in sinks. This sink therefore sends **metadata only**: the stitch name, method, **redacted URL** (query stripped — it can carry `?api_key=…`), status, attempt counts, drift path/level/change, phase, and timing. It never sends `event.input` (headers still hold the live `authorization`/`cookie`), the response `value`, or a `delta` chunk. +> A custom `TraceSink` receives the **raw** event — core only redacts inside its own built-in sinks. This sink therefore sends **metadata only**: the stitch name, method, **redacted URL** (query stripped — it can carry `?api_key=…`), status, attempt counts, drift path/level/change, phase, and timing. It never sends `event.input` (headers still hold the live `authorization`/`cookie`), the response `data`, or a `delta` chunk. ## License diff --git a/packages/sentry/src/index.ts b/packages/sentry/src/index.ts index a960537f..6d46b818 100644 --- a/packages/sentry/src/index.ts +++ b/packages/sentry/src/index.ts @@ -18,7 +18,12 @@ // `event.input` (its headers carry the live `authorization`/`cookie`), `event.data`, // or a `delta` chunk. Both the URL userinfo (`user:pass@`) and the query string are // dropped — either can carry a credential (`?api_key=…`). -import type { StitchEvent, TraceContext, TraceSink } from 'stitchapi'; +import type { + AtLeastOne, + StitchEvent, + TraceContext, + TraceSink, +} from 'stitchapi'; import { compact } from 'stitchapi'; // --------------------------------------------------------------------------- @@ -61,19 +66,34 @@ export interface SentrySinkOptions { * Breadcrumb the happy-path lifecycle (`start` / `result` / `done`). Default * `false` — these are noisy in Sentry, where breadcrumbs matter most just before * an error. Retries, circuit trips, and drift findings are always breadcrumbed. + * + * DELIBERATE DIVERGENCE (contract P8): `@stitchapi/pino`'s same-named + * `lifecycle` defaults to `true`. Sentry breadcrumbs/events cost quota, so the + * lifecycle is off by default here; pino log lines are cheap and + * level-filtered, so it is on by default there. */ lifecycle?: boolean; /** - * Capture `error` events as Sentry issues via `captureMessage`. Default `true`. - * Set `false` to only leave breadcrumbs (e.g. when your framework already reports - * the error to Sentry and you just want the stitch trail). + * Capture Sentry issues via `captureMessage`: `errors` for every `error` event + * (default `true`) and `drift` for an **error-level** `drift` finding (a breaking + * API change; default `false`). `capture: false` disables both — only breadcrumbs + * are left (e.g. when your framework already reports the error to Sentry and you + * just want the stitch trail). `capture: true` (or omitting the option) resolves + * both sub-fields to the defaults above. Pass a {@link SentryCaptureOptions} + * envelope to set them independently. */ - captureErrors?: boolean; + capture?: boolean | AtLeastOne; +} + +/** Envelope for {@link SentrySinkOptions.capture}. */ +export interface SentryCaptureOptions { + /** Capture `error` events as Sentry issues via `captureMessage`. Default `true`. */ + errors?: boolean; /** * Also capture an **error-level** `drift` finding (a breaking API change) as its * own Sentry issue, not just a breadcrumb. Default `false`. */ - captureDrift?: boolean; + drift?: boolean; } // --------------------------------------------------------------------------- @@ -101,6 +121,17 @@ function redactUrl(url: string): string { return q === -1 ? url : `${url.slice(0, q)}?…`; } +/** Resolve {@link SentrySinkOptions.capture} to its two booleans (P24 envelope). */ +function resolveCapture( + capture: boolean | AtLeastOne | undefined, +): { errors: boolean; drift: boolean } { + if (capture === false) return { errors: false, drift: false }; + if (capture === true || capture === undefined) { + return { errors: true, drift: false }; + } + return { errors: capture.errors ?? true, drift: capture.drift ?? false }; +} + const DRIFT_TO_SENTRY: Record = { error: 'error', warn: 'warning', @@ -214,8 +245,9 @@ export function sentrySink( options: SentrySinkOptions = {}, ): TraceSink { const lifecycle = options.lifecycle ?? false; - const captureErrors = options.captureErrors ?? true; - const captureDrift = options.captureDrift ?? false; + const { errors: captureErrors, drift: captureDrift } = resolveCapture( + options.capture, + ); return { handle(event: StitchEvent, ctx: TraceContext): void { diff --git a/packages/sentry/test/sentry.spec.ts b/packages/sentry/test/sentry.spec.ts index 67017dd0..da7c62c2 100644 --- a/packages/sentry/test/sentry.spec.ts +++ b/packages/sentry/test/sentry.spec.ts @@ -110,7 +110,7 @@ describe('sentrySink', () => { expect(on.breadcrumbs).toHaveLength(1); }); - test('error-level drift is breadcrumbed; captured only with captureDrift', () => { + test('error-level drift is breadcrumbed; captured only with capture.drift', () => { const a = mockSentry(); sentrySink(a.sentry).handle(ev.driftError, ctx); expect(a.breadcrumbs).toHaveLength(1); @@ -121,13 +121,19 @@ describe('sentrySink', () => { expect(a.captures).toHaveLength(0); const b = mockSentry(); - sentrySink(b.sentry, { captureDrift: true }).handle(ev.driftError, ctx); + sentrySink(b.sentry, { capture: { drift: true } }).handle( + ev.driftError, + ctx, + ); expect(b.captures).toHaveLength(1); }); - test('captureErrors:false still breadcrumbs the error but does not capture it as an issue', () => { + test('capture.errors:false still breadcrumbs the error but does not capture it as an issue', () => { const { sentry, breadcrumbs, captures } = mockSentry(); - sentrySink(sentry, { captureErrors: false }).handle(ev.error, ctx); + sentrySink(sentry, { capture: { errors: false } }).handle( + ev.error, + ctx, + ); // The error trail is preserved, but the framework owns the issue. expect(captures).toHaveLength(0); @@ -135,7 +141,34 @@ describe('sentrySink', () => { expect(breadcrumbs[0]!.level).toBe('error'); }); - test('captureDrift only captures an error-level finding — a warn-level drift is breadcrumbed only', () => { + test('capture: false disables both errors and drift capture, but keeps breadcrumbs', () => { + const { sentry, breadcrumbs, captures } = mockSentry(); + const sink = sentrySink(sentry, { capture: false }); + sink.handle(ev.error, ctx); + sink.handle(ev.driftError, ctx); + + expect(captures).toHaveLength(0); + expect(breadcrumbs).toHaveLength(2); + }); + + test('capture: true resolves to the same defaults as omitting the option', () => { + const withTrue = mockSentry(); + sentrySink(withTrue.sentry, { capture: true }).handle(ev.error, ctx); + sentrySink(withTrue.sentry, { capture: true }).handle( + ev.driftError, + ctx, + ); + + const omitted = mockSentry(); + sentrySink(omitted.sentry).handle(ev.error, ctx); + sentrySink(omitted.sentry).handle(ev.driftError, ctx); + + // errors: true (captured), drift: false (breadcrumb only) in both cases. + expect(withTrue.captures).toHaveLength(1); + expect(omitted.captures).toHaveLength(1); + }); + + test('capture.drift only captures an error-level finding — a warn-level drift is breadcrumbed only', () => { const driftWarn: StitchEvent = { type: 'drift', finding: { @@ -146,9 +179,9 @@ describe('sentrySink', () => { at: 0, }; const { sentry, breadcrumbs, captures } = mockSentry(); - sentrySink(sentry, { captureDrift: true }).handle(driftWarn, ctx); + sentrySink(sentry, { capture: { drift: true } }).handle(driftWarn, ctx); - // captureDrift is gated on an error-level finding; a warn drift only crumbs. + // capture.drift is gated on an error-level finding; a warn drift only crumbs. expect(captures).toHaveLength(0); expect(breadcrumbs).toHaveLength(1); expect(breadcrumbs[0]).toMatchObject({ @@ -157,6 +190,16 @@ describe('sentrySink', () => { }); }); + test('old flat spellings are DELETED and the empty capture bag is rejected (compile-time)', () => { + // @ts-expect-error — `captureErrors` was folded into `capture` (no alias, P24) + void sentrySink(mockSentry().sentry, { captureErrors: false }); + // @ts-expect-error — `captureDrift` was folded into `capture` (no alias, P24) + void sentrySink(mockSentry().sentry, { captureDrift: true }); + // @ts-expect-error — `{}` is not a valid capture envelope (CONTRACT.md P20): use `true`/omit + void sentrySink(mockSentry().sentry, { capture: {} }); + expect(true).toBe(true); + }); + test('delta and info events are never sent (raw data / announcements)', () => { const { sentry, breadcrumbs, captures } = mockSentry(); const sink = sentrySink(sentry); @@ -191,7 +234,7 @@ describe('sentrySink', () => { }); }); - test('metadata only: no secret query, no input/value/chunk reaches Sentry', () => { + test('metadata only: no secret query, no input/data/chunk reaches Sentry', () => { const { sentry, breadcrumbs, captures } = mockSentry(); const sink = sentrySink(sentry, { lifecycle: true }); for (const e of Object.values(ev)) sink.handle(e, ctx); diff --git a/packages/shell/README.md b/packages/shell/README.md index a1715c8d..81cd687d 100644 --- a/packages/shell/README.md +++ b/packages/shell/README.md @@ -12,7 +12,7 @@ browser bundle. ```ts import { shell } from '@stitchapi/shell'; -const git = shell({ command: 'git', env: { PATH: process.env.PATH! } }); +const git = shell('git', { env: { PATH: process.env.PATH! } }); const status = await git({ body: ['status', '--porcelain'] }); // stdout (string) ``` @@ -22,8 +22,9 @@ const status = await git({ body: ['status', '--porcelain'] }); // stdout (string Not "mitigated by escaping" — **structurally impossible**, the same bar that rejected host-inferred bearer tokens in StitchAPI's auth: -- **Static executable.** `command` is bound in `shell({ command })`, **never** taken from call - input — exactly as a credential is bound at construction. +- **Static executable.** `command` is bound at construction — `shell(command, …)` or + `shell({ command, … })` — **never** taken from call input, exactly as a credential is bound at + construction. - **`argv` is an array, never a string.** Arguments are a `string[]` passed straight to `child_process.execFile`. There is **no shell** (`shell: true` is never set), so `;` `|` `$()` backticks `*` `>` are inert data, never interpreted. @@ -35,16 +36,19 @@ host-inferred bearer tokens in StitchAPI's auth: ## Result -- Exit `0` → the value is `stdout` (a `string`; pass `decode: 'json'` to `JSON.parse` it). +- Exit `0` → the value is `stdout` (a `string`; pass `responseType: 'json'` to `JSON.parse` it). - A non-zero exit → a `StitchError` (status `500`) whose `.body` is `{ exitCode, stdout, stderr }` (or accept it as a normal result with `acceptStatus`). - A timeout / caller `AbortSignal` aborts the subprocess (it runs inside the resilience chain). ## Options -`shell(options)` takes the static `command`, optional `cwd` / `env` / `decode` / `maxBuffer`, plus -the usual `StitchConfig` keys (`retry`, `throttle`, `timeout`, `circuit`, `trace`, …) — all applied -by the engine around the subprocess. +Two spellings: the positional shorthand `shell(command, options?)` names the required `command`, or +pass the full `ShellOptions` envelope `shell({ command, … })`. The options are the optional `cwd` / +`env` / `responseType` (`'text'` default, or `'json'` — the shared `StitchConfig` slot, narrowed) / +`maxBufferBytes` (default 10 MiB), plus the usual `StitchConfig` keys (`retry`, `throttle`, +`timeout`, `circuit`, `trace`, …) — all applied by the engine around the subprocess. The positional +options bag must set at least one field — all-defaults is spelled by omitting it, never `{}`. ## Contributing diff --git a/packages/shell/src/index.ts b/packages/shell/src/index.ts index 7a2148ad..75da042e 100644 --- a/packages/shell/src/index.ts +++ b/packages/shell/src/index.ts @@ -6,7 +6,8 @@ // // SECURITY — injection is impossible by CONSTRUCTION, not by escaping (the "structural, not // advisory" bar that rejected host-inferred bearer tokens in #6): -// • the executable is STATIC, bound in `shell({ command })`, NEVER taken from call input; +// • the executable is STATIC, bound at construction (`shell(command)` / `shell({ command })`), +// NEVER taken from call input; // • arguments are an ARRAY of strings passed straight to `execFile` — there is NO shell // (`shell: true` is never set, no `/bin/sh -c`), so `;` `|` `$()` backticks `*` `>` are inert // data, never interpreted; @@ -19,6 +20,7 @@ import { compact, stitch } from 'stitchapi'; import type { AdapterRequest, AdapterResponse, + AtLeastOne, Stitch, StitchConfig, Surface, @@ -29,8 +31,7 @@ interface ShellDefaults { command: string; cwd?: string; env?: Record; - decode: 'text' | 'json'; - maxBuffer: number; + maxBufferBytes: number; } // Run the static command with the call's argv. The ONLY input is the argv array (`req.body`); @@ -59,7 +60,7 @@ function runCommand( cwd: d.cwd, env: d.env ?? {}, // FAIL-CLOSED: no inherited process.env signal: req.signal, - maxBuffer: d.maxBuffer, + maxBuffer: d.maxBufferBytes, encoding: 'utf8', }), (err, stdout, stderr) => { @@ -80,8 +81,11 @@ function runCommand( reject(err); return; } + // Honour the shared StitchConfig.responseType slot (narrowed to 'json' | 'text' + // in ShellOptions): the engine threads it onto the request, the surface reads it + // here. Absent → 'text' (stdout is the value, verbatim). let body: unknown = stdout; - if (d.decode === 'json') { + if (req.responseType === 'json') { try { body = JSON.parse(stdout); } catch { @@ -106,10 +110,15 @@ function shellSurface(d: ShellDefaults): Surface { }; } -/** Options for {@link shell}: the static `command` + run controls, plus the shared StitchConfig keys - * (`retry` / `throttle` / `timeout` / `circuit` / `trace` all apply via the resilience chain). */ -export type ShellOptions = Partial> & { - /** The executable — STATIC, bound here at construction, NEVER from call input. An absolute path +/** + * Options for {@link shell}: the static `command` + run controls, plus the shared StitchConfig keys + * (`retry` / `throttle` / `timeout` / `circuit` / `trace` all apply via the resilience chain). + * `command` is required by design (CONTRACT.md P15) — the positional `shell(command, options?)` + * shorthand names it, so the options bag there is `Omit`. + */ +export interface ShellOptions + extends Partial> { + /** The executable — STATIC, bound at construction, NEVER from call input. An absolute path * needs no `PATH`; a bare name (`'git'`) needs `env: { PATH: process.env.PATH }`. */ command: string; /** Working directory for the subprocess (default: the process cwd). */ @@ -117,35 +126,54 @@ export type ShellOptions = Partial> & { /** Subprocess environment. FAIL-CLOSED: empty by default — pass exactly what's needed; nothing * from `process.env` leaks in unless you put it here. */ env?: Record; - /** How to read stdout: `'text'` (default — the value is the string) or `'json'` (JSON.parse it). */ - decode?: 'text' | 'json'; + /** How to read stdout — the shared {@link StitchConfig.responseType} slot, narrowed to what a + * subprocess can yield: `'text'` (default — the value is the stdout string) or `'json'` + * (`JSON.parse` it, falling back to the raw text). Honoured by the shell surface directly. */ + responseType?: 'json' | 'text'; /** Max stdout/stderr bytes buffered (default 10 MiB); exceeding it fails the call. */ - maxBuffer?: number; -}; + maxBufferBytes?: number; +} /** - * `shell({ command })` — a stitch that runs a static local command, its `stdout` the result. The - * call supplies the argument vector as a `string[]` `body`; everything else (retry/throttle/ - * timeout/trace) is the usual StitchConfig. `T` is the result type (`string` for `decode: 'text'`, - * your shape for `'json'`). + * `shell(command, options?)` — a stitch that runs a static local command, its `stdout` the result. + * The call supplies the argument vector as a `string[]` `body`; everything else (retry/throttle/ + * timeout/trace) is the usual StitchConfig. `T` is the result type (`string` for the default + * `responseType: 'text'`, your shape for `'json'`). + * + * Two spellings (CONTRACT.md P15): the positional shorthand names the required `command`, or pass + * the full {@link ShellOptions} envelope. The positional options bag must set at least one field — + * all-defaults is spelled by omitting it, never `{}` (P20). * * @example * ```ts * import { shell } from '@stitchapi/shell'; * - * const git = shell({ command: 'git', env: { PATH: process.env.PATH! } }); + * const git = shell('git', { env: { PATH: process.env.PATH! } }); * const status = await git({ body: ['status', '--porcelain'] }); // stdout string * ``` */ -export function shell(opts: ShellOptions): Stitch { - const { command, cwd, env, decode, maxBuffer, ...rest } = opts; +export function shell( + command: string, + options?: AtLeastOne>, +): Stitch; +export function shell(options: ShellOptions): Stitch; +export function shell( + commandOrOptions: string | ShellOptions, + positionalOptions?: AtLeastOne>, +): Stitch { + const opts: ShellOptions = + typeof commandOrOptions === 'string' + ? { ...positionalOptions, command: commandOrOptions } + : commandOrOptions; + const { command, cwd, env, maxBufferBytes, ...rest } = opts; const d: ShellDefaults = { command, - decode: decode ?? 'text', - maxBuffer: maxBuffer ?? 10 * 1024 * 1024, + maxBufferBytes: maxBufferBytes ?? 10 * 1024 * 1024, }; if (cwd !== undefined) d.cwd = cwd; if (env !== undefined) d.env = env; + // `responseType` stays in `rest` — it is a shared StitchConfig key, so it rides the config + // into the engine's base request, where the surface honours it (see runCommand). return stitch({ ...rest, kind: shellSurface(d), diff --git a/packages/shell/test/shell.spec.ts b/packages/shell/test/shell.spec.ts index a56ed636..5718e4b8 100644 --- a/packages/shell/test/shell.spec.ts +++ b/packages/shell/test/shell.spec.ts @@ -10,21 +10,28 @@ import { tmpdir } from 'node:os'; const NODE = process.execPath; // an absolute path — no PATH needed to resolve it test('runs a static command; stdout is the result (text mode)', async () => { + const echo = shell(NODE); + await expect( + echo({ body: ['-e', 'process.stdout.write("hello")'] }), + ).resolves.toBe('hello'); +}); + +test('the envelope form shell({ command }) is the same surface', async () => { const echo = shell({ command: NODE }); await expect( echo({ body: ['-e', 'process.stdout.write("hello")'] }), ).resolves.toBe('hello'); }); -test('decode: "json" parses stdout', async () => { - const j = shell<{ a: number }>({ command: NODE, decode: 'json' }); +test('responseType: "json" parses stdout', async () => { + const j = shell<{ a: number }>(NODE, { responseType: 'json' }); await expect( j({ body: ['-e', 'process.stdout.write(JSON.stringify({a:1}))'] }), ).resolves.toEqual({ a: 1 }); }); test('argv elements are literal — shell metacharacters are inert (there is no shell)', async () => { - const dump = shell({ command: NODE, decode: 'json' }); + const dump = shell(NODE, { responseType: 'json' }); const evil = '; rm -rf / `whoami` $HOME && echo pwned'; const out = await dump({ body: [ @@ -42,9 +49,8 @@ test('argv elements are literal — shell metacharacters are inert (there is no test('fail-closed env: process.env does NOT leak into the subprocess', async () => { process.env['SHELL_SECRET_LEAK'] = 'nope'; try { - const dumpEnv = shell>({ - command: NODE, - decode: 'json', + const dumpEnv = shell>(NODE, { + responseType: 'json', }); const out = await dumpEnv({ body: ['-e', 'process.stdout.write(JSON.stringify(process.env))'], @@ -58,7 +64,7 @@ test('fail-closed env: process.env does NOT leak into the subprocess', async () test('explicit env IS passed to the subprocess', async () => { const dumpEnv = shell>({ command: NODE, - decode: 'json', + responseType: 'json', env: { FOO: 'bar' }, }); const out = await dumpEnv({ @@ -68,7 +74,7 @@ test('explicit env IS passed to the subprocess', async () => { }); test('a non-zero exit surfaces as a StitchError carrying the exit code + stderr', async () => { - const fail = shell({ command: NODE }); + const fail = shell(NODE); await expect( fail({ body: ['-e', 'process.stderr.write("boom"); process.exit(3)'], @@ -80,14 +86,14 @@ test('a non-zero exit surfaces as a StitchError carrying the exit code + stderr' }); test('arguments must be a string[] — a non-array body is rejected', async () => { - const x = shell({ command: NODE }); + const x = shell(NODE); await expect( x({ body: { not: 'an array' } as unknown as string[] }), ).rejects.toThrow(/string\[\]/); }); test('the resilience chain wraps the subprocess — a per-attempt timeout aborts it', async () => { - const slow = shell({ command: NODE, timeout: { perAttempt: 50 } }); + const slow = shell(NODE, { timeout: { perAttempt: 50 } }); await expect( slow({ body: ['-e', 'setTimeout(() => {}, 5000)'] }), ).rejects.toBeTruthy(); @@ -97,13 +103,13 @@ test('a spawn failure (missing binary) rejects as a transport error — NOT a 50 // ENOENT carries a STRING `code`, so `runCommand` rejects (transport error) rather than // resolving to a status-500 "response" the way a non-zero EXIT (numeric code) does — the // resilience chain then sees a real transport failure (retryable/abortable), not a result. - const missing = shell({ command: '/no/such/binary-xyz-stitchapi' }); + const missing = shell('/no/such/binary-xyz-stitchapi'); await expect(missing({ body: [] })).rejects.toThrow(/ENOENT/); }); test('cwd sets the subprocess working directory', async () => { const dir = realpathSync(tmpdir()); - const pwd = shell({ command: NODE, cwd: dir }); + const pwd = shell(NODE, { cwd: dir }); const out = await pwd({ body: ['-e', 'process.stdout.write(process.cwd())'], }); @@ -111,16 +117,28 @@ test('cwd sets the subprocess working directory', async () => { expect(realpathSync(out)).toBe(dir); }); -test('decode: "json" falls back to the raw text when stdout is not valid JSON', async () => { - const j = shell({ command: NODE, decode: 'json' }); +test('responseType: "json" falls back to the raw text when stdout is not valid JSON', async () => { + const j = shell(NODE, { responseType: 'json' }); await expect( j({ body: ['-e', 'process.stdout.write("not json")'] }), ).resolves.toBe('not json'); }); -test('exceeding maxBuffer rejects as a transport error (not a 500 response)', async () => { - const tiny = shell({ command: NODE, maxBuffer: 4 }); +test('exceeding maxBufferBytes rejects as a transport error (not a 500 response)', async () => { + const tiny = shell(NODE, { maxBufferBytes: 4 }); await expect( tiny({ body: ['-e', 'process.stdout.write("x".repeat(100))'] }), ).rejects.toThrow(/maxBuffer/i); }); + +test('old spellings are DELETED and the empty options bag is rejected (compile-time)', () => { + // @ts-expect-error — `decode` was renamed to `responseType` (no alias) + void shell({ command: NODE, decode: 'json' }); + // @ts-expect-error — `maxBuffer` was renamed to `maxBufferBytes` (no alias) + void shell({ command: NODE, maxBuffer: 4 }); + // @ts-expect-error — responseType is narrowed to 'json' | 'text' for a subprocess + void shell(NODE, { responseType: 'blob' }); + // @ts-expect-error — `{}` is not a valid options bag (CONTRACT.md P20): omit it instead + void shell(NODE, {}); + expect(true).toBe(true); +}); diff --git a/packages/solid/README.md b/packages/solid/README.md index efa9794d..9cbbb5f6 100644 --- a/packages/solid/README.md +++ b/packages/solid/README.md @@ -14,7 +14,7 @@ These primitives are a thin layer over [`@stitchapi/query-core`](../query-core), pnpm add @stitchapi/solid@rc @stitchapi/query-core@rc stitchapi@rc solid-js ``` -`stitchapi`, `@stitchapi/query-core`, and `solid-js` (`^1.8`) are peer dependencies. `@tanstack/solid-query` is an **optional** peer — only needed if you use `queryOptions`. +`stitchapi`, `@stitchapi/query-core`, and `solid-js` (`^1.8`) are peer dependencies. `@tanstack/solid-query` is an **optional** peer — only needed if you use `stitchQueryOptions`. ## `createStitch` — request / response @@ -78,7 +78,7 @@ Same store shape as `createStitch`. `state.data` is the accumulated chunks (`mod ## Store shape ```ts -interface StitchStore { +interface SolidStitchStore { state: { status: 'idle' | 'pending' | 'streaming' | 'success' | 'error'; data: T | undefined; @@ -98,17 +98,19 @@ interface StitchStore { ## Optional: TanStack Query -`queryOptions(stitch, input)` returns a plain `{ queryKey, queryFn }` object — no import of `@tanstack/solid-query` required, so it works even if you never install it. +`stitchQueryOptions(stitch, input)` returns a plain `{ queryKey, queryFn }` object — no import of `@tanstack/solid-query` required, so it works even if you never install it. (It is not named a bare `queryOptions` because TanStack Query exports its own `queryOptions` — the names would clash on import.) ```tsx -import { queryOptions } from '@stitchapi/solid'; +import { stitchQueryOptions } from '@stitchapi/solid'; import { createQuery } from '@tanstack/solid-query'; const query = createQuery(() => - queryOptions(getUser, { params: { id: id() } }), + stitchQueryOptions(getUser, { params: { id: id() } }), ); ``` +The `queryKey` is derived by `@stitchapi/query-core`'s `deriveQueryKey` — a stable name for the stitch plus a sanitised copy of the input (secret header values redacted, runtime-only `signal`/`onProgress` dropped) — so every framework binding keys a stitch identically. + ## License Apache-2.0 diff --git a/packages/solid/package.json b/packages/solid/package.json index 38f8257d..33d2d27e 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -2,7 +2,7 @@ "name": "@stitchapi/solid", "version": "1.0.0-rc.5", "type": "commonjs", - "description": "Solid bindings for StitchAPI — createStitch/createStitchStream primitives that reconcile a Solid store from the query-core reactive store. Streaming-first: re-render as `delta` chunks arrive. Plus an optional TanStack Query queryOptions helper.", + "description": "Solid bindings for StitchAPI — createStitch/createStitchStream primitives that reconcile a Solid store from the query-core reactive store. Streaming-first: re-render as `delta` chunks arrive. Plus an optional TanStack Query stitchQueryOptions helper.", "main": "lib/index.js", "module": "lib/index.mjs", "types": "lib/index.d.ts", diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 25b3b05b..8369d414 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -10,7 +10,7 @@ // - `createStitchStream` — the streaming primitive: re-renders as `delta` chunks // arrive. This is the differentiator over plain // request/response query libraries. -// - `queryOptions` — an OPTIONAL TanStack Query adapter (returns a plain +// - `stitchQueryOptions` — an OPTIONAL TanStack Query adapter (returns a plain // POJO, so it needs no import of `@tanstack/solid-query`). import { type CreateStitchQueryOptions, @@ -18,7 +18,7 @@ import { type QueryOutput, type StitchLike, type StitchQuery, - type StitchQueryState, + type StitchQueryResult, createStitchQuery, } from '@stitchapi/query-core'; import { createEffect, on, onCleanup } from 'solid-js'; @@ -31,7 +31,8 @@ export type { QueryOutput, StitchLike, StitchQuery, - StitchQueryState, + StitchQueryOptions, + StitchQueryResult, } from '@stitchapi/query-core'; // --------------------------------------------------------------------------- @@ -44,7 +45,7 @@ export type { export interface SolidStitchStore { /** The reactive state — a Solid store proxy. Read fields inside tracking * scopes (effects, JSX) to re-run on change. */ - readonly state: StitchQueryState; + readonly state: StitchQueryResult; /** Abort the in-flight run and re-run from scratch. */ refetch: () => void; /** Abort the in-flight run, if any. */ @@ -70,89 +71,10 @@ export interface CreateStitchOptions extends CreateStitchQueryOptions {} // Shared driver // --------------------------------------------------------------------------- -// --- key derivation (shared logic; duplicated in @stitchapi/react) ---------- -// These helpers are intentionally copied verbatim from `@stitchapi/react`'s key -// derivation: they are separate published packages, so a cross-package import -// would add a runtime dependency. Keep the copies in lock-step. - -/** The `__config` slice a key derives from. Mirrors core's `nameOf` - * (`name ?? path ?? 'stitch'`) plus a `url` fallback for URL-configured stitches. */ -type KeyConfig = { name?: string; path?: string; url?: string }; - -/** A stable, human-meaningful name for the stitch. Mirrors core's `nameOf` - * (`packages/core/src/engine.ts`) — `name ?? path ?? url ?? 'stitch'` — so two - * DISTINCT nameless stitches (`/users/{id}` vs `/orders/{id}`) don't collapse to - * the literal `'stitch'` and collide on one cache entry. */ -function nameOf(stitch: unknown): string { - const cfg = (stitch as { __config?: KeyConfig }).__config; - return cfg?.name ?? cfg?.path ?? cfg?.url ?? 'stitch'; -} - -// Header names whose VALUES are secrets — mirrors core's private `SECRET_HEADERS` -// trace denylist (`packages/core/src/trace.ts`), which is not exported. We redact -// the value (rather than dropping the header) so the key stays stable per token -// AND callers who legitimately vary a response by a non-secret header (e.g. -// `accept-language`) keep separate cache entries. Compared case-insensitively; the -// `*-token` / `*-api-key` suffix rules catch vendor spellings without enumerating. -const SECRET_HEADERS = new Set([ - 'authorization', - 'proxy-authorization', - 'cookie', - 'set-cookie', - 'x-api-key', - 'x-auth-token', -]); -const REDACTED = '[redacted]'; - -function isSecretHeader(name: string): boolean { - const k = name.toLowerCase(); - return ( - SECRET_HEADERS.has(k) || k.endsWith('-token') || k.endsWith('-api-key') - ); -} - -/** - * Build the value that goes into a cache/query key from a stitch's per-call input. - * Never puts the raw input in the key: - * - * - drops `signal` / `onProgress` — runtime-only, never-serialised (CONTRACT.md); - * `onProgress` in particular churns identity every render, which would refetch - * forever if it entered the key; - * - redacts the VALUES of secret-bearing headers (`authorization`, `cookie`, …) - * so a bearer token can't leak into a persisted / devtools-visible key, while - * keeping non-secret headers so they still vary the cache; - * - keeps every other field (`params` / `query` / `body` / `variables` / …) as-is. - * - * `null` / `undefined` inputs stay `null`; a primitive input is returned unchanged. - */ -function keyInputFor(input: unknown): unknown { - if (input === null || input === undefined) return null; - if (typeof input !== 'object') return input; - - const { - signal: _signal, - onProgress: _onProgress, - ...rest - } = input as { - signal?: unknown; - onProgress?: unknown; - headers?: Record; - } & Record; - - if (rest.headers && typeof rest.headers === 'object') { - const headers: Record = {}; - for (const [k, v] of Object.entries(rest.headers)) { - headers[k] = isSecretHeader(k) ? REDACTED : v; - } - rest.headers = headers; - } - return rest; -} - // The store's seed value, read before the effect mirrors the first real snapshot // (the effect is non-deferred, so this is only ever visible for an instant). It // matches core's idle snapshot exactly so `state` is well-formed from the start. -const IDLE_STATE: StitchQueryState = { +const IDLE_STATE: StitchQueryResult = { status: 'idle', data: undefined, error: undefined, @@ -167,13 +89,13 @@ function createStitchInternal( stitch: StitchLike, input: MaybeAccessor, options: MaybeAccessor>, - stream: boolean, + streaming: boolean, ): SolidStitchStore { // The Solid store we reconcile from the core query's snapshots. `reconcile` // does a structural diff so only the fields that actually changed notify // their dependents (mirrors core's identity-stable snapshots, fine-grained). - const [state, setState] = createStore>( - IDLE_STATE as StitchQueryState, + const [state, setState] = createStore>( + IDLE_STATE as StitchQueryResult, ); // Hold the latest `stitch` in a ref-like closure. A caller who passes an @@ -202,7 +124,7 @@ function createStitchInternal( stableStitch, currentInput, compact({ - stream, + streaming, mode, enabled, onSuccess, @@ -315,59 +237,17 @@ export function createStitchStream( } // --------------------------------------------------------------------------- -// queryOptions — optional TanStack Query adapter +// stitchQueryOptions — optional TanStack Query adapter // --------------------------------------------------------------------------- -// IDENTICAL to `@stitchapi/react`'s `queryOptions` (no framework import) — the -// POJO shape `@tanstack/solid-query`'s `createQuery(options)` consumes is the same -// `{ queryKey, queryFn }` TanStack uses everywhere. - -/** The plain object {@link stitchQueryOptions} returns — structurally compatible with - * TanStack Query's `createQuery(options)` without importing the library. */ -export interface StitchQueryOptions { - queryKey: readonly unknown[]; - queryFn: (ctx?: { signal?: AbortSignal }) => Promise; -} - -/** - * Build a TanStack-Query-compatible options object for a stitch, WITHOUT a hard - * dependency on `@tanstack/solid-query` — it just returns a POJO. Pass it straight - * to `createQuery`: - * - * ```tsx - * import { createQuery } from '@tanstack/solid-query'; - * import { stitchQueryOptions } from '@stitchapi/solid'; - * - * const query = createQuery(() => stitchQueryOptions(getUser, { params: { id: id() } })); - * ``` - * - * The `queryFn` awaits the stitch (the validated output); the `queryKey` is a - * stable name for the stitch plus a sanitised copy of the input (secret header - * values redacted, runtime-only `signal`/`onProgress` dropped), so TanStack caches - * per call without leaking a bearer token into the key or refetching every render. - */ -export function stitchQueryOptions>( - stitch: S, - input: QueryInput, -): StitchQueryOptions>; -export function stitchQueryOptions( - stitch: StitchLike, - input: Input, -): StitchQueryOptions; -export function stitchQueryOptions( - stitch: StitchLike, - input: unknown, -): StitchQueryOptions { - return { - queryKey: [nameOf(stitch), keyInputFor(input)], - queryFn: () => Promise.resolve(stitch(input)), - }; -} - -/** - * @deprecated Renamed to {@link stitchQueryOptions} — a bare `queryOptions` collides - * with TanStack Query's own `queryOptions` export when both are imported. See - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). Kept through the - * `1.0.0-rc` line and removed at the 1.0 GA cut. - */ -export const queryOptions = stitchQueryOptions; +// The single implementation lives in `@stitchapi/query-core` (already a runtime +// peer of this package): `stitchQueryOptions(stitch, input)` returns the plain +// `{ queryKey, queryFn }` POJO that `@tanstack/solid-query`'s +// `createQuery(options)` consumes — no import of TanStack itself. The key is +// `deriveQueryKey`'s stable, secret-redacted derivation, shared verbatim across +// every framework binding (CONTRACT.md P9). Named `stitchQueryOptions` (not a +// bare `queryOptions`) because TanStack Query exports its own `queryOptions` — +// see ADR 0012. +// Need the key alone (e.g. for TanStack invalidation)? Import `deriveQueryKey` +// from `@stitchapi/query-core` — it is already an installed peer. +export { stitchQueryOptions } from '@stitchapi/query-core'; diff --git a/packages/solid/test/primitives.spec.ts b/packages/solid/test/primitives.spec.ts index 77602cb5..4e94193f 100644 --- a/packages/solid/test/primitives.spec.ts +++ b/packages/solid/test/primitives.spec.ts @@ -6,7 +6,6 @@ import { type SolidStitchStore, createStitch, createStitchStream, - queryOptions, stitchQueryOptions, } from '../src'; @@ -359,16 +358,11 @@ describe('stitchQueryOptions', () => { }); }); -describe('queryOptions (deprecated alias)', () => { - test('queryOptions stays a deprecated alias of stitchQueryOptions (ADR 0012)', () => { - expect(queryOptions).toBe(stitchQueryOptions); - }); -}); - // --- stitchQueryOptions: cache-key derivation regressions ------------------ // The `queryKey` derivation shared the same three bugs as `@stitchapi/react`'s -// (fixed there in #406). Each of these FAILED before the port. Keep in lock-step -// with `@stitchapi/react`'s `packages/react/test/hooks.spec.tsx`. +// (fixed there in #406). Each of these FAILED before the port. The derivation +// now lives once in `@stitchapi/query-core` (`deriveQueryKey`); these stay as +// re-export-level regression coverage of the behaviours this binding promises. describe('stitchQueryOptions — no cache collision between nameless stitches', () => { // Bug 1 (correctness): `name ?? 'stitch'` keyed every nameless stitch as the diff --git a/packages/svelte/README.md b/packages/svelte/README.md index 9f77f118..2051b901 100644 --- a/packages/svelte/README.md +++ b/packages/svelte/README.md @@ -14,7 +14,7 @@ These stores are a thin layer over [`@stitchapi/query-core`](../query-core), the pnpm add @stitchapi/svelte@rc @stitchapi/query-core@rc stitchapi@rc svelte ``` -`stitchapi` and `svelte` (`^4 || ^5`) are peer dependencies. `@tanstack/svelte-query` is an **optional** peer — only needed if you use `queryOptions`. +`stitchapi` and `svelte` (`^4 || ^5`) are peer dependencies. `@tanstack/svelte-query` is an **optional** peer — only needed if you use `stitchQueryOptions`. ## `stitchStore` — request / response @@ -61,12 +61,10 @@ The run starts on the store's first subscriber (so `$user` fetches when the comp Same state shape as `stitchStore`. `data` is the accumulated chunks (`mode: 'append'`, default) or the latest chunk (`mode: 'replace'`); `chunks` is the running list; `status` is `'streaming'` until the terminal `result`, then `'success'`. -`useStitch` / `useStitchStream` are aliases of these two, for callers who prefer the `use*` naming. - ## State shape ```ts -interface StitchQueryState { +interface StitchQueryResult { status: 'idle' | 'pending' | 'streaming' | 'success' | 'error'; data: T | undefined; error: unknown; @@ -82,13 +80,13 @@ The store value is this state; `refetch` / `cancel` are attached as methods on t ## Optional: TanStack Query -`queryOptions(stitch, input)` returns a plain `{ queryKey, queryFn }` object — no import of `@tanstack/svelte-query` required, so it works even if you never install it. +`stitchQueryOptions(stitch, input)` returns a plain `{ queryKey, queryFn }` object — no import of `@tanstack/svelte-query` required, so it works even if you never install it. It is re-exported from [`@stitchapi/query-core`](../query-core), the one shared implementation, so a stitch keys identically across every framework binding (secret header values redacted, runtime-only `signal`/`onProgress` kept out of the key). ```ts -import { queryOptions } from '@stitchapi/svelte'; +import { stitchQueryOptions } from '@stitchapi/svelte'; import { createQuery } from '@tanstack/svelte-query'; -const query = createQuery(queryOptions(getUser, { params: { id } })); +const query = createQuery(stitchQueryOptions(getUser, { params: { id } })); ``` ## License diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 198753c0..6cc84b81 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "@stitchapi/svelte", "version": "1.0.0-rc.5", "type": "commonjs", - "description": "Svelte bindings for StitchAPI — stitchStore/stitchStreamStore Svelte stores over @stitchapi/query-core (unary + streaming deltas), plus an optional TanStack-shaped queryOptions helper. Works on Svelte 4 and 5.", + "description": "Svelte bindings for StitchAPI — stitchStore/stitchStreamStore Svelte stores over @stitchapi/query-core (unary + streaming deltas), plus an optional TanStack-shaped stitchQueryOptions helper. Works on Svelte 4 and 5.", "main": "lib/index.js", "module": "lib/index.mjs", "types": "lib/index.d.ts", diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index d299ab04..35767a5e 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -13,17 +13,18 @@ // - `stitchStreamStore` — the streaming store: emits a new state as each `delta` // chunk arrives. The differentiator over plain // request/response query libraries. -// - `useStitch` / `useStitchStream` — hook-style aliases for the two above, for -// callers who prefer the `use*` naming. -// - `queryOptions` — an OPTIONAL TanStack Query adapter (returns a plain POJO, -// so it needs no import of `@tanstack/svelte-query`). +// - `stitchQueryOptions` — an OPTIONAL TanStack Query adapter (returns a plain +// POJO, so it needs no import of `@tanstack/svelte-query`). +// Re-exported verbatim from `@stitchapi/query-core`, the +// single shared implementation, so a stitch keys +// identically no matter which framework binding built it. import { type CreateStitchQueryOptions, type QueryInput, type QueryOutput, type StitchLike, type StitchQuery, - type StitchQueryState, + type StitchQueryResult, createStitchQuery, } from '@stitchapi/query-core'; import { type Readable, readable } from 'svelte/store'; @@ -34,7 +35,19 @@ export type { QueryOutput, StitchLike, StitchQuery, - StitchQueryState, + StitchQueryOptions, + StitchQueryResult, +} from '@stitchapi/query-core'; + +// The TanStack Query adapter is the ONE shared implementation in query-core +// (`deriveQueryKey` and its helpers included, for callers who key their own +// caches). Named `stitchQueryOptions` — not a bare `queryOptions` — because +// TanStack Query itself exports a `queryOptions` (ADR 0012). +export { + deriveQueryKey, + keyInputFor, + nameOf, + stitchQueryOptions, } from '@stitchapi/query-core'; // --------------------------------------------------------------------------- @@ -48,7 +61,7 @@ export type { * store's FIRST subscriber — and `destroy()`ed when the last unsubscribes, so an * unused store costs nothing and a torn-down scope aborts its run. */ -export interface SvelteStitchStore extends Readable> { +export interface SvelteStitchStore extends Readable> { /** Abort the in-flight run and re-run from scratch. */ refetch: () => void; /** Abort the in-flight run, if any. */ @@ -62,7 +75,7 @@ export interface SvelteStitchStore extends Readable> { function makeStore( stitch: StitchLike, input: unknown, - options: CreateStitchQueryOptions & { stream: boolean }, + options: CreateStitchQueryOptions, ): SvelteStitchStore { // The query is created EAGERLY (so `getSnapshot` has a real handle to seed // the store and `refetch`/`cancel` work before the first subscriber), but its @@ -80,7 +93,7 @@ function makeStore( // returns a `stop` callback invoked when the last subscriber leaves. We seed // with the current snapshot, push every notification through `set`, fire the // deferred fetch, and tear the query down on stop. - const store = readable>(query.getSnapshot(), (set) => { + const store = readable>(query.getSnapshot(), (set) => { const unsubscribe = query.subscribe(() => set(query.getSnapshot())); // Sync once in case state advanced between creation and subscription, // then start the run if the caller didn't opt out. @@ -109,6 +122,9 @@ function makeStore( * The run starts on the store's first subscriber (so `$store` in a component * fetches when the component mounts) and is aborted when the last subscriber * leaves (component teardown). Call `refetch()` to re-run, `cancel()` to abort. + * The returned value is a Svelte store — re-create it when `input`/`options` + * change (e.g. derive it inside a `$:` block keyed on those) and let scope + * teardown destroy it. * * @example * ```svelte @@ -137,7 +153,7 @@ export function stitchStore( input: unknown, options: CreateStitchQueryOptions = {}, ): SvelteStitchStore { - return makeStore(stitch, input, { ...options, stream: false }); + return makeStore(stitch, input, { ...options, streaming: false }); } // --------------------------------------------------------------------------- @@ -176,156 +192,5 @@ export function stitchStreamStore( input: unknown, options: CreateStitchQueryOptions = {}, ): SvelteStitchStore { - return makeStore(stitch, input, { ...options, stream: true }); -} - -// --------------------------------------------------------------------------- -// useStitch / useStitchStream — hook-style aliases -// --------------------------------------------------------------------------- - -/** Alias for {@link stitchStore}, for callers who prefer the `use*` naming. The - * returned value is a Svelte store — re-create it when `input`/`options` change - * (e.g. derive it inside a `$:` block keyed on those) and let scope teardown - * destroy it. */ -export const useStitch = stitchStore; - -/** Alias for {@link stitchStreamStore}. */ -export const useStitchStream = stitchStreamStore; - -// --------------------------------------------------------------------------- -// queryOptions — optional TanStack Query adapter -// --------------------------------------------------------------------------- - -// IDENTICAL to `@stitchapi/react`'s `queryOptions` — it imports no framework, so -// the POJO is framework-neutral and feeds `@tanstack/svelte-query`'s -// `createQuery` exactly as it feeds React's `useQuery`. - -// --- key derivation (shared logic; duplicated in @stitchapi/react + swr) ---- -// These helpers are intentionally copied verbatim from `@stitchapi/react` -// (`packages/react/src/index.ts`, landed in #406) and `@stitchapi/swr`: they are -// separate published packages, so a cross-package import would add a runtime -// dependency. Keep the copies in lock-step. - -/** The `__config` slice a key derives from. Mirrors core's `nameOf` - * (`name ?? path ?? 'stitch'`) plus a `url` fallback for URL-configured stitches. */ -type KeyConfig = { name?: string; path?: string; url?: string }; - -/** A stable, human-meaningful name for the stitch. Mirrors core's `nameOf` - * (`packages/core/src/engine.ts`) — `name ?? path ?? url ?? 'stitch'` — so two - * DISTINCT nameless stitches (`/users/{id}` vs `/orders/{id}`) don't collapse to - * the literal `'stitch'` and collide on one cache entry. */ -function nameOf(stitch: unknown): string { - const cfg = (stitch as { __config?: KeyConfig }).__config; - return cfg?.name ?? cfg?.path ?? cfg?.url ?? 'stitch'; -} - -// Header names whose VALUES are secrets — mirrors core's private `SECRET_HEADERS` -// trace denylist (`packages/core/src/trace.ts`), which is not exported. We redact -// the value (rather than dropping the header) so the key stays stable per token -// AND callers who legitimately vary a response by a non-secret header (e.g. -// `accept-language`) keep separate cache entries. Compared case-insensitively; the -// `*-token` / `*-api-key` suffix rules catch vendor spellings without enumerating. -const SECRET_HEADERS = new Set([ - 'authorization', - 'proxy-authorization', - 'cookie', - 'set-cookie', - 'x-api-key', - 'x-auth-token', -]); -const REDACTED = '[redacted]'; - -function isSecretHeader(name: string): boolean { - const k = name.toLowerCase(); - return ( - SECRET_HEADERS.has(k) || k.endsWith('-token') || k.endsWith('-api-key') - ); + return makeStore(stitch, input, { ...options, streaming: true }); } - -/** - * Build the value that goes into a cache/query key from a stitch's per-call input. - * Never puts the raw input in the key: - * - * - drops `signal` / `onProgress` — runtime-only, never-serialised (CONTRACT.md); - * `onProgress` in particular churns identity every render, which would refetch - * forever if it entered the key; - * - redacts the VALUES of secret-bearing headers (`authorization`, `cookie`, …) - * so a bearer token can't leak into a persisted / devtools-visible key, while - * keeping non-secret headers so they still vary the cache; - * - keeps every other field (`params` / `query` / `body` / `variables` / …) as-is. - * - * `null` / `undefined` inputs stay `null`; a primitive input is returned unchanged. - */ -function keyInputFor(input: unknown): unknown { - if (input === null || input === undefined) return null; - if (typeof input !== 'object') return input; - - const { - signal: _signal, - onProgress: _onProgress, - ...rest - } = input as { - signal?: unknown; - onProgress?: unknown; - headers?: Record; - } & Record; - - if (rest.headers && typeof rest.headers === 'object') { - const headers: Record = {}; - for (const [k, v] of Object.entries(rest.headers)) { - headers[k] = isSecretHeader(k) ? REDACTED : v; - } - rest.headers = headers; - } - return rest; -} - -/** The plain object {@link stitchQueryOptions} returns — structurally compatible with - * TanStack Query's `createQuery(options)` without importing the library. */ -export interface StitchQueryOptions { - queryKey: readonly unknown[]; - queryFn: (ctx?: { signal?: AbortSignal }) => Promise; -} - -/** - * Build a TanStack-Query-compatible options object for a stitch, WITHOUT a hard - * dependency on `@tanstack/svelte-query` — it just returns a POJO. Pass it - * straight to `createQuery`: - * - * ```ts - * import { createQuery } from '@tanstack/svelte-query'; - * import { stitchQueryOptions } from '@stitchapi/svelte'; - * - * const query = createQuery(stitchQueryOptions(getUser, { params: { id } })); - * ``` - * - * The `queryFn` awaits the stitch (the validated output); the `queryKey` is a - * stable name for the stitch plus a sanitised copy of the input (secret header - * values redacted, runtime-only `signal`/`onProgress` dropped), so TanStack caches - * per call without leaking a bearer token into the key or refetching every render. - */ -export function stitchQueryOptions>( - stitch: S, - input: QueryInput, -): StitchQueryOptions>; -export function stitchQueryOptions( - stitch: StitchLike, - input: Input, -): StitchQueryOptions; -export function stitchQueryOptions( - stitch: StitchLike, - input: unknown, -): StitchQueryOptions { - return { - queryKey: [nameOf(stitch), keyInputFor(input)], - queryFn: () => Promise.resolve(stitch(input)), - }; -} - -/** - * @deprecated Renamed to {@link stitchQueryOptions} — a bare `queryOptions` collides - * with TanStack Query's own `queryOptions` export when both are imported. See - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). Kept through the - * `1.0.0-rc` line and removed at the 1.0 GA cut. - */ -export const queryOptions = stitchQueryOptions; diff --git a/packages/svelte/test/stores.spec.ts b/packages/svelte/test/stores.spec.ts index 4f9606e9..954d6510 100644 --- a/packages/svelte/test/stores.spec.ts +++ b/packages/svelte/test/stores.spec.ts @@ -1,16 +1,13 @@ // @stitchapi/svelte store behaviour, driven by FAKE stitches in node env (no DOM): // we subscribe to the readable store and assert the emitted state transitions — // exactly how Svelte's `$store` / `subscribe` would observe them at runtime. +import { stitchQueryOptions, stitchStore, stitchStreamStore } from '../src'; + import { - queryOptions, - stitchQueryOptions, - stitchStore, - stitchStreamStore, - useStitch, - useStitchStream, -} from '../src'; - -import type { StitchCallResult, StitchLike } from '@stitchapi/query-core'; + type StitchCallResult, + type StitchLike, + stitchQueryOptions as coreStitchQueryOptions, +} from '@stitchapi/query-core'; import type { StitchEvent } from 'stitchapi'; import { describe, expect, test, vi } from 'vitest'; @@ -172,10 +169,6 @@ describe('stitchStore', () => { unsubscribe(); }); - test('useStitch is an alias of stitchStore', () => { - expect(useStitch).toBe(stitchStore); - }); - test('forwards onSuccess to the underlying query (fires on success after subscribe)', async () => { let received: unknown; const store = stitchStore( @@ -266,15 +259,18 @@ describe('stitchStreamStore', () => { expect(last?.chunks).toEqual([]); unsubscribe(); }); - - test('useStitchStream is an alias of stitchStreamStore', () => { - expect(useStitchStream).toBe(stitchStreamStore); - }); }); // --- stitchQueryOptions ---------------------------------------------------------- +// The implementation is HOISTED into `@stitchapi/query-core` (nameOf / +// keyInputFor / deriveQueryKey own the deep coverage there); these tests verify +// the re-export wiring and one end-to-end shape through this package's entry. describe('stitchQueryOptions', () => { + test('is the ONE shared query-core implementation, re-exported', () => { + expect(stitchQueryOptions).toBe(coreStitchQueryOptions); + }); + test('returns a TanStack-shaped POJO with key + async queryFn', async () => { const stitch = unaryStitch(async () => ({ ok: true }), { name: 'getThing', @@ -295,43 +291,9 @@ describe('stitchQueryOptions', () => { const opts = stitchQueryOptions(stitch, { params: { id: '7' } }); expect(opts.queryKey[1]).toEqual({ params: { id: '7' } }); }); -}); - -describe('queryOptions (deprecated alias)', () => { - test('queryOptions stays a deprecated alias of stitchQueryOptions (ADR 0012)', () => { - expect(queryOptions).toBe(stitchQueryOptions); - }); -}); - -// --- stitchQueryOptions: cache-key derivation regressions ------------------ -// The `queryKey` derivation shared the same three bugs @stitchapi/react fixed in -// #406 (nameless-collision, secret-header leak, refetch storm). Each of these -// FAILED before the port. Kept in lock-step with `packages/react/test/hooks.spec.tsx`. - -describe('stitchQueryOptions — no cache collision between nameless stitches', () => { - // Bug 1 (correctness): `name ?? 'stitch'` keyed every nameless stitch as the - // literal 'stitch', so two distinct endpoints with same-shaped input collided - // on one TanStack Query cache entry. Fixed by mirroring core's `nameOf` - // (name ?? path ?? url ?? 'stitch'). - test('two nameless stitches with different paths get DIFFERENT keys', () => { - const getUser = unaryStitch(async () => 1, { path: '/users/{id}' }); - const getOrder = unaryStitch(async () => 1, { path: '/orders/{id}' }); - const input = { params: { id: '1' } }; - - const userKey = stitchQueryOptions(getUser, input).queryKey; - const orderKey = stitchQueryOptions(getOrder, input).queryKey; - - expect(userKey).not.toEqual(orderKey); - expect(userKey[0]).toBe('/users/{id}'); - expect(orderKey[0]).toBe('/orders/{id}'); - }); -}); -describe('stitchQueryOptions — no secret leak in the query key', () => { - // Bug 2 (security): the raw `input` went straight into the queryKey, so a - // per-call `authorization` header serialised the bearer token into the - // TanStack Query key (persisted, and shown in the devtools panel). Fixed by - // redacting denylisted header VALUES. + // Smoke test through THIS package's entry (deep redaction coverage lives in + // query-core): a per-call bearer token must never serialise into the key. test('a per-call authorization header does not put the token in the key', () => { const getUser = unaryStitch(async () => 1, { name: 'getUser' }); const { queryKey } = stitchQueryOptions(getUser, { @@ -341,56 +303,4 @@ describe('stitchQueryOptions — no secret leak in the query key', () => { expect(JSON.stringify(queryKey)).not.toContain('SECRET123'); }); - - test('redacts the secret header but keeps a benign one (no fresh collision)', () => { - const getUser = unaryStitch(async () => 1, { name: 'getUser' }); - const [, keyInput] = stitchQueryOptions(getUser, { - params: { id: '1' }, - headers: { - authorization: 'Bearer SECRET123', - 'accept-language': 'en-US', - }, - }).queryKey; - - const headers = (keyInput as { headers: Record }) - .headers; - expect(headers['authorization']).not.toContain('SECRET123'); - expect(headers['accept-language']).toBe('en-US'); - }); -}); - -describe('stitchQueryOptions — no refetch storm from runtime-only input fields', () => { - // Bug 3 (reliability): `signal`/`onProgress` are runtime-only (never - // serialised, per CONTRACT.md). An inline `onProgress` churns identity every - // render, so keying it re-fetched forever. Fixed by excluding both fields. - test('two distinct inline onProgress functions produce EQUAL keys', () => { - const getUser = unaryStitch(async () => 1, { name: 'getUser' }); - const base = { params: { id: '1' } }; - - const keyA = stitchQueryOptions(getUser, { - ...base, - onProgress: () => {}, - }).queryKey; - const keyB = stitchQueryOptions(getUser, { - ...base, - onProgress: () => {}, - }).queryKey; - - expect(keyA).toEqual(keyB); - }); - - test('a per-call signal does not enter the key', () => { - const getUser = unaryStitch(async () => 1, { name: 'getUser' }); - const controller = new AbortController(); - - const withSignal = stitchQueryOptions(getUser, { - params: { id: '1' }, - signal: controller.signal, - }).queryKey; - const without = stitchQueryOptions(getUser, { - params: { id: '1' }, - }).queryKey; - - expect(withSignal).toEqual(without); - }); }); diff --git a/packages/swr/src/index.ts b/packages/swr/src/index.ts index 9c73a6d4..23ff99eb 100644 --- a/packages/swr/src/index.ts +++ b/packages/swr/src/index.ts @@ -27,6 +27,13 @@ import useSWR, { type SWRConfiguration, type SWRResponse } from 'swr'; // streamable) lives in `@stitchapi/query-core`; a real stitch satisfies both. export type StitchLike = (input?: Input) => PromiseLike; +// `QueryOutput` / `QueryInput` deliberately re-state `@stitchapi/query-core`'s +// inference helpers over THIS package's minimal `StitchLike` tier (CONTRACT.md P9 +// de-list): same identifier, same conditional shape, but inferring from the +// await-only duck-type above — a cross-package import would add a runtime +// dependency this adapter intentionally does not have. query-core's rich tier is +// assignable to the minimal one, so both spellings agree on any real stitch. + /** The validated output type of a stitch (or `StitchLike`). */ export type QueryOutput = S extends Stitch @@ -184,26 +191,28 @@ export function swrKey( * Pass SWR options as the third argument (`{ revalidateOnFocus, refreshInterval, * … }`). For conditional fetching use {@link swrKey} with a bare `useSWR`. */ +// The third parameter is `options` (house vocabulary); its TYPE stays SWR's own +// `SWRConfiguration` — an adapter mirror keeps upstream spelling (CONTRACT.md P18). export function useStitchSWR>( stitch: S, input: QueryInput, - config?: SWRConfiguration>, + options?: SWRConfiguration>, ): SWRResponse>; export function useStitchSWR( stitch: StitchLike, input: Input, - config?: SWRConfiguration, + options?: SWRConfiguration, ): SWRResponse; export function useStitchSWR( stitch: StitchLike, input: unknown, - config?: SWRConfiguration, + options?: SWRConfiguration, ): SWRResponse { // The stitch is the fetcher; SWR caches by `swrKey`. `Promise.resolve` lifts // the stitch's thenable result into a real Promise for SWR. return useSWR( swrKey(stitch, input), () => Promise.resolve(stitch(input)), - config, + options, ); } diff --git a/packages/swr/test/swr.spec.tsx b/packages/swr/test/swr.spec.tsx index ebb88650..ce8f7b52 100644 --- a/packages/swr/test/swr.spec.tsx +++ b/packages/swr/test/swr.spec.tsx @@ -193,7 +193,7 @@ describe('useStitchSWR', () => { expect(calls).toBe(1); }); - test('forwards the SWR config (third argument) to useSWR', async () => { + test('forwards the SWR options (third argument) to useSWR', async () => { const getUser = unaryStitch(async () => ({ name: 'fresh' })); const { result } = renderHook( () => @@ -205,7 +205,7 @@ describe('useStitchSWR', () => { { wrapper }, ); - // `fallbackData` is a config option, so its presence proves the third + // `fallbackData` is an SWR option, so its presence proves the third // argument reached useSWR: the cached value shows immediately… expect(result.current.data).toEqual({ name: 'cached' }); // …then the stitch fetcher revalidates to the fresh value. diff --git a/packages/vercel-ai/README.md b/packages/vercel-ai/README.md index 28c14afc..fb0d535e 100644 --- a/packages/vercel-ai/README.md +++ b/packages/vercel-ai/README.md @@ -41,6 +41,15 @@ const { text } = await generateText({ - `inputSchema` — the schema the model fills (a Zod schema, or any AI SDK `Schema`). - `toInput` — maps the model's args onto the stitch's `{ params, query, body }`. Omit it when the args _are_ the stitch input. +`inputSchema` is the one required field, so it also goes positionally: when the model's args _are_ the stitch input and no description is needed, pass the schema directly — + +```ts +stitchTool(getUser, z.object({ params: z.object({ id: z.string() }) })); +// ≡ stitchTool(getUser, { inputSchema: z.object({ … }) }) +``` + +The two forms are told apart by the `inputSchema` key: an object carrying one is the options envelope, anything else is the schema itself. + The tool's result is the stitch's validated output, so the model reasons over real data, not a guess. A failure rejects, so the AI SDK's tool-error handling reports it. ## `stitchExecute` diff --git a/packages/vercel-ai/src/index.ts b/packages/vercel-ai/src/index.ts index d5f1de61..9144ff39 100644 --- a/packages/vercel-ai/src/index.ts +++ b/packages/vercel-ai/src/index.ts @@ -112,6 +112,24 @@ export interface StitchToolOptions { toInput?: (args: Args) => Input; } +/** + * A schema value accepted positionally by {@link stitchTool} — any object (a Zod + * schema, an AI SDK `Schema`, …) that does NOT carry an `inputSchema` key. An object + * that DOES carry one is read as the {@link StitchToolOptions} envelope instead; + * that key is what tells the two apart. + */ +export type StitchToolSchema = object & { inputSchema?: never }; + +/** The envelope form is the one whose second argument carries an `inputSchema` key — + * a schema itself never does. */ +function isToolOptions( + value: StitchToolOptions | StitchToolSchema, +): value is StitchToolOptions { + return ( + typeof value === 'object' && value !== null && 'inputSchema' in value + ); +} + /** * Wrap a stitch as a Vercel AI SDK tool. Drop it into a `tools` map: * @@ -133,6 +151,14 @@ export interface StitchToolOptions { * }); * ``` * + * `inputSchema` is the one required field, so it gets a positional shorthand: when the + * model's args ARE the stitch input and no description is needed, + * `stitchTool(stitch, schema)` ≡ `stitchTool(stitch, { inputSchema: schema })`: + * + * ```ts + * tools: { getUser: stitchTool(getUser, z.object({ params: z.object({ id: z.string() }) })) } + * ``` + * * Works with AI SDK v4 (reads `parameters`) and v5 (reads `inputSchema`) — the tool * carries both. */ @@ -147,10 +173,23 @@ export function stitchTool( stitch: StitchLike, options: StitchToolOptions, ): StitchTool; +export function stitchTool>( + stitch: S, + inputSchema: StitchToolSchema, +): StitchTool, QueryOutput>; +export function stitchTool( + stitch: StitchLike, + inputSchema: StitchToolSchema, +): StitchTool; export function stitchTool( stitch: StitchLike, - options: StitchToolOptions, + optionsOrSchema: StitchToolOptions | StitchToolSchema, ): StitchTool { + const options: StitchToolOptions = isToolOptions( + optionsOrSchema, + ) + ? optionsOrSchema + : { inputSchema: optionsOrSchema }; // `compact` is the wrong tool here: `parameters`/`inputSchema` are required `unknown` // keys it would optionalize. Keep the explicit spread to omit only `description`. return { diff --git a/packages/vercel-ai/test/ai.spec.ts b/packages/vercel-ai/test/ai.spec.ts index 2f8a8bc3..8e4fe0e9 100644 --- a/packages/vercel-ai/test/ai.spec.ts +++ b/packages/vercel-ai/test/ai.spec.ts @@ -95,4 +95,42 @@ describe('stitchTool', () => { ); expect('description' in tool).toBe(false); }); + + // --- positional shorthand (P15): stitchTool(stitch, schema) ------------- + + test('positional shorthand ≡ stitchTool(stitch, { inputSchema: schema })', async () => { + let seen: unknown; + const stitch = unaryStitch(async (input) => { + seen = input; + return { name: 'Ada' }; + }); + const tool = stitchTool(stitch, schema); + + expect(tool.parameters).toBe(schema); + expect(tool.inputSchema).toBe(schema); + expect('description' in tool).toBe(false); + // The args ARE the stitch input — no toInput in the shorthand. + await expect(tool.execute({ params: { id: '7' } })).resolves.toEqual({ + name: 'Ada', + }); + expect(seen).toEqual({ params: { id: '7' } }); + }); + + test('the two forms are told apart by the inputSchema key, not shape', () => { + const stitch = unaryStitch(async () => 1); + // A schema-like arg WITHOUT an inputSchema key is the schema itself — + // even one carrying envelope-sounding keys like `description`. + const schemaWithDescription: { description: string; type: string } = { + description: 'a JSON-schema description field', + type: 'object', + }; + const positional = stitchTool(stitch, schemaWithDescription); + expect(positional.inputSchema).toBe(schemaWithDescription); + expect(positional.parameters).toBe(schemaWithDescription); + expect('description' in positional).toBe(false); + + // An arg WITH an inputSchema key is the options envelope. + const envelope = stitchTool(stitch, { inputSchema: schema }); + expect(envelope.inputSchema).toBe(schema); + }); }); diff --git a/packages/vue/README.md b/packages/vue/README.md index fd8a7a43..3ec7ee6f 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -14,7 +14,7 @@ These composables are a thin layer over [`@stitchapi/query-core`](../query-core) pnpm add @stitchapi/vue@rc @stitchapi/query-core@rc stitchapi@rc vue ``` -`stitchapi` (`>=0.7.0`) and `vue` (`^3.4`) are peer dependencies. `@tanstack/vue-query` is **not** required — `queryOptions` returns a plain object. +`stitchapi` (`^1.0.0-rc.1`) and `vue` (`^3.4`) are peer dependencies. `@tanstack/vue-query` is **not** required — `stitchQueryOptions` returns a plain object. ## `useStitch` — request / response @@ -74,7 +74,7 @@ Same return shape as `useStitch`. `data` is the accumulated chunks (`mode: 'appe Each field is a `ComputedRef`, so destructuring keeps reactivity (and `.value` is unwrapped automatically in templates): ```ts -interface UseStitchReturn { +interface UseStitchResult { status: ComputedRef<'idle' | 'pending' | 'streaming' | 'success' | 'error'>; data: ComputedRef; error: ComputedRef; @@ -90,13 +90,13 @@ interface UseStitchReturn { ## Optional: TanStack Query -`queryOptions(stitch, input)` returns a plain `{ queryKey, queryFn }` object — no import of `@tanstack/vue-query` required, so it works even if you never install it. +`stitchQueryOptions(stitch, input)` returns a plain `{ queryKey, queryFn }` object — no import of `@tanstack/vue-query` required, so it works even if you never install it. It is implemented once in [`@stitchapi/query-core`](../query-core) and re-exported here, so a stitch keys identically in every framework binding. (Named with the `stitch` prefix because TanStack Query exports its own `queryOptions`.) ```ts -import { queryOptions } from '@stitchapi/vue'; +import { stitchQueryOptions } from '@stitchapi/vue'; import { useQuery } from '@tanstack/vue-query'; -const { data } = useQuery(queryOptions(getUser, { params: { id } })); +const { data } = useQuery(stitchQueryOptions(getUser, { params: { id } })); ``` ## License diff --git a/packages/vue/package.json b/packages/vue/package.json index 4bf5732e..9b499daf 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -2,7 +2,7 @@ "name": "@stitchapi/vue", "version": "1.0.0-rc.5", "type": "commonjs", - "description": "Vue 3 bindings for StitchAPI — reactive useStitch/useStitchStream composables over @stitchapi/query-core, plus an optional TanStack Query queryOptions helper. Streaming-first: re-render as `delta` chunks arrive.", + "description": "Vue 3 bindings for StitchAPI — reactive useStitch/useStitchStream composables over @stitchapi/query-core, plus an optional TanStack Query stitchQueryOptions helper. Streaming-first: re-render as `delta` chunks arrive.", "main": "lib/index.js", "module": "lib/index.mjs", "types": "lib/index.d.ts", diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index a88a82cd..78407fc4 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -11,16 +11,19 @@ // - `useStitchStream` — the streaming composable: re-renders as `delta` chunks // arrive. This is the differentiator over plain // request/response query libraries. -// - `queryOptions` — an OPTIONAL TanStack Query adapter (returns a plain POJO, -// so it needs no import of `@tanstack/vue-query`). +// - `stitchQueryOptions` — an OPTIONAL TanStack Query adapter (a plain POJO, so +// it needs no import of `@tanstack/vue-query`). Implemented +// once in `@stitchapi/query-core` and re-exported here. import { type CreateStitchQueryOptions, type QueryInput, type QueryOutput, type StitchLike, type StitchQuery, - type StitchQueryState, + type StitchQueryResult, createStitchQuery, + keyInputFor, + nameOf, } from '@stitchapi/query-core'; import { compact } from 'stitchapi'; import { @@ -39,7 +42,18 @@ export type { QueryOutput, StitchLike, StitchQuery, - StitchQueryState, + StitchQueryOptions, + StitchQueryResult, +} from '@stitchapi/query-core'; + +// The TanStack Query adapter is implemented once in `@stitchapi/query-core` +// (`deriveQueryKey` + `stitchQueryOptions`) so a stitch keys identically in every +// framework binding. Re-exported here so Vue apps import from one place. +export { + deriveQueryKey, + keyInputFor, + nameOf, + stitchQueryOptions, } from '@stitchapi/query-core'; // --------------------------------------------------------------------------- @@ -51,8 +65,8 @@ export type { * own `ComputedRef` (so the object stays destructurable WITHOUT losing * reactivity, the Vue idiom) plus the imperative `refetch` / `cancel` handles. */ -export interface UseStitchReturn { - readonly status: ComputedRef['status']>; +export interface UseStitchResult { + readonly status: ComputedRef['status']>; /** The validated output (unary) or the latest streamed value (streaming). */ readonly data: ComputedRef; /** The thrown reason on failure. */ @@ -70,97 +84,26 @@ export interface UseStitchReturn { } // --------------------------------------------------------------------------- -// Shared driver +// Composable options // --------------------------------------------------------------------------- -interface UseStitchOptions extends CreateStitchQueryOptions {} - -// --- key derivation (shared logic; duplicated in @stitchapi/react + swr) ---- -// These helpers are intentionally copied verbatim from `@stitchapi/react` -// (`packages/react/src/index.ts`, landed in #406) and `@stitchapi/swr`: they are -// separate published packages, so a cross-package import would add a runtime -// dependency. Keep the copies in lock-step. Used BOTH by the reactive dep key -// below and by `stitchQueryOptions`. - -/** The `__config` slice a key derives from. Mirrors core's `nameOf` - * (`name ?? path ?? 'stitch'`) plus a `url` fallback for URL-configured stitches. */ -type KeyConfig = { name?: string; path?: string; url?: string }; - -/** A stable, human-meaningful name for the stitch. Mirrors core's `nameOf` - * (`packages/core/src/engine.ts`) — `name ?? path ?? url ?? 'stitch'` — so two - * DISTINCT nameless stitches (`/users/{id}` vs `/orders/{id}`) don't collapse to - * the literal `'stitch'` and collide on one cache entry. */ -function nameOf(stitch: unknown): string { - const cfg = (stitch as { __config?: KeyConfig }).__config; - return cfg?.name ?? cfg?.path ?? cfg?.url ?? 'stitch'; -} - -// Header names whose VALUES are secrets — mirrors core's private `SECRET_HEADERS` -// trace denylist (`packages/core/src/trace.ts`), which is not exported. We redact -// the value (rather than dropping the header) so the key stays stable per token -// AND callers who legitimately vary a response by a non-secret header (e.g. -// `accept-language`) keep separate cache entries. Compared case-insensitively; the -// `*-token` / `*-api-key` suffix rules catch vendor spellings without enumerating. -const SECRET_HEADERS = new Set([ - 'authorization', - 'proxy-authorization', - 'cookie', - 'set-cookie', - 'x-api-key', - 'x-auth-token', -]); -const REDACTED = '[redacted]'; - -function isSecretHeader(name: string): boolean { - const k = name.toLowerCase(); - return ( - SECRET_HEADERS.has(k) || k.endsWith('-token') || k.endsWith('-api-key') - ); -} - /** - * Build the value that goes into a cache/query key from a stitch's per-call input. - * Never puts the raw input in the key: - * - * - drops `signal` / `onProgress` — runtime-only, never-serialised (CONTRACT.md); - * `onProgress` in particular churns identity every render, which would refetch - * forever if it entered the key; - * - redacts the VALUES of secret-bearing headers (`authorization`, `cookie`, …) - * so a bearer token can't leak into a persisted / devtools-visible key, while - * keeping non-secret headers so they still vary the cache; - * - keeps every other field (`params` / `query` / `body` / `variables` / …) as-is. - * - * `null` / `undefined` inputs stay `null`; a primitive input is returned unchanged. + * Options for {@link useStitch} / {@link useStitchStream}: the query-core + * options minus `streaming` — which composable you call decides that + * (`useStitch` is unary, `useStitchStream` streams). */ -function keyInputFor(input: unknown): unknown { - if (input === null || input === undefined) return null; - if (typeof input !== 'object') return input; +export interface UseStitchOptions + extends Omit, 'streaming'> {} - const { - signal: _signal, - onProgress: _onProgress, - ...rest - } = input as { - signal?: unknown; - onProgress?: unknown; - headers?: Record; - } & Record; - - if (rest.headers && typeof rest.headers === 'object') { - const headers: Record = {}; - for (const [k, v] of Object.entries(rest.headers)) { - headers[k] = isSecretHeader(k) ? REDACTED : v; - } - rest.headers = headers; - } - return rest; -} +// --------------------------------------------------------------------------- +// Shared driver +// --------------------------------------------------------------------------- // A structural key of the input so an equal-shaped literal does not re-create the // handle, but `{ id: 1 }` → `{ id: 2 }` does. Mirrors `@stitchapi/react`. Sanitises -// first via `keyInputFor` so an inline `onProgress` (fresh identity per render) -// can't churn the key and loop, and a per-call `signal` adds no non-deterministic -// noise — both are runtime-only. +// first via query-core's `keyInputFor` so an inline `onProgress` (fresh identity +// per render) can't churn the key and loop, and a per-call `signal` adds no +// non-deterministic noise — both are runtime-only. function defaultKey(input: unknown): string { try { return JSON.stringify(keyInputFor(input)); @@ -178,16 +121,16 @@ function useStitchInternal( stitch: StitchLike, input: MaybeRefOrGetter, options: MaybeRefOrGetter>, - stream: boolean, -): UseStitchReturn { + streaming: boolean, +): UseStitchResult { // The single reactive cell every consumer reads through. The core hands out a // NEW frozen snapshot only on a real change, so swapping the ref is cheap and // never tears. `shallowRef` is deliberate: the snapshot is already immutable, // so deep reactivity would only add overhead. - const snapshot = shallowRef>( + const snapshot = shallowRef>( // Seed with a synchronous read below once the first handle exists; this // placeholder is replaced before any consumer can observe it. - undefined as unknown as StitchQueryState, + undefined as unknown as StitchQueryResult, ); let query: StitchQuery | undefined; @@ -205,7 +148,7 @@ function useStitchInternal( stitch, resolvedInput, compact({ - stream, + streaming, mode: opts.mode, enabled: opts.enabled, onSuccess: opts.onSuccess, @@ -235,7 +178,7 @@ function useStitchInternal( return JSON.stringify([ name, defaultKey(toValue(input)), - stream, + streaming, opts.mode ?? null, opts.enabled ?? null, ]); @@ -249,9 +192,10 @@ function useStitchInternal( query?.destroy(); }); - const field = >( + const field = >( k: K, - ): ComputedRef[K]> => computed(() => snapshot.value[k]); + ): ComputedRef[K]> => + computed(() => snapshot.value[k]); return { status: field('status'), @@ -297,17 +241,17 @@ export function useStitch>( stitch: S, input: MaybeRefOrGetter>, options?: MaybeRefOrGetter>>, -): UseStitchReturn>; +): UseStitchResult>; export function useStitch( stitch: StitchLike, input: MaybeRefOrGetter, options?: MaybeRefOrGetter>, -): UseStitchReturn; +): UseStitchResult; export function useStitch( stitch: StitchLike, input: MaybeRefOrGetter, options: MaybeRefOrGetter> = {}, -): UseStitchReturn { +): UseStitchResult { return useStitchInternal(stitch, input, options, false); } @@ -317,7 +261,7 @@ export function useStitch( /** * Run a streaming stitch (an `sse` / `stream` surface) and re-render as each - * `delta` chunk arrives. Same return shape as {@link useStitch}; `data` is the + * `delta` chunk arrives. Same result shape as {@link useStitch}; `data` is the * accumulated chunks (`mode: 'append'`, default) or the latest chunk * (`mode: 'replace'`), and `chunks` is the running list. `status` is * `'streaming'` until the terminal `result`, then `'success'`. @@ -335,74 +279,16 @@ export function useStitchStream>( stitch: S, input: MaybeRefOrGetter>, options?: MaybeRefOrGetter>>, -): UseStitchReturn>; +): UseStitchResult>; export function useStitchStream( stitch: StitchLike, input: MaybeRefOrGetter, options?: MaybeRefOrGetter>, -): UseStitchReturn; +): UseStitchResult; export function useStitchStream( stitch: StitchLike, input: MaybeRefOrGetter, options: MaybeRefOrGetter> = {}, -): UseStitchReturn { +): UseStitchResult { return useStitchInternal(stitch, input, options, true); } - -// --------------------------------------------------------------------------- -// queryOptions — optional TanStack Query adapter -// --------------------------------------------------------------------------- - -// IDENTICAL to `@stitchapi/react`'s `queryOptions` — a plain POJO with no -// framework import, so it feeds `@tanstack/vue-query`'s `useQuery(options)` -// (or any other) just the same. - -/** The plain object {@link stitchQueryOptions} returns — structurally compatible with - * TanStack Query's `useQuery(options)` without importing the library. */ -export interface StitchQueryOptions { - queryKey: readonly unknown[]; - queryFn: (ctx?: { signal?: AbortSignal }) => Promise; -} - -/** - * Build a TanStack-Query-compatible options object for a stitch, WITHOUT a hard - * dependency on `@tanstack/vue-query` — it just returns a POJO. Pass it straight - * to `useQuery`: - * - * ```ts - * import { useQuery } from '@tanstack/vue-query'; - * import { stitchQueryOptions } from '@stitchapi/vue'; - * - * const { data } = useQuery(stitchQueryOptions(getUser, { params: { id } })); - * ``` - * - * The `queryFn` awaits the stitch (the validated output); the `queryKey` is a - * stable name for the stitch plus a sanitised copy of the input (secret header - * values redacted, runtime-only `signal`/`onProgress` dropped), so TanStack caches - * per call without leaking a bearer token into the key or refetching every render. - */ -export function stitchQueryOptions>( - stitch: S, - input: QueryInput, -): StitchQueryOptions>; -export function stitchQueryOptions( - stitch: StitchLike, - input: Input, -): StitchQueryOptions; -export function stitchQueryOptions( - stitch: StitchLike, - input: unknown, -): StitchQueryOptions { - return { - queryKey: [nameOf(stitch), keyInputFor(input)], - queryFn: () => Promise.resolve(stitch(input)), - }; -} - -/** - * @deprecated Renamed to {@link stitchQueryOptions} — a bare `queryOptions` collides - * with TanStack Query's own `queryOptions` export when both are imported. See - * [ADR 0012](../../../docs/adr/0012-integration-symbol-naming.md). Kept through the - * `1.0.0-rc` line and removed at the 1.0 GA cut. - */ -export const queryOptions = stitchQueryOptions; diff --git a/packages/vue/test/composables.spec.ts b/packages/vue/test/composables.spec.ts index 9b6af4e4..f0a8b470 100644 --- a/packages/vue/test/composables.spec.ts +++ b/packages/vue/test/composables.spec.ts @@ -3,12 +3,7 @@ // and the core publishes synchronously on each transition, so a `flush()` (one // awaited microtask) is enough to settle the fake stitch's resolved promises. // Driven by FAKE stitches — no engine, no network. -import { - queryOptions, - stitchQueryOptions, - useStitch, - useStitchStream, -} from '../src'; +import { stitchQueryOptions, useStitch, useStitchStream } from '../src'; import type { StitchCallResult, StitchLike } from '@stitchapi/query-core'; import type { StitchEvent } from 'stitchapi'; @@ -274,9 +269,13 @@ describe('useStitchStream', () => { }); }); -// --- stitchQueryOptions ---------------------------------------------------------- +// --- stitchQueryOptions ---------------------------------------------------- +// `stitchQueryOptions` is implemented ONCE in `@stitchapi/query-core` (which owns +// the full key-derivation test suite — nameless-collision, secret-header +// redaction, runtime-only field dropping) and re-exported here. These are smoke +// tests that the re-export is wired and behaves through this barrel. -describe('stitchQueryOptions', () => { +describe('stitchQueryOptions (re-exported from @stitchapi/query-core)', () => { test('returns a TanStack-shaped POJO with key + async queryFn', async () => { const stitch = unaryStitch(async () => ({ ok: true }), { name: 'getThing', @@ -292,101 +291,3 @@ describe('stitchQueryOptions', () => { expect(opts.queryKey[0]).toBe('stitch'); }); }); - -describe('queryOptions (deprecated alias)', () => { - test('queryOptions stays a deprecated alias of stitchQueryOptions (ADR 0012)', () => { - expect(queryOptions).toBe(stitchQueryOptions); - }); -}); - -// --- stitchQueryOptions: cache-key derivation regressions ------------------ -// The `queryKey` derivation shared the same three bugs @stitchapi/react fixed in -// #406 (nameless-collision, secret-header leak, refetch storm). Each of these -// FAILED before the port. Kept in lock-step with `packages/react/test/hooks.spec.tsx`. - -describe('stitchQueryOptions — no cache collision between nameless stitches', () => { - // Bug 1 (correctness): `name ?? 'stitch'` keyed every nameless stitch as the - // literal 'stitch', so two distinct endpoints with same-shaped input collided - // on one TanStack Query cache entry. Fixed by mirroring core's `nameOf` - // (name ?? path ?? url ?? 'stitch'). - test('two nameless stitches with different paths get DIFFERENT keys', () => { - const getUser = unaryStitch(async () => 1, { path: '/users/{id}' }); - const getOrder = unaryStitch(async () => 1, { path: '/orders/{id}' }); - const input = { params: { id: '1' } }; - - const userKey = stitchQueryOptions(getUser, input).queryKey; - const orderKey = stitchQueryOptions(getOrder, input).queryKey; - - expect(userKey).not.toEqual(orderKey); - expect(userKey[0]).toBe('/users/{id}'); - expect(orderKey[0]).toBe('/orders/{id}'); - }); -}); - -describe('stitchQueryOptions — no secret leak in the query key', () => { - // Bug 2 (security): the raw `input` went straight into the queryKey, so a - // per-call `authorization` header serialised the bearer token into the - // TanStack Query key (persisted, and shown in the devtools panel). Fixed by - // redacting denylisted header VALUES. - test('a per-call authorization header does not put the token in the key', () => { - const getUser = unaryStitch(async () => 1, { name: 'getUser' }); - const { queryKey } = stitchQueryOptions(getUser, { - params: { id: '1' }, - headers: { authorization: 'Bearer SECRET123' }, - }); - - expect(JSON.stringify(queryKey)).not.toContain('SECRET123'); - }); - - test('redacts the secret header but keeps a benign one (no fresh collision)', () => { - const getUser = unaryStitch(async () => 1, { name: 'getUser' }); - const [, keyInput] = stitchQueryOptions(getUser, { - params: { id: '1' }, - headers: { - authorization: 'Bearer SECRET123', - 'accept-language': 'en-US', - }, - }).queryKey; - - const headers = (keyInput as { headers: Record }) - .headers; - expect(headers['authorization']).not.toContain('SECRET123'); - expect(headers['accept-language']).toBe('en-US'); - }); -}); - -describe('stitchQueryOptions — no refetch storm from runtime-only input fields', () => { - // Bug 3 (reliability): `signal`/`onProgress` are runtime-only (never - // serialised, per CONTRACT.md). An inline `onProgress` churns identity every - // render, so keying it re-fetched forever. Fixed by excluding both fields. - test('two distinct inline onProgress functions produce EQUAL keys', () => { - const getUser = unaryStitch(async () => 1, { name: 'getUser' }); - const base = { params: { id: '1' } }; - - const keyA = stitchQueryOptions(getUser, { - ...base, - onProgress: () => {}, - }).queryKey; - const keyB = stitchQueryOptions(getUser, { - ...base, - onProgress: () => {}, - }).queryKey; - - expect(keyA).toEqual(keyB); - }); - - test('a per-call signal does not enter the key', () => { - const getUser = unaryStitch(async () => 1, { name: 'getUser' }); - const controller = new AbortController(); - - const withSignal = stitchQueryOptions(getUser, { - params: { id: '1' }, - signal: controller.signal, - }).queryKey; - const without = stitchQueryOptions(getUser, { - params: { id: '1' }, - }).queryKey; - - expect(withSignal).toEqual(without); - }); -}); diff --git a/scripts/check-contract.mjs b/scripts/check-contract.mjs index 8bbf1e61..9dc17d76 100644 --- a/scripts/check-contract.mjs +++ b/scripts/check-contract.mjs @@ -1,19 +1,26 @@ #!/usr/bin/env node // API meta-contract gate — see docs/CONTRACT.md. // -// A RATCHET, not a hard gate. The current surface predates the contract and violates it -// in many places (that backlog is docs/CONTRACT.md §6). So this script freezes today's -// violations in scripts/contract-violations.baseline.json and fails ONLY when a NEW -// violation appears — exactly like the repo's ESLint-suppression ratchet. The gate never -// blocks unrelated work, but the surface can only get more consistent, never less. +// A RATCHET, not a hard gate. The pre-contract backlog (docs/CONTRACT.md §6) was worked +// down to ZERO by the GA hard-break sweep; the same-day P24 addition (§6/§7) then found +// one pre-existing real match (R8, @stitchapi/nest's seamConfig/seamToken) it did not fix +// and briefly baselined it. That match has since been converted — folded into +// `StitchFeatureOptions.seam: AtLeastOne` — so +// scripts/contract-violations.baseline.json is empty again and the gate fails on the +// FIRST new violation — exactly like the repo's ESLint-suppression ratchet at zero. The +// mechanism is kept (rather than hard-failing inline) so a deliberate, contract-aligned +// exception can still be baselined with a committed diff for review. // // pnpm check:contract # check working tree against the baseline (CI/hook mode) // node scripts/check-contract.mjs --list # print every current violation, grouped // node scripts/check-contract.mjs --update # rewrite the baseline to the current set // // Rules are intentionally HIGH-PRECISION source-text checks (no TS type info), so a flagged -// line is a real violation, not a guess. Shape-diffing (full P9), duration-type conformance, -// and default-value inversion (P8) need the TS checker and are deferred to a type-aware phase. +// line is a real violation, not a guess. Deferred to a type-aware phase (needs the TS +// checker): shape-diffing (full P9), duration-type conformance, default-value inversion +// (P8), and cross-FILE envelope resolution for R6 — the nested-toggle check only sees an +// `*Options` bag declared in the SAME file, so an imported all-optional envelope at a +// `boolean | X` slot is under-flagged, never guessed at. import { existsSync, readFileSync, @@ -99,8 +106,9 @@ function interfaceBlocks(src) { } // True when the declaration at `idx` is immediately preceded by a JSDoc block carrying -// `@deprecated`. A deprecated member is a known, time-boxed migration alias (P19), not an -// active violation — so the rules skip it, and a rename-under-alias clears the finding. +// `@deprecated`. Post-GA, a @deprecated marker is itself a violation (R7, amended P19) — +// R1–R4/R6 still skip deprecated declarations only so a hypothetical alias is reported +// ONCE (as R7's shim finding), not double-counted under the naming rules too. function deprecatedBefore(src, idx) { let j = idx; while (j > 0 && /[\s{;,(]/.test(src[j - 1])) j--; // skip whitespace + the field anchor @@ -162,9 +170,16 @@ function indexExports(indexPath) { } // ---- rules ---------------------------------------------------------------- -// P3 — banned consumer-input suffix. Carve-outs: the well-known StitchConfig authoring -// family, and any *Like* duck-type (P18: an adapter mirror keeps its upstream spelling). -const BANNED_SUFFIX = /(Opts|Info|Params|Config)$/; +// P3 — banned type-name suffix. Consumer-input side: *Opts/*Info/*Params/*Config (the +// envelope suffix is *Options). Produced-shape side: *Return/*State (the result suffix +// is *Result — this is what would have caught `UseStitchReturn`; *Response/*Info are +// also banned by P3 but *Response is left to the type-aware phase — too many legitimate +// mirrors of the platform `Response` family for a source-text check). Carve-outs: the +// well-known StitchConfig authoring family, and any *Like* duck-type (P18: an adapter +// mirror keeps its upstream spelling). Verified before adding Return/State: no exported +// declaration in packages/*/src carries either suffix post-sweep (the only `AppState` is +// a react-native ambient .d.ts mirror, which tsFiles() already excludes). +const BANNED_SUFFIX = /(Opts|Info|Params|Config|Return|State)$/; const SUFFIX_CARVEOUT = new Set([ 'StitchConfig', 'ResolvedStitchConfig', @@ -178,27 +193,142 @@ const isLike = (n) => /Like/.test(n); // full shape-diff is the deferred type-aware phase). Flagged when ≥2 packages export one. const UNIQUE_WATCH = new Set([ 'StitchStore', - 'RequestSeam', - 'StitchHost', 'StitchError', - // `StitchLike` de-listed (P9): it is two deliberate, compatible tiers, not a clash. The - // canonical RICH shape — `(input?) => StitchCallResult` (awaitable + streamable) — lives in - // `@stitchapi/query-core` and is re-exported by the five TanStack-family bindings - // (react/vue/solid/svelte/angular). The three stream-LESS adapters (swr/rtk-query/vercel-ai) - // intentionally use a MINIMAL await-only `(input?) => PromiseLike` duck-type — they never call - // `.stream()`, so requiring `StitchCallResult` would wrongly reject a plain awaitable callable. - // query-core's rich shape is assignable to the minimal one, so a real stitch satisfies both; the - // by-name R5 proxy can't see that the difference is intentional, hence the de-list. - // `StitchErrorLike` de-listed: the host adapters' error duck-type is now uniformly - // named `StitchErrorLike` (`Error & { status? }`) across elysia/hono/express/fastify/nest/next — - // one structural contract (P9). The mis-named `StitchError` duck-types (which shadowed core's - // real `StitchError` class) were renamed here, so bare `StitchError` is now core-only. - // `queryOptions` was watch-listed as the bare TanStack alias; the canonical - // `stitchQueryOptions` rename (ADR 0012) is now applied to ALL five framework bindings - // (react/vue/solid/svelte/angular), and the bare `queryOptions` survives only as a uniform - // `@deprecated` re-export of it — no longer a competing canonical, so it is de-listed (P16). + // De-listed names (post-sweep surface — each verified against the real ≥2-package + // export map, one line of rationale each): + // - StitchLike + QueryOutput + QueryInput: blessed two-tier duck-types (P9) — + // query-core's RICH canonical (awaitable + streamable), re-exported by the + // TanStack-family bindings, plus a deliberate MINIMAL await-only redeclaration in + // the stream-less adapters (swr/rtk-query/vercel-ai), which never call `.stream()`. + // - StreamableStitchLike: rtk-query's streaming tier of the same blessed family. + // - StreamStitchSseOptions + StitchErrorOptions: intentionally IDENTICAL option + // envelopes declared per host adapter (elysia/hono/express/fastify/nest/next) — + // one structural contract, same-name-same-shape by design (P9). + // - StitchEventSource: core-owned; host adapters re-export core's type verbatim. + // - StitchQueryOptions / stitchQueryOptions / deriveQueryKey / nameOf / keyInputFor + // (and the rest of the query family): query-core-owned canonicals re-exported + // verbatim by the framework bindings — one declaration site, many surfaces. + // - StitchErrorLike: the hosts' uniform error duck-type (`Error & { status? }`) — + // one structural contract across all six adapters (P9). + // Stale watch entries removed: bare `RequestSeam`, `StitchHost`, and `queryOptions` + // were deleted from the surface entirely by the hard-break sweep — nothing left to watch. ]); +// P24 — a shared leading-word prefix across ≥2 flat members of one exported interface is an +// envelope candidate. Two carve-outs are STRUCTURAL (excluded before grouping, never guessed at +// per-symbol): `on*`/`is*` handler/guard verbs, and a percentile family (`…P50`/`…P95`/`…P99`, or +// a bare `p50`/`p95`/`p99`). Everything else is checked against a curated allow-list below — each +// entry is a verified non-match (a foreign-SDK/standard mirror, a discriminated-union pair, a +// derived/internal read-view, or a plugin-extension-hook bag), one rationale per entry, exactly +// like the R5 UNIQUE_WATCH de-listed names above. +const CONVENTIONAL_MEMBER = /^(?:on|is)[A-Z]|^[Pp]\d+$/; + +// Split on camelCase word boundaries; the group key is the lowercased leading word. +const splitCamel = (name) => + name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(' '); +const leadingWord = (name) => splitCamel(name)[0].toLowerCase(); + +// Keyed `InterfaceName.prefix` — one entry per verified exemption, never a blanket interface- or +// package-level skip, so a NEW group in an already-allow-listed interface still gets flagged. +const PREFIX_GROUP_ALLOW = new Map([ + // (a) Foreign-SDK/standard mirrors (P18/P22) — the pair IS the mirrored contract's own + // vocabulary, not house-coined, so there is nothing to fold: + [ + 'OAuth2Options.client', + 'clientId/clientSecret/clientAuth mirror RFC 6749 (P18/P22)', + ], + [ + 'StitchQueryOptions.query', + "queryKey/queryFn mirror TanStack's own queryOptions() vocabulary (P3/P18/P22) — this rule's own motivating example is itself exempt", + ], + [ + 'DocSearchHit.page', + 'pageUrl/pageTitle mirror the persisted Orama index document schema (P18)', + ], + ['XhrLike.response', 'responseType/response mirror the XHR API (P18)'], + [ + 'RnStreamingXhr.response', + 'responseType/responseText mirror the XHR API (P18)', + ], + [ + 'CacheLifecycleApi.cache', + 'cacheDataLoaded/cacheEntryRemoved mirror RTK Query lifecycle names (P18)', + ], + // Discriminated-union pairs — mutually exclusive by `X?: never` on the sibling variant, so + // the two never co-exist and there is no envelope to nest: + [ + 'FastifyStitchPluginSeamOptions.seam', + 'seam/seamConfig are an XOR pair (seamConfig?: never) — the prebuilt-seam variant, not two co-options', + ], + [ + 'FastifyStitchPluginConfigOptions.seam', + 'seamConfig/seam are the same XOR pair from the build-a-seam variant (seam?: never)', + ], + // Off the published surface, or a derived/internal read-view rather than an authored config: + [ + 'ParsedRequest.body', + 'body/bodyType is the CLI-internal from-curl parser type — CONTRACT.md P1 records it as explicitly off the published surface', + ], + [ + 'FingerprintInput.transform', + 'transform/transformVersion is a derived read-view combining StitchConfig.transform (top-level sugar) and CacheOptions.transformVersion (nested) for the hash — the two are not co-located in any authored config', + ], + [ + 'CliIO.write', + 'write/writeErr/writeFile are independent I/O primitives (stdout/stderr/filesystem) on a host-capability interface, not sub-options of one capability', + ], + [ + 'CliIO.load', + 'load/loadModule are independent verbs (registry loader vs. generic import), not sub-options of one capability', + ], + [ + 'Surface.resume', + "resumeToken/resumeRetry are independent P21 plugin-extension hooks — resumeToken's documented pairing is with applyResume (a different prefix); nesting only these two would fragment that pairing without simplifying anything", + ], + // (b) P12 dominant-field carve-out — the second member is a discriminator/tag for the + // dominant field, not an independent option: + [ + 'AdapterRequest.body', + 'bodyType is a discriminator tag for the dominant body payload, not a second option — carve-out (b), the canonical case', + ], + // Coincidental prefix collision — a house field plus an unrelated field that happens to + // mirror a foreign standard's naming: + [ + 'WindowChannelOptions.target', + "targetOrigin mirrors window.postMessage()'s own parameter name (P22); target (the destination handle) is unrelated — coincidental collision, not a group", + ], + [ + 'CookieSessionOptions.login', + 'login (the required, dominant login Stitch) and loginInput (an unrelated input-resolver callback) are different value-kinds, not two knobs of one capability', + ], +]); + +// Top-level (depth-0) field/method names of an interface body, in source order, deduped by name +// (an overloaded method — `set(...)` declared twice — is one field, not two). +function topLevelFieldNames(body) { + const out = []; + const seen = new Set(); + let depth = 0; + let offset = 0; + for (const line of body.split('\n')) { + if (depth === 0) { + const m = /^\s*(?:readonly\s+)?([A-Za-z_]\w*)\s*\??\s*[:(]/.exec( + line, + ); + if (m && !seen.has(m[1])) { + seen.add(m[1]); + out.push({ name: m[1], index: offset + m.index }); + } + } + for (const ch of line) { + if (ch === '{' || ch === '(') depth++; + else if (ch === '}' || ch === ')') depth--; + } + offset += line.length + 1; + } + return out; +} + function collect() { const violations = []; const add = (rule, file, symbol, detail, line) => @@ -235,7 +365,9 @@ function collect() { 'R1', file, name, - `banned suffix on consumer type → *Options (P3)`, + /(Return|State)$/.test(name) + ? `banned suffix on produced shape → *Result (P3)` + : `banned suffix on consumer type → *Options (P3)`, lineOf(src, index), ); } @@ -303,9 +435,16 @@ function collect() { } } - // R6 — a StitchConfig capability slot typed as a bare all-optional `*Options` bag - // accepts the opaque empty object `{}` (P20). The fix is `Scalar | AtLeastOne` - // so the all-defaults case is a scalar and `{}` is a compile error. + // R6 — a config slot that accepts the opaque empty object `{}` (P20). Two envelope + // tiers are covered, both resolved against SAME-FILE declarations only (cross-file + // resolution is the deferred type-aware phase — see the header): + // (a) a StitchConfig capability slot typed as a bare all-optional bag + // → fix: `Scalar | AtLeastOne` so all-defaults is a scalar; + // (b) a NESTED option-envelope toggle (the SseOptions.reconnect class): a member + // of an exported `*Options` interface typed `boolean | X` where X is an + // all-optional bag, or typed as a bare all-optional `*Options` bag + // → fix: `boolean | AtLeastOne` (the AtLeastOne wrapper naturally clears + // the finding — `AtLeastOne` is never an all-optional local interface). const allOptional = new Set( blocks.filter((b) => isAllOptional(b.body)).map((b) => b.name), ); @@ -324,6 +463,90 @@ function collect() { ); } } + for (const blk of blocks) { + if (!/Options$/.test(blk.name)) continue; + // (b1) boolean-toggle member: `member?: boolean | X` with X all-optional here + const tre = + /(?:^|\n)\s*(?:readonly\s+)?([A-Za-z_]\w*)\s*\??:\s*boolean\s*\|\s*([A-Z]\w*)\s*[;,\n]/g; + let t6; + while ((t6 = tre.exec(blk.body))) { + if ( + allOptional.has(t6[2]) && + !deprecatedBefore(src, blk.bodyStart + t6.index) + ) + add( + 'R6', + file, + `${blk.name}.${t6[1]}`, + `toggle admits all-optional ${t6[2]} — {} enables silently → boolean|AtLeastOne<${t6[2]}> (P20)`, + lineOf(src, blk.bodyStart + t6.index), + ); + } + // (b2) bare all-optional *Options member inside an *Options envelope + const bre = + /(?:^|\n)\s*(?:readonly\s+)?([A-Za-z_]\w*)\s*\??:\s*([A-Z]\w*Options)\s*;/g; + let b6; + while ((b6 = bre.exec(blk.body))) { + if ( + allOptional.has(b6[2]) && + !deprecatedBefore(src, blk.bodyStart + b6.index) + ) + add( + 'R6', + file, + `${blk.name}.${b6[1]}`, + `all-optional ${b6[2]} accepts {} → Scalar|AtLeastOne<${b6[2]}> (P20)`, + lineOf(src, blk.bodyStart + b6.index), + ); + } + } + + // R8 — a shared leading-word prefix across ≥2 flat members of the SAME exported + // interface, not on the curated allow-list (P24). The conventional on*/is*/percentile + // prefixes are excluded from grouping entirely (never even reach the allow-list); a + // deprecated declaration is skipped like R1/R3/R4/R6 (a hypothetical alias is R7's + // finding, not double-counted here). + for (const blk of blocks) { + if (deprecatedBefore(src, blk.index)) continue; + const fields = topLevelFieldNames(blk.body).filter( + (f) => !CONVENTIONAL_MEMBER.test(f.name), + ); + const groups = new Map(); // leading word -> field entries + for (const f of fields) { + const word = leadingWord(f.name); + (groups.get(word) ?? groups.set(word, []).get(word)).push( + f, + ); + } + for (const [word, members] of groups) { + if (members.length < 2) continue; + if (PREFIX_GROUP_ALLOW.has(`${blk.name}.${word}`)) continue; + add( + 'R8', + file, + `${blk.name}.${word}`, + `${members.map((m) => m.name).join('/')} share the "${word}" prefix → fold into one envelope (P24)`, + lineOf(src, blk.bodyStart + members[0].index), + ); + } + } + + // R7 — a `@deprecated` marker anywhere in a published package's src (amended P19). + // The GA hard-break sweep removed every pre-GA migration shim; post-GA policy is + // that deprecation aliases do not accumulate on the surface — a removal is a + // semver-major, not a shim. One finding per file (first occurrence) keeps the + // baseline key stable if a stray marker gains siblings before it's purged. + { + const d = src.indexOf('@deprecated'); + if (d !== -1) + add( + 'R7', + file, + '@deprecated', + `deprecated shim on the published surface — remove, don't alias (amended P19: GA shipped shim-free)`, + lineOf(src, d), + ); + } } for (const id of indexExports(join(srcRoot, 'index.ts'))) { diff --git a/scripts/contract-violations.baseline.json b/scripts/contract-violations.baseline.json index 9ac1d271..d2a69924 100644 --- a/scripts/contract-violations.baseline.json +++ b/scripts/contract-violations.baseline.json @@ -1,54 +1,5 @@ { "generatedBy": "scripts/check-contract.mjs --update", - "count": 6, - "violations": [ - { - "rule": "R6", - "file": "packages/core/src/types.ts", - "symbol": "StitchConfig.hooks", - "detail": "all-optional Hooks accepts {} → Scalar|AtLeastOne (P20)", - "line": 602, - "key": "R6|packages/core/src/types.ts|StitchConfig.hooks" - }, - { - "rule": "R6", - "file": "packages/core/src/types.ts", - "symbol": "StitchConfig.input", - "detail": "all-optional InputSchemas accepts {} → Scalar|AtLeastOne (P20)", - "line": 602, - "key": "R6|packages/core/src/types.ts|StitchConfig.input" - }, - { - "rule": "R6", - "file": "packages/core/src/types.ts", - "symbol": "StitchConfig.multipart", - "detail": "all-optional MultipartOptions accepts {} → Scalar|AtLeastOne (P20)", - "line": 602, - "key": "R6|packages/core/src/types.ts|StitchConfig.multipart" - }, - { - "rule": "R6", - "file": "packages/core/src/types.ts", - "symbol": "StitchConfig.sse", - "detail": "all-optional SseOptions accepts {} → Scalar|AtLeastOne (P20)", - "line": 602, - "key": "R6|packages/core/src/types.ts|StitchConfig.sse" - }, - { - "rule": "R6", - "file": "packages/core/src/types.ts", - "symbol": "StitchConfig.stream", - "detail": "all-optional StreamOptions accepts {} → Scalar|AtLeastOne (P20)", - "line": 602, - "key": "R6|packages/core/src/types.ts|StitchConfig.stream" - }, - { - "rule": "R6", - "file": "packages/core/src/types.ts", - "symbol": "StitchConfig.throttle", - "detail": "all-optional ThrottleOptions accepts {} → Scalar|AtLeastOne (P20)", - "line": 602, - "key": "R6|packages/core/src/types.ts|StitchConfig.throttle" - } - ] + "count": 0, + "violations": [] }