stitchkit 0.72.5 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/remote.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ApiError,
3
3
  createClient
4
- } from "./index-6d32zk83.js";
4
+ } from "./index-58jzmnn4.js";
5
5
  import"./index-1rxswfbv.js";
6
6
  import"./index-nt1mp8km.js";
7
7
  import {
package/dist/testing.js CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  import {
25
25
  createClient,
26
26
  createClients
27
- } from "./index-6d32zk83.js";
27
+ } from "./index-58jzmnn4.js";
28
28
  import"./index-1rxswfbv.js";
29
29
  import"./index-nt1mp8km.js";
30
30
  import {
package/llms-full.txt CHANGED
@@ -61,7 +61,7 @@ own, recorded as an ADR.
61
61
  | `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the store and managed-resource conformance kits |
62
62
  | `stitchkit/declaration` | build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
63
63
  | `stitchkit/react` | browser | stable | `createCursorQuery`, `createCacheBridge` |
64
- | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 10 of the 17 minors since 0.56.2, most recently 0.69.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
64
+ | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 10 of the 19 minors since 0.56.2, most recently 0.69.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
65
65
  | `stitchkit/agent-runtime/harness` | server | evolving | resource-aware process-local facade over the canonical Agent runtime; supervision stays outside |
66
66
  | `stitchkit/agent-runtime/coding-tools` | server (Bun or Node) | evolving | bounded host-authorized direct file and shell tools; a root boundary, not an OS sandbox |
67
67
  | `stitchkit/agent-runtime/openrouter` | server | evolving | isolated OpenRouter language-model adapter |
@@ -69,7 +69,7 @@ own, recorded as an ADR.
69
69
  | `stitchkit/agent-runtime/sqlite/bun` | server (Bun) | evolving | durable built-in SQLite store for the agent runtime |
70
70
  | `stitchkit/agent-runtime/sqlite/node` | server (Node ≥ 22.5) | evolving | durable built-in SQLite store for the agent runtime |
71
71
  | `stitchkit-tui` | terminal (Bun) | evolving | optional official OpenTUI host over a caller-composed headless runtime |
72
- | `stitchkit/application` | server | evolving<br>_redefined in 4 of the 17 minors since 0.56.2, most recently 0.72.0_ | managed resource graph, readiness, admission, schedules and bounded shutdown |
72
+ | `stitchkit/application` | server | evolving<br>_redefined in 4 of the 19 minors since 0.56.2, most recently 0.72.0_ | managed resource graph, readiness, admission, schedules and bounded shutdown |
73
73
  | `stitchkit/application/grammy` | server | evolving | isolated grammY polling and webhook lifecycle adapters |
74
74
  | `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
75
75
 
@@ -2005,6 +2005,18 @@ proves that the remote operation did not begin; Stitchkit never silently retries
2005
2005
  an ambiguous write. Response consumption/cancellation belongs to the operation,
2006
2006
  and `close()` interrupts active work and destroys owned connections.
2007
2007
 
2008
+ **A caller's cancellation reaches the server on both lanes.** The transport picks
2009
+ a raw-socket lane under Bun and `node:http` elsewhere, and both propagate
2010
+ `AbortSignal` the same way: aborting the request tears the connection down, and
2011
+ the handler's `ctx.signal` fires — measured at roughly 300 ms for a 200 ms abort
2012
+ over either lane, matching plain TCP. `close()` is a different thing: it ends the
2013
+ *transport*, not one request, and a request-level abort does not close it.
2014
+
2015
+ The one way to see cancellation appear not to work is to pass the signal to the
2016
+ plain callable — `api.thing(args, { signal })` — where options are ignored in
2017
+ silence. See [per-call cancellation](#per-call-cancellation): `withOptions` is the
2018
+ only door.
2019
+
2008
2020
  The response total is a unary-body policy, not a stream-buffer measurement. A
2009
2021
  long-lived NDJSON/SSE client opts into streaming explicitly instead of choosing
2010
2022
  an arbitrarily large integer:
@@ -2078,6 +2090,62 @@ including an explicitly declared empty `200` or `205`. A missing body for a
2078
2090
  declared output, or a body for an endpoint with no output, fails loudly instead
2079
2091
  of changing the typed result.
2080
2092
 
2093
+ Validation runs in one direction. The response is checked against the endpoint's
2094
+ `output` schema before it reaches the caller; arguments are **not** checked
2095
+ against `input` or `params` before the request is sent. A value the contract
2096
+ forbids travels to the server and comes back as a `VALIDATION_ERROR` — `400`,
2097
+ naming every offending field in the `message` and again in `details.issues` as
2098
+ `{ path, code, message }`. That holds for a JSON body, a query string and a path
2099
+ parameter alike, so a rejected argument always costs a round trip.
2100
+
2101
+ It is tempting to close that gap locally: the schemas are on the contract object,
2102
+ and `input` is the very Zod object the server validates with, so parsing it before
2103
+ the call looks free. **It is not, and it fails in the direction that breaks
2104
+ working code.** A call's arguments and the server's `input` are not the same
2105
+ value, for two independent reasons.
2106
+
2107
+ An argument object is typed as the intersection of `params`, `input` and any
2108
+ multipart fields, so `input` covers one factor of it. Parsed over the whole
2109
+ object it silently strips the keys it does not own — or, being `.strict()`,
2110
+ refuses them:
2111
+
2112
+ ```ts
2113
+ // params: { id }, input: z.object({ text }).strict()
2114
+ contract.endpoints.update.input.safeParse({ id: 'item-1', text: 'hello' })
2115
+ // → refused: unrecognized_keys: ["id"] the server accepts this call
2116
+ ```
2117
+
2118
+ And on a query string or a multipart body the wire carries strings while the
2119
+ caller holds live values, so a coercing schema answers differently on each side:
2120
+
2121
+ ```ts
2122
+ // input: z.object({ flag: z.coerce.boolean() })
2123
+ api.list({ flag: false })
2124
+ // local safeParse({ flag: false }) → false
2125
+ // server safeParse({ flag: 'false' }) → true z.coerce.boolean()('false') is true
2126
+ ```
2127
+
2128
+ And a plain JSON body is not the safe exception it looks like, because it still
2129
+ crosses `JSON.stringify`. A schema holding any type that does not survive that
2130
+ round trip — `z.date()`, `z.map()`, `z.set()`, `z.bigint()`, `z.instanceof(...)`,
2131
+ or `NaN` / `Infinity` inside a `z.number()` — agrees locally and refuses on the
2132
+ server:
2133
+
2134
+ ```ts
2135
+ // input: z.object({ when: z.date() }), POST, no path params
2136
+ check({ when: new Date() }) // accepted: a live Date
2137
+ // server // refused: expected date, received string
2138
+ ```
2139
+
2140
+ This one fails in the safe direction — nothing valid is blocked — but it means a
2141
+ local check passes traffic the server will reject, which is the opposite of what
2142
+ it was added for.
2143
+
2144
+ One schema, one call, opposite answers — inside a single release, with no version
2145
+ skew involved. Argument validation therefore stays on the server, which is the
2146
+ only side that sees what was actually sent. A rejected argument costs a round
2147
+ trip, and the refusal names every offending field.
2148
+
2081
2149
  An explicit contract `HEAD` operation is exposed like any other typed method.
2082
2150
  Because HEAD endpoints are `rawResponse`, it resolves to the untouched
2083
2151
  `Response`, giving the caller direct access to status and headers without JSON
@@ -2115,11 +2183,22 @@ mistaking their callback context for Stitchkit transport options.
2115
2183
  argument is ignored, in silence, and the request runs to completion while the
2116
2184
  caller believes it was cancelled or bounded. TypeScript refuses the extra
2117
2185
  argument at a typed call site and says nothing at an untyped one, which is where
2118
- the mistake actually happens. Nothing can catch it at runtime either: probing the
2119
- second argument is exactly what the callback safety above forbids, since reading
2120
- a foreign object's `signal` may execute someone else's getter. So the rule is
2121
- simply this — **per-call options only ever go through `withOptions`**, and a
2122
- cancellation that appears to do nothing is the first thing to check.
2186
+ the mistake actually happens. Nothing can catch it at runtime **on the bare
2187
+ callable**: probing its second argument is exactly what the callback safety above
2188
+ forbids, since reading a foreign object's `signal` may execute someone else's
2189
+ getter. So the rule is simply this — **per-call options only ever go through
2190
+ `withOptions`**, and a cancellation that appears to do nothing is the first thing
2191
+ to check.
2192
+
2193
+ `withOptions` itself is guarded, because there the count settles it without
2194
+ reading anything. Its arity depends on the endpoint — one argument when there is
2195
+ no contract input, two when there is — and a call carrying more throws a
2196
+ `TypeError` naming the endpoint and the correct shape. That guard exists because
2197
+ the silent version of this mistake is expensive: the options are dropped, the
2198
+ request goes out uncancelled, the caller still receives `REQUEST_ABORTED`
2199
+ (cancellation is decided from the signal it was handed, not from what the
2200
+ transport did), and the server runs the operation to its own deadline. Every
2201
+ symptom then points at the transport, and the investigation goes there.
2123
2202
 
2124
2203
  ```ts
2125
2204
  const controller = new AbortController()
@@ -2139,10 +2218,20 @@ the same client-only errors:
2139
2218
 
2140
2219
  | Failure | `ApiError.code` | `status` |
2141
2220
  |---------|-----------------|----------|
2221
+ | arguments refused before sending | `VALIDATION_ERROR` | `0` |
2142
2222
  | caller `AbortSignal` | `REQUEST_ABORTED` | `0` |
2143
2223
  | endpoint/client timeout | `REQUEST_TIMEOUT` | `0` |
2144
2224
  | other transport failure | `UNKNOWN_ERROR` | `0` |
2145
2225
 
2226
+ `status: 0` is what separates a refusal made here from one made by the server:
2227
+ the same `VALIDATION_ERROR` arrives with `400` when the server refused, and
2228
+ `details.issues` is the same `{ path, code, message }` array either way, so one
2229
+ rendering serves both. A refusal raised here is always a **rejection** — on both
2230
+ transports, for every call shape — and it never reaches the server. The client
2231
+ refuses this way for a missing path param, a missing scoped prefix key, a
2232
+ non-flat field in a `GET` input, and a missing or invalid multipart file. → ADR
2233
+ 0148.
2234
+
2146
2235
  Abort and timeout do not emit `network_error` and are not retried. The same
2147
2236
  options work for query, JSON, multipart and raw-response calls. Stitchkit does
2148
2237
  not expose upload progress: Fetch has no portable upload-progress primitive.
@@ -2186,9 +2275,16 @@ const work = createClient(requestWorkContract, {
2186
2275
 
2187
2276
  The same adapter accepts unrelated contract shapes without learning their DTOs
2188
2277
  or operation inventory. `createClient` chooses method/path from the contract,
2189
- serializes only the declared arguments, forwards `.withOptions(..., { signal })`
2190
- and validates the response through the endpoint's output schema. A caller payload
2191
- cannot replace the configured base URL or reserved operation path.
2278
+ forwards `.withOptions(..., { signal })` and validates the response through the
2279
+ endpoint's output schema. A caller payload cannot replace the configured base URL
2280
+ or reserved operation path.
2281
+
2282
+ It does **not** filter your arguments down to the declared ones. Keys the contract
2283
+ does not declare are consumed for the path where they belong there and otherwise
2284
+ sent as they are — `api.list({ q: 'hello', note: 'internal' })` puts
2285
+ `?q=hello&note=internal` on the wire, and the server drops `note` only because its
2286
+ schema is not `.strict()`. Nothing was validated away on this side: pass the
2287
+ object the contract declares, not a wider one it happens to contain.
2192
2288
 
2193
2289
  Contract metadata describes an effect; it does not authorize one. Authentication,
2194
2290
  scope and destination policy remain in the application/server boundary. A schema
@@ -9496,6 +9592,143 @@ makes one thing your job rather than the resolver's:
9496
9592
  The mechanical part is identical either way. Only the *noticing* differs, and an
9497
9593
  exact pin moves it onto you.
9498
9594
 
9595
+ ## Released migration: 0.74.0
9596
+
9597
+ One change, and it only reaches you if you catch a refusal the **client** raised — one it made while
9598
+ planning the request, before anything was sent. A server refusal is unchanged.
9599
+
9600
+ ### If you match on the text of a client-side refusal
9601
+
9602
+ A missing path param, a missing scoped prefix key, a non-flat field in a `GET` input, a missing or
9603
+ invalid multipart file: each used to arrive as a plain `Error` carrying only a sentence. They now
9604
+ arrive as an `ApiError` in the same shape a server validation failure uses.
9605
+
9606
+ ```ts
9607
+ // before
9608
+ catch (e) {
9609
+ if (/Missing path param/.test(e.message)) …
9610
+ }
9611
+
9612
+ // after
9613
+ catch (e) {
9614
+ if (e.code === 'VALIDATION_ERROR' && e.status === 0) … // refused here, nothing was sent
9615
+ }
9616
+ // e.details.issues[0].path === 'id'
9617
+ ```
9618
+
9619
+ `status` is what separates the two worlds, and it is not new — `0` has always meant *this never
9620
+ reached the server*, as `REQUEST_ABORTED` and `REQUEST_TIMEOUT` already used it:
9621
+
9622
+ | | `code` | `status` |
9623
+ |---|---|---|
9624
+ | the client refused your arguments | `VALIDATION_ERROR` | `0` |
9625
+ | the server refused your arguments | `VALIDATION_ERROR` | `400` |
9626
+
9627
+ `details.issues` is the same `{ path, code, message }` array on both, so one rendering serves both —
9628
+ and `zodIssues` / `ZodIssueSummary` are now importable from `stitchkit` itself, not only from
9629
+ `stitchkit/server`.
9630
+
9631
+ ### If you are on `createHttpClient` and your call site relied on a synchronous throw
9632
+
9633
+ This is the sharp one, because it is invisible in a diff. Those refusals used to be thrown
9634
+ **synchronously** on the Ky-backed client — before the call returned a promise — so
9635
+ `api.upload({}).catch(handler)` never reached the handler, while the same mistake on the bare-fetch
9636
+ client rejected normally. They now reject on both.
9637
+
9638
+ ```ts
9639
+ // before, on createHttpClient only
9640
+ try {
9641
+ api.upload({}) // threw here
9642
+ } catch (e) { … }
9643
+
9644
+ // after, on both transports
9645
+ await api.upload({}).catch(handler)
9646
+ ```
9647
+
9648
+ A `try/catch` around an `await`ed call keeps working. A `try/catch` around an un-awaited call no
9649
+ longer catches anything — which was already true on the other transport.
9650
+
9651
+ Not a migration, but worth knowing: a missing multipart file used to be reported as
9652
+ `UNKNOWN_ERROR`, the code that means *this client cannot tell you what happened*, on the one refusal
9653
+ where it is certain nothing was sent. → ADR 0148 carries the reasoning, including why argument
9654
+ validation stays on the server.
9655
+
9656
+ ## Released migration: 0.73.0
9657
+
9658
+ Two breaking changes. Nothing you send to a server changed, and only one of these
9659
+ can reach a call site the compiler already checks.
9660
+
9661
+ ### If you call `withOptions` from a call site that lost its types
9662
+
9663
+ `withOptions` takes one argument on an endpoint with no contract input, two when
9664
+ there is one. Passing two to a no-input endpoint used to succeed and silently
9665
+ drop the options: the request went out **uncancelled**, the caller still received
9666
+ `REQUEST_ABORTED` — cancellation is decided from the signal it was handed, not
9667
+ from what the transport did — and the server ran the operation to its own
9668
+ deadline. Every symptom then points at the transport, and the investigation goes
9669
+ there. It now throws a `TypeError` naming the endpoint and the correct shape.
9670
+
9671
+ ```ts
9672
+ // before — the options were dropped and the request was never cancelled
9673
+ api.ping.withOptions({}, { signal })
9674
+
9675
+ // after
9676
+ api.ping.withOptions({ signal })
9677
+ ```
9678
+
9679
+ A typed call site already refused the wrong arity, so this reaches only code that
9680
+ does not have the types: a `Record<string, …>` view of the client, a hand-written
9681
+ double, a dynamic dispatch. The guard counts arguments and never reads them, so a
9682
+ method handed to a callback API behaves exactly as before.
9683
+
9684
+ ### If you match on `ApiError.message`
9685
+
9686
+ An `ApiError` that nothing explained used to carry `API Error: ${code}` — a
9687
+ string that reads like an explanation while only restating the code, so a caller
9688
+ could not tell an origin that explained a failure from one that said nothing. It
9689
+ now says which it is.
9690
+
9691
+ ```ts
9692
+ // before
9693
+ err.message === 'API Error: INVALID_INPUT'
9694
+
9695
+ // after
9696
+ err.message === 'INVALID_INPUT (no message supplied)' // better: match err.code
9697
+ ```
9698
+
9699
+ Match `err.code` instead: it is the contract, and it did not change. An error
9700
+ carrying a real message is untouched. An empty message now counts as no message,
9701
+ for the same reason — an empty string is not an explanation either.
9702
+
9703
+ ### If a diagnostic journal lock file is already on disk
9704
+
9705
+ One operator step, and only on a host that has been **renamed** since its lock was written.
9706
+
9707
+ A lock written before this version carries no machine identity, so there is nothing to compare and
9708
+ the host name is all that is left. If the name still matches, the lock reclaims exactly as it always
9709
+ did and there is nothing to do. If the name has changed, the lock is `unattributable` and is refused
9710
+ — the same refusal this release exists to end, except that this particular file predates the fix and
9711
+ cannot be repaired from the inside. Delete it once:
9712
+
9713
+ ```sh
9714
+ rm <journal path>.lock # only when the host was renamed since the file was written
9715
+ ```
9716
+
9717
+ Locks written from this version on carry the machine identity, so the situation cannot recur. To see
9718
+ which case you are in without guessing, read the refusal instead of the file:
9719
+ `readDiagnosticJournalLockDiagnosis(err)` returns `attribution: 'unattributable'` for exactly this
9720
+ one, and `'another-machine'` for a lock that genuinely belongs elsewhere and must **not** be deleted.
9721
+
9722
+ Two related fixes need no migration, but change what you will see. An error the
9723
+ transport raised through `createHttpClient` now carries the transport's own text
9724
+ in `message` and the original error as `cause`, where before it carried neither
9725
+ and filed the text under `details.message` alone; if you followed the retry rule
9726
+ in [the client guide](./client.md) — inspect the adapter's `cause`, retry only
9727
+ when it proves dispatch did not happen — that rule was not executable on this
9728
+ transport and now is. And the client guide now states which direction validation
9729
+ runs: responses are checked against `output`, arguments are **not** checked
9730
+ before being sent.
9731
+
9499
9732
  ## Released migration: 0.72.0
9500
9733
 
9501
9734
  Nothing you *pass* changed. Both items are about types you read or build, and
@@ -12236,6 +12469,9 @@ The browser-and-server entrypoint. Re-exports everything from
12236
12469
  | `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
12237
12470
  | `createHttpClient` | function | the Ky-based HTTP transport; on Next.js SSR its first attempt stays request-memoizable while every retry is a distinct transport attempt — [guide](../guide/client.md#createhttpclient) |
12238
12471
  | `ApiError` | class | a non-2xx or client failure, with `code` / `status` / `details` / `hint`, optional readonly `traceId` from `x-request-id`, and standard `cause` preserving an injected transport failure |
12472
+ | `zodIssues` | function | a `ZodError` → structured `{ path, code, message }[]` — the exact projection a `VALIDATION_ERROR` carries in `ApiError.details.issues`, so a caller renders one shape. Also on `stitchkit/server`; same name, one definition |
12473
+ | `ZodIssueSummary` | _type_ | one structured validation issue (`{ path, code, message }`) — the element type of `details.issues`. Also on `stitchkit/server` |
12474
+ | `formatZodError` | function | a `ZodError` → a readable, field-summarised string. Also on `stitchkit/server` |
12239
12475
  | `HttpClient` | _type_ | the transport interface `createClient` builds on |
12240
12476
  | `ConfiguredHttpClient` | _type_ | a framework-created `HttpClient` carrying its readonly `baseUrl` for URL builders |
12241
12477
  | `HttpClientConfig` | _type_ | config for `createHttpClient`; retry `limit` counts retries after the initial attempt (default 2 = at most 3 GET attempts), with `statusCodes: []` by default; `fetch` installs an explicit transport and is mutually exclusive with the legacy Bun-only `unix` option — [details](../guide/client.md#createhttpclient) |
@@ -12760,6 +12996,7 @@ cutovers are covered by the executable
12760
12996
  | `DiagnosticJournalConfig` / `DiagnosticJournal` | _type_ | owner schema/path/limits/failure observer and the synchronous `submit`, bounded-wait `flush`/`close`, status handle |
12761
12997
  | `DiagnosticJournalLimitsSchema` / `DiagnosticJournalLimits` | schema / _type_ | positive event, pending-item, pending-byte, file-byte and retained-file limits |
12762
12998
  | `DiagnosticJournalLockPolicySchema` / `DiagnosticJournalLockPolicy` | schema / _type_ | `refuse` (default) or `reclaim-stale`, which reclaims only a lock whose recorded owner is provably gone |
12999
+ | `readDiagnosticJournalLockDiagnosis` / `DiagnosticJournalLockDiagnosis` | function / _type_ | why a `reclaim-stale` acquisition refused — owner alive, another machine, or a lock this host cannot attribute — read off the thrown `EEXIST` |
12763
13000
  | `DiagnosticJournalSubmitResultSchema` / `DiagnosticJournalSubmitResult` | schema / _type_ | accepted epoch/sequence or explicit invalid, oversized, capacity, closed or failed refusal |
12764
13001
  | `DiagnosticJournalStatusSchema` / `DiagnosticJournalStatus` | schema / _type_ | state, limits, exact admission/write/failure counters, pending ownership, rotations, partial tails and last safe sequences |
12765
13002
  | `DiagnosticJournalFrameSchema` / `DiagnosticJournalFrame` | schema / _type_ | version-1 JSONL frame carrying process epoch, contiguous accepted sequence and schema-validated JSON event |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.72.5",
3
+ "version": "0.74.0",
4
4
  "description": "Contract-first backend framework — one defineContract() into an HTTP API, MCP tools, AI-agent tools and a typed client. Bun and Node.",
5
5
  "keywords": [
6
6
  "bun",