@zerotal/arch 1.7.2 → 1.7.4

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/api-surface.md CHANGED
@@ -185,11 +185,11 @@ interface ToolContext = {
185
185
  root: string
186
186
  }
187
187
 
188
- type BlockOutcome = { status: 'created'; text: string;} | { status: 'updated'; text: string;} | { status: 'unchanged'; text: string;} | { status: 'conflict'; reason: string;}
188
+ type BlockOutcome = { status: 'created'; text: string } | { status: 'updated'; text: string } | { status: 'unchanged'; text: string } | { status: 'conflict'; reason: string }
189
189
 
190
- type ConfigOutcome = { status: 'created' | 'updated' | 'unchanged'; text: string;} | { status: 'conflict'; reason: string;}
190
+ type ConfigOutcome = { status: 'created' | 'updated' | 'unchanged'; text: string } | { status: 'conflict'; reason: string }
191
191
 
192
- type ProbeResult = { ok: true; data: unknown;} | { ok: false; message: string;}
192
+ type ProbeResult = { ok: true; data: unknown } | { ok: false; message: string }
193
193
 
194
194
  type ProbeTopic = 'doctor' | 'routes' | 'schema' | 'app-info'
195
195
 
@@ -272,7 +272,7 @@ interface ToolOutcome = {
272
272
  text: string
273
273
  }
274
274
 
275
- type DecodedFrame = { ok: true; request: JsonRpcRequest;} | { ok: false; failure: JsonRpcFailure;}
275
+ type DecodedFrame = { ok: true; request: JsonRpcRequest } | { ok: false; failure: JsonRpcFailure }
276
276
 
277
277
  type Era = 'modern' | 'legacy'
278
278
 
package/docs/about.md CHANGED
@@ -43,7 +43,7 @@ a theme.
43
43
 
44
44
  ### 1. Bun-native, source-only
45
45
 
46
- Zerotal runs **only on Bun** (≥ 1.1) and leans on Bun's APIs throughout — `Bun.sql`
46
+ Zerotal runs **only on Bun** (≥ 1.3.14) and leans on Bun's APIs throughout — `Bun.sql`
47
47
  for the database, `Bun.CryptoHasher` for hashing, `Bun.build` for bundling. Because
48
48
  Bun runs and type-strips TypeScript natively, packages ship as **`.ts` source with no
49
49
  compiled `dist/`**. You always read real source and get accurate types; there's
@@ -660,9 +660,9 @@ The [README](../README.md) has a package-by-package table with links.
660
660
  scaffolding, migrations, the dev server, the worker, and tests all run through it.
661
661
  - **No build, ever.** `bun run dev` / `bun test` / `bun run typecheck`. No compile
662
662
  step to remember.
663
- - **Reference apps are the best teacher.** Full working apps under `apps/` exercise the
664
- framework end-to-end a Flow-based finance app and a Flow + Auth + ORM starter
665
- are the most complete real-world examples. Read them alongside the docs.
663
+ - **Starters are the fastest way in.** `bun create zerotal my-app` scaffolds a working
664
+ app from one of six starters `api`, `admin`, `flow`, `react`, `vue`, or `minimal`.
665
+ Read the generated code alongside the docs.
666
666
  - **Conventions are documented, not magic.** When something "just works" (a model you
667
667
  never registered, a policy suddenly enforced), [Conventions](/docs/conventions)
668
668
  explains exactly what the framework discovered and why.
package/docs/changelog.md CHANGED
@@ -17,12 +17,128 @@ summary across the suite.
17
17
  Each version lists changes under three headings:
18
18
 
19
19
  - **Added** — new features and APIs (safe to adopt incrementally).
20
- - **Changed** — behavior changes; **breaking** ones are called out explicitly and
21
- appear only in major releases.
20
+ - **Changed** — behavior changes; **breaking** ones are called out explicitly, in
21
+ bold, as **BREAKING**.
22
22
  - **Fixed** — bug fixes.
23
23
 
