stitchkit 0.73.0 → 0.74.1

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-0tfm7beg.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-0tfm7beg.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 18 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 18 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
 
@@ -2098,19 +2098,55 @@ naming every offending field in the `message` and again in `details.issues` as
2098
2098
  `{ path, code, message }`. That holds for a JSON body, a query string and a path
2099
2099
  parameter alike, so a rejected argument always costs a round trip.
2100
2100
 
2101
- An application that needs a local refusal can parse the schema itself before
2102
- calling `contract.endpoints.<name>.input` is the same Zod object the server
2103
- validates with:
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:
2104
2120
 
2105
2121
  ```ts
2106
- const parsed = contract.endpoints.create.input.safeParse(args)
2107
- if (!parsed.success) return refuse(parsed.error)
2108
- await api.create(args)
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
2109
2126
  ```
2110
2127
 
2111
- It then owns a second error shape beside this one, so make the local refusal name
2112
- the offending fields too. A caller who receives a bare code with no text cannot
2113
- tell which gate refused or why and reads it as a refusal of permission.
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 "safe" is
2141
+ about traffic, not about diagnostics. The check stays silent exactly where it
2142
+ promised to speak: the refusal then arrives from the server, through a different
2143
+ path and without the field names the local gate would have given. A gate that
2144
+ lets through what it was added to catch is not a cheap gate, it is a quiet one.
2145
+
2146
+ One schema, one call, opposite answers — inside a single release, with no version
2147
+ skew involved. Argument validation therefore stays on the server, which is the
2148
+ only side that sees what was actually sent. A rejected argument costs a round
2149
+ trip, and the refusal names every offending field.
2114
2150
 
2115
2151
  An explicit contract `HEAD` operation is exposed like any other typed method.
2116
2152
  Because HEAD endpoints are `rawResponse`, it resolves to the untouched
@@ -2184,10 +2220,20 @@ the same client-only errors:
2184
2220
 
2185
2221
  | Failure | `ApiError.code` | `status` |
2186
2222
  |---------|-----------------|----------|
2223
+ | arguments refused before sending | `VALIDATION_ERROR` | `0` |
2187
2224
  | caller `AbortSignal` | `REQUEST_ABORTED` | `0` |
2188
2225
  | endpoint/client timeout | `REQUEST_TIMEOUT` | `0` |
2189
2226
  | other transport failure | `UNKNOWN_ERROR` | `0` |
2190
2227
 
2228
+ `status: 0` is what separates a refusal made here from one made by the server:
2229
+ the same `VALIDATION_ERROR` arrives with `400` when the server refused, and
2230
+ `details.issues` is the same `{ path, code, message }` array either way, so one
2231
+ rendering serves both. A refusal raised here is always a **rejection** — on both
2232
+ transports, for every call shape — and it never reaches the server. The client
2233
+ refuses this way for a missing path param, a missing scoped prefix key, a
2234
+ non-flat field in a `GET` input, and a missing or invalid multipart file. → ADR
2235
+ 0148.
2236
+
2191
2237
  Abort and timeout do not emit `network_error` and are not retried. The same
2192
2238
  options work for query, JSON, multipart and raw-response calls. Stitchkit does
2193
2239
  not expose upload progress: Fetch has no portable upload-progress primitive.
