@vielzeug/codex 26.10.7 → 26.10.10

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.
@@ -1,6 +1,6 @@
1
1
  # Vielzeug: Full Documentation
2
2
 
3
- > Complete documentation for 42 packages. Version: 26.10.6
3
+ > Complete documentation for 42 packages. Version: 26.10.9
4
4
 
5
5
  ---
6
6
 
@@ -105,7 +105,7 @@ console.log(parsed.entries);
105
105
  - `archiveAuthHealthSchema` + `AUTH_SCHEMA_VERSION` — typed issuer readiness checked against the issuer DDL stamp
106
106
  - `archiveErrorResponseSchema` — the one error envelope, code-typed across the wire
107
107
  - `createAuthWorker` — scanner-safe confirmation, live session-version enforcement, durable budgets, metadata export and coordinated erasure
108
- - `AUTH_ISSUER_SERVICE` — required uncached issuer check after local catalog JWT verification
108
+ - `AUTH_ISSUER_SERVICE` — signing-key retrieval and an uncached issuer check after local catalog JWT verification
109
109
  - `ArchiveDerivationError` — distinguishes faulty collection code from invalid submitted content
110
110
  - Moderation — reader reports, the operator soft-hide and the report queue, keyed on a verified `roles` claim
111
111
  - `worker.tap()` / `issuer.tap()` — typed sanitized runtime events; neither core logs by default
@@ -129,26 +129,26 @@ console.log(parsed.entries);
129
129
 
130
130
  ## API Overview
131
131
 
132
- | Symbol | Purpose | Execution mode | Common gotcha |
133
- | --- | --- | --- | --- |
134
- | `defineArchiveCollection` | Compose validation, indexing, identity and wire schemas | Sync | Invalid content derives to `null`; callback faults throw |
135
- | `createArchiveWorker` | Build the catalog Worker | Sync | Collection names must be unique |
136
- | `createAuthWorker` | Build the passwordless issuer | Sync | `AUTH_ARCHIVE` is required |
137
- | `worker.tap`, `issuer.tap` | Observe sanitized runtime outcomes | Sync | Neither core logs by default; observer exceptions are swallowed |
138
- | `ArchiveDerivationError` | Identify a collection implementation/runtime fault | Sync | Faults become sanitized 500s, not content-validation 422s |
139
- | `ARCHIVE_LIMITS` | Shared application bounds | Sync | Page bounds do not cap total exports |
140
- | `archiveErrorResponseSchema`, `archiveAuthHealthSchema` | Validate error and issuer-health responses | Sync | Unknown error codes and health states reject |
141
- | `ArchiveError`, `ArchiveDefinitionError` | Public base and invalid-definition errors | Sync | Thrown locally, not transmitted as error objects |
142
- | `sessionCookieValue` | Compose shared session-cookie attributes | Sync | Deployments must choose `secure` from their environment |
143
- | `isWellFormedText` | Check control characters and lone surrogates | Sync | Does not trim or impose a length bound |
132
+ | Symbol | Purpose | Execution mode | Common gotcha |
133
+ | ------------------------------------------------------- | ------------------------------------------------------- | -------------- | --------------------------------------------------------------- |
134
+ | `defineArchiveCollection` | Compose validation, indexing, identity and wire schemas | Sync | Invalid content derives to `null`; callback faults throw |
135
+ | `createArchiveWorker` | Build the catalog Worker | Sync | Collection names must be unique |
136
+ | `createAuthWorker` | Build the passwordless issuer | Sync | `AUTH_ARCHIVE` is required |
137
+ | `worker.tap`, `issuer.tap` | Observe sanitized runtime outcomes | Sync | Neither core logs by default; observer exceptions are swallowed |
138
+ | `ArchiveDerivationError` | Identify a collection implementation/runtime fault | Sync | Faults become sanitized 500s, not content-validation 422s |
139
+ | `ARCHIVE_LIMITS` | Shared application bounds | Sync | Page bounds do not cap total exports |
140
+ | `archiveErrorResponseSchema`, `archiveAuthHealthSchema` | Validate error and issuer-health responses | Sync | Unknown error codes and health states reject |
141
+ | `ArchiveError`, `ArchiveDefinitionError` | Public base and invalid-definition errors | Sync | Thrown locally, not transmitted as error objects |
142
+ | `sessionCookieValue` | Compose shared session-cookie attributes | Sync | Deployments must choose `secure` from their environment |
143
+ | `isWellFormedText` | Check control characters and lone surrogates | Sync | Does not trim or impose a length bound |
144
144
 
145
145
  ## Package Entry Point
146
146
 
147
- | Import | Purpose |
148
- | --- | --- |
149
- | `@vielzeug/archive` | Browser-safe collections, constants, schemas and errors |
150
- | `@vielzeug/archive/worker` | Catalog factory, issuer-session service binding and event types |
151
- | `@vielzeug/archive/auth` | Issuer factory and binding types |
147
+ | Import | Purpose |
148
+ | -------------------------- | --------------------------------------------------------------------------- |
149
+ | `@vielzeug/archive` | Browser-safe collections, constants, schemas and errors |
150
+ | `@vielzeug/archive/worker` | Catalog factory, issuer signing-key/session service binding and event types |
151
+ | `@vielzeug/archive/auth` | Issuer factory and binding types |
152
152
 
153
153
  ## Collections
154
154
 
@@ -160,14 +160,14 @@ Creates a frozen collection and its contract; malformed names, versions or vocab
160
160
  function defineArchiveCollection(definition: ArchiveCollectionDefinition): ArchiveCollection;
161
161
  ```
162
162
 
163
- | Parameter | Contract |
164
- | --- | --- |
165
- | `name` | Lowercase `[a-z0-9-]{1,32}` |
166
- | `contentVersion` | Positive safe integer |
167
- | `payloadSchema` | Spell schema whose output is a stable JSON object |
168
- | `filters`, `tags` | Optional readonly arrays; omitted means empty vocabulary |
169
- | `canonicalize` | Optional pure, idempotent, schema-valid normalization |
170
- | `index` | Derive `filter`, `tags` and searchable `title` without mutating the payload |
163
+ | Parameter | Contract |
164
+ | ----------------- | --------------------------------------------------------------------------- |
165
+ | `name` | Lowercase `[a-z0-9-]{1,32}` |
166
+ | `contentVersion` | Positive safe integer |
167
+ | `payloadSchema` | Spell schema whose output is a stable JSON object |
168
+ | `filters`, `tags` | Optional readonly arrays; omitted means empty vocabulary |
169
+ | `canonicalize` | Optional pure, idempotent, schema-valid normalization |
170
+ | `index` | Derive `filter`, `tags` and searchable `title` without mutating the payload |
171
171
 
172
172
  **Returns:** `ArchiveCollection`.
173
173
 
@@ -188,14 +188,14 @@ if (derived === null) throw new Error('Invalid recipe');
188
188
  console.log(derived.contentHash);
189
189
  ```
190
190
 
191
- | Method / property | Result |
192
- | --- | --- |
193
- | `derive(raw)` | `Promise` |
194
- | `contract.entry` | Public entry schema |
195
- | `contract.ownedEntry`, `ownedEntries` | Owner view with `clientKey` and `hidden` |
196
- | `contract.page` | Entries and the forward cursor |
191
+ | Method / property | Result |
192
+ | -------------------------------------- | --------------------------------------------------- |
193
+ | `derive(raw)` | `Promise` |
194
+ | `contract.entry` | Public entry schema |
195
+ | `contract.ownedEntry`, `ownedEntries` | Owner view with `clientKey` and `hidden` |
196
+ | `contract.page` | Entries and the forward cursor |
197
197
  | `contract.publishInput`, `updateInput` | Publish and revision-guarded update request schemas |
198
- | `filters`, `tags` | Frozen vocabulary snapshots |
198
+ | `filters`, `tags` | Frozen vocabulary snapshots |
199
199
 
200
200
  `derive` returns `null` for invalid JSON objects, schema-invalid input, invalid titles, too many tags and oversized canonical payloads. Unexpected schema, callback or hashing failures throw `ArchiveDerivationError`. Non-idempotent or schema-invalid canonicalization, unstable schema transformations, index mutation and out-of-vocabulary projections are also derivation faults. Routes map `null` to 422 `invalid-payload` and faults to a sanitized 500 `internal`.
201
201
  Schema output that is lossy or no longer JSON is a derivation fault, not invalid
@@ -229,22 +229,28 @@ import { createArchiveWorker, type ArchiveWorkerEnv } from '@vielzeug/archive/wo
229
229
  import { s } from '@vielzeug/spell';
230
230
 
231
231
  const notes = defineArchiveCollection({
232
- name: 'notes', contentVersion: 1,
232
+ name: 'notes',
233
+ contentVersion: 1,
233
234
  payloadSchema: s.object({ title: s.string().min(1).max(128) }),
234
235
  index: ({ title }) => ({ title, filter: null, tags: [] }),
235
236
  });
236
237
  export default createArchiveWorker([notes]);
237
238
  ```
238
239
 
239
- | Method | Contract |
240
- | --- | --- |
241
- | `fetch(request, env, ctx)` | Returns `Promise` |
240
+ | Method | Contract |
241
+ | --------------------------------- | -------------------------------------------------------------------------------- |
242
+ | `fetch(request, env, ctx)` | Returns `Promise` |
242
243
  | `scheduled(controller, env, ctx)` | Sweeps expired retry keys and revocation tombstones; emits failures and rethrows |
243
- | `tap(handler, { signal? }?)` | Returns unsubscribe; a pre-aborted signal registers nothing |
244
+ | `tap(handler, { signal? }?)` | Returns unsubscribe; a pre-aborted signal registers nothing |
244
245
 
245
246
  `AUTH_ISSUER_SERVICE` is a required `fetch`-shaped service binding to the issuer.
247
+ The catalog fetches signing keys from the configured `JWT_JWKS_URL` through that
248
+ binding, not through global `fetch`. The URL still selects the issuer's JWKS
249
+ route and must satisfy the environment's transport rules. Key requests use GET
250
+ without a session cookie and do not follow redirects.
246
251
  The catalog verifies the signed JWT locally, then asks the issuer to validate its
247
- current account session version. It does not cache successful issuer verdicts.
252
+ current account session version. JOSE caches signing keys; the catalog does not
253
+ cache successful issuer session verdicts.
248
254
  If the service exchange fails or exceeds its fixed 5000 ms timeout (body decoding
249
255
  included), the catalog returns `503 auth-unavailable`; the timeout does not cancel
250
256
  work already started by the issuer.
@@ -295,11 +301,11 @@ import { createAuthWorker, type AuthWorkerEnv } from '@vielzeug/archive/auth';
295
301
  export default createAuthWorker();
296
302
  ```
297
303
 
298
- | Method | Contract |
299
- | --- | --- |
300
- | `fetch(request, env, ctx?)` | Returns `Promise` |
304
+ | Method | Contract |
305
+ | ---------------------------------- | ------------------------------------------------------------------------------- |
306
+ | `fetch(request, env, ctx?)` | Returns `Promise` |
301
307
  | `scheduled(controller, env, ctx?)` | Removes expired links, closed send windows and unverified unreferenced accounts |
302
- | `tap(handler, { signal? }?)` | Observes typed `AuthEvent`; returns unsubscribe; pre-aborted signals are inert |
308
+ | `tap(handler, { signal? }?)` | Observes typed `AuthEvent`; returns unsubscribe; pre-aborted signals are inert |
303
309
 
304
310
  Magic links expire after 15 minutes and work once. The durable per-email budget allows three sends per hour; consumption, expiration and send failure do not refund it. Ordinary sessions last at most 30 days. Operator sessions use `OPERATOR_SESSION_MAX_AGE_SECONDS` (900 seconds). `OPERATOR_EMAILS` controls newly minted role claims.
305
311
 
@@ -326,12 +332,44 @@ contract; `/auth` exports the factory and issuer binding types.
326
332
  Opening a magic link performs a safe GET that displays a click-confirmation page,
327
333
  not redemption. Only a marked POST from an exact allowed origin or
328
334
  `PUBLIC_ORIGIN` consumes `{ token }` and returns 204 with the session cookie. The
329
- confirmation page navigates to `/signed-in` after success; the POST is not a
330
- redirect. Confirmation responses are `no-store` with no-referrer and restrictive
331
- CSP; the page removes the token from the address bar and loads no external assets.
335
+ confirmation page navigates to the fixed `${PUBLIC_ORIGIN}/signed-in` destination;
336
+ your application must serve that page because the issuer serves only the
337
+ confirmation endpoint. The POST is not a redirect. Confirmation responses are
338
+ `no-store` with no-referrer and a restrictive CSP; inline script and styles
339
+ require a fresh response nonce, with no
340
+ `unsafe-inline`. The page removes the token from the address bar before rendering
341
+ the controls and loads no external assets. `APP_NAME` supplies escaped page and
342
+ email branding; the confirmation has keyboard focus, live status announcements,
343
+ 44-pixel minimum action heights, a warm near-white paper and copper light scheme,
344
+ a deep neutral dark scheme, serif headings, sans-serif body text and reduced-motion
345
+ styles. Without JavaScript, it links to `${PUBLIC_ORIGIN}/account` as “Request a
346
+ new sign-in link” but cannot redeem a link.
332
347
  GET displays confirmation for any syntactically valid token, including a used
333
348
  token; only POST establishes whether the link is still redeemable. POST accepts
334
- exactly `{ token }` and rejects query parameters.
349
+ exactly `{ token }` and rejects query parameters. With valid issuer configuration,
350
+ invalid or missing GET tokens, duplicate token parameters and unsupported query
351
+ parameters return HTTP 400 with branded HTML recovery, not a JSON error. POST
352
+ errors retain the JSON `ArchiveErrorResponse` schema and their HTTP status.
353
+
354
+ The button announces pending sign-in and blocks duplicate submissions. HTTP 410
355
+ replaces it with a new-link action; HTTP 400 also disables resubmission. Every
356
+ failure offers the fixed `${PUBLIC_ORIGIN}/account` recovery link labelled
357
+ “Request a new sign-in link”. Like `/signed-in`, `/account` is a fixed reference
358
+ issuer convention: the application must serve its sign-in entry there, not the issuer.
359
+ Recovery is secondary while retry remains possible, and becomes the primary action
360
+ for invalid or expired links. It replaces the footer exit link to avoid duplicate
361
+ navigation. The initial “Back to app” footer links to `${PUBLIC_ORIGIN}/`.
362
+ HTTP 429 delays manual retry using unsigned integer seconds or a syntactically valid
363
+ HTTP-date `Retry-After`, rounded up
364
+ and bounded to 1-300 seconds (30 seconds for missing or invalid values). HTTP 503
365
+ and network failures offer explicit retry; no failure starts an automatic request.
366
+ All returned response bodies, including discarded stale responses, are cancelled
367
+ without delaying recovery; cancellation failures are ignored. Each attempt aborts
368
+ its controller on completion. Stalled requests abort after 15 seconds with a
369
+ timeout-specific message, distinct from connection failures. Leaving the page aborts its request and
370
+ clears timers; bfcache restoration resumes only the remaining cooldown, not a
371
+ request. Navigation after exact 204 success is fixed at
372
+ `${PUBLIC_ORIGIN}/signed-in`; query input never controls either destination.
335
373
 
336
374
  The issuer never logs directly. `tap` emits sanitized request and scheduled
337
375
  outcomes; observer failures are swallowed, abort detaches the observer and failed
@@ -341,39 +379,39 @@ scheduled work emits then rethrows. `AuthDiagnostic` reports only a category.
341
379
 
342
380
  Every route below is mounted under `/api/v1`. Mutation guards require an exact allowed `Origin` and `x-archive-request: 1`. Registered publishers pin `x-archive-publisher` to the identity captured when preparing private requests. The header is a freshness precondition, not authority.
343
381
 