24
- Patch and minor releases are backward compatible. Before taking a **major** release,
25
- read its section here and apply each migration note.
24
+ Breaking changes belong in major releases, and while the 1.x line is young they may
25
+ also land in a minor or a patch — always labelled, always with migration steps. Read
26
+ the section for every version you cross and apply its migration notes, not only the
27
+ majors. [Releases and versioning](/docs/support-policy#releases-and-versioning) explains
28
+ when that carve-out ends.
29
+
30
+ ## 1.7.4 — 2026-08-21
31
+
32
+ A debug panel that was reaching production, a column type MySQL would not index, and
33
+ 2,060 icons.
34
+
35
+ ### Fixed
36
+
37
+ - **DevTools no longer appears on a production page.** The provider is gated on the
38
+ environment, so in production its routes are absent — and the browser client took that as
39
+ permission to start anyway and "connect to nothing". It did not: it mounted the panel first
40
+ and discovered the absence afterwards, so an app calling `DevTools.start()` unconditionally
41
+ served a floating DevTools bar to every visitor, its tabs reading
42
+ `Could not read the map — HTTP 404`.
43
+
44
+ `start()` now probes for the routes and builds nothing unless they answer — no shell, no
45
+ shadow root, no `EventSource`, no listeners. Any failure (404, offline, CSP) is read as
46
+ absent. **If your app calls `DevTools.start()`, take this release.**
47
+
48
+ - **A string column could not carry an index on MySQL.** `table.string()` compiled to `TEXT`
49
+ on every engine and discarded its `length`, and MySQL refuses to key a TEXT column without
50
+ a prefix length — so `table.string("email").unique()` failed at `CREATE TABLE`. MySQL now
51
+ gets `VARCHAR(length)`; SQLite and PostgreSQL keep `TEXT`. `char()` had the same bug and the
52
+ same fix. Found by the new MySQL suite on its first run against a real server.
53
+
54
+ ### Added
55
+
56
+ - **`<Icon name="inbox" />` — 2,060 icons, bundled, typed by name.** The set ships inside
57
+ `@zerotal/flow-ui`, so there is nothing to install and no generator to run: a fresh app gets
58
+ autocomplete over every name and a compile error on a typo. Rendered on the server as inline
59
+ SVG, so there is no icon font, no sprite, no request per glyph, and nothing for a strict CSP
60
+ to block. Four icons are drawn for sign-in flows the set has no name for — `passkey`,
61
+ `two-factor`, `otp`, `magic-link` — and three brand marks ship for the social-login providers
62
+ `@zerotal/auth` supports. See [Icons](/docs/flow/icons).
63
+
64
+ - **The ORM suite runs against MySQL 8 in CI, and the job blocks merges.** The same smoke
65
+ suite that covers PostgreSQL — schema DDL and `ALTER`, identity columns, CRUD, type
66
+ round-trips, unique and NOT NULL enforcement, row locks, transaction rollback. MySQL moves
67
+ from _experimental_ to _supported, hardening_; see the
68
+ [Support Policy](/docs/support-policy).
69
+
70
+ ### Changed
71
+
72
+ - **The starters link by route name.** Every hard-coded `href="/about"` in the React and Vue
73
+ templates now goes through `route()`, and the templates ship the generated route table so a
74
+ freshly scaffolded app type-checks before its first `zt dev`.
75
+
76
+ ### Documented
77
+
78
+ - **The HTTP client guide is one page.** Eight pages became one, written from where the
79
+ package is used — your app calling somebody else's service — with straight URLs instead of
80
+ a route map threaded through every example. See [HTTP Client](/docs/client).
81
+
82
+ - **`route()` in Inertia**, for links and for form submissions, including the one thing Inertia
83
+ adds: a page renders in two processes, so `defineRoutes()` has to run in the SSR entry too.
84
+ See [Building URLs](/docs/inertia/rendering#building-urls-with-route).
85
+
86
+ - **Every package changelog has the release headings it was missing.** `[Unreleased]` had
87
+ accumulated four releases of shipped work — `@zerotal/flow-ui`'s newest heading read
88
+ `[1.5.0]` while 1.7.3 was on npm. Cutting a release now moves them.
89
+
90
+ ## 1.7.3 — 2026-08-20
91
+
92
+ Two fields that accepted input and threw it away, and a CI job that was testing nothing.
93
+
94
+ ### Fixed
95
+
96
+ - **A boolean column could not hold a boolean on PostgreSQL.** `table.boolean()` compiled to
97
+ `INTEGER` on every engine — right for SQLite, which has no boolean type, and rejected by
98
+ PostgreSQL, which has a real one:
99
+
100
+ ```text
101
+ column "active" is of type integer but expression is of type boolean (42804)
102
+ ```
103
+
104
+ Every insert of `true` failed, and so did every `where("active", true)`. The storage type
105
+ now comes from the dialect, as the auto-increment column already did. SQLite and MySQL are
106
+ unchanged — MySQL's `BOOLEAN` is a synonym for `TINYINT(1)` and `INTEGER` takes 0/1 either
107
+ way, so there was nothing broken there to fix.
108
+
109
+ **Existing PostgreSQL tables keep their integer columns.** New tables get `BOOLEAN`; a table
110
+ already created needs an `ALTER` if you want the column converted:
111
+
112
+ ```sql
113
+ ALTER TABLE posts ALTER COLUMN active TYPE boolean USING active <> 0;
114
+ ```
115
+
116
+ - **A bound password field discarded every keystroke.** Flow's client-writable set was
117
+ `fillable` minus `hidden`, which conflates two allow-lists answering different questions:
118
+ `fillable` governs what may be _written_, `hidden` governs what may be _shown_. A password
119
+ is in both, so subtracting made it unwritable — `<input type="password"
120
+ value={this.user.password} blur />` accepted typing and dropped it on arrival.
121
+
122
+ `hidden` is no longer subtracted. It is still never sent: the stored hash does not leave the
123
+ server and the field arrives empty. A hidden value **the client supplied** survives until
124
+ save; one **the server produced** is never echoed back, and a half-typed one is stripped
125
+ from the durable snapshot before it is persisted.
126
+
127
+ ### Changed
128
+
129
+ - **The PostgreSQL CI job blocks merges.** It had been running the ORM suite beside a Postgres
130
+ container without connecting to it, so it reported success without testing anything. A smoke
131
+ suite now exercises schema DDL, identity columns, CRUD, type round-trips, row locks and
132
+ transaction rollback against a real PostgreSQL 16, and a failure fails the build. The
133
+ boolean defect above is what it found on its first real run.
134
+
135
+ ### Documented
136
+
137
+ - **Flow pages take their model from the route, not from a query.** The docs opened every
138
+ model example by fetching the record in `onMount()`, which predates a route being able to
139
+ hand a component the record. `models.md` leads with the bound form; `lifecycle.md` no longer
140
+ presents the old id-plus-`onHydrate`-re-query as the correct pattern. The old shape still
141
+ works — it is simply two fields and a query doing what one field now does.
26
142
 
27
143
  ## 1.7.2 — 2026-08-18
28
144
 
@@ -50,7 +166,7 @@ saying so.
50
166
  - **Flow bundles the socket client into its runtime.** A page that declares a `socket:`
51
167
  listener is live with no script of your own. Flow apps own no bundle entry, so the contract
52
168
  used to be "publish `window.Socket` yourself" — and when you didn't, the listeners were
53
- *silently inert*: no error, no warning, no subscription, so a live feature with no script
169
+ _silently inert_: no error, no warning, no subscription, so a live feature with no script
54
170
  looked exactly like a live feature that was never written. An app that needs a configured
55
171
  client still assigns `window.Socket` before the runtime loads and that one is used as-is; a
56
172
  page with no listeners opens no connection at all.
@@ -60,7 +176,7 @@ saying so.
60
176
  - **A patch no longer writes back into a file input.** A file input's `value` belongs to the
61
177
  user agent, and assigning anything but `""` throws `InvalidStateError`. The write was legal
62
178
  while the bound property was empty and threw on the very patch carrying an upload's result
63
- — and the throw escaped the frame handler, so the DOM never updated *and* the action's ack
179
+ — and the throw escaped the frame handler, so the DOM never updated _and_ the action's ack
64
180
  never resolved. Since frames are chained per component, every later action queued behind a
65
181
  promise that would never settle: the page rendered correctly and ignored every click for
66
182
  the rest of its life.
@@ -1,35 +1,39 @@
1
1
  ---
2
- title: API Client
3
- description: Call your HTTP API with full TypeScript inference of params, body, query, and response shapes.
2
+ title: HTTP Client
3
+ description: Call another service from your app requests, auth, timeouts, retries, errors, and a circuit breaker.
4
4
  ---
5
5
 
6
- # API Client
6
+ # HTTP Client
7
7
 
8
- A type-safe HTTP client bound to a route map. The TypeScript compiler infers
9
- request body types, path parameters, query parameters, and response shapes directly
10
- from the map no casting required.
8
+ Your application calling somebody else's: charging a card, sending a message,
9
+ looking up an address. `@zerotal/client` is the outbound HTTP client — a thin,
10
+ expressive wrapper over `fetch` that adds what every real integration ends up
11
+ needing anyway and that is tedious to get right by hand.
11
12
 
12
- This is the **frontend/SPA** client for calling _your own_ typed API. For
13
- **server-to-server** outgoing HTTP from inside your app, reach for core's `Http`
14
- facade ([HTTP Client helpers](/docs/context)) instead — it's the fluent,
15
- fakeable, server-side HTTP client. Same protocol, different jobs.
13
+ ```ts
14
+ import { Client } from "@zerotal/client";
15
+
16
+ const charge = await Client.post("https://api.stripe.com/v1/charges", {
17
+ amount: 2000,
18
+ currency: "usd",
19
+ source: token,
20
+ });
21
+ ```
22
+
23
+ Timeouts, retries with backoff, bearer tokens, a circuit breaker for the upstream
24
+ that has started failing, and errors you can branch on.
25
+
26
+ > **For requests coming _into_ your app**, see [Routing](/docs/routing). This page is
27
+ > only about requests going out.
16
28
 
17
- ## Getting Started
29
+ ## Install and register
18
30
 
19
31
  ```bash
20
- # in your project root
21
32
  bun add @zerotal/client
22
33
  ```
23
34
 
24
- You can use the client two ways: create instances yourself with `createApiClient()`
25
- (typical for the browser/frontend), or register `ClientProvider` to resolve a shared,
26
- config-driven client from the container (typical for server-side and console code).
27
-
28
- ## Register the provider
29
-
30
- Registering the provider is optional — it gives you one container-bound `client`
31
- built from `config/client.ts`. Add `ClientProvider` to the providers array in
32
- `bootstrap/providers.ts`:
35
+ Add `ClientProvider` to `bootstrap/providers.ts` to get one shared, configured
36
+ client from the container:
33
37
 
34
38
  ```ts
35
39
  // bootstrap/providers.ts
@@ -43,26 +47,68 @@ const providers = [
43
47
  export default providers;
44
48
  ```
45
49
 
46
- Registering the provider switches on the following:
50
+ It binds a client under the `client` key in the `web`, `console`, `worker`, `test`
51
+ and `repl` environments — so a queued job calling a gateway uses the same client a
52
+ controller does — and exposes it as the `Client` facade.
47
53
 
48
- - `onRegister` — binds an `ApiClient` as a lazy singleton under the `client` key, built from the `client` config namespace.
49
- - `onBooted` — pre-resolves that async singleton so the `Client` facade can be accessed synchronously after boot.
54
+ ## Making requests
50
55
 
51
- The provider is active in the `web`, `console`, `worker`, `test`, and `repl`
52
- environments. Resolve the shared client via the container or the `Client` facade:
56
+ Five methods, each taking a URL. Pass an absolute URL, or set a `baseUrl` in config
57
+ and pass a path:
53
58
 
54
59
  ```ts
55
- // in a controller or service
56
60
  import { Client } from "@zerotal/client";
57
61
 
58
- const users = await Client.get("/api/users");
62
+ await Client.get("https://api.example.com/v1/invoices");
63
+ await Client.post("https://api.example.com/v1/invoices", { customer: "cus_123" });
64
+ await Client.put("https://api.example.com/v1/invoices/in_9", { memo: "Paid" });
65
+ await Client.patch("https://api.example.com/v1/invoices/in_9", { memo: "Paid" });
66
+ await Client.delete("https://api.example.com/v1/invoices/in_9");
67
+ ```
68
+
69
+ The response body is decoded for you — JSON when the response is JSON, text
70
+ otherwise. A 2xx returns; anything else throws, which [Errors](#errors) covers.
71
+
72
+ ### Query parameters
73
+
74
+ ```ts
75
+ await Client.get("https://api.example.com/v1/charges", undefined, {
76
+ query: { limit: 25, status: "succeeded", created: { gte: 1_700_000_000 } },
77
+ });
59
78
  ```
60
79
 
61
- ## Configuration
80
+ Nested objects and arrays are serialized with bracket notation, so
81
+ `{ created: { gte: 1 } }` becomes `created[gte]=1`.
62
82
 
63
- Create `config/client.ts` with the `ClientConfig()` helper (or `satisfies
64
- ClientConfigShape`) so every field stays type-checked. The shape extends
65
- [`ApiClientConfig`](/docs/client/references) every client option below is valid here:
83
+ ### Request bodies
84
+
85
+ A plain object or array is JSON-encoded with the matching `Content-Type`. Anything
86
+ `fetch` understands natively — `FormData`, `URLSearchParams`, `Blob`, `ArrayBuffer`,
87
+ a raw string — passes straight through, so the runtime sets the header itself
88
+ (including multipart boundaries):
89
+
90
+ ```ts
91
+ // Form-encoded, which several gateways still require
92
+ await Client.post(
93
+ "https://api.example.com/v1/charges",
94
+ new URLSearchParams({ amount: "2000", currency: "usd" }),
95
+ );
96
+ ```
97
+
98
+ ### Headers
99
+
100
+ Per request, merged over the client's defaults:
101
+
102
+ ```ts
103
+ await Client.post("https://api.example.com/v1/messages", payload, {
104
+ headers: { "Idempotency-Key": crypto.randomUUID() },
105
+ });
106
+ ```
107
+
108
+ ## Authentication
109
+
110
+ Most APIs want a bearer token. Set it once and every request carries
111
+ `Authorization: Bearer …`:
66
112
 
67
113
  ```ts
68
114
  // config/client.ts
@@ -70,59 +116,220 @@ import { ClientConfig } from "@zerotal/client";
70
116
  import { env } from "zerotal";
71
117
 
72
118
  export default ClientConfig({
73
- baseUrl: env("API_BASE_URL", "https://api.example.com"),
119
+ baseUrl: "https://api.example.com",
120
+ token: env("PAYMENTS_API_KEY", ""),
74
121
  headers: { Accept: "application/json" },
75
122
  });
76
123
  ```
77
124
 
78
- | Field | Required | Default | Description |
79
- | ----------------- | -------- | ------- | -------------------------------------------------------------- |
80
- | `baseUrl` | no | `""` | Base URL prepended to every request path. |
81
- | `headers` | no | `{}` | Default headers sent with every request. |
82
- | `token` | no | — | Bearer token (string or resolver) attached as `Authorization`. |
83
- | `withCredentials` | no | `false` | Send cookies (`credentials: 'include'`); also turns CSRF on. |
84
- | `csrf` | no | — | CSRF cookie/header names, or a boolean to force on/off. |
85
- | `timeout` | no | — | Default per-request timeout in ms (`0`/omitted = none). |
86
- | `retry` | no | — | Default retry policy (number of attempts or `RetryOptions`). |
125
+ A `token` may also be a function, including an async one, which is how credentials
126
+ that expire are handled it is resolved per request:
87
127
 
88
- > **Note** — The provider carries no framework-level defaults of its own; `ApiClient`
89
- > applies its own internal defaults. The config file is just typed input.
128
+ ```ts
129
+ export default ClientConfig({
130
+ token: async () => await currentAccessToken(),
131
+ });
132
+ ```
90
133
 
91
- ## Create a client directly
134
+ Change it at runtime with `setToken(token)`, or pass `null` to clear it. Its type is
135
+ `TokenSource`.
92
136
 
93
- In the browser (or anywhere you want a standalone instance), build a client with
94
- `createApiClient<Routes>()`:
137
+ For an API that wants something other than a bearer token, set the header directly:
95
138
 
96
139
  ```ts
97
- // app/api/client.ts
98
- import { createApiClient } from "@zerotal/client";
99
- import { env } from "zerotal";
100
- import type { Routes } from "./api-types.ts";
140
+ export default ClientConfig({
141
+ headers: { "X-Api-Key": env("PARTNER_API_KEY", "") },
142
+ });
143
+ ```
101
144
 
102
- export const api = createApiClient<Routes>({
103
- baseUrl: env("API_BASE_URL", "https://api.example.com"),
104
- headers: { Accept: "application/json" },
145
+ ### Refreshing on a 401
146
+
147
+ `onUnauthorized` receives the error and a `retry` function, so a token can be
148
+ refreshed and the original request replayed once:
149
+
150
+ ```ts
151
+ export default ClientConfig({
152
+ onUnauthorized: async (error, retry) => retry({ Authorization: `Bearer ${await refresh()}` }),
153
+ });
154
+ ```
155
+
156
+ ## Timeouts
157
+
158
+ There is no timeout by default, because the right one depends on the upstream. Set a
159
+ default and override per request:
160
+
161
+ ```ts
162
+ export default ClientConfig({ timeout: 10_000 });
163
+
164
+ // This one is slow and we accept that
165
+ await Client.post("https://api.example.com/v1/reports", body, { timeout: 60_000 });
166
+ ```
167
+
168
+ `timeout: 0` disables it. Pass your own `signal` to cancel for any other reason; it
169
+ is combined with the timeout rather than replacing it.
170
+
171
+ ## Retries
172
+
173
+ A network blip or a `503` is worth trying again; a `422` never is. `retry` retries
174
+ idempotent requests on network errors, 5xx and 429 with exponential backoff, and
175
+ honours a `Retry-After` header when the server sends one:
176
+
177
+ ```ts
178
+ export default ClientConfig({ retry: 2 });
179
+
180
+ await Client.post("https://api.example.com/v1/charges", body, { retry: false });
181
+ ```
182
+
183
+ Turning retries **off** for a charge is the deliberate part: retrying a payment you
184
+ cannot prove failed is how a customer gets billed twice. Send an idempotency key if
185
+ you want both. Pass `RetryOptions` instead of a number to tune the delays and which
186
+ statuses qualify.
187
+
188
+ ## Errors
189
+
190
+ A non-2xx throws `ApiClientError`, carrying what you need to decide what happened:
191
+
192
+ ```ts
193
+ import { ApiClientError } from "@zerotal/client";
194
+
195
+ try {
196
+ await Client.post("https://api.example.com/v1/charges", body);
197
+ } catch (error) {
198
+ if (error instanceof ApiClientError) {
199
+ error.status; // 402
200
+ error.body; // raw response text
201
+ error.headers; // Headers, when available
202
+ error.retryAfterMs; // parsed Retry-After, or null
203
+ }
204
+ throw error;
205
+ }
206
+ ```
207
+
208
+ A `422` carrying a field-error body throws `ValidationError`, a subclass that reads
209
+ those fields for you — `has(field)`, `first(field)`, `all()`, `fields()`, and
210
+ `validationMessage` for the top-level message.
211
+
212
+ `onError` fires for every non-2xx before the throw and `onForbidden` for a 403 —
213
+ both good places to log or alert without wrapping each call site.
214
+
215
+ ## Circuit breaker
216
+
217
+ When an upstream is down, continuing to call it wastes your own capacity and slows
218
+ everything queued behind it. A `CircuitBreaker` stops after a threshold of
219
+ consecutive failures, fails fast for a cooldown, then lets a single request through
220
+ to test the water:
221
+
222
+ ```ts
223
+ export default ClientConfig({
224
+ circuitBreaker: { threshold: 5, cooldownMs: 30_000 },
105
225
  });
106
226
  ```
107
227
 
108
- > **Tip** Don't instantiate `ApiClient` directly; `createApiClient<Routes>(config)`
109
- > binds the route map type for you.
228
+ While it is open, calls throw `CircuitBreakerOpenError` **without** a request being
229
+ made catch that specifically to fall back to a cached answer or a queued retry.
230
+ `state` reports the `CircuitState` (`closed`, `open`, `half-open`), `failures` the
231
+ current count, and `reset()` closes it by hand.
232
+
233
+ Pass a shared `CircuitBreaker` instance instead of options when several clients talk
234
+ to the same upstream and should trip together.
235
+
236
+ ## Files
237
+
238
+ Upload with `FormData`; download by asking for the body you want:
239
+
240
+ ```ts
241
+ const form = new FormData();
242
+ form.append("file", Bun.file("./invoice.pdf"));
243
+ await Client.post("https://api.example.com/v1/documents", form);
244
+
245
+ const pdf = await Client.get("https://api.example.com/v1/documents/doc_1", undefined, {
246
+ responseType: "blob",
247
+ });
248
+ ```
249
+
250
+ `responseType` takes `auto` (the default), `json`, `text`, `blob` or `arrayBuffer`.
251
+
252
+ ## Interceptors
253
+
254
+ `onRequest` runs before every request and can rewrite the outgoing `RequestConfig`;
255
+ `onResponse` runs after every 2xx with the `ResponseContext`. Both take one function
256
+ or an array:
257
+
258
+ ```ts
259
+ export default ClientConfig({
260
+ onRequest: (config) => {
261
+ config.headers["X-Request-Id"] = crypto.randomUUID();
262
+ return config;
263
+ },
264
+ });
265
+ ```
266
+
267
+ To read the status or headers of a single response without a global interceptor,
268
+ pass `meta` in the request options.
269
+
270
+ ## Testing
271
+
272
+ The client calls `fetch`, so a test replaces `fetch`:
273
+
274
+ ```ts
275
+ import { test, expect } from "bun:test";
276
+
277
+ test("charges the card", async () => {
278
+ globalThis.fetch = async () => Response.json({ id: "ch_1", paid: true });
279
+
280
+ const charge = await Client.post("https://api.example.com/v1/charges", { amount: 2000 });
281
+ expect(charge.paid).toBe(true);
282
+ });
283
+ ```
284
+
285
+ Return a non-2xx `Response` to cover the failure paths, and throw from the stub to
286
+ exercise retries and the breaker. For requests your app _receives_ in tests, see
287
+ [Testing](/docs/testing).
288
+
289
+ ## Configuration reference
290
+
291
+ Every field of `ClientConfigShape` (which extends `ApiClientConfig`) is optional:
292
+
293
+ | Field | Description |
294
+ | ----------------- | --------------------------------------------------------------------------- |
295
+ | `baseUrl` | Prepended to every request path. Omit it and pass absolute URLs. |
296
+ | `headers` | Default headers for every request. |
297
+ | `token` | Bearer token — a string, or a (possibly async) resolver called per request. |
298
+ | `timeout` | Default per-request timeout in ms. `0` disables. |
299
+ | `retry` | Default retry policy — a count, or `RetryOptions`. |
300
+ | `circuitBreaker` | A `CircuitBreaker` instance to share, or options for a dedicated one. |
301
+ | `withCredentials` | Send cookies (`credentials: 'include'`), and turn CSRF on. |
302
+ | `csrf` | CSRF cookie/header names, or a boolean. |
303
+ | `onRequest` | Interceptor(s) run before every request. |
304
+ | `onResponse` | Interceptor(s) run after every 2xx. |
305
+ | `onError` | Called for every non-2xx before the error is thrown. |
306
+ | `onUnauthorized` | Called on 401, with a `retry` function. |
307
+ | `onForbidden` | Called on 403. |
308
+
309
+ Per-request options — `RequestOptions`, plus `query` on `GetOptions` and
310
+ `params`/`query` on `MutationOptions` — override the client's defaults: `headers`,
311
+ `timeout`, `retry`, `responseType`, `signal`, `init` and `meta`.
312
+
313
+ ## Calling your own API
314
+
315
+ Everything above treats a URL as a string, which is the right trade for a service
316
+ you hit a handful of endpoints on. Nobody should describe someone else's API in
317
+ types to send three requests to it.
318
+
319
+ If you are calling **your own** API repeatedly, `createApiClient<Routes>(config)`
320
+ binds a route map, so paths, params, query and response types are inferred and a
321
+ renamed endpoint becomes a build error. It returns the same `ApiClient` with a
322
+ narrower type; the `Client` facade above is bound to the base map and answers
323
+ `unknown`. The route-map types are listed in the [API reference](/docs/api).
110
324
 
111
- ## The rest of the guide
325
+ ## Realtime
112
326
 
113
- | Page | What it covers |
114
- | ------------------------------------------ | ------------------------------------------------------------------------------ |
115
- | [Requests](/docs/client/requests) | The typed route map, making requests, and shaping them with interceptors. |
116
- | [Authentication](/docs/client/auth) | Bearer tokens, CSRF, and refreshing credentials on a 401. |
117
- | [Error Handling](/docs/client/errors) | What a failed request throws, and how to tell the failure modes apart. |
118
- | [Resilience](/docs/client/resilience) | Timeouts, retries, and the circuit breaker that spares a failing upstream. |
119
- | [File Transfers](/docs/client/files) | Uploading and downloading binary payloads. |
120
- | [Testing the Client](/docs/client/testing) | Stub the global fetch, cover the failure paths, and drive the circuit breaker. |
121
- | [References](/docs/client/references) | Every ApiClient method, config key, and error type in one table. |
327
+ This package also ships `Socket`, `Channel` and `PresenceChannel` — a WebSocket
328
+ client speaking Zerotal's broadcast protocol. Different job, documented where it is
329
+ used: [Broadcasting → Client](/docs/broadcasting/client).
122
330
 
123
331
  ## Next steps
124
332
 
125
- - [Rate Limiting](/docs/rate-limiting) — throttle outbound and inbound traffic.
126
- - [Authentication](/docs/authentication) — issue the tokens your interceptors attach.
127
- - [Validator](/docs/validator) — the server side that produces the `ValidationError` body.
128
- - [Telemetry](/docs/telemetry) — observe request failures and circuit state.
333
+ - [Rate Limiting](/docs/rate-limiting) — throttle what you send and what you accept.
334
+ - [Telemetry](/docs/telemetry) — trace outbound calls and watch circuit state.
335
+ - [Queues](/docs/queue) — where a slow or flaky integration usually belongs.
@@ -8,7 +8,7 @@ description: Get the Zerotal monorepo running locally and pass the checks your c
8
8
  Zerotal is a Bun-native monorepo of composable packages. This guide covers getting the
9
9
  repo running locally, the project layout, and the checks your change needs to pass.
10
10
 
11
- > **Warning** — Bun ≥ 1.1 is required. Node.js is not supported; Zerotal uses `Bun.sql`, `Bun.CryptoHasher`, `Bun.build`, and other Bun-native APIs throughout.
11
+ > **Warning** — Bun ≥ 1.3.14 is required. Node.js is not supported; Zerotal uses `Bun.sql`, `Bun.CryptoHasher`, `Bun.build`, and other Bun-native APIs throughout.
12
12
 
13
13
  ## Getting set up
14
14
 
@@ -32,7 +32,7 @@ packages/ # the framework — one directory per @zerotal/* package
32
32
  auth/ cache/ queue/ … # feature packages
33
33
  testing/ # factories, fakes, test app harness
34
34
  create-zerotal/ # the `bun create zerotal` scaffolder
35
- apps/ # example apps used for end-to-end testing and the docs site
35
+ apps/ # applications in this workspace
36
36
  docs/ # this documentation site
37
37
  docs/ # the markdown documentation (what you're reading)
38
38
  ```
@@ -149,6 +149,24 @@ that keeps it complete.
149
149
  The same rule governs a page's own length: a section that has grown into a second
150
150
  subject is a sign it wants to be its own page, or to move to the one that owns it.
151
151
 
152
+ ## Changelogs
153
+
154
+ Each package keeps its own `CHANGELOG.md`. Add what you changed under
155
+ `## [Unreleased]` in the package you changed — that is where in-flight work
156
+ belongs, and the entry travels with the code rather than being reconstructed later.
157
+
158
+ Cutting a release moves those entries under the version, which is a step and not an
159
+ automatic consequence:
160
+
161
+ ```bash
162
+ bun run changelog:release 1.7.4
163
+ ```
164
+
165
+ Skipping it is how `[Unreleased]` came to hold four releases of shipped work —
166
+ `@zerotal/flow-ui`'s newest heading read `[1.5.0]` while 1.7.3 was on npm. Someone
167
+ upgrading reads "Unreleased" and concludes none of it is in the version they are
168
+ taking, when all of it is.
169
+
152
170
  ## Pull requests
153
171
 
154
172
  - Keep PRs focused — one logical change per PR.