@@ -2231,9 +2277,16 @@ const work = createClient(requestWorkContract, {
2231
2277
 
2232
2278
  The same adapter accepts unrelated contract shapes without learning their DTOs
2233
2279
  or operation inventory. `createClient` chooses method/path from the contract,
2234
- serializes only the declared arguments, forwards `.withOptions(..., { signal })`
2235
- and validates the response through the endpoint's output schema. A caller payload
2236
- cannot replace the configured base URL or reserved operation path.
2280
+ forwards `.withOptions(..., { signal })` and validates the response through the
2281
+ endpoint's output schema. A caller payload cannot replace the configured base URL
2282
+ or reserved operation path.
2283
+
2284
+ It does **not** filter your arguments down to the declared ones. Keys the contract
2285
+ does not declare are consumed for the path where they belong there and otherwise
2286
+ sent as they are — `api.list({ q: 'hello', note: 'internal' })` puts
2287
+ `?q=hello&note=internal` on the wire, and the server drops `note` only because its
2288
+ schema is not `.strict()`. Nothing was validated away on this side: pass the
2289
+ object the contract declares, not a wider one it happens to contain.
2237
2290
 
2238
2291
  Contract metadata describes an effect; it does not authorize one. Authentication,
2239
2292
  scope and destination policy remain in the application/server boundary. A schema
@@ -9541,6 +9594,67 @@ makes one thing your job rather than the resolver's:
9541
9594
  The mechanical part is identical either way. Only the *noticing* differs, and an
9542
9595
  exact pin moves it onto you.
9543
9596
 
9597
+ ## Released migration: 0.74.0
9598
+
9599
+ One change, and it only reaches you if you catch a refusal the **client** raised — one it made while
9600
+ planning the request, before anything was sent. A server refusal is unchanged.
9601
+
9602
+ ### If you match on the text of a client-side refusal
9603
+
9604
+ A missing path param, a missing scoped prefix key, a non-flat field in a `GET` input, a missing or
9605
+ invalid multipart file: each used to arrive as a plain `Error` carrying only a sentence. They now
9606
+ arrive as an `ApiError` in the same shape a server validation failure uses.
9607
+
9608
+ ```ts
9609
+ // before
9610
+ catch (e) {
9611
+ if (/Missing path param/.test(e.message)) …
9612
+ }
9613
+
9614
+ // after
9615
+ catch (e) {
9616
+ if (e.code === 'VALIDATION_ERROR' && e.status === 0) … // refused here, nothing was sent
9617
+ }
9618
+ // e.details.issues[0].path === 'id'
9619
+ ```
9620
+
9621
+ `status` is what separates the two worlds, and it is not new — `0` has always meant *this never
9622
+ reached the server*, as `REQUEST_ABORTED` and `REQUEST_TIMEOUT` already used it:
9623
+
9624
+ | | `code` | `status` |
9625
+ |---|---|---|
9626
+ | the client refused your arguments | `VALIDATION_ERROR` | `0` |
9627
+ | the server refused your arguments | `VALIDATION_ERROR` | `400` |
9628
+
9629
+ `details.issues` is the same `{ path, code, message }` array on both, so one rendering serves both —
9630
+ and `zodIssues` / `ZodIssueSummary` are now importable from `stitchkit` itself, not only from
9631
+ `stitchkit/server`.
9632
+
9633
+ ### If you are on `createHttpClient` and your call site relied on a synchronous throw
9634
+
9635
+ This is the sharp one, because it is invisible in a diff. Those refusals used to be thrown
9636
+ **synchronously** on the Ky-backed client — before the call returned a promise — so
9637
+ `api.upload({}).catch(handler)` never reached the handler, while the same mistake on the bare-fetch
9638
+ client rejected normally. They now reject on both.
9639
+
9640
+ ```ts
9641
+ // before, on createHttpClient only
9642
+ try {
9643
+ api.upload({}) // threw here
9644
+ } catch (e) { … }
9645
+
9646
+ // after, on both transports
9647
+ await api.upload({}).catch(handler)
9648
+ ```
9649
+
9650
+ A `try/catch` around an `await`ed call keeps working. A `try/catch` around an un-awaited call no
9651
+ longer catches anything — which was already true on the other transport.
9652
+
9653
+ Not a migration, but worth knowing: a missing multipart file used to be reported as
9654
+ `UNKNOWN_ERROR`, the code that means *this client cannot tell you what happened*, on the one refusal
9655
+ where it is certain nothing was sent. → ADR 0148 carries the reasoning, including why argument
9656
+ validation stays on the server.
9657
+
9544
9658
  ## Released migration: 0.73.0
9545
9659
 
9546
9660
  Two breaking changes. Nothing you send to a server changed, and only one of these
@@ -9588,6 +9702,25 @@ Match `err.code` instead: it is the contract, and it did not change. An error
9588
9702
  carrying a real message is untouched. An empty message now counts as no message,
9589
9703
  for the same reason — an empty string is not an explanation either.
9590
9704
 
9705
+ ### If a diagnostic journal lock file is already on disk
9706
+
9707
+ One operator step, and only on a host that has been **renamed** since its lock was written.
9708
+
9709
+ A lock written before this version carries no machine identity, so there is nothing to compare and
9710
+ the host name is all that is left. If the name still matches, the lock reclaims exactly as it always
9711
+ did and there is nothing to do. If the name has changed, the lock is `unattributable` and is refused
9712
+ — the same refusal this release exists to end, except that this particular file predates the fix and
9713
+ cannot be repaired from the inside. Delete it once:
9714
+
9715
+ ```sh
9716
+ rm <journal path>.lock # only when the host was renamed since the file was written
9717
+ ```
9718
+
9719
+ Locks written from this version on carry the machine identity, so the situation cannot recur. To see
9720
+ which case you are in without guessing, read the refusal instead of the file:
9721
+ `readDiagnosticJournalLockDiagnosis(err)` returns `attribution: 'unattributable'` for exactly this
9722
+ one, and `'another-machine'` for a lock that genuinely belongs elsewhere and must **not** be deleted.
9723
+
9591
9724
  Two related fixes need no migration, but change what you will see. An error the
9592
9725
  transport raised through `createHttpClient` now carries the transport's own text
9593
9726
  in `message` and the original error as `cause`, where before it carried neither
@@ -12338,6 +12471,9 @@ The browser-and-server entrypoint. Re-exports everything from
12338
12471
  | `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
12339
12472
  | `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) |
12340
12473
  | `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 |
12474
+ | `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 |
12475
+ | `ZodIssueSummary` | _type_ | one structured validation issue (`{ path, code, message }`) — the element type of `details.issues`. Also on `stitchkit/server` |
12476
+ | `formatZodError` | function | a `ZodError` → a readable, field-summarised string. Also on `stitchkit/server` |
12341
12477
  | `HttpClient` | _type_ | the transport interface `createClient` builds on |
12342
12478
  | `ConfiguredHttpClient` | _type_ | a framework-created `HttpClient` carrying its readonly `baseUrl` for URL builders |
12343
12479
  | `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) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.73.0",
3
+ "version": "0.74.1",
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",