344
- | Route | Contract |
345
- | --- | --- |
346
- | `GET /health` | Public catalog readiness; 200 `ok` or 503 `degraded` |
347
- | `POST /sign-out` | Catalog-local sign-out; `{ ok: true }` and expired session cookie, without issuer access |
348
- | `GET /collections/:collection` | Collection metadata |
349
- | `GET /collections/:collection/entries` | `author`, `filter`, `tag`, `search`, `sort`, `cursor`, `limit`; forward entry page |
350
- | `POST /collections/:collection/entries` | `{ clientKey, payload }`; 201 create or 200 replay |
351
- | `GET /collections/:collection/entries/:id` | Public entry; always `no-store` |
352
- | `PUT /collections/:collection/entries/:id` | Owner-only `{ expectedRevision, payload }` |
353
- | `DELETE /collections/:collection/entries/:id` | Owner or operator removal; 204 |
354
- | `PUT` / `DELETE /collections/:collection/entries/:id/like` | Like / unlike visible entries only; guarded write and response; like-state response |
355
- | `POST /collections/:collection/entries/:id/report` | `{ reason }`; idempotent per entry/reporter; 204 |
356
- | `PUT` / `DELETE /collections/:collection/entries/:id/hidden` | Operator hide / unhide; 204 |
357
- | `GET /collections/:collection/reports` | Operator page; `status=unresolved` (default) or `resolved`, `cursor`, `limit` |
358
- | `PUT /collections/:collection/reports/:id/resolved` | Operator resolves report by its unique report ID; idempotent 204 |
359
- | `DELETE /publishers/:id` | Operator erases Archive profile; not issuer account |
360
- | `GET /moderation-log` | Operator forward page; `cursor`, `limit` |
361
- | `GET` / `POST` / `PUT /me` | Identity, register with optional `{ name }`, rename with required `{ name }` |
362
- | `DELETE /me` | Profile-only cascade; 204; does not clear cookie |
363
- | `DELETE /me/account` | Revoke issuer/subject and atomically erase profile; 204; coordinated issuer-erasure seam |
364
- | `GET /me/publications?collection=…` | Owner entries including hidden state and retry keys |
365
- | `GET /me/likes` | Forward page `{ ids, cursor }`; `cursor`, `limit` |
366
- | `GET /me/likes?ids=…` | Subset lookup `{ ids }`; at most `likeStateIds` input IDs |
367
- | `GET /me/reports` | Own forward report page across collections; `cursor`, `limit` |
368
- | `GET /auth/health` | Public issuer readiness; typed `AuthHealth`, 200 `ok` or 503 `degraded`; no email or peer probe |
369
- | `GET /auth/session` | Internal issuer-session check for the catalog service binding; exact `{ ok: true }`, `no-store`, no PII; 401 for an invalid, stale or absent-account session |
370
- | `POST /auth/sign-out-all` | Requires marker, exact origin, session and captured `ACCOUNT_HEADER`; atomically increments `session_version`, deletes outstanding links, preserves account and catalog data; 200 `{ ok: true }` with an expired cookie only on durable success |
371
- | `GET /auth/jwks.json` | Active and configured retired public verification keys |
372
- | `POST /auth/sign-in` | `{ email }` (trimmed and lowercased); 202 `{ ok: true }` |
373
- | `GET /auth/verify?token=…` | Safe no-store click-confirmation page; no token consumption or session change |
374
- | `POST /auth/verify` | JSON `{ token }`, marker and exact allowed origin or `PUBLIC_ORIGIN`; consume once; 204 with session cookie |
375
- | `GET /auth/account` | `{ id, email, createdAt, lastSignInAt }`; no tokens or budget data |
376
- | `DELETE /auth/account` | Requires `ACCOUNT_HEADER` with the captured issuer account UUID; catalog `/me/account` via `AUTH_ARCHIVE`, require exact 204, then issuer user and cookie clear; 204 |
382
+ | Route | Contract |
383
+ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
384
+ | `GET /health` | Public catalog readiness; 200 `ok` or 503 `degraded` |
385
+ | `POST /sign-out` | Catalog-local sign-out; `{ ok: true }` and expired session cookie, without issuer access |
386
+ | `GET /collections/:collection` | Collection metadata |
387
+ | `GET /collections/:collection/entries` | `author`, `filter`, `tag`, `search`, `sort`, `cursor`, `limit`; forward entry page |
388
+ | `POST /collections/:collection/entries` | `{ clientKey, payload }`; 201 create or 200 replay |
389
+ | `GET /collections/:collection/entries/:id` | Public entry; always `no-store` |
390
+ | `PUT /collections/:collection/entries/:id` | Owner-only `{ expectedRevision, payload }` |
391
+ | `DELETE /collections/:collection/entries/:id` | Owner or operator removal; 204 |
392
+ | `PUT` / `DELETE /collections/:collection/entries/:id/like` | Like / unlike visible entries only; guarded write and response; like-state response |
393
+ | `POST /collections/:collection/entries/:id/report` | `{ reason }`; idempotent per entry/reporter; 204 |
394
+ | `PUT` / `DELETE /collections/:collection/entries/:id/hidden` | Operator hide / unhide; 204 |
395
+ | `GET /collections/:collection/reports` | Operator page; `status=unresolved` (default) or `resolved`, `cursor`, `limit` |
396
+ | `PUT /collections/:collection/reports/:id/resolved` | Operator resolves report by its unique report ID; idempotent 204 |
397
+ | `DELETE /publishers/:id` | Operator erases Archive profile; not issuer account |
398
+ | `GET /moderation-log` | Operator forward page; `cursor`, `limit` |
399
+ | `GET` / `POST` / `PUT /me` | Identity, register with optional `{ name }`, rename with required `{ name }` |
400
+ | `DELETE /me` | Profile-only cascade; 204; does not clear cookie |
401
+ | `DELETE /me/account` | Revoke issuer/subject and atomically erase profile; 204; coordinated issuer-erasure seam |
402
+ | `GET /me/publications?collection=…` | Owner entries including hidden state and retry keys |
403
+ | `GET /me/likes` | Forward page `{ ids, cursor }`; `cursor`, `limit` |
404
+ | `GET /me/likes?ids=…` | Subset lookup `{ ids }`; at most `likeStateIds` input IDs |
405
+ | `GET /me/reports` | Own forward report page across collections; `cursor`, `limit` |
406
+ | `GET /auth/health` | Public issuer readiness; typed `AuthHealth`, 200 `ok` or 503 `degraded`; no email or peer probe |
407
+ | `GET /auth/session` | Internal issuer-session check for the catalog service binding; exact `{ ok: true }`, `no-store`, no PII; 401 for an invalid, stale or absent-account session |
408
+ | `POST /auth/sign-out-all` | Requires marker, exact origin, session and captured `ACCOUNT_HEADER`; atomically increments `session_version`, deletes outstanding links, preserves account and catalog data; 200 `{ ok: true }` with an expired cookie only on durable success |
409
+ | `GET /auth/jwks.json` | Active and configured retired public verification keys |
410
+ | `POST /auth/sign-in` | `{ email }` (trimmed and lowercased); 202 `{ ok: true }` |
411
+ | `GET /auth/verify?token=…` | Safe no-store click-confirmation HTML; with valid issuer configuration, invalid queries return 400 recovery HTML; no token consumption or session change |
412
+ | `POST /auth/verify` | JSON `{ token }`, marker and exact allowed origin or `PUBLIC_ORIGIN`; consume once; 204 with session cookie |
413
+ | `GET /auth/account` | `{ id, email, createdAt, lastSignInAt }`; no tokens or budget data |
414
+ | `DELETE /auth/account` | Requires `ACCOUNT_HEADER` with the captured issuer account UUID; catalog `/me/account` via `AUTH_ARCHIVE`, require exact 204, then issuer user and cookie clear; 204 |
377
415
 
378
416
  Forward pages are newest first with opaque context-bound keyset cursors. Keep the same query context when following a cursor; end at `cursor: null`. `maxPageSize` bounds each page, not the total number of liked IDs, reports or audit actions you can export. The subset-like query is not a page and cannot combine `ids` with `cursor` or `limit`.
379
417
 
@@ -404,8 +442,10 @@ clear the cookie. This revokes all sessions, including the caller's, after a
404
442
  successful response. Requests authorized before revocation may finish; there is
405
443
  no cross-database transactional or in-flight cancellation guarantee.
406
444
 
407
- The catalog requires `AUTH_ISSUER_SERVICE`, a service binding to the issuer. After
408
- local JWT signature/claims verification, it requests
445
+ The catalog requires `AUTH_ISSUER_SERVICE`, a service binding to the issuer for
446
+ both JWKS retrieval and live session validation. Signing-key retrieval uses
447
+ `JWT_JWKS_URL` through the binding, so it does not depend on a public same-zone
448
+ Worker-to-Worker fetch. After local JWT signature/claims verification, it requests
409
449
  `GET /api/v1/auth/session` through that binding on every private request. Only a
410
450
  strictly validated 401 `unauthenticated` remains a 401. Malformed, unexpected,
411
451
  provider and 5xx responses become 503 `auth-unavailable`; there is no authority
@@ -427,37 +467,37 @@ Effective hide, unhide, operator removal and publisher erasure append audit rows
427
467
 
428
468
  ## Constants and Schemas
429
469
 
430
- | Export | Value or parsed output |
431
- | --- | --- |
432
- | `ARCHIVE_CONTRACT_VERSION`, `ARCHIVE_SCHEMA_VERSION` | Both debut at `1`; wire and storage versions are independent |
433
- | `AUTH_SCHEMA_VERSION` | `1`; issuer DDL stamp checked by `/auth/health` |
434
- | `ARCHIVE_COLLECTION_NAME_PATTERN` | `/^[a-z0-9-]{1,32}$/` |
435
- | `ARCHIVE_OPERATOR_ROLE` | `'archive-operator'` |
436
- | `SESSION_COOKIE_NAME`, `SESSION_COOKIE_PATH` | `'archive_session'`, `'/api/v1'` |
437
- | `SESSION_MAX_AGE_SECONDS` | `2592000` |
438
- | `OPERATOR_SESSION_MAX_AGE_SECONDS` | `900`; shared root contract |
439
- | `ACCOUNT_HEADER` | `'X-Archive-Account'`; required issuer-account UUID on coordinated erasure |
440
- | `PUBLISHER_HEADER`, `REQUEST_MARKER_HEADER` | `'x-archive-publisher'`, `'x-archive-request'` |
441
- | `ILL_FORMED_TEXT`, `isWellFormedText(value: string): boolean` | Reject control characters and lone UTF-16 surrogates |
442
- | `uuidSchema`, `isoTimestampSchema`, `hex64Schema` | UUID, ISO timestamp, lowercase SHA-256 hex strings |
443
- | `archiveAliasSchema` | Trimmed, well-formed alias of at most 24 characters |
444
- | `archiveAuthorSchema` | `{ id: string; name: string }` |
445
- | `archiveIdentitySchema` | `ArchiveIdentity` |
446
- | `archiveAccountSchema` | `ArchiveAccount`; issuer metadata, excluding private fields |
447
- | `archiveLimitsSchema` | All numeric `ARCHIVE_LIMITS` fields |
448
- | `archiveHealthSchema` | `ArchiveHealth` |
449
- | `archiveAuthHealthSchema` | `AuthHealth` |
450
- | `archiveErrorResponseSchema` | `ArchiveErrorResponse` |
451
- | `archiveOkSchema` | `{ ok: true }` |
452
- | `archiveCollectionMetaSchema` | `ArchiveCollectionMeta` |
453
- | `archiveLikedIdsSchema` | `{ ids: string[] }` subset response |
454
- | `archiveLikesPageSchema` | `ArchiveLikesPage` |
455
- | `archiveLikeStateSchema` | `{ id: string; likeCount: number; liked: boolean }` |
456
- | `archiveReportsSchema`, `archiveOwnReportsSchema` | `ArchiveReports`, `ArchiveOwnReports` |
457
- | `archiveModerationLogSchema` | `ArchiveModerationLog` |
458
- | `archiveSortSchema` | `ArchiveSort` |
459
- | `ARCHIVE_ERROR_CODES` | Values of `ArchiveErrorCode` below |
460
- | `ARCHIVE_MODERATION_ACTIONS` | `['hide', 'unhide', 'remove', 'erase-publisher']` |
470
+ | Export | Value or parsed output |
471
+ | ------------------------------------------------------------- | -------------------------------------------------------------------------- |
472
+ | `ARCHIVE_CONTRACT_VERSION`, `ARCHIVE_SCHEMA_VERSION` | Both debut at `1`; wire and storage versions are independent |
473
+ | `AUTH_SCHEMA_VERSION` | `1`; issuer DDL stamp checked by `/auth/health` |
474
+ | `ARCHIVE_COLLECTION_NAME_PATTERN` | `/^[a-z0-9-]{1,32}$/` |
475
+ | `ARCHIVE_OPERATOR_ROLE` | `'archive-operator'` |
476
+ | `SESSION_COOKIE_NAME`, `SESSION_COOKIE_PATH` | `'archive_session'`, `'/api/v1'` |
477
+ | `SESSION_MAX_AGE_SECONDS` | `2592000` |
478
+ | `OPERATOR_SESSION_MAX_AGE_SECONDS` | `900`; shared root contract |
479
+ | `ACCOUNT_HEADER` | `'X-Archive-Account'`; required issuer-account UUID on coordinated erasure |
480
+ | `PUBLISHER_HEADER`, `REQUEST_MARKER_HEADER` | `'x-archive-publisher'`, `'x-archive-request'` |
481
+ | `ILL_FORMED_TEXT`, `isWellFormedText(value: string): boolean` | Reject control characters and lone UTF-16 surrogates |
482
+ | `uuidSchema`, `isoTimestampSchema`, `hex64Schema` | UUID, ISO timestamp, lowercase SHA-256 hex strings |
483
+ | `archiveAliasSchema` | Trimmed, well-formed alias of at most 24 characters |
484
+ | `archiveAuthorSchema` | `{ id: string; name: string }` |
485
+ | `archiveIdentitySchema` | `ArchiveIdentity` |
486
+ | `archiveAccountSchema` | `ArchiveAccount`; issuer metadata, excluding private fields |
487
+ | `archiveLimitsSchema` | All numeric `ARCHIVE_LIMITS` fields |
488
+ | `archiveHealthSchema` | `ArchiveHealth` |
489
+ | `archiveAuthHealthSchema` | `AuthHealth` |
490
+ | `archiveErrorResponseSchema` | `ArchiveErrorResponse` |
491
+ | `archiveOkSchema` | `{ ok: true }` |
492
+ | `archiveCollectionMetaSchema` | `ArchiveCollectionMeta` |
493
+ | `archiveLikedIdsSchema` | `{ ids: string[] }` subset response |
494
+ | `archiveLikesPageSchema` | `ArchiveLikesPage` |
495
+ | `archiveLikeStateSchema` | `{ id: string; likeCount: number; liked: boolean }` |
496
+ | `archiveReportsSchema`, `archiveOwnReportsSchema` | `ArchiveReports`, `ArchiveOwnReports` |
497
+ | `archiveModerationLogSchema` | `ArchiveModerationLog` |
498
+ | `archiveSortSchema` | `ArchiveSort` |
499
+ | `ARCHIVE_ERROR_CODES` | Values of `ArchiveErrorCode` below |
500
+ | `ARCHIVE_MODERATION_ACTIONS` | `['hide', 'unhide', 'remove', 'erase-publisher']` |
461
501
 
462
502
  ### `sessionCookieValue`
463
503
 
@@ -467,11 +507,11 @@ Composes a session cookie using the shared security attributes.
467
507
  function sessionCookieValue(token: string, options: { maxAgeSeconds: number; secure: boolean }): string;
468
508
  ```
469
509
 
470
- | Parameter | Contract |
471
- | --- | --- |
472
- | `token` | Session token, or empty string when clearing |
473
- | `options.maxAgeSeconds` | Cookie lifetime; `0` clears it |
474
- | `options.secure` | Include `Secure` for production HTTPS |
510
+ | Parameter | Contract |
511
+ | ----------------------- | -------------------------------------------- |
512
+ | `token` | Session token, or empty string when clearing |
513
+ | `options.maxAgeSeconds` | Cookie lifetime; `0` clears it |
514
+ | `options.secure` | Include `Secure` for production HTTPS |
475
515
 
476
516
  **Returns:** the `Set-Cookie` value with `HttpOnly`, `SameSite=Strict`, the shared path and optional `Secure`.
477
517
 
@@ -510,11 +550,22 @@ console.log(isWellFormedText('Rice'), isWellFormedText('\u0000'));
510
550
 
511
551
  ```ts
512
552
  const limits = {
513
- aliasChars: 24, cursorChars: 1024, idempotencyRetentionMs: 172800000,
514
- likeStateIds: 50, maxPageSize: 50, maxPublications: 50, pageSize: 10,
515
- payloadBytes: 8192, publicCacheSeconds: 30, reportReasonChars: 256,
516
- requestBytes: 12288, searchChars: 64, searchTerms: 6, tagsPerEntry: 16,
517
- titleChars: 128, vocabChars: 64,
553
+ aliasChars: 24,
554
+ cursorChars: 1024,
555
+ idempotencyRetentionMs: 172800000,
556
+ likeStateIds: 50,
557
+ maxPageSize: 50,
558
+ maxPublications: 50,
559
+ pageSize: 10,
560
+ payloadBytes: 8192,
561
+ publicCacheSeconds: 30,
562
+ reportReasonChars: 256,
563
+ requestBytes: 12288,
564
+ searchChars: 64,
565
+ searchTerms: 6,
566
+ tagsPerEntry: 16,
567
+ titleChars: 128,
568
+ vocabChars: 64,
518
569
  };
519
570
  ```
520
571
 
@@ -585,16 +636,35 @@ type ArchiveSort = 'liked' | 'recent';
585
636
  type ArchiveDerivationStage = 'canonicalize' | 'hash' | 'index' | 'schema';
586
637
  type ArchiveIdentity = { id: string; name: string; operator: boolean } | null;
587
638
  type ArchiveAccount = {
588
- id: string; email: string; createdAt: string; lastSignInAt: string | null;
639
+ id: string;
640
+ email: string;
641
+ createdAt: string;
642
+ lastSignInAt: string | null;
589
643
  };
590
644
  type ArchiveCollectionMeta = { filters: string[]; name: string; tags: string[] };
591
645
  type ArchiveLikesPage = { cursor: string | null; ids: string[] };
592
646
  type ArchiveErrorCode =
593
- | 'account-changed' | 'auth-unavailable' | 'bad-request' | 'conflict' | 'forbidden' | 'gone'
594
- | 'internal' | 'invalid-payload' | 'likes-disabled' | 'method-not-allowed'
595
- | 'not-found' | 'publisher-changed' | 'publishing-disabled' | 'quota'
596
- | 'rate-limited' | 'record-unavailable' | 'timeout' | 'too-large'
597
- | 'unauthenticated' | 'unavailable' | 'unsupported-media';
647
+ | 'account-changed'
648
+ | 'auth-unavailable'
649
+ | 'bad-request'
650
+ | 'conflict'
651
+ | 'forbidden'
652
+ | 'gone'
653
+ | 'internal'
654
+ | 'invalid-payload'
655
+ | 'likes-disabled'
656
+ | 'method-not-allowed'
657
+ | 'not-found'
658
+ | 'publisher-changed'
659
+ | 'publishing-disabled'
660
+ | 'quota'
661
+ | 'rate-limited'
662
+ | 'record-unavailable'
663
+ | 'timeout'
664
+ | 'too-large'
665
+ | 'unauthenticated'
666
+ | 'unavailable'
667
+ | 'unsupported-media';
598
668
  type ArchiveErrorResponse = {
599
669
  error: { code: ArchiveErrorCode; message: string; requestId: string };
600
670
  };
@@ -602,11 +672,22 @@ type ArchiveHealth = {
602
672
  dbSchemaVersion: number | null;
603
673
  features: { likes: boolean; publishing: boolean };
604
674
  limits: {
605
- aliasChars: number; cursorChars: number; idempotencyRetentionMs: number;
606
- likeStateIds: number; maxPageSize: number; maxPublications: number;
607
- pageSize: number; payloadBytes: number; publicCacheSeconds: number;
608
- reportReasonChars: number; requestBytes: number; searchChars: number;
609
- searchTerms: number; tagsPerEntry: number; titleChars: number; vocabChars: number;
675
+ aliasChars: number;
676
+ cursorChars: number;
677
+ idempotencyRetentionMs: number;
678
+ likeStateIds: number;
679
+ maxPageSize: number;
680
+ maxPublications: number;
681
+ pageSize: number;
682
+ payloadBytes: number;
683
+ publicCacheSeconds: number;
684
+ reportReasonChars: number;
685
+ requestBytes: number;
686
+ searchChars: number;
687
+ searchTerms: number;
688
+ tagsPerEntry: number;
689
+ titleChars: number;
690
+ vocabChars: number;
610
691
  };
611
692
  schemaVersion: 1;
612
693
  status: 'ok' | 'degraded';
@@ -621,23 +702,36 @@ type AuthHealth = {
621
702
  type ArchiveReports = {
622
703
  cursor: string | null;
623
704
  reports: {
624
- id: string; createdAt: string; entryId: string; entryTitle: string;
625
- reason: string; reporter: { id: string; name: string }; resolvedAt: string | null;
705
+ id: string;
706
+ createdAt: string;
707
+ entryId: string;
708
+ entryTitle: string;
709
+ reason: string;
710
+ reporter: { id: string; name: string };
711
+ resolvedAt: string | null;
626
712
  }[];
627
713
  };
628
714
  type ArchiveOwnReports = {
629
715
  cursor: string | null;
630
716
  reports: {
631
- collection: string; createdAt: string; entryId: string; id: string;
632
- reason: string; resolvedAt: string | null;
717
+ collection: string;
718
+ createdAt: string;
719
+ entryId: string;
720
+ id: string;
721
+ reason: string;
722
+ resolvedAt: string | null;
633
723
  }[];
634
724
  };
635
725
  type ArchiveModerationAction = 'hide' | 'unhide' | 'remove' | 'erase-publisher';
636
726
  type ArchiveModerationLog = {
637
727
  cursor: string | null;
638
728
  actions: {
639
- id: string; action: ArchiveModerationAction; collection: string | null;
640
- createdAt: string; operatorId: string; targetId: string;
729
+ id: string;
730
+ action: ArchiveModerationAction;
731
+ collection: string | null;
732
+ createdAt: string;
733
+ operatorId: string;
734
+ targetId: string;
641
735
  }[];
642
736
  };
643
737
  ```
@@ -736,7 +830,7 @@ interface AuthWorkerHandler {
736
830
  tap(handler: (event: AuthEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;
737
831
  }
738
832
  type AuthDiagnostic = {
739
- readonly kind: 'configuration' | 'database' | 'email' | 'catalog' | 'internal';
833
+ readonly kind: 'configuration' | 'database' | 'schema' | 'email' | 'catalog' | 'internal';
740
834
  };
741
835
  type AuthEvent =
742
836
  | {
@@ -766,10 +860,10 @@ window resets atomically on a later sign-in request.
766
860
 
767
861
  ## Errors
768
862
 
769
- | Exported class | Trigger | Properties |
770
- | --- | --- | --- |
771
- | `ArchiveError` | Base for public package errors | `name`, `message`, optional `cause` |
772
- | `ArchiveDefinitionError` | Invalid collection definition or duplicate registered collection names | Extends `ArchiveError` |
863
+ | Exported class | Trigger | Properties |
864
+ | ------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
865
+ | `ArchiveError` | Base for public package errors | `name`, `message`, optional `cause` |
866
+ | `ArchiveDefinitionError` | Invalid collection definition or duplicate registered collection names | Extends `ArchiveError` |
773
867
  | `ArchiveDerivationError` | Unexpected schema/canonicalization/index/hash fault or violated collection invariant | Extends `ArchiveError`; readonly `stage: ArchiveDerivationStage`; optional `cause` |
774
868
 
775
869
  `ArchiveError` and `ArchiveDefinitionError` accept `(message: string, opts?: ErrorOptions)`.
@@ -844,7 +938,8 @@ if (!meResponse.ok) throw new Error('Identity unavailable');
844
938
  const me = archiveIdentitySchema.parse(await meResponse.json());
845
939
  if (me === null) throw new Error('Register a publisher first');
846
940
  const mineResponse = await fetch('/api/v1/me/publications?collection=recipes', {
847
- credentials: 'same-origin', headers: { [PUBLISHER_HEADER]: me.id },
941
+ credentials: 'same-origin',
942
+ headers: { [PUBLISHER_HEADER]: me.id },
848
943
  });
849
944
  if (!mineResponse.ok) throw new Error('Own publications unavailable');
850
945
  const owned = recipesCollection.contract.ownedEntries.parse(await mineResponse.json());
@@ -923,15 +1018,15 @@ anonymous read budget or call the issuer.
923
1018
  The Worker reads its environment fail-closed — every binding must be present and
924
1019
  well-formed, or the route answers 503 without naming what is missing:
925
1020
 
926
- | Variable | Meaning |
927
- | ------------------------ | ---------------------------------------------------------------- |
928
- | `APP_ENV` | `local` or `production` (transport rules and the pepper policy) |
929
- | `ALLOWED_ORIGINS` | Exact canonical origins; never wildcards |
930
- | `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | Your identity provider |
931
- | `AUTH_ISSUER_SERVICE` | Required issuer service binding for live session-version checks |
932
- | `LIKES_ENABLED` / `PUBLISHING_ENABLED` | Feature flags |
933
- | `RATE_LIMIT_KEY_SECRET` | HMAC pepper. Required ≥ 32 chars in production; local may fall back |
934
- | `DB` / `READ_LIMIT` / `WRITE_LIMIT` | D1 + rate-limit bindings |
1021
+ | Variable | Meaning |
1022
+ | ---------------------------------------------- | ----------------------------------------------------------------------------------------- |
1023
+ | `APP_ENV` | `local` or `production` (transport rules and the pepper policy) |
1024
+ | `ALLOWED_ORIGINS` | Exact canonical origins; never wildcards |
1025
+ | `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | Exact issuer claim, audience and issuer JWKS endpoint URL |
1026
+ | `AUTH_ISSUER_SERVICE` | Required issuer service binding for signing-key retrieval and live session-version checks |
1027
+ | `LIKES_ENABLED` / `PUBLISHING_ENABLED` | Feature flags |
1028
+ | `RATE_LIMIT_KEY_SECRET` | HMAC pepper. Required ≥ 32 chars in production; local may fall back |
1029
+ | `DB` / `READ_LIMIT` / `WRITE_LIMIT` | D1 + rate-limit bindings |
935
1030
 
936
1031
  Both limiter bindings must run 60-second windows (`simple` limiters:
937
1032
  `period: 60`): the platform binding does not expose its period at runtime, so the
@@ -941,6 +1036,14 @@ make the header lie.
941
1036
  Generated Env types (wrangler's `worker-configuration.d.ts`) satisfy the package's
942
1037
  structural `ArchiveWorkerEnv` — no `@cloudflare/workers-types` dependency, no casts.
943
1038
 
1039
+ Signing keys are fetched from `JWT_JWKS_URL` through `AUTH_ISSUER_SERVICE`, not
1040
+ global `fetch`. Point the URL at the bound issuer's `/api/v1/auth/jwks.json`
1041
+ route; retain the HTTPS production or loopback HTTP local configuration rules.
1042
+ This transport supports Workers on the same Cloudflare zone without a public
1043
+ Worker-to-Worker fetch. JOSE caches signing keys, but every private request still
1044
+ checks the current issuer session version. A signing-key provider failure is
1045
+ `503 auth-unavailable`, not a reason to clear the browser's signed-in state.
1046
+
944
1047
  ## The auth issuer
945
1048
 
946
1049
  `createAuthWorker()` from `@vielzeug/archive/auth` is the reference passwordless
@@ -954,19 +1057,19 @@ sign-in time and claims the token. The issuer's D1 schema ships at
954
1057
  `migrations_dir` there. The catalog's `JWT_ISSUER` and `JWT_JWKS_URL` must identify
955
1058
  the issuer, and its audience must match the issuer's `AUDIENCE`.
956
1059
 
957
- | Variable | Meaning |
958
- | --- | --- |
959
- | `APP_NAME` | The app's display name: the magic-link email's subject and copy name it |
960
- | `ALLOWED_ORIGINS` | Exact origins allowed to trigger unsafe routes (the app origin(s)) |
961
- | `APP_ENV` | `local` or `production` — production requires HTTPS and sets Secure cookies |
962
- | `AUDIENCE` / `AUTH_ISSUER` | The claims every minted session carries; the catalog verifies the same pair |
963
- | `AUTH_DB` / `AUTH_EMAIL` | The issuer's D1 and send-email bindings |
964
- | `AUTH_ARCHIVE` | Required service binding to the catalog Worker; deletion calls its profile-erasure route |
965
- | `AUTH_JWT_PRIVATE_JWK` | The RS256 private key as a JWK JSON string: a wrangler secret in production |
966
- | `AUTH_JWT_PREVIOUS_PUBLIC_JWKS` | Optional retired public RS256 JWKS; publish during rotation until old sessions expire |
967
- | `OPERATOR_EMAILS` | Comma-separated emails whose newly minted sessions carry the operator role |
968
- | `PUBLIC_ORIGIN` | App origin for links, confirmation POST origin checks and client navigation after sign-in |
969
- | `SENDER_ADDRESS` | The address magic links come from: a domain onboarded to Cloudflare Email Service |
1060
+ | Variable | Meaning |
1061
+ | ------------------------------- | ----------------------------------------------------------------------------------------- |
1062
+ | `APP_NAME` | The app's display name in magic-link email and escaped confirmation-page branding |
1063
+ | `ALLOWED_ORIGINS` | Exact origins allowed to trigger unsafe routes (the app origin(s)) |
1064
+ | `APP_ENV` | `local` or `production` — production requires HTTPS and sets Secure cookies |
1065
+ | `AUDIENCE` / `AUTH_ISSUER` | The claims every minted session carries; the catalog verifies the same pair |
1066
+ | `AUTH_DB` / `AUTH_EMAIL` | The issuer's D1 and send-email bindings |
1067
+ | `AUTH_ARCHIVE` | Required service binding to the catalog Worker; deletion calls its profile-erasure route |
1068
+ | `AUTH_JWT_PRIVATE_JWK` | The RS256 private key as a JWK JSON string: a wrangler secret in production |
1069
+ | `AUTH_JWT_PREVIOUS_PUBLIC_JWKS` | Optional retired public RS256 JWKS; publish during rotation until old sessions expire |
1070
+ | `OPERATOR_EMAILS` | Comma-separated emails whose newly minted sessions carry the operator role |
1071
+ | `PUBLIC_ORIGIN` | App origin for links, confirmation POST origin checks and client navigation after sign-in |
1072
+ | `SENDER_ADDRESS` | The address magic links come from: a domain onboarded to Cloudflare Email Service |
970
1073
 
971
1074
  Under `wrangler dev` the send-email binding is simulated — the link prints to the
972
1075
  issuer's console, so that console is the inbox. Demo-only Alice/Bob routes are
@@ -995,10 +1098,38 @@ Opening `GET /auth/verify?token=…` renders a no-store confirmation page and do
995
1098
  not consume the link or change the session, so email scanners cannot sign in by
996
1099
  fetching it. A deliberate click sends `POST /auth/verify` with JSON `{ token }`,
997
1100
  the request marker and an exact allowed origin or `PUBLIC_ORIGIN`.
998
- Success is 204 with Set-Cookie; the page then navigates to `/signed-in`. The
999
- redemption update and account sign-in stamp are trigger-backed and atomic.
1101
+ Success is 204 with Set-Cookie; the page then navigates to the fixed
1102
+ `${PUBLIC_ORIGIN}/signed-in` destination. Your application must serve that page;
1103
+ the issuer only serves the confirmation endpoint. The redemption update and
1104
+ account sign-in stamp are trigger-backed and atomic.
1000
1105
  The page removes the token from the address bar, loads no external assets, and
1001
- sets a restrictive CSP and `Referrer-Policy: no-referrer`.
1106
+ sets a restrictive CSP and `Referrer-Policy: no-referrer`. Its inline CSS and
1107
+ script use a fresh nonce, never `unsafe-inline`. The page follows light/dark
1108
+ preferences, supports reduced motion, focuses the confirmation action and
1109
+ announces pending, success and recovery states. Without JavaScript it links to
1110
+ `${PUBLIC_ORIGIN}/account` as “Request a new sign-in link” and does not pretend
1111
+ to complete sign-in.
1112
+
1113
+ Archive only serves the confirmation endpoint. Your application must serve its
1114
+ sign-in entry at the fixed `${PUBLIC_ORIGIN}/account` recovery destination and
1115
+ the success page at `${PUBLIC_ORIGIN}/signed-in`. These are reference issuer
1116
+ route conventions, not configurable destinations. The initial “Back to app”
1117
+ exit links to `${PUBLIC_ORIGIN}/`. No query parameter supplies a destination.
1118
+ Expired or already-used links (POST 410) require a new link rather than a retry.
1119
+ HTTP 429 enables manual retry after a `Retry-After` cooldown bounded to 1-300
1120
+ seconds, accepting unsigned integer seconds or valid HTTP-date syntax and
1121
+ defaulting to 30 seconds for missing or invalid values, including negative or
1122
+ fractional numbers. HTTP 503 and network failures offer a retry button and a
1123
+ secondary “Request a new sign-in link” action; invalid or expired links make
1124
+ that recovery action primary. The page never
1125
+ automatically retries, aborts stalled requests after 15 seconds and releases
1126
+ requests/timers when you leave it. Response bodies, including stale responses,
1127
+ are cancelled without waiting, and completed attempts abort their controllers.
1128
+
1129
+ With valid issuer configuration, malformed GET links return HTTP 400 with branded
1130
+ recovery HTML rather than the JSON error envelope. POST errors retain the JSON
1131
+ error schema. A well-formed expired token still gets GET 200 confirmation: its
1132
+ redemption status is determined only by the deliberate POST.
1002
1133
 
1003
1134
  `GET /auth/account` exports account metadata only. For full erasure, call
1004
1135
  `DELETE /auth/account` with `ACCOUNT_HEADER` (`X-Archive-Account`) set to the
@@ -1054,9 +1185,9 @@ against the same file. Point wrangler at the directory:
1054
1185
  {
1055
1186
  "binding": "DB",
1056
1187
  "database_name": "my-app",
1057
- "migrations_dir": "./node_modules/@vielzeug/archive/migrations"
1058
- }
1059
- ]
1188
+ "migrations_dir": "./node_modules/@vielzeug/archive/migrations",
1189
+ },
1190
+ ],
1060
1191
  }
1061
1192
  ```
1062
1193
 
@@ -1377,6 +1508,8 @@ console.log(byRole, health, profile);
1377
1508
  - **`tryParseJson`**: preserve JSON syntax failures from `/object`
1378
1509
  - **`allocate`**: lossless proportional distribution from `/math`
1379
1510
  - **`chunk`, `zip`, `compact`**: typed collection transforms from `/array`
1511
+ - **`paginate`**: one page plus its totals from an in-memory list, in a one- or zero-based page base
1512
+ - **`sort`**: selector, `{ key: direction }`, or ordered grid criteria from one entry point
1380
1513
  - **`range`, `average`, `median`**: common numeric operations from `/math`
1381
1514
  - **`assert`, `tap`**: typed control-flow helpers from `/function`
1382
1515
  - **`isEqual`**: structural equality from package root
@@ -1404,6 +1537,8 @@ console.log(byRole, health, profile);
1404
1537
  | --- | --- | --- | --- |
1405
1538
  | `groupBy` | Group values by key (prototype-pollution guarded) | Sync | Root export |
1406
1539
  | `chunk` / `zip` / `compact` | Typed collection transforms | Sync | Available from `/array` |
1540
+ | `paginate` | Cut one page and its totals out of a list | Sync | Pages past the last return empty `items`, unclamped |
1541
+ | `sort` | Sort by a selector, keyed fields, or ordered path criteria | Sync | Criteria paths are resolved safely; mixed value types throw |
1407
1542
  | `range` / `average` / `median` | Numeric generation and aggregation | Sync | Available from `/math` |
1408
1543
  | `assert` | Assert and narrow a condition | Sync | Available from `/function` |
1409
1544
  | `retry` | Retry async work | Async | Rethrows final error |
@@ -1424,7 +1559,7 @@ console.log(byRole, health, profile);
1424
1559
  | Import | Purpose |
1425
1560
  | --- | --- |
1426
1561
  | `@vielzeug/arsenal` | Curated common utilities |
1427
- | `@vielzeug/arsenal/array` | Immutable transforms, set operations, sorting, fuzzy search |
1562
+ | `@vielzeug/arsenal/array` | Immutable transforms, pagination, set operations, sorting, fuzzy search |
1428
1563
  | `@vielzeug/arsenal/async` | Retry, cancellation, task pool, timing |
1429
1564
  | `@vielzeug/arsenal/cache` | In-memory cache and memoization |
1430
1565
  | `@vielzeug/arsenal/encoding` | Base64url and native byte compression |
@@ -1452,6 +1587,35 @@ unzip(rows): tupleOfArrays
1452
1587
 
1453
1588
  `take`, `takeLast`, `drop`, and `dropLast` return new arrays and normalize negative counts to zero. Set operations accept optional selectors for object identity.
1454
1589
 
1590
+ ### paginate
1591
+
1592
+ ```ts
1593
+ paginate(items: readonly T[], options?: PaginateOptions): PageEnvelope
1594
+ ```
1595
+
1596
+ Cuts one page out of an in-memory list and returns the items with the arithmetic that produced them. `pageOffset` selects the caller's page base: `1` (default) for one-based paging, `0` for zero-based servers such as Spring. The default `page` is the first page in that base, and the default `pageSize` is `20`. `totalPages` is at least 1, even for an empty list, so `page (array: readonly T[], selector: (item: T) => unknown, direction?: SortDirection): T[];
1597
+ sort(array: readonly T[], selectors: SortSelectors): T[];
1598
+ sort(array: readonly T[], criteria: ReadonlyArray>): T[];
1599
+ ```
1600
+
1601
+ Three forms, one entry point. The selector form sorts by one extracted value; the selectors form accepts an object mapping property keys to directions; the criteria form sorts by an ordered list of paths, which is the shape a data grid hands over when its sortable columns are runtime strings.
1602
+
1603
+ ```ts
1604
+ import { sort } from '@vielzeug/arsenal/array';
1605
+
1606
+ const users = [{ name: 'Ada', age: 41 }, { name: 'Grace', age: 30 }];
1607
+ sort(users, (user) => user.name);
1608
+ sort(users, { name: 'asc', age: 'desc' });
1609
+
1610
+ const rows = [
1611
+ { address: { city: 'Berlin' }, age: 41 },
1612
+ { address: { city: 'Aachen' }, age: 30 },
1613
+ ];
1614
+ sort(rows, [{ key: 'address.city' }, { key: 'age', direction: 'desc' }]);
1615
+ ```
1616
+
1617
+ Criteria paths are resolved with `getPath`, so dotted and bracketed paths (`'address.city'`, `'tags[0]'`) work and prototype-access segments are refused. The first criterion that differs wins and earlier ties keep their input order. Ordering is delegated to `compare`, so a column that mixes value types (a number beside a string) throws a `TypeError` instead of guessing an order.
1618
+
1455
1619
  ### fuzzyFilter / fuzzyScore
1456
1620
 
1457
1621
  ```ts
@@ -1717,6 +1881,16 @@ type FuzzySelection = FuzzyOptions & {
1717
1881
 
1718
1882
  type ScoredResult = { item: T; score: number };
1719
1883
 
1884
+ type PageEnvelope = Readonly;
1885
+
1886
+ type PaginateOptions = Readonly;
1887
+
1888
+ type SortDirection = 'asc' | 'desc';
1889
+
1890
+ type SortSelectors = Partial>;
1891
+
1892
+ type SortCriterion = Readonly;
1893
+
1720
1894
  type CacheOptions = {
1721
1895
  capacity?: number;
1722
1896
  now?: () => number;
@@ -1791,6 +1965,45 @@ const byPrice = sort(products, (product) => product.price);
1791
1965
  console.log(publishedLabels, byId, byPrice);
1792
1966
  ```
1793
1967
 
1968
+ ## Page an In-Memory List
1969
+
1970
+ Use `paginate` when the whole list is local and a view needs one page plus its totals.
1971
+
1972
+ ```ts
1973
+ import { paginate } from '@vielzeug/arsenal/array';
1974
+
1975
+ const rows = Array.from({ length: 45 }, (_, index) => ({ id: index + 1 }));
1976
+
1977
+ const first = paginate(rows, { pageSize: 10 });
1978
+ const past = paginate(rows, { page: 9, pageSize: 10 });
1979
+
1980
+ console.log(first.totalPages, past.items); // 5 []
1981
+ ```
1982
+
1983
+ `page` is echoed rather than clamped, so a stale page number reads as an empty page instead of
1984
+ silently jumping elsewhere. Pass `{ pageOffset: 0 }` for a zero-based caller and the envelope comes
1985
+ back in that same base.
1986
+
1987
+ ## Sort by Grid Criteria
1988
+
1989
+ `sort` accepts the ordering a data grid already holds: an ordered list of column paths, each with a direction.
1990
+
1991
+ ```ts
1992
+ import { sort } from '@vielzeug/arsenal/array';
1993
+
1994
+ const rows = [
1995
+ { address: { city: 'Berlin' }, age: 41 },
1996
+ { address: { city: 'Aachen' }, age: 30 },
1997
+ { address: { city: 'Berlin' }, age: 27 },
1998
+ ];
1999
+
2000
+ const ordered = sort(rows, [{ key: 'address.city' }, { key: 'age', direction: 'desc' }]);
2001
+ ```
2002
+
2003
+ Paths are resolved safely, so nested and indexed columns work and prototype access is refused. The
2004
+ first criterion that differs decides the order and ties keep their input order. Keep each column
2005
+ typed: a column mixing numbers and strings throws a `TypeError` rather than guessing an order.
2006
+
1794
2007
  ## Search Explicit Fields
1795
2008
 
1796
2009
  Search string arrays directly. Object collections require `select`, so callers define exactly what can match.
@@ -1972,8 +2185,10 @@ const settings = parsed.ok ? Settings.parse(parsed.value) : { theme: 'system' };
1972
2185
  - filterMap - Filter and map array elements (id: `array-filter`)
1973
2186
  - groupBy - Group array by key (id: `array-group`)
1974
2187
  - filterMap - Transform array elements (id: `array-map`)
2188
+ - paginate - Page an array (id: `array-paginate`)
1975
2189
  - fuzzyFilter - Explicit searchable fields (id: `array-search`)
1976
2190
  - fuzzyFilter - Unicode normalization (id: `array-search-normalize`)
2191
+ - sort - Selector, selectors, or grid criteria (id: `array-sort-criteria`)
1977
2192
  - uniq - Remove duplicates (id: `array-uniq`)
1978
2193
  - attempt - Safe async execution with isFail/isOk helpers (id: `async-attempt`)
1979
2194
  - parallel - Controlled parallel execution (id: `async-parallel`)
@@ -2072,6 +2287,8 @@ await waitUntil(() => view.queryByText('Saved') !== null);
2072
2287
  - `fireClick`, `fireInput`, `fireKeyDown`, and peers dispatch exact synchronous events.
2073
2288
  - `waitUntil`, `eventually`, and `waitForEvent` provide bounded, abortable async waiting.
2074
2289
  - `delay` models explicit cancellable timer scheduling.
2290
+ - `walkFlatTree` traverses the flat tree across slot boundaries jsdom's light-DOM queries miss.
2291
+ - `installFormInternalsPolyfill` gives jsdom the `ElementInternals` form-association API.
2075
2292
  - `AssayError`, `AssayQueryError`, and `AssayTimeoutError` provide typed failures.
2076
2293
 
2077
2294
  ## Documentation
@@ -2098,12 +2315,14 @@ await waitUntil(() => view.queryByText('Saved') !== null);
2098
2315
  | `fire*` / `dispatch` | Dispatches platform event instances | Sync | Does not reproduce browser default behavior |
2099
2316
  | `waitUntil` / `eventually` / `waitForEvent` | Waits for conditions, assertions, or events | Async | Use a signal or timeout for bounded waits |
2100
2317
  | `delay` | Schedules a cancellable timer | Async | Pass a signal to cancel a pending wait |
2318
+ | `walkFlatTree` | Walks the flat tree across slot boundaries | Sync | jsdom-only need: browsers already use the flat tree |
2319
+ | `installFormInternalsPolyfill` | Polyfills `ElementInternals` form association for jsdom | Sync | Global patches: only install in suites that need them |
2101
2320
 
2102
2321
  ## Package Entry Point
2103
2322
 
2104
2323
  | Import | Purpose |
2105
2324
  | ----------------- | ---------------------------------------------------- |
2106
- | `@vielzeug/assay` | DOM queries, events, wait helpers, errors, and types |
2325
+ | `@vielzeug/assay` | DOM queries, events, wait helpers, test-environment helpers, errors, and types |
2107
2326
 
2108
2327
  ## Queries
2109
2328
 
@@ -2195,6 +2414,57 @@ await delay(100, { signal });
2195
2414
 
2196
2415
  `timeout` and delay milliseconds must be finite, non-negative, and no greater than 2,147,483,647 ms; `interval` must be finite, positive, and within the same timer limit. Invalid durations throw `RangeError`. `eventually()` also accepts `message` for diagnostic timeout context.
2197
2416
 
2417
+ ## Test-environment helpers
2418
+
2419
+ ### `walkFlatTree(root, visit)`
2420
+
2421
+ ```ts
2422
+ function walkFlatTree(root: Element, visit: (element: HTMLElement) => void): void;
2423
+ ```
2424
+
2425
+ Walks the flat tree below `root`, calling `visit` for each assigned or ordinary descendant (not for `root` itself).
2426
+ At each `` it expands `assignedElements()` instead of descending into its (always empty, in jsdom) literal
2427
+ children. jsdom only
2428
+ implements the light DOM tree, so `querySelectorAll()` never crosses a slot into its assigned elements; real
2429
+ browsers use the flat tree for that. This helper closes the gap for tests that must find form-associated (or
2430
+ otherwise slotted) elements through shadow boundaries.
2431
+
2432
+ ```ts
2433
+ const named: HTMLElement[] = [];
2434
+
2435
+ walkFlatTree(form, (el) => {
2436
+ if (el.hasAttribute('name')) named.push(el);
2437
+ });
2438
+ ```
2439
+
2440
+ ### `installFormInternalsPolyfill()`
2441
+
2442
+ ```ts
2443
+ function installFormInternalsPolyfill(): () => void;
2444
+ ```
2445
+
2446
+ jsdom implements none of the `ElementInternals` form-association API: not `setFormValue`, not `setValidity`, not
2447
+ the `checkValidity`/`reportValidity`/`validity`/`validationMessage` mixin real browsers put on every
2448
+ `formAssociated: true` element, not even `FormData` collecting a form-associated element's set value, or
2449
+ `.reset()` invoking `formResetCallback()`. Every one of those gaps is exercised by `useField()` from
2450
+ `@vielzeug/ore`, so any package testing a form-associated component needs all of them.
2451
+
2452
+ Call it from a test setup when the suite tests form-associated components; it returns an `uninstall()` that
2453
+ restores every patched global. The patches are global monkey-patches and skip APIs already implemented by the
2454
+ environment, so suites without form-associated components shouldn't install them. If `ElementInternals` is absent,
2455
+ the installer is a no-op. A downstream package should
2456
+ rely on this instead of hand-rolling its own copy.
2457
+
2458
+ ```ts
2459
+ // vitest.setup.ts
2460
+ import { installFormInternalsPolyfill } from '@vielzeug/assay';
2461
+ import { install } from '@vielzeug/ore/testing';
2462
+ import { afterAll, afterEach } from 'vitest';
2463
+
2464
+ install(afterEach);
2465
+ afterAll(installFormInternalsPolyfill());
2466
+ ```
2467
+
2198
2468
  ## Types
2199
2469
 
2200
2470
  ```ts
@@ -2349,6 +2619,36 @@ expect(onSave).toHaveBeenCalledOnce();
2349
2619
  Refine's testing entry point contains only Refine-specific assertions and typed mount wrappers. Import Assay helpers
2350
2620
  directly instead of routing generic DOM operations through another package.
2351
2621
 
2622
+ When a suite tests a form-associated custom element (one built on `useField()` from `@vielzeug/ore`), install the
2623
+ form-internals polyfill in the setup file so jsdom gains the `ElementInternals` API it lacks:
2624
+
2625
+ ```ts
2626
+ // vitest.setup.ts
2627
+ import { installFormInternalsPolyfill } from '@vielzeug/assay';
2628
+ import { install } from '@vielzeug/ore/testing';
2629
+ import { afterAll, afterEach } from 'vitest';
2630
+
2631
+ install(afterEach);
2632
+ afterAll(installFormInternalsPolyfill());
2633
+ ```
2634
+
2635
+ ## Traverse slotted content in jsdom
2636
+
2637
+ Use `walkFlatTree()` when a test must visit descendants assigned through slots. It expands each slot into its assigned
2638
+ elements, including nested slots, instead of relying on jsdom's light-DOM-only `querySelectorAll()`.
2639
+
2640
+ ```ts
2641
+ import { walkFlatTree } from '@vielzeug/assay';
2642
+
2643
+ const named: HTMLElement[] = [];
2644
+ walkFlatTree(form, (element) => {
2645
+ if (element.hasAttribute('name')) named.push(element);
2646
+ });
2647
+ ```
2648
+
2649
+ The callback receives descendants in the flat tree, not the root element or slot elements themselves. Browsers already
2650
+ use the flat tree for DOM algorithms; this helper is for test code that needs equivalent traversal in jsdom.
2651
+
2352
2652
  ## Best Practices
2353
2653
 
2354
2654
  - Scope multiple assertions with `within()` rather than repeatedly querying the document.
@@ -4523,6 +4823,7 @@ try {
4523
4823
  - **`withBearerAuth()` / `withRequestId()` / `withLogging()`**: composable middleware configured at construction.
4524
4824
  - **`tap()`**: signal-owned structured transport observation.
4525
4825
  - **Error taxonomy**: HTTP, network, timeout, abort, parse, and schema failures are distinct, actionable classes.
4826
+ - **RFC 9457 problem details**: an `application/problem+json` error body parses onto `CourierHttpError.problem` while the HTTP status stays authoritative.
4526
4827
  - **`cancelAll()` / `dispose()`**: abort active requests and tear down the transport.
4527
4828
 
4528
4829
  ## Documentation
@@ -4554,6 +4855,9 @@ try {
4554
4855
  | `withRequestId()` | Adds request identifier middleware | Sync | Default generator uses `crypto.randomUUID()` |
4555
4856
  | `withLogging()` | Logs request result metadata | Sync | Requires explicit logger; URLs may contain sensitive query values |
4556
4857
  | `tap()` | Observe structured transport events | Sync | Observation only; optional signal owns lifetime |
4858
+ | `CourierHttpError.problem` | Parsed RFC 9457 problem details from an error body | Sync | Present only when the response is `application/problem+json` |
4859
+ | `ProblemDetail` | RFC 9457 problem members and extension members | Sync | Extension values are `unknown`; Courier does not validate their shapes |
4860
+ | `problemDetailError()` | Build an HTTP error that carries problem details | Sync | Intended for mocks and middleware, not for parsing a response |
4557
4861
 
4558
4862
  ## Package Entry Point
4559
4863
 
@@ -4799,7 +5103,7 @@ type PrefetchConfig = Omit,
4799
5103
  | Error | Trigger | Notable properties |
4800
5104
  | --- | --- | --- |
4801
5105
  | `CourierError` | Base class for all Courier errors | Use `instanceof` to narrow |
4802
- | `CourierHttpError` | Non-2xx HTTP response | `status`, `data`, `headers`, `method`, `url`; `CourierHttpError.is(e, status?)` narrows by status |
5106
+ | `CourierHttpError` | Non-2xx HTTP response | `status`, `data`, `headers`, `method`, `url`, `problem`; `CourierHttpError.is(e, status?)` narrows by status |
4803
5107
  | `CourierNetworkError` | Request failure without response | `method`, `url`, `cause` |
4804
5108
  | `CourierTimeoutError` | Timeout signal aborts request | `method`, `url`, `cause` |
4805
5109
  | `CourierAbortError` | Caller or client cancellation | `method`, `url`, `cause` |
@@ -4810,6 +5114,72 @@ type PrefetchConfig = Omit,
4810
5114
 
4811
5115
  Errors thrown directly by custom middleware are preserved rather than reclassified.
4812
5116
 
5117
+ ### Problem details
5118
+
5119
+ ```ts
5120
+ export type ProblemDetail = Readonly;
5121
+
5122
+ export const PROBLEM_JSON_MEDIA_TYPE = 'application/problem+json';
5123
+
5124
+ export function isProblemMediaType(contentType: string | null | undefined): boolean;
5125
+
5126
+ export function problemDetailError(
5127
+ problem: ProblemDetail,
5128
+ options?: { message?: string; method?: string; status?: number; url?: string },
5129
+ ): CourierHttpError;
5130
+ ```
5131
+
5132
+ When a non-2xx response answers with `application/problem+json` (RFC 9457), `CourierHttpError.problem`
5133
+ holds a shallow-frozen copy of the parsed JSON object and the error message prefers `detail`, then
5134
+ `title`, then the response status text (or `HTTP ` when status text is empty). Any other error
5135
+ body keeps the shape it already had on `data`, with `problem` left `undefined`. Courier checks that
5136
+ the parsed body is an object; it does not validate the types or contents of registered or extension
5137
+ members. RFC 9457 treats an omitted `type` as `about:blank`, but Courier does not insert that value
5138
+ into the object.
5139
+
5140
+ | Member | Purpose | Common gotcha |
5141
+ | --- | --- | --- |
5142
+ | `ProblemDetail` | The five registered RFC 9457 members plus extensions | Every member is optional; extension values have type `unknown` |
5143
+ | `CourierHttpError.problem` | Parsed problem document | For response errors, it is a frozen shallow copy and is `undefined` unless the media type matches and the body is an object |
5144
+ | `PROBLEM_JSON_MEDIA_TYPE` | Registered media type constant | Compare with `isProblemMediaType()`, not `===`, to allow parameters |
5145
+ | `isProblemMediaType()` | Match `application/problem+json` and its parameterised forms | Plain `application/json` never matches |
5146
+ | `problemDetailError()` | Build a problem-carrying `CourierHttpError` without a server | Status defaults to numeric `problem.status`, then `500`; a supplied `options.status` takes precedence |
5147
+
5148
+ ```ts
5149
+ problemDetailError(problem, options?)
5150
+ ```
5151
+
5152
+ `problemDetailError()` returns a `CourierHttpError` with the supplied problem object on both
5153
+ `error.problem` and `error.data`. Its defaults are `method: 'GET'`, `url: ''`, and status from
5154
+ `problem.status` when numeric or `500` otherwise. The error message uses `options.message`, then
5155
+ `problem.detail`, `problem.title`, and finally `HTTP `. Unlike a parsed response problem,
5156
+ the supplied object is not copied or frozen.
5157
+
5158
+ The HTTP status line stays authoritative: `error.status` is the response status even when
5159
+ `problem.status` disagrees. A JSON array or primitive leaves `data` as that parsed value; malformed
5160
+ JSON leaves `data` as its raw text. Neither produces a partial `problem`.
5161
+
5162
+ ```ts
5163
+ import { createCourier, CourierHttpError, problemDetailError, type ProblemDetail } from '@vielzeug/courier';
5164
+
5165
+ const courier = createCourier({ baseUrl: '/api' });
5166
+
5167
+ try {
5168
+ await courier.post('/invoices');
5169
+ } catch (error) {
5170
+ if (CourierHttpError.is(error)) {
5171
+ const problem: ProblemDetail | undefined = error.problem;
5172
+ console.log(error.status, problem?.title, problem?.detail, problem?.type);
5173
+ }
5174
+ }
5175
+
5176
+ const mockedError = problemDetailError(
5177
+ { title: 'Insufficient credit', detail: 'Choose a lower amount.', status: 402 },
5178
+ { method: 'POST', url: '/invoices' },
5179
+ );
5180
+ console.log(mockedError.status, mockedError.problem?.detail);
5181
+ ```
5182
+
4813
5183
  ### Usage Guide
4814
5184
 
4815
5185
  ## Basic Usage
@@ -4998,6 +5368,55 @@ async function loadUser(): Promise {
4998
5368
  - Do not display cancellation as an application error during navigation.
4999
5369
  - `CourierSchemaValidationError` carries the raw pre-validation `data`.
5000
5370
 
5371
+ ## Problem Details
5372
+
5373
+ When a server answers a failure with `application/problem+json` (RFC 9457), Courier parses the
5374
+ JSON object onto `CourierHttpError.problem` and chooses the message from `detail`, then `title`,
5375
+ then the HTTP status text. The parsed object is shallow-frozen; extension members remain `unknown`
5376
+ and Courier does not validate their contents. Nothing is configured.
5377
+
5378
+ ```ts
5379
+ import { CourierHttpError, createCourier, type ProblemDetail } from '@vielzeug/courier';
5380
+
5381
+ const courier = createCourier({ baseUrl: '/api' });
5382
+
5383
+ async function submitInvoice(body: unknown): Promise {
5384
+ try {
5385
+ await courier.post('/invoices', { body });
5386
+ return 'Saved.';
5387
+ } catch (error) {
5388
+ if (!CourierHttpError.is(error)) throw error;
5389
+
5390
+ const problem = error.problem;
5391
+ if (problem?.title === 'Insufficient credit') return `Blocked: ${problem.detail ?? 'no credit'}`;
5392
+
5393
+ // Extension members are ordinary properties on the same document.
5394
+ const field = problem?.['errors'] as Record | undefined;
5395
+
5396
+ return field ? Object.values(field).join(', ') : `Request failed (${error.status}).`;
5397
+ }
5398
+ }
5399
+ ```
5400
+
5401
+ `error.status` is always the HTTP status from the response, so a `problem.status` that disagrees
5402
+ never overrides it. `problem` stays `undefined` for any other content type, and a `problem+json`
5403
+ body that is a JSON array or primitive keeps that parsed value on `data`; malformed JSON keeps its
5404
+ raw text there. Neither case creates a partial `problem`. RFC 9457 treats an omitted `type` as
5405
+ `about:blank`, but Courier does not add a `type` property. Use `problemDetailError()` to raise the
5406
+ same error shape from a mock or middleware; it stores the supplied problem object directly on both
5407
+ `problem` and `data`.
5408
+
5409
+ ```ts
5410
+ import { problemDetailError } from '@vielzeug/courier';
5411
+
5412
+ const mockFailure = problemDetailError(
5413
+ { title: 'Insufficient credit', detail: 'Choose a lower amount.', status: 402 },
5414
+ { method: 'POST', url: '/invoices' },
5415
+ );
5416
+
5417
+ console.log(mockFailure.status, mockFailure.problem?.detail);
5418
+ ```
5419
+
5001
5420
  ## Composing with a State Layer
5002
5421
 
5003
5422
  Courier owns HTTP transport and optional parsed GET caching. Observable query state, optimistic mutations, collection navigation, and stream state belong to the owning state layer (e.g. Sourcerer, TanStack Query, or consumer code). Compose Courier calls into that layer instead of nesting state machines.
@@ -5116,6 +5535,7 @@ There is no `use()` method. To change middleware, create a new client. This keep
5116
5535
  - Cached Read and Prefetch (id: `cached-read`)
5117
5536
  - createCourier - Transport Client (id: `create-courier`)
5118
5537
  - middleware - Immutable Chain (id: `middleware`)
5538
+ - problem+json - RFC 9457 error bodies (id: `problem-details`)
5119
5539
 
5120
5540
  ---
5121
5541
 
@@ -15548,7 +15968,7 @@ define('my-counter', {
15548
15968
  - Props via `prop.*` helpers (`prop.string`, `prop.number`, `prop.bool`, `prop.oneOf`, `prop.json`, `prop.data`) or raw `PropDef` objects
15549
15969
  - `setup(props)` takes only props and returns an `HTMLResult` directly: `return html\`...\``
15550
15970
  - Lifecycle hooks: `onMounted`, `onCleanup`, `onEvent`, `onElement`, `watchEffect`: plain functions imported from `@vielzeug/ore`, called directly from `setup()` or any composable it calls
15551
- - Directives: `each` (keyed reactive list rendering), `classMap`, `styleMap`, `when`, `live`, `unsafeHtml`
15971
+ - Directives: `each` (transactional keyed list reconciliation), `classMap`, `styleMap`, `when`, `live`, `model`, `unsafeHtml`
15552
15972
  - Host bindings via `bind({ attr, class, style, on })`: pass `{ target: el }` to bind any off-host element
15553
15973
  - Reactive ARIA sync via explicit `aria-*` keys in `bind({ attr }, { target })`, with automatic cleanup on disconnect
15554
15974
  - Context via `provide(key, value)` / `inject(key)`; typed emit/slots via `useEmit()` / `useSlots()`
@@ -15596,6 +16016,7 @@ All browser-runtime symbols below are imported from `@vielzeug/ore`. Lifecycle/c
15596
16016
  | `each()` | Keyed list rendering with DOM diffing | Sync | Duplicate keys report `ore:error`; plain `T[]` is a one-time static render |
15597
16017
  | `when()` | Conditional branch rendering | Sync | Getter-fn computed disposed on cleanup; static bool skips subscription |
15598
16018
  | `live(signal)` | One-way binding that skips stale writes during input | Sync | Use for controlled inputs alongside a manual `@input` handler |
16019
+ | `model(signal)` | Two-way form-control binding (value/checked + write-back) | Sync | Plain `value`/`checked` attribute only; pass `get` to keep numeric signals numeric |
15599
16020
  | `onMounted(fn)` | DOM-ready callback | Setup only | Must be called synchronously during `setup()` |
15600
16021
  | `onCleanup(fn)` | Register teardown | Setup only | Called on component disconnect |
15601
16022
  | `onEvent(target, …)` | Scoped event listener with auto-cleanup | Setup only | No-ops on null target; removed on disconnect |
@@ -15710,12 +16131,13 @@ setup(_props) {
15710
16131
  | `prop.string(defaultValue?)` | `PropDef` | Reflects by default |
15711
16132
  | `prop.bool(defaultValue?)` | `PropDef` | Any non-null attribute value other than `"false"` parses as `true`; `"false"` or absent attribute is `false` |
15712
16133
  | `prop.number(defaultValue?)` | `PropDef` | Returns default (not NaN) and warns in dev when attribute is not a valid number |
15713
- | `prop.oneOf(allowed, defaultValue)` | `PropDef` | Restricts to provided string union |
16134
+ | `prop.oneOf(allowed, defaultValue?)` | `PropDef` | Restricts to a fixed set of string or number members; `parse` falls back to `defaultValue` (or `undefined` when omitted) for anything outside the set. Numeric members match the attribute parsed as a number |
15714
16135
  | `prop.json(defaultValue)` | `PropDef` | JSON.parse from attribute; `reflect: false` |
15715
16136
  | `prop.data(defaultValue?)` | `PropDef` | JS-only: never reads/writes an attribute; use for objects, arrays, callbacks, or any non-serialisable value |
15716
16137
 
15717
16138
  > **Choosing the right prop helper:**
15718
16139
  >
16140
+ > - **`prop.oneOf`**: the honest way to type a union-valued prop: the restriction is enforced at runtime (`parse` falls back for anything outside the set), not just in a type parameter a raw attribute value could violate. Pass an `as const` array of the allowed string or number members.
15719
16141
  > - **`prop.json`**: value can be declared in HTML (``); attribute string is `JSON.parse`d.
15720
16142
  > - **`prop.data`**: value is always set from JavaScript (objects, arrays, callbacks, class instances); the attribute is never read. Use this for both data and function props.
15721
16143
 
@@ -15748,7 +16170,16 @@ define('data-grid', {
15748
16170
  ### `html`
15749
16171
 
15750
16172
  Tagged template literal that returns an `HTMLResult`. Supports text interpolation, ordinary attributes (`attr=`),
15751
- boolean attributes (`?attr=`), events (`@event=`), refs (`ref=`), and nested templates.
16173
+ boolean attributes (`?attr=`), explicit property bindings (`.prop=`), events (`@event=` with optional
16174
+ `.stop`/`.prevent`/`.once`/`.capture` modifiers), refs (`ref=`), and nested templates.
16175
+
16176
+ `.prop=${value}` always writes the element property (never an attribute): the explicit channel for structured
16177
+ values (arrays, objects, callbacks). Prototype-polluting names (`__proto__`, `constructor`, `prototype`) are
16178
+ rejected at compile time.
16179
+
16180
+ `@event.stop`, `.prevent`, `.once`, and `.capture` are shorthand for the matching listener behavior
16181
+ (`stopPropagation`/`preventDefault` applied before the handler runs; `once`/`capture` passed as listener
16182
+ options). An unknown modifier throws at compile time.
15752
16183
 
15753
16184
  ### `css`
15754
16185
 
@@ -15763,9 +16194,28 @@ Tagged template literal that returns a `CSSResult` for use in `styles`.
15763
16194
  | `classMap(record)` | Reactive class string from object map |
15764
16195
  | `styleMap(record)` | Reactive inline style string from object map |
15765
16196
  | `live(signal)` | One-way binding that skips stale writes during active user input; use with `@input` handler |
16197
+ | `model(signal, options?)` | Two-way form-control binding: writes the signal to `value`/`checked` and writes user input back on the control's change event |
15766
16198
  | `unsafeHtml(value)` | HTML rendering sink; sanitize untrusted values before calling |
15767
16199
 
15768
- `each()` treats number and string keys as distinct. A duplicate-key update reports `ore:error` and preserves the last valid list.
16200
+ `each()` treats number and string keys as distinct. A reconciliation failure (a duplicate key, or a render function that throws) reports `ore:error` and preserves the last valid list: entries created during the failed pass are rolled back, so the previous list stays exactly on screen.
16201
+
16202
+ ### `model(signal, options?)`
16203
+
16204
+ Two-way binding for a form control: `value=${model(signal)}`. It writes the signal to the control's `value`/`checked` property on every change and writes user input back into the signal on the control's change event (`'change'` for checkboxes, radios, and ``; `'input'` otherwise). This is the complete controlled-input loop that the `live()` + manual `@input` handler pair used to require.
16205
+
16206
+ ```ts
16207
+ const text = signal('');
16208
+ const agree = signal(false);
16209
+ const count = signal(0);
16210
+
16211
+ html`
16212
+
16213
+
16214
+ Number(el.value) })} />
16215
+ `;
16216
+ ```
16217
+
16218
+ `options` overrides the defaults for non-native controls: `event` sets the write-back event name and `get` reads the current value out of the element (default `el.checked` for checkboxes/radios, `el.value` otherwise). Because a native text input's `value` is always a string, pass a `get` like `(el) => Number(el.value)` to keep a numeric signal numeric. `model()` can only drive a plain `value`/`checked` attribute binding; using it on a boolean (`?checked=`) or other attribute throws.
15769
16219
 
15770
16220
  ### `unsafeHtml`
15771
16221
 
@@ -15865,6 +16315,8 @@ Import from `@vielzeug/ore`.
15865
16315
  Wire a form-associated element to `ElementInternals`. Requires `formAssociated: true` on the component definition. The `disabled` state tracking via `internals.states` (CustomStateSet) is skipped with a dev warning if the API is unavailable in the current environment.
15866
16316
 
15867
16317
  ```ts
16318
+ type FormFieldValidity = { flags: ValidityStateFlags; message: string };
16319
+
15868
16320
  type FormFieldOptions = {
15869
16321
  disabled?: Readable;
15870
16322
  /**
@@ -15877,8 +16329,13 @@ type FormFieldOptions = {
15877
16329
  /** Called when the ancestor resets (see onFormReset): restore local field state here. */
15878
16330
  onReset?: () => void;
15879
16331
  toFormValue?: (value: T) => File | FormData | string | null;
15880
- /** Recomputed reactively and passed straight to internals.setValidity(). null = always valid. */
15881
- validationMessage?: Readable;
16332
+ /**
16333
+ * Recomputed reactively and passed straight to internals.setValidity().
16334
+ * null (or omitted) = always valid. Flags and message travel together because
16335
+ * the platform's setValidity() contract couples them (flags without a
16336
+ * non-empty message throw); a single { flags, message } | null input makes a
16337
+ * desynced pair unrepresentable.
16338
+ */
15882
16339
  validity?: Readable;
15883
16340
  value: Signal | Readable;
15884
16341
  };
@@ -15892,15 +16349,20 @@ type FormFieldHandle = {
15892
16349
  };
15893
16350
  ```
15894
16351
 
15895
- Pass `validity`/`validationMessage` to make `required`-style constraints participate in native constraint validation
15896
- through `checkValidity()` and `reportValidity()`:
16352
+ Pass a single `validity` signal to make `required`-style constraints participate in native constraint validation
16353
+ through `checkValidity()` and `reportValidity()`. The `{ flags, message }` pair is the one validity input: the old
16354
+ split `validity` + `validationMessage` options are gone, since they could drift apart into a state the platform
16355
+ rejects.
15897
16356
 
15898
16357
  ```ts
15899
16358
  const isBlank = (v: string) => v.trim() === '';
15900
16359
 
15901
16360
  useField({
15902
- validationMessage: computed(() => (required.value && isBlank(value.value) ? 'This field is required.' : '')),
15903
- validity: computed(() => (required.value && isBlank(value.value) ? { valueMissing: true } : null)),
16361
+ validity: computed(() =>
16362
+ required.value && isBlank(value.value)
16363
+ ? { flags: { valueMissing: true }, message: 'This field is required.' }
16364
+ : null,
16365
+ ),
15904
16366
  value,
15905
16367
  });
15906
16368
  ```
@@ -15913,8 +16375,7 @@ Import from `@vielzeug/ore/testing`.
15913
16375
  | ------------------------ | ------------------------------------------------------------------------------------------ |
15914
16376
  | `mount(setup, options?)` | Mount a component and return a test fixture |
15915
16377
  | `cleanup()` | Remove all mounted elements and reset test state |
15916
- | `install(afterEach, options?)` | Register auto-cleanup; pass `{ formInternals: true }` to also install the `ElementInternals`/`FormData`/`.reset()` jsdom polyfill (see below) |
15917
- | `installFormInternalsPolyfill()` | Installs the form-internals polyfill directly (returns an `uninstall()` that restores every patched global). Usually called via `install(afterEach, { formInternals: true })` |
16378
+ | `install(afterEach)` | Register auto-cleanup after each test |
15918
16379
  | `flush(options?)` | Drain reactive updates and animation frames |
15919
16380
  | `renderHook(setup)` | Run lifecycle hooks in isolation; overload accepts `propDefs` as first arg for typed props |
15920
16381
  | `OreTimeoutError` | Error thrown when `flush()` cannot settle tracked Ore work |
@@ -15924,7 +16385,16 @@ Import from `@vielzeug/ore/testing`.
15924
16385
  Import `within`, named dispatchers such as `fireClick`, and waits such as `waitUntil` or `waitForEvent` from
15925
16386
  `@vielzeug/assay`.
15926
16387
 
15927
- > **Form-associated component testing:** jsdom implements none of the `ElementInternals` form-association API: `install(afterEach, { formInternals: true })` polyfills `setFormValue`/`setValidity`/`checkValidity`/`reportValidity`/`validationMessage`/`validity`/`states`, mixes `checkValidity`/`reportValidity`/`validity`/`validationMessage` onto the host element itself (real browsers do this for any `formAssociated: true` element), makes `FormData` collect a form-associated element's set value, and makes `.reset()` invoke `formResetCallback()`. Every patch is a guarded no-op when its target already exists, and `installFormInternalsPolyfill()` returns an `uninstall()` that restores every patched global. The polyfill is opt-in (`{ formInternals: true }`) because the patches are global: suites without form-associated components shouldn't carry them. A downstream package (e.g. a component library built on `ore`) should rely on this instead of hand-rolling its own copy.
16388
+ > **Form-associated component testing:** jsdom implements none of the `ElementInternals` form-association API.
16389
+ > `@vielzeug/assay` exports `installFormInternalsPolyfill()` for this: it polyfills
16390
+ > `setFormValue`/`setValidity`/`checkValidity`/`reportValidity`/`validationMessage`/`validity`/`states`, mixes
16391
+ > `checkValidity`/`reportValidity`/`validity`/`validationMessage` onto the host element itself (real browsers do
16392
+ > this for any `formAssociated: true` element), makes `FormData` collect a form-associated element's set value, and
16393
+ > makes `.reset()` invoke `formResetCallback()`. Every patch is a guarded no-op when its target already
16394
+ > exists, and the function returns an `uninstall()` that restores every patched global. Call it directly from a
16395
+ > test setup (alongside `install(afterEach)`) only when the suite tests form-associated components: the patches
16396
+ > are global, so suites without them shouldn't carry them. A downstream package (e.g. a component library built
16397
+ > on `ore`) should rely on it instead of hand-rolling its own copy.
15928
16398
 
15929
16399
  #### `Fixture` interface
15930
16400
 
@@ -16093,18 +16563,26 @@ type CSSResult = {
16093
16563
 
16094
16564
  type LiveBinding = { readonly source: Readable };
16095
16565
 
16566
+ type ModelOptions = {
16567
+ /** Write-back event (default: 'change' for checkbox/radio/select, 'input' otherwise). */
16568
+ event?: string;
16569
+ /** Value reader (default: el.checked for checkbox/radio, el.value otherwise). */
16570
+ get?: (el: HTMLElement) => T;
16571
+ };
16572
+
16096
16573
  type EmitFn> = {
16097
16574
  >(event: K): boolean;
16098
16575
  >>(event: K, detail: T[K]): boolean;
16099
16576
  };
16100
16577
  // KeysWithoutDetail is an internal helper type, not exported.
16101
16578
 
16579
+ type FormFieldValidity = { flags: ValidityStateFlags; message: string };
16580
+
16102
16581
  type FormFieldOptions = {
16103
16582
  disabled?: Readable;
16104
16583
  emptyStringForNull?: boolean;
16105
16584
  onReset?: () => void;
16106
16585
  toFormValue?: (value: T) => File | FormData | string | null;
16107
- validationMessage?: Readable;
16108
16586
  validity?: Readable;
16109
16587
  value: Signal | Readable;
16110
16588
  };
@@ -16258,11 +16736,13 @@ define('x-button', {
16258
16736
 
16259
16737
  ## template bindings
16260
16738
 
16261
- `html` supports text, attributes, booleans, properties, events, refs, and nested templates.
16739
+ `html` supports text, attributes, booleans, properties, events, refs, and nested templates. Event bindings accept
16740
+ `.stop`, `.prevent`, `.once`, and `.capture`: the first two apply before the handler runs, while the latter two use
16741
+ native listener options. Unknown modifiers throw while Ore compiles the template.
16262
16742
 
16263
16743
  ```ts
16264
16744
  import { computed, signal } from '@vielzeug/ripple';
16265
- import { define, html, ref } from '@vielzeug/ore';
16745
+ import { define, html, model, ref } from '@vielzeug/ore';
16266
16746
 
16267
16747
  define('profile-name', {
16268
16748
  setup() {
@@ -16271,10 +16751,7 @@ define('profile-name', {
16271
16751
 
16272
16752
  return html`
16273
16753
  'Current: ' + name.value)}>Name
16274
- 'Current name ' + name.value}
16275
- @input=${(event: Event) => {
16276
- name.value = (event.target as HTMLInputElement).value;
16277
- }} />
16754
+ 'Current name ' + name.value} />
16278
16755
  Hello ${name}
16279
16756
  `;
16280
16757
  },
@@ -16283,8 +16760,7 @@ define('profile-name', {
16283
16760
 
16284
16761
  ## directives
16285
16762
 
16286
- Ore exports `each`, `classMap`, `styleMap`, `when`, `live`, and `unsafeHtml` from `@vielzeug/ore`. Use ordinary
16287
- attribute bindings plus native event handlers for two-way input state; no special model directive is required.
16763
+ Ore exports `each`, `classMap`, `styleMap`, `when`, `live`, `model`, and `unsafeHtml` from `@vielzeug/ore`.
16288
16764
 
16289
16765
  ```ts
16290
16766
  import { signal } from '@vielzeug/ripple';
@@ -16332,9 +16808,50 @@ each(
16332
16808
  );
16333
16809
  ```
16334
16810
 
16335
- ## live form bindings
16811
+ Each reactive reconciliation is transactional. Ore creates missing entries before committing updates or removing stale
16812
+ entries; if a render callback throws, entries created during that pass are rolled back and the last successful list
16813
+ remains on screen. Ore reports the failure through `ore:error` with phase `each-reconcile`.
16814
+
16815
+ ## model: two-way form bindings
16816
+
16817
+ Use `model(signal)` for a controlled input: it writes the signal to the control's `value`/`checked` property and
16818
+ writes user input back on the control's change event (`'change'` for checkboxes, radios, and selects; `'input'`
16819
+ otherwise). This replaces the `live()` + manual `@input` handler pair with one binding.
16820
+
16821
+ ```ts
16822
+ import { signal } from '@vielzeug/ripple';
16823
+ import { define, html, model } from '@vielzeug/ore';
16824
+
16825
+ define('live-search', {
16826
+ setup() {
16827
+ const query = signal('');
16828
+ const agree = signal(false);
16829
+
16830
+ return html`
16831
+
16832
+
16833
+ `;
16834
+ },
16835
+ });
16836
+ ```
16837
+
16838
+ A native text input's `value` is always a string, so pass a `get` reader to keep a numeric signal numeric, and an
16839
+ `event` name for custom controls that emit their own change event:
16840
+
16841
+ ```ts
16842
+ const count = signal(0);
16843
+
16844
+ html`
16845
+ Number(el.value) })} />
16846
+ (el as MyPicker).selection })} />
16847
+ `;
16848
+ ```
16336
16849
 
16337
- Use `live(signal)` for inputs that should preserve in-progress user edits instead of overwriting the DOM on stale writes.
16850
+ ## live: one-way bindings that preserve user edits
16851
+
16852
+ Use `live(signal)` when a binding must skip a stale app-state write while the user is mid-edit, without writing
16853
+ user input back. `model()` covers the common controlled-input case; `live()` is for one-way bindings that only
16854
+ need the stale-write guard.
16338
16855
 
16339
16856
  ```ts
16340
16857
  import { signal } from '@vielzeug/ripple';
@@ -16698,7 +17215,7 @@ define('signup-form', {
16698
17215
  - Bind host attributes and classes via `bind()` rather than mutating the element directly.
16699
17216
  - Provide context at the nearest ancestor: avoid global context singletons.
16700
17217
  - Call `onCleanup()` for every resource allocated in `setup()` (WebSockets, intervals, external subscriptions).
16701
- - Use `live(signal)` for form inputs to prevent clobbering user-in-progress edits.
17218
+ - Use `model(signal)` for two-way form inputs; use `live(signal)` for a one-way binding that must not clobber user-in-progress edits.
16702
17219
  - Extract composable helper functions freely: `onMounted`/`onCleanup`/`bind`/... resolve the active component through implicit context, so they work from any function called (transitively) during `setup()`, with no need to pass them in as parameters.
16703
17220
  - Test component mounting and lifecycle with `@vielzeug/ore/testing`; import generic DOM events, queries, and waits
16704
17221
  from `@vielzeug/assay`.
@@ -20417,6 +20934,7 @@ Classification guides discovery and bundle selection. It is not a reason to remo
20417
20934
  - **Framework agnostic**: works anywhere HTML can be rendered
20418
20935
  - **Tree-shakeable**: import only the component entry points you register
20419
20936
  - **Comprehensive surface**: inputs, content, disclosure, feedback, layout, and overlay primitives
20937
+ - **Native form integration**: form-associated controls submit values, validate supported required constraints, and reset with their form
20420
20938
  - **Typed framework declarations**: DOM, React, and Vue tags derive from the component property contracts
20421
20939
 
20422
20940
  ### Prerequisites
@@ -20520,6 +21038,14 @@ The declarations cover every supported tag and derive component properties from
20520
21038
 
20521
21039
  Form controls expose `.value` or `.checked` and dispatch standard `input` and `change` events. Read the property from `event.currentTarget`.
20522
21040
 
21041
+ Finite-choice properties use Ore's `prop.oneOf()` parsing: values outside a component's documented options resolve to
21042
+ the declared default, or `undefined` when no default is declared.
21043
+
21044
+ Form-associated controls participate in native form submission, validity checks, and reset. A named control's value
21045
+ is available through `new FormData(form)`, and supported `required` constraints affect `form.checkValidity()`,
21046
+ `form.reportValidity()`, and submit blocking. The `error` property renders and announces feedback; it does not by
21047
+ itself set native constraint validity.
21048
+
20523
21049
  Stateful overlays expose controlled `open`, optional `default-open`, and an `open-change` custom event with `{ open, reason }` detail. Component pages define additional detail fields and reasons.
20524
21050
 
20525
21051
  ## Utilities
@@ -20625,6 +21151,20 @@ grid.rows = [
20625
21151
  ];
20626
21152
  ```
20627
21153
 
21154
+ Enumerated properties such as `variant`, `size`, and `color` accept the values listed on each component's reference
21155
+ page. An unsupported value resolves to the property's declared default (or `undefined` when it has no default).
21156
+
21157
+ In an Ore template, `model()` can bind a Refine field's `value` to a Ripple signal and write user input back:
21158
+
21159
+ ```ts
21160
+ import { signal } from '@vielzeug/ripple';
21161
+ import { html, model } from '@vielzeug/ore';
21162
+ import '@vielzeug/refine/input';
21163
+
21164
+ const email = signal('');
21165
+ const field = html``;
21166
+ ```
21167
+
20628
21168
  ## Handle Events
20629
21169
 
20630
21170
  Form controls dispatch standard `input` and `change` events. Read their current property from `event.currentTarget`.
@@ -20648,6 +21188,37 @@ dialog?.addEventListener('open-change', (event: CustomEvent) => {
20648
21188
  });
20649
21189
  ```
20650
21190
 
21191
+ ## Native Form Validation
21192
+
21193
+ Refine's form-associated controls integrate with native form submission, `FormData`, validity checks, and reset.
21194
+ Use `required` for a required-value constraint; setting `error` displays and announces feedback but does not, by
21195
+ itself, invalidate the form.
21196
+
21197
+ ```html
21198
+
21199
+
21200
+
21201
+ Choose a role…
21202
+ Editor
21203
+ Admin
21204
+
21205
+ Save
21206
+
21207
+ import '@vielzeug/refine/input';
21208
+ import '@vielzeug/refine/select';
21209
+ import '@vielzeug/refine/button';
21210
+
21211
+ const form = document.querySelector('#profile-form');
21212
+ form.addEventListener('submit', (event) => {
21213
+ event.preventDefault();
21214
+ console.log(new FormData(form).get('email'));
21215
+ });
21216
+
21217
+ ```
21218
+
21219
+ The browser runs native validation before dispatching `submit`. Call `form.reportValidity()` to show validation
21220
+ feedback directly, or `form.checkValidity()` for a boolean check without the browser's validation UI.
21221
+
20651
21222
  ## Compose Slots
20652
21223
 
20653
21224
  Default slots hold primary content. Named slots identify component-owned regions.
@@ -20842,16 +21413,16 @@ ripple.dispose();
20842
21413
 
20843
21414
  ## Features
20844
21415
 
20845
- - `createRipple()` creates an isolated graph and lifetime boundary.
21416
+ - `createRipple({ onError })` creates an isolated graph with one runtime failure callback.
20846
21417
  - `signal()` stores writable values with configurable equality.
20847
21418
  - `computed()` derives lazy read-only values.
20848
21419
  - `effect()` reacts to dependency changes with cleanup support.
20849
21420
  - `batch()` coalesces synchronous writes and notifications.
20850
21421
  - `createScope()` groups owned reactive work.
20851
21422
  - `watch()` observes one selected source transition.
20852
- - `tap()` provides runtime observability for all graph events.
20853
21423
  - `resource()` owns focused latest-request async state.
20854
21424
  - `fromSubscribable()` creates a disposable bridge for external `{ getSnapshot, subscribe }` sources.
21425
+ - `RippleError`, `RippleCycleError`, and `RippleDisposedError` distinguish package-originated failures.
20855
21426
 
20856
21427
  ## Documentation
20857
21428
 
@@ -20880,15 +21451,18 @@ ripple.dispose();
20880
21451
  | `createScope()` | Group owned reactive work | Sync | Call `run()` to activate it |
20881
21452
  | `untrack()` | Read without tracking | Sync | Read still happens immediately |
20882
21453
  | `watch()` | Observe selected output | Sync | Use `effect()` for broad reads |
20883
- | `tap()` | Observe runtime events | Sync | Handler errors are swallowed |
21454
+ | `resource()` | Load from tracked reactive input | Sync factory / Async loader | `reload()` returns `void`; completion is observable state |
20884
21455
  | `fromSubscribable()` | Bridge external source | Sync | Returned bridge must be disposed |
20885
21456
  | `isReactive()` | Test Ripple's marked readable contract | Sync | Not a security or provenance check |
21457
+ | `RippleError` | Base package error | Sync | Runtime callback failures retain their original value |
21458
+ | `RippleCycleError` | Computed cycle or runaway flush | Sync | Flush failures reach `onError`, not the write site |
21459
+ | `RippleDisposedError` | Work after disposal | Sync | Cached reads remain available |
20886
21460
 
20887
21461
  ## Package Entry Point
20888
21462
 
20889
21463
  | Import | Purpose |
20890
21464
  | --- | --- |
20891
- | `@vielzeug/ripple` | All primitives, types, and errors: signals, computed, effects, scopes, watch, tap, and the isolated graph factory |
21465
+ | `@vielzeug/ripple` | All primitives, types, and errors: signals, computed, effects, scopes, watch, and the isolated graph factory |
20892
21466
 
20893
21467
  ## Graph Creation
20894
21468
 
@@ -20898,11 +21472,14 @@ ripple.dispose();
20898
21472
  function createRipple(options?: RippleOptions): Ripple;
20899
21473
  ```
20900
21474
 
20901
- Creates one isolated reactive graph. Factories on the returned object share scheduling, ownership, and error boundaries. `dispose()` is terminal: `ripple.disposed` becomes `true`, existing owned work is disposed, and creating more graph work throws `RippleDisposedRuntimeError`. Create a new graph for a new lifetime.
21475
+ Creates one isolated reactive graph. Factories on the returned object share scheduling, ownership, and error boundaries. `dispose()` is terminal: `ripple.disposed` becomes `true`, existing owned work is disposed, and creating more graph work throws `RippleDisposedError`. Create a new graph for a new lifetime.
20902
21476
 
20903
21477
  | Parameter | Type | Description |
20904
21478
  | --- | --- | --- |
20905
- | `options.errorPolicy` | `'throw' \| 'swallow'` | Controls whether computed refresh, effect, cleanup, and listener failures rethrow. Default `'throw'`. Error events are always emitted through `tap()`. |
21479
+ | `options.onError` | `(error, context) => void` | Handles computed refresh, effect, cleanup, listener, and flush-loop failures. Default rethrows asynchronously via `queueMicrotask`; pass a no-op to silence. A failure in one node never interrupts sibling propagation. |
21480
+
21481
+ `onError` is the only runtime option. `RippleErrorContext.kind` is `cleanup`, `computed`,
21482
+ `effect`, `flush`, or `listener`; `name` is optional. There is no event stream or error-policy option.
20906
21483
 
20907
21484
  **Returns:** `Ripple`.
20908
21485
 
@@ -20941,7 +21518,7 @@ console.log(isReactive(signal(0)));
20941
21518
 
20942
21519
  ## Default Graph Functions
20943
21520
 
20944
- Root helpers (`signal`, `computed`, `effect`, `batch`, `createScope`, `untrack`, `watch`, `resource`, `fromSubscribable`) operate on a process-lifetime default graph. Use them only when the application has one graph for its entire lifetime: they are never disposed. For isolated, disposable graphs (tests, SSR, embedded features) use `createRipple()`.
21521
+ Root helpers (`signal`, `computed`, `effect`, `batch`, `createScope`, `untrack`, `watch`, `resource`, `fromSubscribable`) operate on a process-lifetime default graph. The graph itself has no public disposal handle; effects, scopes, resources, and bridges still require owner-managed disposal. For isolated, disposable graphs (tests, SSR, embedded features) use `createRipple()`.
20945
21522
 
20946
21523
  ### `signal(initial, options?)`
20947
21524
 
@@ -20973,7 +21550,7 @@ cart.update((state) => ({ ...state, items: state.items + 1 }));
20973
21550
  function computed(derive: () => T, options?: ComputedOptions): Readable;
20974
21551
  ```
20975
21552
 
20976
- Creates a lazy read-only value from reactive reads in `derive`. `peek()` may trigger derivation and throw directly. During dependency propagation, a computed refresh failure is reported through `tap()` with context kind `computed` and does not prevent sibling dependents from updating. A disposed computed returns its last cached value from `peek()` without recomputing.
21553
+ Creates a lazy read-only value from reactive reads in `derive`. `peek()` may trigger derivation and throw directly. During dependency propagation, a computed refresh failure is reported to `onError` with context kind `computed` and does not prevent sibling dependents from updating. A disposed computed returns its last cached value from `peek()` without recomputing; a computed disposed before its first evaluation throws `RippleDisposedError` instead of returning a value.
20977
21554
 
20978
21555
  **Returns:** `Readable`.
20979
21556
 
@@ -20997,6 +21574,12 @@ function effect(callback: () => Cleanup | undefined, options?: EffectOptions): E
20997
21574
 
20998
21575
  Runs immediately and reruns when its tracked reads change. A returned cleanup runs before the next callback or disposal.
20999
21576
 
21577
+ | Parameter | Type | Description |
21578
+ | --- | --- | --- |
21579
+ | `options.name` | `string` | Optional label in error context |
21580
+
21581
+ Effects flush synchronously; `EffectOptions` has no scheduler option. Use `batch()` to coalesce writes.
21582
+
21000
21583
  **Returns:** `EffectHandle`.
21001
21584
 
21002
21585
  **Example:**
@@ -21080,16 +21663,31 @@ function resource(
21080
21663
 
21081
21664
  Creates a disposable readable async state and starts loading immediately. Source changes and `reload()` abort stale work before starting the next loader. The last successful value is preserved as `previous`, including when that value is `undefined`.
21082
21665
 
21666
+ | Parameter | Type | Description |
21667
+ | --- | --- | --- |
21668
+ | `source` | `() => Source` | Tracks reactive input reads |
21669
+ | `loader` | `(source, context) => Promise` | Receives input and a request-owned cancellation signal |
21670
+ | `options.name` | `string` | Optional resource label |
21671
+
21672
+ **Returns:** `Resource`. Source and loader failures become error state, not `onError` reports.
21673
+
21674
+ **Example:**
21675
+
21083
21676
  ```ts
21677
+ import { resource, signal } from '@vielzeug/ripple';
21678
+
21679
+ const userId = signal('42');
21084
21680
  const user = resource(
21085
21681
  () => userId.value,
21086
21682
  (id, { signal }) => fetch(`/users/${id}`, { signal }).then((response) => response.json()),
21087
21683
  );
21684
+ console.log(user.value);
21685
+ user.dispose();
21088
21686
  ```
21089
21687
 
21090
- The state is `{ status: 'pending', previous? }`, `{ status: 'success', value }`, or `{ status: 'error', error, previous? }`. Reads remain available after disposal; `reload()` and new subscriptions throw `RippleDisposedResourceError`.
21688
+ The state is `{ status: 'pending', previous? }`, `{ status: 'success', value }`, or `{ status: 'error', error, previous? }`. Reads remain available after disposal; `reload()` and new subscriptions throw `RippleDisposedError`.
21091
21689
 
21092
- ## Watch and Observability
21690
+ ## Watch
21093
21691
 
21094
21692
  ### `watch(source, callback, options?)`
21095
21693
 
@@ -21117,41 +21715,6 @@ stop.dispose();
21117
21715
 
21118
21716
  ---
21119
21717
 
21120
- ### `ripple.tap(handler, options?)`
21121
-
21122
- ```ts
21123
- function tap(
21124
- handler: (event: RippleEvent) => void,
21125
- options?: { signal?: AbortSignal },
21126
- ): Unsubscribe;
21127
- ```
21128
-
21129
- Runtime observability for writes, computes, effects, disposals, and reported errors. Computed refresh errors are reported with context kind `computed` without interrupting sibling propagation. During graph disposal, taps remain active for owned cleanup errors and node disposal events; `dispose:graph` is emitted last. Handler errors are swallowed. The returned function unsubscribes, and an optional signal auto-detaches it.
21130
-
21131
- The `Ripple.tap()` method is available on instances created with `createRipple()`. After runtime disposal it returns a no-op unsubscribe.
21132
-
21133
- **Returns:** `Unsubscribe`.
21134
-
21135
- **Example:**
21136
-
21137
- ```ts
21138
- import { createRipple } from '@vielzeug/ripple';
21139
-
21140
- const ripple = createRipple({ errorPolicy: 'swallow' });
21141
- const stop = ripple.tap((event) => {
21142
- if (event.type === 'error') console.error(event.context.kind, event.error);
21143
- if (event.type === 'write') console.log(`${event.name}: ${event.previous} → ${event.next}`);
21144
- });
21145
-
21146
- const count = ripple.signal(0);
21147
- count.value = 1;
21148
-
21149
- stop();
21150
- ripple.dispose();
21151
- ```
21152
-
21153
- ---
21154
-
21155
21718
  ### `fromSubscribable(source, options?)`
21156
21719
 
21157
21720
  ```ts
@@ -21167,12 +21730,20 @@ Bridges an external `{ getSnapshot, subscribe }` source into a disposable reacti
21167
21730
  ```ts
21168
21731
  import { fromSubscribable } from '@vielzeug/ripple';
21169
21732
 
21170
- const routerState = fromSubscribable({
21171
- getSnapshot: () => router.getSnapshot(),
21172
- subscribe: (cb) => router.subscribe(cb),
21733
+ let current = 'idle';
21734
+ const listeners = new Set void>();
21735
+ const state = fromSubscribable({
21736
+ getSnapshot: () => current,
21737
+ subscribe: (listener) => {
21738
+ listeners.add(listener);
21739
+ return () => { listeners.delete(listener); };
21740
+ },
21173
21741
  });
21174
21742
 
21175
- routerState.dispose();
21743
+ current = 'ready';
21744
+ for (const listener of listeners) listener();
21745
+ console.log(state.value);
21746
+ state.dispose();
21176
21747
  ```
21177
21748
 
21178
21749
  ## Types
@@ -21187,20 +21758,13 @@ type ComputedOptions = { equals?: Equality; name?: string };
21187
21758
  type EffectOptions = { name?: string };
21188
21759
  type WatchOptions = { equals?: Equality; immediate?: boolean; name?: string };
21189
21760
 
21190
- type RippleErrorContext = { readonly kind: 'cleanup' | 'computed' | 'effect' | 'listener'; readonly name?: string };
21191
- type RippleErrorPolicy = 'throw' | 'swallow';
21192
- type RippleOptions = { errorPolicy?: RippleErrorPolicy };
21193
-
21194
- type RippleEvent =
21195
- | { readonly type: 'compute'; readonly name?: string }
21196
- | { readonly type: 'effect'; readonly name?: string }
21197
- | { readonly type: 'write'; readonly name?: string; readonly next: unknown; readonly previous: unknown }
21198
- | { readonly type: 'dispose'; readonly name?: string; readonly node: 'effect' | 'graph' | 'scope' }
21199
- | { readonly type: 'error'; readonly error: unknown; readonly context: RippleErrorContext };
21761
+ type RippleErrorContext = { readonly kind: 'cleanup' | 'computed' | 'effect' | 'flush' | 'listener'; readonly name?: string };
21762
+ type RippleOptions = { onError?: (error: unknown, context: RippleErrorContext) => void };
21200
21763
 
21201
21764
  interface Readable {
21202
21765
  readonly name?: string;
21203
21766
  peek(): T;
21767
+ /** Change-only: the listener is not called on subscribe, only after a tracked source changes. */
21204
21768
  subscribe(listener: () => void): Unsubscribe;
21205
21769
  readonly value: T;
21206
21770
  }
@@ -21241,7 +21805,6 @@ interface Ripple extends Disposable {
21241
21805
  options?: ResourceOptions,
21242
21806
  ): Resource;
21243
21807
  signal(initial: T, options?: SignalOptions): Signal;
21244
- tap(handler: (event: RippleEvent) => void, options?: { signal?: AbortSignal }): Unsubscribe;
21245
21808
  untrack(fn: () => T): T;
21246
21809
  watch(source: Readable | (() => T), callback: (value: T, previous: T | undefined) => void, options?: WatchOptions): EffectHandle;
21247
21810
  }
@@ -21251,12 +21814,13 @@ interface Ripple extends Disposable {
21251
21814
 
21252
21815
  | Error | Trigger | Notable properties |
21253
21816
  | --- | --- | --- |
21254
- | `RippleError` | Base Ripple error | Use `instanceof RippleError` to narrow unknown values. |
21255
- | `RippleComputedCycleError` | Computed dependency reads itself through a cycle | Extends `RippleError`. |
21256
- | `RippleDisposedResourceError` | `reload()` or `subscribe()` used after resource disposal | Extends `RippleError`. |
21257
- | `RippleDisposedRuntimeError` | Factory or execution API used after `ripple.dispose()` | Extends `RippleError`. |
21258
- | `RippleDisposedScopeError` | `scope.run()` after scope disposal | Extends `RippleError`. |
21259
- | `RippleInfiniteLoopError` | Effect flush exceeds graph iteration limit | Extends `RippleError`. |
21817
+ | `RippleError` | Base Ripple error; a new subscription to a disposed external-source bridge | Use `instanceof RippleError` to narrow unknown values. |
21818
+ | `RippleCycleError` | A computed reads itself, or a flush exceeds the iteration limit | Extends `RippleError`. A flush-loop breach is reported to `onError` (context kind `flush`) instead of throwing from the write site. |
21819
+ | `RippleDisposedError` | New graph work, writes, or subscriptions on a disposed runtime; `run()` on a disposed scope; `reload()` or new subscriptions on a disposed resource; reading a never-evaluated disposed computed | Extends `RippleError`. Cached reads and repeated disposal remain valid. |
21820
+
21821
+ These are the three exported error classes. User callback failures are reported as their original
21822
+ values through `onError`; they are not wrapped in `RippleError`. A direct computed read can throw
21823
+ its derivation error to the caller.
21260
21824
 
21261
21825
  ### Usage Guide
21262
21826
 
@@ -21282,10 +21846,8 @@ Use `createRipple()` for tests, SSR requests, embedded applications, or independ
21282
21846
  ```ts
21283
21847
  import { createRipple } from '@vielzeug/ripple';
21284
21848
 
21285
- const ripple = createRipple({ errorPolicy: 'swallow' });
21286
-
21287
- ripple.tap((event) => {
21288
- if (event.type === 'error') console.log(event.context.kind, event.error);
21849
+ const ripple = createRipple({
21850
+ onError: (error, context) => console.log(context.kind, error),
21289
21851
  });
21290
21852
 
21291
21853
  const count = ripple.signal(0);
@@ -21371,26 +21933,28 @@ const stopWatch = ripple.watch(
21371
21933
  stopWatch.dispose();
21372
21934
  ```
21373
21935
 
21374
- ## Observability
21936
+ ## Error Handling
21375
21937
 
21376
- `tap()` receives writes, computes, effects, disposals, and reported errors. Computed refresh failures are isolated and reported without interrupting sibling propagation. During runtime disposal, owned cleanup errors and node disposal events are emitted before the final graph disposal event; handlers remain active until that sequence completes. Tap-handler errors are swallowed.
21938
+ Failures inside effect callbacks, computed derivations, cleanups, and listeners never corrupt the graph: the failing node is reported and siblings keep propagating. The `onError` option decides what reporting means. By default failures rethrow asynchronously (via `queueMicrotask`), surfacing as unhandled errors; pass a handler to route them to a logger or error boundary instead:
21377
21939
 
21378
21940
  ```ts
21379
- const ripple = createRipple({ errorPolicy: 'swallow' });
21380
-
21381
- ripple.tap((event) => {
21382
- switch (event.type) {
21383
- case 'write':
21384
- console.log(`${event.name}: ${event.previous} → ${event.next}`);
21385
- break;
21386
- case 'error':
21387
- console.error(`${event.context.kind}:`, event.error);
21388
- break;
21389
- }
21941
+ const ripple = createRipple({
21942
+ onError: (error, context) => {
21943
+ // context.kind: 'cleanup' | 'computed' | 'effect' | 'flush' | 'listener'
21944
+ console.error(`${context.kind}:`, error);
21945
+ },
21390
21946
  });
21391
21947
  ```
21392
21948
 
21393
- `errorPolicy` controls whether computed refresh, effect, cleanup, and listener failures rethrow (`'throw'`, default) or are silenced (`'swallow'`). Error events are emitted through `tap()` regardless of policy.
21949
+ `onError` is the only runtime option. Effect options accept only a `name`; effects do not have a
21950
+ microtask scheduler. Use `batch()` to coalesce related writes.
21951
+
21952
+ A direct computed read can throw its derivation error to the caller. `onError` handles computed
21953
+ refresh failures during propagation, not every possible synchronous exception. The exported
21954
+ hierarchy is `RippleError`, `RippleCycleError`, and `RippleDisposedError`; callback failures retain
21955
+ their original values.
21956
+
21957
+ A runaway effect (one that keeps re-triggering itself) is caught by the flush iteration limit and reported with context kind `flush`; the flush is abandoned and the graph stays usable for later writes.
21394
21958
 
21395
21959
  ## Bridging External Sources
21396
21960
 
@@ -21426,15 +21990,19 @@ const user = ripple.resource(
21426
21990
 
21427
21991
  const stop = ripple.effect(() => {
21428
21992
  const state = user.value;
21429
- if (state.status === 'success') renderUser(state.value);
21430
- if (state.status === 'error') renderError(state.error);
21993
+ if (state.status === 'success') console.log(state.value);
21994
+ if (state.status === 'error') console.error(state.error);
21431
21995
  });
21432
21996
 
21433
21997
  stop.dispose();
21434
21998
  user.dispose();
21435
21999
  ```
21436
22000
 
21437
- Disposed resources retain readable state but reject `reload()` and new subscriptions with `RippleDisposedResourceError`. Use Sourcerer for paginated collection state and a dedicated server-state cache for shared keyed reads, retries, and invalidation.
22001
+ Source and loader failures become resource error state rather than `onError` reports.
22002
+ `reload()` returns `void`; observe state to know when loading finishes. Disposed resources retain
22003
+ readable state but throw `RippleDisposedError` from `reload()` and new subscriptions. Use Sourcerer
22004
+ for paginated collection state and a dedicated server-state cache for shared keyed reads, retries,
22005
+ and invalidation.
21438
22006
 
21439
22007
  ## Object State
21440
22008
 
@@ -21554,13 +22122,21 @@ ripple.dispose();
21554
22122
 
21555
22123
  ## Gotchas
21556
22124
 
22125
+ ### `subscribe()` is change-only
22126
+
22127
+ `Readable.subscribe(listener)` never calls the listener on subscribe: it fires only after a tracked source changes. Read the current value at the call site when you need an initial state.
22128
+
21557
22129
  ### `subscribe()` forces computed evaluation
21558
22130
 
21559
22131
  `Readable.subscribe()` calls `peek()` before registering the listener. For signals this is a no-op, but for computeds it forces `refresh()`: the derivation runs immediately even if no one reads `.value`. This ensures `equals` comparison works on the first dependency change. Avoid subscribing to expensive computeds unless you need their value.
21560
22132
 
22133
+ ### Diamonds recompute each path once per write
22134
+
22135
+ A computed with dependents refreshes eagerly during propagation, so effects never re-run for unchanged derived values. The tradeoff: in a diamond (`a → b, c → d`), one write to `a` recomputes both `b` and `c` and then `d` once per changed path. Values a consumer observes are always consistent: a dependent reading a stale intermediate pulls the fresh value on read, so no glitched state is ever visible. Prefer keeping diamond depth shallow when a derivation is expensive.
22136
+
21561
22137
  ### Computed first-run failure is recoverable
21562
22138
 
21563
- If a computed's `derive` throws on its first run (e.g., a source is `null`), the computed commits the partial dependencies it tracked before the throw. When a dependency changes and the derivation can succeed, the computed refreshes and notifies its dependents. Effects that read a failing computed report the error through `tap()` and re-run when the computed recovers.
22139
+ If a computed's `derive` throws on its first run (e.g., a source is `null`), the computed commits the partial dependencies it tracked before the throw. When a dependency changes and the derivation can succeed, the computed refreshes and notifies its dependents. Effects that read a failing computed report the error through `onError` and re-run when the computed recovers.
21564
22140
 
21565
22141
  ## Best Practices
21566
22142
 
@@ -21571,8 +22147,7 @@ If a computed's `derive` throws on its first run (e.g., a source is `null`), the
21571
22147
  - Batch related synchronous writes.
21572
22148
  - Use `watch()` only for selected source transitions; callback-only reads are untracked.
21573
22149
  - Dispose external-source bridges when their owner ends.
21574
- - Use `tap()` for runtime observability; set `errorPolicy: 'swallow'` to silence rethrow.
21575
- - Use `resource()` for focused async state and Sourcerer for paginated collection workflows.
22150
+ - Route runtime failures with `onError` and observe async failures through resource state.
21576
22151
 
21577
22152
  ### Examples
21578
22153
 
@@ -21591,7 +22166,7 @@ If a computed's `derive` throws on its first run (e.g., a source is `null`), the
21591
22166
  - Async Resource (id: `async-resource`)
21592
22167
  - Create Graph, Signal, Computed & Effect (id: `basic-signal`)
21593
22168
  - Batch & Untrack (id: `batch-untrack`)
21594
- - Microtask Effect (id: `effect-options`)
22169
+ - Named Effect and onError (id: `effect-options`)
21595
22170
  - Nested Effect Ownership (id: `scope-ownership`)
21596
22171
  - Immutable State (id: `store-basics`)
21597
22172
  - Watch Selected Value (id: `watch-selected-value`)
@@ -28350,7 +28925,9 @@ try {
28350
28925
  - `createPageSource()`: loads numbered pages and exposes direct navigation commands
28351
28926
  - `createCursorSource()`: follows opaque cursors returned by the loader
28352
28927
  - `createInfiniteSource()`: appends pages while preserving loaded items
28928
+ - `createResource()`: starts loading one value immediately, or awaits `reload()` with `start: false`
28353
28929
  - `setParams()`: replaces consumer-owned loader parameters and resets pagination
28930
+ - `PageSource.apply()`: reconciles page and structurally compared params in at most one request
28354
28931
  - `subscribe()`: publishes complete immutable state replacements
28355
28932
 
28356
28933
  ## Documentation
@@ -28377,7 +28954,10 @@ try {
28377
28954
  | `createPageSource()` | Load numbered pages | Async | Construction is inert |
28378
28955
  | `createCursorSource()` | Load server-issued cursor pages | Async | `after` and `before` cannot coexist initially |
28379
28956
  | `createInfiniteSource()` | Append numbered pages | Async | `loadMore()` is a no-op while loading or exhausted |
28957
+ | `createResource()` | Load one reactive value | Async | Starts loading during construction; `start: false` opts out |
28380
28958
  | `SourcererError` | Base package error | Sync | Loader errors retain their original type |
28959
+ | `SourcererConfigurationError` | Invalid pagination configuration | Sync | Pages and page sizes must be positive integers |
28960
+ | `SourcererDisposedError` | Command after source disposal | Sync | In-flight work instead settles without committing |
28381
28961
 
28382
28962
  ## Package Entry Point
28383
28963
 
@@ -28462,6 +29042,7 @@ await source.reload();
28462
29042
 
28463
29043
  | Method | Returns | Description |
28464
29044
  | --- | --- | --- |
29045
+ | `apply(update)` | `Promise` | Reconcile `{ params, page }` in at most one load; params compare structurally |
28465
29046
  | `first()` | `Promise` | Load page one |
28466
29047
  | `goTo(page)` | `Promise` | Load a positive page |
28467
29048
  | `last()` | `Promise` | Load the last known page |
@@ -28473,6 +29054,14 @@ await source.reload();
28473
29054
  | `subscribe(listener)` | `() => void` | Register a state listener and return its unsubscribe function |
28474
29055
  | `dispose()` | `void` | Abort work and stop notifications |
28475
29056
 
29057
+ `apply(update: PageSourceUpdate): Promise` compares params structurally with
29058
+ `isEqual`. Changed params use the supplied positive page without clamping, or page one when omitted.
29059
+ Unchanged params with a page use `goTo()` and clamp after the first successful load.
29060
+ An unchanged request does not refetch a committed result or duplicate an active request; it loads
29061
+ an inert source or retries an uncommitted request after failure. Omitted or `undefined` params
29062
+ preserve the requested value; use `setParams(undefined)` when `undefined` is a valid replacement.
29063
+ The update does not include page size; use `setPageSize()` separately.
29064
+
28476
29065
  ---
28477
29066
 
28478
29067
  ### `createCursorSource()`
@@ -28550,6 +29139,54 @@ await source.loadMore();
28550
29139
  | `subscribe(listener)` | `() => void` | Register a state listener and return its unsubscribe function |
28551
29140
  | `dispose()` | `void` | Abort work and stop notifications |
28552
29141
 
29142
+ ---
29143
+
29144
+ ### `createResource()`
29145
+
29146
+ ```ts
29147
+ function createResource(config: ResourceConfig): Resource
29148
+ ```
29149
+
29150
+ Returns observable state for one asynchronously loaded value. It does not suspend rendering,
29151
+ cache by key, or track external reactive inputs.
29152
+
29153
+ | Parameter | Type | Description |
29154
+ | --- | --- | --- |
29155
+ | `config.load` | `(context: ResourceLoadContext) => Promise` | Loader; receives a signal aborted on disposal and on the next `reload()` |
29156
+ | `config.start` | `boolean` | Start the initial load during construction; defaults to `true` |
29157
+
29158
+ **Returns:** `Resource`.
29159
+
29160
+ ```ts
29161
+ import { createResource } from '@vielzeug/sourcerer';
29162
+
29163
+ const profile = createResource({
29164
+ load: ({ signal }) => fetch('/me', { signal }).then((response) => response.json()),
29165
+ start: false,
29166
+ });
29167
+
29168
+ const stop = profile.subscribe((state) => console.log(state.data, state.loading, state.error));
29169
+ console.log(profile.state);
29170
+ try {
29171
+ await profile.reload();
29172
+ } finally {
29173
+ stop();
29174
+ profile.dispose();
29175
+ }
29176
+ ```
29177
+
29178
+ | Method | Returns | Description |
29179
+ | --- | --- | --- |
29180
+ | `reload()` | `Promise` | Refetch with a fresh signal; rejects so callers can await it |
29181
+ | `subscribe(listener)` | `() => void` | Register a state listener and return its unsubscribe function |
29182
+ | `dispose()` | `void` | Abort the in-flight load and stop notifications |
29183
+
29184
+ Construction starts the initial load unless `start: false`. Its failure surfaces through
29185
+ `state.error`; a later `reload()` rejects and retains the previous `data` on failure.
29186
+ A superseded or disposed request settles without touching state. Subscriptions are change-only:
29187
+ read `state` for the current snapshot, including the initial loading state. `dispose()` is idempotent,
29188
+ aborts `disposalSignal`, and leaves the last snapshot readable; subscriptions after disposal are inert.
29189
+
28553
29190
  ## Types
28554
29191
 
28555
29192
  ### Page source types
@@ -28568,8 +29205,11 @@ type PageSourceConfig = Readonly): Promise>;
28568
29205
  } & (undefined extends TParams ? { params?: TParams } : { params: TParams })
28569
29206
  >;
28570
29207
 
29208
+ type PageSourceUpdate = Readonly;
29209
+
28571
29210
  type PageSource = {
28572
29211
  [Symbol.dispose](): void;
29212
+ apply(update: PageSourceUpdate): Promise;
28573
29213
  readonly disposalSignal: AbortSignal;
28574
29214
  dispose(): void;
28575
29215
  readonly disposed: boolean;
@@ -28588,6 +29228,28 @@ type PageSource = {
28588
29228
 
28589
29229
  The conditional config field makes `params` optional only when `TParams` includes `undefined`.
28590
29230
 
29231
+ ### Resource types
29232
+
29233
+ ```ts
29234
+ type ResourceLoadContext = Readonly;
29235
+
29236
+ type ResourceState = Readonly;
29237
+
29238
+ type ResourceConfig = Readonly;
29239
+ start?: boolean;
29240
+ }>;
29241
+
29242
+ type Resource = {
29243
+ [Symbol.dispose](): void;
29244
+ readonly disposalSignal: AbortSignal;
29245
+ dispose(): void;
29246
+ readonly disposed: boolean;
29247
+ reload(): Promise;
29248
+ readonly state: ResourceState;
29249
+ subscribe(listener: (state: ResourceState) => void): () => void;
29250
+ };
29251
+ ```
29252
+
28591
29253
  ### Local source types
28592
29254
 
28593
29255
  ```ts
@@ -28736,7 +29398,7 @@ console.log(source.state.params);
28736
29398
  source.dispose();
28737
29399
  ```
28738
29400
 
28739
- A non-optional parameter type requires an initial `params` value. Sourcerer compares parameter values with `Object.is`; reuse a value when setting it again should be a no-op.
29401
+ A non-optional parameter type requires an initial `params` value. `setParams()` compares values with `Object.is`; `apply()` compares structurally, avoiding redundant requests from a router or reactive effect. Both can load an inert source or retry an uncommitted request. See [Apply Page and Filter Together](#apply-page-and-filter-together).
28740
29402
 
28741
29403
  While replacement parameters load, `state.params` continues to describe the committed items and `state.pendingParams` contains the requested value. Page-only navigation does not set `pendingParams`.
28742
29404
 
@@ -28756,6 +29418,47 @@ await source.setPageSize(50);
28756
29418
 
28757
29419
  After the first successful load, navigation is clamped to the known `pageCount`. Before that load, `goTo()` accepts any positive page because the total is unknown.
28758
29420
 
29421
+ ## Apply Page and Filter Together
29422
+
29423
+ A URL query, a filter form, or a router state usually carries the page and the filters at once. `apply()` accepts both in one call and issues at most one request.
29424
+
29425
+ ```ts
29426
+ import { createPageSource } from '@vielzeug/sourcerer';
29427
+
29428
+ const names = ['Ada', 'Grace', 'Linus', 'Ada Byron'];
29429
+ const source = createPageSource({
29430
+ load: async ({ page, pageSize, params }) => {
29431
+ const matching = names.filter((name) => name.includes(params.search));
29432
+ const start = (page - 1) * pageSize;
29433
+ return { items: matching.slice(start, start + pageSize), totalItems: matching.length };
29434
+ },
29435
+ pageSize: 1,
29436
+ params: { search: '' },
29437
+ });
29438
+
29439
+ try {
29440
+ await source.apply({ page: 2, params: { search: 'Ada' } });
29441
+ console.log(source.state.items); // ['Ada Byron']
29442
+ await source.apply({ params: { search: 'Ada' } });
29443
+ console.log(source.state.pagination.page); // 2; no extra request
29444
+ await source.apply({ params: { search: 'Grace' } });
29445
+ console.log(source.state.pagination.page); // 1
29446
+ } finally {
29447
+ source.dispose();
29448
+ }
29449
+ ```
29450
+
29451
+ Parameters are compared structurally, so a freshly built object with the same contents does not
29452
+ refetch a committed result or duplicate active work. An inert source still loads, and an unchanged
29453
+ request can retry after failure. When parameters change,
29454
+ `page` is validated but never clamped: the page count still describes the previous filter set, so
29455
+ clamping against it would silently move the reader elsewhere. Omit `page` in that case and the
29456
+ source returns to page one. When parameters are unchanged, `apply()` routes through `goTo()` and
29457
+ clamps to the known page count.
29458
+
29459
+ Omitted or `undefined` params keep the requested value. Use `setParams(undefined)` when
29460
+ your parameter type allows `undefined` and you want to replace the value with it.
29461
+
28759
29462
  ## Filter Local Collections
28760
29463
 
28761
29464
  Use a local source when data is already in memory. Its commands are synchronous.
@@ -28831,6 +29534,44 @@ source.dispose();
28831
29534
 
28832
29535
  `loadMore()` is a no-op while another request is active or after `pagination.hasMore` becomes false. `setParams()` and `setPageSize()` replace the feed from page one.
28833
29536
 
29537
+ ## Load a Single Value
29538
+
29539
+ Use a resource for one reactive value: a profile, a settings document, a pricing summary. It loads once and refetches on demand.
29540
+
29541
+ ```ts
29542
+ import { createResource } from '@vielzeug/sourcerer';
29543
+
29544
+ type Profile = { name: string };
29545
+ const profile = createResource({
29546
+ load: async ({ signal }) => {
29547
+ const response = await fetch('/me', { signal });
29548
+ if (!response.ok) throw new Error(`Profile request failed: ${response.status}`);
29549
+ return response.json();
29550
+ },
29551
+ start: false,
29552
+ });
29553
+
29554
+ const stop = profile.subscribe((state) => console.log(state.data, state.loading, state.error));
29555
+ console.log(profile.state);
29556
+ try {
29557
+ await profile.reload();
29558
+ } catch (error) {
29559
+ console.error(error);
29560
+ } finally {
29561
+ stop();
29562
+ profile.dispose();
29563
+ }
29564
+ ```
29565
+
29566
+ A resource starts loading during construction by default; the example uses `start: false` to await
29567
+ the first load explicitly. Automatic initial-load failures land in `state.error` without an
29568
+ unhandled rejection. Explicit `reload()` calls reject current failures and preserve previous `data`.
29569
+ Reloading aborts older work, whose promise settles without committing.
29570
+
29571
+ Read `state` before subscribing when you need the initial snapshot: subscriptions notify only
29572
+ changes. A resource does not track Ripple signals or cache values by key; call `reload()` when
29573
+ external inputs change. Disposal aborts work and retains the last readable snapshot.
29574
+
28834
29575
  ## Handle Requests and Errors
28835
29576
 
28836
29577
  A new remote command aborts older work. Superseded or disposed work settles without committing. A current loader failure preserves committed items, writes the error to state, and rejects the command.
@@ -28963,6 +29704,7 @@ Use Scout to compute indexed local matches, Ripple to project state into reactiv
28963
29704
  - [Page Params with URL State](./examples/remote-search-with-url-state.md)
28964
29705
  - [Cursor-Based Pagination](./examples/cursor-based-pagination.md)
28965
29706
  - [Infinite Scroll](./examples/infinite-scroll.md)
29707
+ - [Load a Single Value](./examples/single-value-resource.md)
28966
29708
  - [Framework Integration](./examples/framework-integration.md)
28967
29709
  - [Remote Data with Courier](./examples/sourcerer-with-courier.md)
28968
29710
  - [Reactive Controls with Ripple](./examples/sourcerer-with-ripple.md)
@@ -28970,12 +29712,14 @@ Use Scout to compute indexed local matches, Ripple to project state into reactiv
28970
29712
 
28971
29713
  ### REPL Examples
28972
29714
 
29715
+ - apply - Page and params in one call (id: `apply`)
28973
29716
  - Cursor Source (id: `cursor-source`)
28974
29717
  - Error Handling (id: `error-handling`)
28975
29718
  - Infinite Source (id: `infinite-source`)
28976
29719
  - Source Lifecycle (id: `lifecycle`)
28977
29720
  - Local Source (id: `local-source`)
28978
29721
  - Page Source (id: `page-source`)
29722
+ - createResource - One reactive value (id: `resource`)
28979
29723
 
28980
29724
  ---
28981
29725