@vielzeug/codex 26.10.6 → 26.10.9

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.8
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
@@ -116,7 +116,8 @@ console.log(parsed.entries);
116
116
  - [Usage Guide](./usage.md) — stand up a collection, deploy the Worker, wire the client.
117
117
  - [API Reference](./api.md) — every export, all three entry points, the full route table.
118
118
  - [Examples](./examples.md) — recipes from first deployment to GDPR erasure.
119
- - [Release Status](./migration.md) — unreleased package and fresh-database scope.
119
+ - [Migration](./migration.md) — breaking changes after the first release and
120
+ fresh-database scope.
120
121
 
121
122
  ## See Also
122
123
 
@@ -146,8 +147,8 @@ console.log(parsed.entries);
146
147
  | Import | Purpose |
147
148
  | --- | --- |
148
149
  | `@vielzeug/archive` | Browser-safe collections, constants, schemas and errors |
149
- | `@vielzeug/archive/worker` | Catalog factory, issuer-session service binding and event types |
150
- | `@vielzeug/archive/auth` | Issuer factory, operator lifetime and binding types |
150
+ | `@vielzeug/archive/worker` | Catalog factory, issuer signing-key/session service binding and event types |
151
+ | `@vielzeug/archive/auth` | Issuer factory and binding types |
151
152
 
152
153
  ## Collections
153
154
 
@@ -192,7 +193,7 @@ console.log(derived.contentHash);
192
193
  | `derive(raw)` | `Promise` |
193
194
  | `contract.entry` | Public entry schema |
194
195
  | `contract.ownedEntry`, `ownedEntries` | Owner view with `clientKey` and `hidden` |
195
- | `contract.page` | Entries and forward/backward cursors |
196
+ | `contract.page` | Entries and the forward cursor |
196
197
  | `contract.publishInput`, `updateInput` | Publish and revision-guarded update request schemas |
197
198
  | `filters`, `tags` | Frozen vocabulary snapshots |
198
199
 
@@ -202,7 +203,7 @@ submitted content. Invalid index-result shapes also throw.
202
203
 
203
204
  Const generics preserve the content-version literal and infer filter/tag unions from vocabulary arrays without `as const`. `NoInfer` prevents `index` from widening those unions; omitted arrays infer `never`, permitting only `filter: null` and empty tags.
204
205
 
205
- Objects serialize with recursively sorted keys; arrays preserve order unless your canonicalizer changes it. SHA-256 hashes the core-owned `{contentVersion, payload}` envelope. Response schemas require stored payloads to remain stable under parsing; a response-validation fault has stage `response`.
206
+ Objects serialize with recursively sorted keys; arrays preserve order unless your canonicalizer changes it. SHA-256 hashes the core-owned `{contentVersion, payload}` envelope. Stored payloads are canonical by construction, so the emitted entry schemas embed the plain payload schema.
206
207
 
207
208
  `filter`, tags and searchable title are persisted projections, not computed during reads. Changing `index` or its vocabulary does not update stored rows. The active definition and persisted dataset must remain consistent; Archive has no projection-update or historical-schema API.
208
209
 
@@ -242,8 +243,13 @@ export default createArchiveWorker([notes]);
242
243
  | `tap(handler, { signal? }?)` | Returns unsubscribe; a pre-aborted signal registers nothing |
243
244
 
244
245
  `AUTH_ISSUER_SERVICE` is a required `fetch`-shaped service binding to the issuer.
246
+ The catalog fetches signing keys from the configured `JWT_JWKS_URL` through that
247
+ binding, not through global `fetch`. The URL still selects the issuer's JWKS
248
+ route and must satisfy the environment's transport rules. Key requests use GET
249
+ without a session cookie and do not follow redirects.
245
250
  The catalog verifies the signed JWT locally, then asks the issuer to validate its
246
- current account session version. It does not cache successful issuer verdicts.
251
+ current account session version. JOSE caches signing keys; the catalog does not
252
+ cache successful issuer session verdicts.
247
253
  If the service exchange fails or exceeds its fixed 5000 ms timeout (body decoding
248
254
  included), the catalog returns `503 auth-unavailable`; the timeout does not cancel
249
255
  work already started by the issuer.
@@ -317,8 +323,8 @@ time. The sweep deletes expired links and closed per-user send windows, then
317
323
  unverified accounts with neither remaining links nor send-budget references.
318
324
  Verified accounts are retained until explicit account deletion.
319
325
 
320
- `@vielzeug/archive/auth` exports `OPERATOR_SESSION_MAX_AGE_SECONDS = 900` alongside
321
- the factory and issuer binding types.
326
+ `OPERATOR_SESSION_MAX_AGE_SECONDS = 900` is part of the browser-safe root
327
+ contract; `/auth` exports the factory and issuer binding types.
322
328
 
323
329
  `AUTH_JWT_PREVIOUS_PUBLIC_JWKS` optionally publishes retired public RS256 keys and verifies sessions minted before rotation; private members reject configuration. The package has no demo sign-in routes.
324
330
 
@@ -345,7 +351,7 @@ Every route below is mounted under `/api/v1`. Mutation guards require an exact a
345
351
  | `GET /health` | Public catalog readiness; 200 `ok` or 503 `degraded` |
346
352
  | `POST /sign-out` | Catalog-local sign-out; `{ ok: true }` and expired session cookie, without issuer access |
347
353
  | `GET /collections/:collection` | Collection metadata |
348
- | `GET /collections/:collection/entries` | `author`, `filter`, `tag`, `search`, `sort`, `cursor`, `limit`; bidirectional entry page |
354
+ | `GET /collections/:collection/entries` | `author`, `filter`, `tag`, `search`, `sort`, `cursor`, `limit`; forward entry page |
349
355
  | `POST /collections/:collection/entries` | `{ clientKey, payload }`; 201 create or 200 replay |
350
356
  | `GET /collections/:collection/entries/:id` | Public entry; always `no-store` |
351
357
  | `PUT /collections/:collection/entries/:id` | Owner-only `{ expectedRevision, payload }` |
@@ -403,8 +409,10 @@ clear the cookie. This revokes all sessions, including the caller's, after a
403
409
  successful response. Requests authorized before revocation may finish; there is
404
410
  no cross-database transactional or in-flight cancellation guarantee.
405
411
 
406
- The catalog requires `AUTH_ISSUER_SERVICE`, a service binding to the issuer. After
407
- local JWT signature/claims verification, it requests
412
+ The catalog requires `AUTH_ISSUER_SERVICE`, a service binding to the issuer for
413
+ both JWKS retrieval and live session validation. Signing-key retrieval uses
414
+ `JWT_JWKS_URL` through the binding, so it does not depend on a public same-zone
415
+ Worker-to-Worker fetch. After local JWT signature/claims verification, it requests
408
416
  `GET /api/v1/auth/session` through that binding on every private request. Only a
409
417
  strictly validated 401 `unauthenticated` remains a 401. Malformed, unexpected,
410
418
  provider and 5xx responses become 503 `auth-unavailable`; there is no authority
@@ -434,7 +442,7 @@ Effective hide, unhide, operator removal and publisher erasure append audit rows
434
442
  | `ARCHIVE_OPERATOR_ROLE` | `'archive-operator'` |
435
443
  | `SESSION_COOKIE_NAME`, `SESSION_COOKIE_PATH` | `'archive_session'`, `'/api/v1'` |
436
444
  | `SESSION_MAX_AGE_SECONDS` | `2592000` |
437
- | `OPERATOR_SESSION_MAX_AGE_SECONDS` | `900`; shared root contract, also re-exported by `/auth` |
445
+ | `OPERATOR_SESSION_MAX_AGE_SECONDS` | `900`; shared root contract |
438
446
  | `ACCOUNT_HEADER` | `'X-Archive-Account'`; required issuer-account UUID on coordinated erasure |
439
447
  | `PUBLISHER_HEADER`, `REQUEST_MARKER_HEADER` | `'x-archive-publisher'`, `'x-archive-request'` |
440
448
  | `ILL_FORMED_TEXT`, `isWellFormedText(value: string): boolean` | Reject control characters and lone UTF-16 surrogates |
@@ -579,10 +587,9 @@ interface ArchiveCollection {
579
587
  interface ArchivePage {
580
588
  cursor: string | null;
581
589
  entries: readonly T[];
582
- previous: string | null;
583
590
  }
584
591
  type ArchiveSort = 'liked' | 'recent';
585
- type ArchiveDerivationStage = 'schema' | 'canonicalize' | 'index' | 'hash' | 'response';
592
+ type ArchiveDerivationStage = 'canonicalize' | 'hash' | 'index' | 'schema';
586
593
  type ArchiveIdentity = { id: string; name: string; operator: boolean } | null;
587
594
  type ArchiveAccount = {
588
595
  id: string; email: string; createdAt: string; lastSignInAt: string | null;
@@ -770,7 +777,7 @@ window resets atomically on a later sign-in request.
770
777
  | --- | --- | --- |
771
778
  | `ArchiveError` | Base for public package errors | `name`, `message`, optional `cause` |
772
779
  | `ArchiveDefinitionError` | Invalid collection definition or duplicate registered collection names | Extends `ArchiveError` |
773
- | `ArchiveDerivationError` | Unexpected schema/canonicalization/index/hash/response fault or violated collection invariant | Extends `ArchiveError`; readonly `stage: ArchiveDerivationStage`; optional `cause` |
780
+ | `ArchiveDerivationError` | Unexpected schema/canonicalization/index/hash fault or violated collection invariant | Extends `ArchiveError`; readonly `stage: ArchiveDerivationStage`; optional `cause` |
774
781
 
775
782
  `ArchiveError` and `ArchiveDefinitionError` accept `(message: string, opts?: ErrorOptions)`.
776
783
  `ArchiveDerivationError` accepts `(stage: ArchiveDerivationStage, message: string, opts?: ErrorOptions)`.
@@ -779,8 +786,9 @@ failures use the wire envelope, not these thrown classes. Internal `ApiError` an
779
786
  `AuthOperationError` are not exported.
780
787
 
781
788
  Stored entries that fail the current wire schema return 503 `record-unavailable`.
782
- Unexpected exceptions in the collection's response-payload callbacks instead
783
- throw `ArchiveDerivationError` with stage `response` and become sanitized 500s.
789
+ Stored payloads are canonical by construction, so the emitted entry schemas embed
790
+ the plain payload schema: parsing a response re-runs that schema, and an
791
+ exception from one of its callbacks becomes a sanitized 500.
784
792
 
785
793
  The complete public error signatures are:
786
794
 
@@ -884,11 +892,12 @@ Lossy or non-JSON schema output and invalid index-result shapes also throw.
884
892
 
885
893
  `contentVersion` participates in content identity. Request handling validates
886
894
  only the current collection definition; it does not interpret historical schemas.
887
- Archive is unreleased and provides no content-upgrade APIs.
895
+ Archive provides no content-upgrade APIs: a new definition never reinterprets
896
+ stored rows.
888
897
 
889
898
  Filters, tags and searchable titles are persisted data derived at publish/update
890
899
  time. Editing `index` or its vocabularies does not recompute existing projections.
891
- Keep the active definition and dataset consistent. For an unreleased development
900
+ Keep the active definition and dataset consistent. For a development
892
901
  deployment, deliberately reset and reseed a fresh database when you change payload
893
902
  or projection semantics; the package does not supply a migration framework.
894
903
  Resetting destroys existing data. Perform it only as an explicit, deliberate
@@ -925,8 +934,8 @@ well-formed, or the route answers 503 without naming what is missing:
925
934
  | ------------------------ | ---------------------------------------------------------------- |
926
935
  | `APP_ENV` | `local` or `production` (transport rules and the pepper policy) |
927
936
  | `ALLOWED_ORIGINS` | Exact canonical origins; never wildcards |
928
- | `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | Your identity provider |
929
- | `AUTH_ISSUER_SERVICE` | Required issuer service binding for live session-version checks |
937
+ | `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | Exact issuer claim, audience and issuer JWKS endpoint URL |
938
+ | `AUTH_ISSUER_SERVICE` | Required issuer service binding for signing-key retrieval and live session-version checks |
930
939
  | `LIKES_ENABLED` / `PUBLISHING_ENABLED` | Feature flags |
931
940
  | `RATE_LIMIT_KEY_SECRET` | HMAC pepper. Required ≥ 32 chars in production; local may fall back |
932
941
  | `DB` / `READ_LIMIT` / `WRITE_LIMIT` | D1 + rate-limit bindings |
@@ -939,6 +948,14 @@ make the header lie.
939
948
  Generated Env types (wrangler's `worker-configuration.d.ts`) satisfy the package's
940
949
  structural `ArchiveWorkerEnv` — no `@cloudflare/workers-types` dependency, no casts.
941
950
 
951
+ Signing keys are fetched from `JWT_JWKS_URL` through `AUTH_ISSUER_SERVICE`, not
952
+ global `fetch`. Point the URL at the bound issuer's `/api/v1/auth/jwks.json`
953
+ route; retain the HTTPS production or loopback HTTP local configuration rules.
954
+ This transport supports Workers on the same Cloudflare zone without a public
955
+ Worker-to-Worker fetch. JOSE caches signing keys, but every private request still
956
+ checks the current issuer session version. A signing-key provider failure is
957
+ `503 auth-unavailable`, not a reason to clear the browser's signed-in state.
958
+
942
959
  ## The auth issuer
943
960
 
944
961
  `createAuthWorker()` from `@vielzeug/archive/auth` is the reference passwordless
@@ -1063,8 +1080,7 @@ The issuer points to
1063
1080
  fresh-database initialization definitions, not upgrades. The issuer DDL initializes
1064
1081
  `auth_users.session_version` to `0` and stamps `auth_schema_meta.applied` as
1065
1082
  `AUTH_SCHEMA_VERSION` (`1`). Neither file automatically deletes or resets data.
1066
- While Archive is unreleased, its
1067
- canonical initial schemas are edited directly. Reset and reseed an unreleased
1083
+ Archive's canonical initial schemas are edited directly. Reset and reseed a
1068
1084
  development database deliberately when its schema or collection semantics no
1069
1085
  longer match; reapplying the initial file does not update an existing dataset.
1070
1086
  The initialization file also stamps the storage schema
@@ -1115,10 +1131,11 @@ POST /api/v1/auth/verify (guarded {token} rede
1115
1131
  Publishing is bounded by `maxPublications` live entries per `(collection, owner)`.
1116
1132
  The quota check runs inside the guarded insert.
1117
1133
 
1118
- `/me/likes` without `ids`, `/me/reports`, operator reports and `/moderation-log`
1119
- use forward keyset pages: follow `cursor` until `null`, preserving query context.
1120
- `limit` defaults to `pageSize` and is at most `maxPageSize`; there is no total
1121
- export bound. `/me/likes?ids=…` remains a subset lookup returning `{ ids }`, not a
1134
+ Every list — entry pages, `/me/likes` without `ids`, `/me/reports`, operator
1135
+ reports and `/moderation-log` — pages forward-only: follow `cursor` until `null`,
1136
+ preserving query context; a cursor minted for another query is a 400. `limit`
1137
+ defaults to `pageSize` and is at most `maxPageSize`; there is no total export
1138
+ bound. `/me/likes?ids=…` remains a subset lookup returning `{ ids }`, not a
1122
1139
  page, and cannot combine `ids` with `cursor` or `limit`.
1123
1140
 
1124
1141
  Shared wire names live in the package — `archive_session` cookie,
@@ -1281,7 +1298,7 @@ export function useRecipes() {
1281
1298
  have them; a value outside it rejects loudly.
1282
1299
  - Keep `canonicalize` pure and schema-true: reordered lists in, canonical form out.
1283
1300
  - Initialize fresh databases with the shipped DDL and deliberately reset/reseed
1284
- unreleased development data when collection or storage semantics change.
1301
+ development data when collection or storage semantics change.
1285
1302
  - Treat `ARCHIVE_ERROR_CODES` and `archiveErrorResponseSchema` as one tested contract.
1286
1303
  - Pin the expected-publisher header from the identity you captured at sign-in, never from storage alone.
1287
1304
  - Grant the operator role claim only through your issuer, and only to accounts that
@@ -1375,6 +1392,8 @@ console.log(byRole, health, profile);
1375
1392
  - **`tryParseJson`**: preserve JSON syntax failures from `/object`
1376
1393
  - **`allocate`**: lossless proportional distribution from `/math`
1377
1394
  - **`chunk`, `zip`, `compact`**: typed collection transforms from `/array`
1395
+ - **`paginate`**: one page plus its totals from an in-memory list, in a one- or zero-based page base
1396
+ - **`sort`**: selector, `{ key: direction }`, or ordered grid criteria from one entry point
1378
1397
  - **`range`, `average`, `median`**: common numeric operations from `/math`
1379
1398
  - **`assert`, `tap`**: typed control-flow helpers from `/function`
1380
1399
  - **`isEqual`**: structural equality from package root
@@ -1402,6 +1421,8 @@ console.log(byRole, health, profile);
1402
1421
  | --- | --- | --- | --- |
1403
1422
  | `groupBy` | Group values by key (prototype-pollution guarded) | Sync | Root export |
1404
1423
  | `chunk` / `zip` / `compact` | Typed collection transforms | Sync | Available from `/array` |
1424
+ | `paginate` | Cut one page and its totals out of a list | Sync | Pages past the last return empty `items`, unclamped |
1425
+ | `sort` | Sort by a selector, keyed fields, or ordered path criteria | Sync | Criteria paths are resolved safely; mixed value types throw |
1405
1426
  | `range` / `average` / `median` | Numeric generation and aggregation | Sync | Available from `/math` |
1406
1427
  | `assert` | Assert and narrow a condition | Sync | Available from `/function` |
1407
1428
  | `retry` | Retry async work | Async | Rethrows final error |
@@ -1422,7 +1443,7 @@ console.log(byRole, health, profile);
1422
1443
  | Import | Purpose |
1423
1444
  | --- | --- |
1424
1445
  | `@vielzeug/arsenal` | Curated common utilities |
1425
- | `@vielzeug/arsenal/array` | Immutable transforms, set operations, sorting, fuzzy search |
1446
+ | `@vielzeug/arsenal/array` | Immutable transforms, pagination, set operations, sorting, fuzzy search |
1426
1447
  | `@vielzeug/arsenal/async` | Retry, cancellation, task pool, timing |
1427
1448
  | `@vielzeug/arsenal/cache` | In-memory cache and memoization |
1428
1449
  | `@vielzeug/arsenal/encoding` | Base64url and native byte compression |
@@ -1450,6 +1471,35 @@ unzip(rows): tupleOfArrays
1450
1471
 
1451
1472
  `take`, `takeLast`, `drop`, and `dropLast` return new arrays and normalize negative counts to zero. Set operations accept optional selectors for object identity.
1452
1473
 
1474
+ ### paginate
1475
+
1476
+ ```ts
1477
+ paginate(items: readonly T[], options?: PaginateOptions): PageEnvelope
1478
+ ```
1479
+
1480
+ 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[];
1481
+ sort(array: readonly T[], selectors: SortSelectors): T[];
1482
+ sort(array: readonly T[], criteria: ReadonlyArray>): T[];
1483
+ ```
1484
+
1485
+ 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.
1486
+
1487
+ ```ts
1488
+ import { sort } from '@vielzeug/arsenal/array';
1489
+
1490
+ const users = [{ name: 'Ada', age: 41 }, { name: 'Grace', age: 30 }];
1491
+ sort(users, (user) => user.name);
1492
+ sort(users, { name: 'asc', age: 'desc' });
1493
+
1494
+ const rows = [
1495
+ { address: { city: 'Berlin' }, age: 41 },
1496
+ { address: { city: 'Aachen' }, age: 30 },
1497
+ ];
1498
+ sort(rows, [{ key: 'address.city' }, { key: 'age', direction: 'desc' }]);
1499
+ ```
1500
+
1501
+ 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.
1502
+
1453
1503
  ### fuzzyFilter / fuzzyScore
1454
1504
 
1455
1505
  ```ts
@@ -1715,6 +1765,16 @@ type FuzzySelection = FuzzyOptions & {
1715
1765
 
1716
1766
  type ScoredResult = { item: T; score: number };
1717
1767
 
1768
+ type PageEnvelope = Readonly;
1769
+
1770
+ type PaginateOptions = Readonly;
1771
+
1772
+ type SortDirection = 'asc' | 'desc';
1773
+
1774
+ type SortSelectors = Partial>;
1775
+
1776
+ type SortCriterion = Readonly;
1777
+
1718
1778
  type CacheOptions = {
1719
1779
  capacity?: number;
1720
1780
  now?: () => number;
@@ -1789,6 +1849,45 @@ const byPrice = sort(products, (product) => product.price);
1789
1849
  console.log(publishedLabels, byId, byPrice);
1790
1850
  ```
1791
1851
 
1852
+ ## Page an In-Memory List
1853
+
1854
+ Use `paginate` when the whole list is local and a view needs one page plus its totals.
1855
+
1856
+ ```ts
1857
+ import { paginate } from '@vielzeug/arsenal/array';
1858
+
1859
+ const rows = Array.from({ length: 45 }, (_, index) => ({ id: index + 1 }));
1860
+
1861
+ const first = paginate(rows, { pageSize: 10 });
1862
+ const past = paginate(rows, { page: 9, pageSize: 10 });
1863
+
1864
+ console.log(first.totalPages, past.items); // 5 []
1865
+ ```
1866
+
1867
+ `page` is echoed rather than clamped, so a stale page number reads as an empty page instead of
1868
+ silently jumping elsewhere. Pass `{ pageOffset: 0 }` for a zero-based caller and the envelope comes
1869
+ back in that same base.
1870
+
1871
+ ## Sort by Grid Criteria
1872
+
1873
+ `sort` accepts the ordering a data grid already holds: an ordered list of column paths, each with a direction.
1874
+
1875
+ ```ts
1876
+ import { sort } from '@vielzeug/arsenal/array';
1877
+
1878
+ const rows = [
1879
+ { address: { city: 'Berlin' }, age: 41 },
1880
+ { address: { city: 'Aachen' }, age: 30 },
1881
+ { address: { city: 'Berlin' }, age: 27 },
1882
+ ];
1883
+
1884
+ const ordered = sort(rows, [{ key: 'address.city' }, { key: 'age', direction: 'desc' }]);
1885
+ ```
1886
+
1887
+ Paths are resolved safely, so nested and indexed columns work and prototype access is refused. The
1888
+ first criterion that differs decides the order and ties keep their input order. Keep each column
1889
+ typed: a column mixing numbers and strings throws a `TypeError` rather than guessing an order.
1890
+
1792
1891
  ## Search Explicit Fields
1793
1892
 
1794
1893
  Search string arrays directly. Object collections require `select`, so callers define exactly what can match.
@@ -1970,8 +2069,10 @@ const settings = parsed.ok ? Settings.parse(parsed.value) : { theme: 'system' };
1970
2069
  - filterMap - Filter and map array elements (id: `array-filter`)
1971
2070
  - groupBy - Group array by key (id: `array-group`)
1972
2071
  - filterMap - Transform array elements (id: `array-map`)
2072
+ - paginate - Page an array (id: `array-paginate`)
1973
2073
  - fuzzyFilter - Explicit searchable fields (id: `array-search`)
1974
2074
  - fuzzyFilter - Unicode normalization (id: `array-search-normalize`)
2075
+ - sort - Selector, selectors, or grid criteria (id: `array-sort-criteria`)
1975
2076
  - uniq - Remove duplicates (id: `array-uniq`)
1976
2077
  - attempt - Safe async execution with isFail/isOk helpers (id: `async-attempt`)
1977
2078
  - parallel - Controlled parallel execution (id: `async-parallel`)
@@ -2070,6 +2171,8 @@ await waitUntil(() => view.queryByText('Saved') !== null);
2070
2171
  - `fireClick`, `fireInput`, `fireKeyDown`, and peers dispatch exact synchronous events.
2071
2172
  - `waitUntil`, `eventually`, and `waitForEvent` provide bounded, abortable async waiting.
2072
2173
  - `delay` models explicit cancellable timer scheduling.
2174
+ - `walkFlatTree` traverses the flat tree across slot boundaries jsdom's light-DOM queries miss.
2175
+ - `installFormInternalsPolyfill` gives jsdom the `ElementInternals` form-association API.
2073
2176
  - `AssayError`, `AssayQueryError`, and `AssayTimeoutError` provide typed failures.
2074
2177
 
2075
2178
  ## Documentation
@@ -2096,12 +2199,14 @@ await waitUntil(() => view.queryByText('Saved') !== null);
2096
2199
  | `fire*` / `dispatch` | Dispatches platform event instances | Sync | Does not reproduce browser default behavior |
2097
2200
  | `waitUntil` / `eventually` / `waitForEvent` | Waits for conditions, assertions, or events | Async | Use a signal or timeout for bounded waits |
2098
2201
  | `delay` | Schedules a cancellable timer | Async | Pass a signal to cancel a pending wait |
2202
+ | `walkFlatTree` | Walks the flat tree across slot boundaries | Sync | jsdom-only need: browsers already use the flat tree |
2203
+ | `installFormInternalsPolyfill` | Polyfills `ElementInternals` form association for jsdom | Sync | Global patches: only install in suites that need them |
2099
2204
 
2100
2205
  ## Package Entry Point
2101
2206
 
2102
2207
  | Import | Purpose |
2103
2208
  | ----------------- | ---------------------------------------------------- |
2104
- | `@vielzeug/assay` | DOM queries, events, wait helpers, errors, and types |
2209
+ | `@vielzeug/assay` | DOM queries, events, wait helpers, test-environment helpers, errors, and types |
2105
2210
 
2106
2211
  ## Queries
2107
2212
 
@@ -2193,6 +2298,57 @@ await delay(100, { signal });
2193
2298
 
2194
2299
  `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.
2195
2300
 
2301
+ ## Test-environment helpers
2302
+
2303
+ ### `walkFlatTree(root, visit)`
2304
+
2305
+ ```ts
2306
+ function walkFlatTree(root: Element, visit: (element: HTMLElement) => void): void;
2307
+ ```
2308
+
2309
+ Walks the flat tree below `root`, calling `visit` for each assigned or ordinary descendant (not for `root` itself).
2310
+ At each `` it expands `assignedElements()` instead of descending into its (always empty, in jsdom) literal
2311
+ children. jsdom only
2312
+ implements the light DOM tree, so `querySelectorAll()` never crosses a slot into its assigned elements; real
2313
+ browsers use the flat tree for that. This helper closes the gap for tests that must find form-associated (or
2314
+ otherwise slotted) elements through shadow boundaries.
2315
+
2316
+ ```ts
2317
+ const named: HTMLElement[] = [];
2318
+
2319
+ walkFlatTree(form, (el) => {
2320
+ if (el.hasAttribute('name')) named.push(el);
2321
+ });
2322
+ ```
2323
+
2324
+ ### `installFormInternalsPolyfill()`
2325
+
2326
+ ```ts
2327
+ function installFormInternalsPolyfill(): () => void;
2328
+ ```
2329
+
2330
+ jsdom implements none of the `ElementInternals` form-association API: not `setFormValue`, not `setValidity`, not
2331
+ the `checkValidity`/`reportValidity`/`validity`/`validationMessage` mixin real browsers put on every
2332
+ `formAssociated: true` element, not even `FormData` collecting a form-associated element's set value, or
2333
+ `.reset()` invoking `formResetCallback()`. Every one of those gaps is exercised by `useField()` from
2334
+ `@vielzeug/ore`, so any package testing a form-associated component needs all of them.
2335
+
2336
+ Call it from a test setup when the suite tests form-associated components; it returns an `uninstall()` that
2337
+ restores every patched global. The patches are global monkey-patches and skip APIs already implemented by the
2338
+ environment, so suites without form-associated components shouldn't install them. If `ElementInternals` is absent,
2339
+ the installer is a no-op. A downstream package should
2340
+ rely on this instead of hand-rolling its own copy.
2341
+
2342
+ ```ts
2343
+ // vitest.setup.ts
2344
+ import { installFormInternalsPolyfill } from '@vielzeug/assay';
2345
+ import { install } from '@vielzeug/ore/testing';
2346
+ import { afterAll, afterEach } from 'vitest';
2347
+
2348
+ install(afterEach);
2349
+ afterAll(installFormInternalsPolyfill());
2350
+ ```
2351
+
2196
2352
  ## Types
2197
2353
 
2198
2354
  ```ts
@@ -2347,6 +2503,36 @@ expect(onSave).toHaveBeenCalledOnce();
2347
2503
  Refine's testing entry point contains only Refine-specific assertions and typed mount wrappers. Import Assay helpers
2348
2504
  directly instead of routing generic DOM operations through another package.
2349
2505
 
2506
+ When a suite tests a form-associated custom element (one built on `useField()` from `@vielzeug/ore`), install the
2507
+ form-internals polyfill in the setup file so jsdom gains the `ElementInternals` API it lacks:
2508
+
2509
+ ```ts
2510
+ // vitest.setup.ts
2511
+ import { installFormInternalsPolyfill } from '@vielzeug/assay';
2512
+ import { install } from '@vielzeug/ore/testing';
2513
+ import { afterAll, afterEach } from 'vitest';
2514
+
2515
+ install(afterEach);
2516
+ afterAll(installFormInternalsPolyfill());
2517
+ ```
2518
+
2519
+ ## Traverse slotted content in jsdom
2520
+
2521
+ Use `walkFlatTree()` when a test must visit descendants assigned through slots. It expands each slot into its assigned
2522
+ elements, including nested slots, instead of relying on jsdom's light-DOM-only `querySelectorAll()`.
2523
+
2524
+ ```ts
2525
+ import { walkFlatTree } from '@vielzeug/assay';
2526
+
2527
+ const named: HTMLElement[] = [];
2528
+ walkFlatTree(form, (element) => {
2529
+ if (element.hasAttribute('name')) named.push(element);
2530
+ });
2531
+ ```
2532
+
2533
+ The callback receives descendants in the flat tree, not the root element or slot elements themselves. Browsers already
2534
+ use the flat tree for DOM algorithms; this helper is for test code that needs equivalent traversal in jsdom.
2535
+
2350
2536
  ## Best Practices
2351
2537
 
2352
2538
  - Scope multiple assertions with `within()` rather than repeatedly querying the document.
@@ -4521,6 +4707,7 @@ try {
4521
4707
  - **`withBearerAuth()` / `withRequestId()` / `withLogging()`**: composable middleware configured at construction.
4522
4708
  - **`tap()`**: signal-owned structured transport observation.
4523
4709
  - **Error taxonomy**: HTTP, network, timeout, abort, parse, and schema failures are distinct, actionable classes.
4710
+ - **RFC 9457 problem details**: an `application/problem+json` error body parses onto `CourierHttpError.problem` while the HTTP status stays authoritative.
4524
4711
  - **`cancelAll()` / `dispose()`**: abort active requests and tear down the transport.
4525
4712
 
4526
4713
  ## Documentation
@@ -4552,6 +4739,9 @@ try {
4552
4739
  | `withRequestId()` | Adds request identifier middleware | Sync | Default generator uses `crypto.randomUUID()` |
4553
4740
  | `withLogging()` | Logs request result metadata | Sync | Requires explicit logger; URLs may contain sensitive query values |
4554
4741
  | `tap()` | Observe structured transport events | Sync | Observation only; optional signal owns lifetime |
4742
+ | `CourierHttpError.problem` | Parsed RFC 9457 problem details from an error body | Sync | Present only when the response is `application/problem+json` |
4743
+ | `ProblemDetail` | RFC 9457 problem members and extension members | Sync | Extension values are `unknown`; Courier does not validate their shapes |
4744
+ | `problemDetailError()` | Build an HTTP error that carries problem details | Sync | Intended for mocks and middleware, not for parsing a response |
4555
4745
 
4556
4746
  ## Package Entry Point
4557
4747
 
@@ -4797,7 +4987,7 @@ type PrefetchConfig = Omit,
4797
4987
  | Error | Trigger | Notable properties |
4798
4988
  | --- | --- | --- |
4799
4989
  | `CourierError` | Base class for all Courier errors | Use `instanceof` to narrow |
4800
- | `CourierHttpError` | Non-2xx HTTP response | `status`, `data`, `headers`, `method`, `url`; `CourierHttpError.is(e, status?)` narrows by status |
4990
+ | `CourierHttpError` | Non-2xx HTTP response | `status`, `data`, `headers`, `method`, `url`, `problem`; `CourierHttpError.is(e, status?)` narrows by status |
4801
4991
  | `CourierNetworkError` | Request failure without response | `method`, `url`, `cause` |
4802
4992
  | `CourierTimeoutError` | Timeout signal aborts request | `method`, `url`, `cause` |
4803
4993
  | `CourierAbortError` | Caller or client cancellation | `method`, `url`, `cause` |
@@ -4808,6 +4998,72 @@ type PrefetchConfig = Omit,
4808
4998
 
4809
4999
  Errors thrown directly by custom middleware are preserved rather than reclassified.
4810
5000
 
5001
+ ### Problem details
5002
+
5003
+ ```ts
5004
+ export type ProblemDetail = Readonly;
5005
+
5006
+ export const PROBLEM_JSON_MEDIA_TYPE = 'application/problem+json';
5007
+
5008
+ export function isProblemMediaType(contentType: string | null | undefined): boolean;
5009
+
5010
+ export function problemDetailError(
5011
+ problem: ProblemDetail,
5012
+ options?: { message?: string; method?: string; status?: number; url?: string },
5013
+ ): CourierHttpError;
5014
+ ```
5015
+
5016
+ When a non-2xx response answers with `application/problem+json` (RFC 9457), `CourierHttpError.problem`
5017
+ holds a shallow-frozen copy of the parsed JSON object and the error message prefers `detail`, then
5018
+ `title`, then the response status text (or `HTTP ` when status text is empty). Any other error
5019
+ body keeps the shape it already had on `data`, with `problem` left `undefined`. Courier checks that
5020
+ the parsed body is an object; it does not validate the types or contents of registered or extension
5021
+ members. RFC 9457 treats an omitted `type` as `about:blank`, but Courier does not insert that value
5022
+ into the object.
5023
+
5024
+ | Member | Purpose | Common gotcha |
5025
+ | --- | --- | --- |
5026
+ | `ProblemDetail` | The five registered RFC 9457 members plus extensions | Every member is optional; extension values have type `unknown` |
5027
+ | `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 |
5028
+ | `PROBLEM_JSON_MEDIA_TYPE` | Registered media type constant | Compare with `isProblemMediaType()`, not `===`, to allow parameters |
5029
+ | `isProblemMediaType()` | Match `application/problem+json` and its parameterised forms | Plain `application/json` never matches |
5030
+ | `problemDetailError()` | Build a problem-carrying `CourierHttpError` without a server | Status defaults to numeric `problem.status`, then `500`; a supplied `options.status` takes precedence |
5031
+
5032
+ ```ts
5033
+ problemDetailError(problem, options?)
5034
+ ```
5035
+
5036
+ `problemDetailError()` returns a `CourierHttpError` with the supplied problem object on both
5037
+ `error.problem` and `error.data`. Its defaults are `method: 'GET'`, `url: ''`, and status from
5038
+ `problem.status` when numeric or `500` otherwise. The error message uses `options.message`, then
5039
+ `problem.detail`, `problem.title`, and finally `HTTP `. Unlike a parsed response problem,
5040
+ the supplied object is not copied or frozen.
5041
+
5042
+ The HTTP status line stays authoritative: `error.status` is the response status even when
5043
+ `problem.status` disagrees. A JSON array or primitive leaves `data` as that parsed value; malformed
5044
+ JSON leaves `data` as its raw text. Neither produces a partial `problem`.
5045
+
5046
+ ```ts
5047
+ import { createCourier, CourierHttpError, problemDetailError, type ProblemDetail } from '@vielzeug/courier';
5048
+
5049
+ const courier = createCourier({ baseUrl: '/api' });
5050
+
5051
+ try {
5052
+ await courier.post('/invoices');
5053
+ } catch (error) {
5054
+ if (CourierHttpError.is(error)) {
5055
+ const problem: ProblemDetail | undefined = error.problem;
5056
+ console.log(error.status, problem?.title, problem?.detail, problem?.type);
5057
+ }
5058
+ }
5059
+
5060
+ const mockedError = problemDetailError(
5061
+ { title: 'Insufficient credit', detail: 'Choose a lower amount.', status: 402 },
5062
+ { method: 'POST', url: '/invoices' },
5063
+ );
5064
+ console.log(mockedError.status, mockedError.problem?.detail);
5065
+ ```
5066
+
4811
5067
  ### Usage Guide
4812
5068
 
4813
5069
  ## Basic Usage
@@ -4996,6 +5252,55 @@ async function loadUser(): Promise {
4996
5252
  - Do not display cancellation as an application error during navigation.
4997
5253
  - `CourierSchemaValidationError` carries the raw pre-validation `data`.
4998
5254
 
5255
+ ## Problem Details
5256
+
5257
+ When a server answers a failure with `application/problem+json` (RFC 9457), Courier parses the
5258
+ JSON object onto `CourierHttpError.problem` and chooses the message from `detail`, then `title`,
5259
+ then the HTTP status text. The parsed object is shallow-frozen; extension members remain `unknown`
5260
+ and Courier does not validate their contents. Nothing is configured.
5261
+
5262
+ ```ts
5263
+ import { CourierHttpError, createCourier, type ProblemDetail } from '@vielzeug/courier';
5264
+
5265
+ const courier = createCourier({ baseUrl: '/api' });
5266
+
5267
+ async function submitInvoice(body: unknown): Promise {
5268
+ try {
5269
+ await courier.post('/invoices', { body });
5270
+ return 'Saved.';
5271
+ } catch (error) {
5272
+ if (!CourierHttpError.is(error)) throw error;
5273
+
5274
+ const problem = error.problem;
5275
+ if (problem?.title === 'Insufficient credit') return `Blocked: ${problem.detail ?? 'no credit'}`;
5276
+
5277
+ // Extension members are ordinary properties on the same document.
5278
+ const field = problem?.['errors'] as Record | undefined;
5279
+
5280
+ return field ? Object.values(field).join(', ') : `Request failed (${error.status}).`;
5281
+ }
5282
+ }
5283
+ ```
5284
+
5285
+ `error.status` is always the HTTP status from the response, so a `problem.status` that disagrees
5286
+ never overrides it. `problem` stays `undefined` for any other content type, and a `problem+json`
5287
+ body that is a JSON array or primitive keeps that parsed value on `data`; malformed JSON keeps its
5288
+ raw text there. Neither case creates a partial `problem`. RFC 9457 treats an omitted `type` as
5289
+ `about:blank`, but Courier does not add a `type` property. Use `problemDetailError()` to raise the
5290
+ same error shape from a mock or middleware; it stores the supplied problem object directly on both
5291
+ `problem` and `data`.
5292
+
5293
+ ```ts
5294
+ import { problemDetailError } from '@vielzeug/courier';
5295
+
5296
+ const mockFailure = problemDetailError(
5297
+ { title: 'Insufficient credit', detail: 'Choose a lower amount.', status: 402 },
5298
+ { method: 'POST', url: '/invoices' },
5299
+ );
5300
+
5301
+ console.log(mockFailure.status, mockFailure.problem?.detail);
5302
+ ```
5303
+
4999
5304
  ## Composing with a State Layer
5000
5305
 
5001
5306
  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.
@@ -5114,6 +5419,7 @@ There is no `use()` method. To change middleware, create a new client. This keep
5114
5419
  - Cached Read and Prefetch (id: `cached-read`)
5115
5420
  - createCourier - Transport Client (id: `create-courier`)
5116
5421
  - middleware - Immutable Chain (id: `middleware`)
5422
+ - problem+json - RFC 9457 error bodies (id: `problem-details`)
5117
5423
 
5118
5424
  ---
5119
5425
 
@@ -15546,7 +15852,7 @@ define('my-counter', {
15546
15852
  - Props via `prop.*` helpers (`prop.string`, `prop.number`, `prop.bool`, `prop.oneOf`, `prop.json`, `prop.data`) or raw `PropDef` objects
15547
15853
  - `setup(props)` takes only props and returns an `HTMLResult` directly: `return html\`...\``
15548
15854
  - Lifecycle hooks: `onMounted`, `onCleanup`, `onEvent`, `onElement`, `watchEffect`: plain functions imported from `@vielzeug/ore`, called directly from `setup()` or any composable it calls
15549
- - Directives: `each` (keyed reactive list rendering), `classMap`, `styleMap`, `when`, `live`, `unsafeHtml`
15855
+ - Directives: `each` (transactional keyed list reconciliation), `classMap`, `styleMap`, `when`, `live`, `model`, `unsafeHtml`
15550
15856
  - Host bindings via `bind({ attr, class, style, on })`: pass `{ target: el }` to bind any off-host element
15551
15857
  - Reactive ARIA sync via explicit `aria-*` keys in `bind({ attr }, { target })`, with automatic cleanup on disconnect
15552
15858
  - Context via `provide(key, value)` / `inject(key)`; typed emit/slots via `useEmit()` / `useSlots()`
@@ -15594,6 +15900,7 @@ All browser-runtime symbols below are imported from `@vielzeug/ore`. Lifecycle/c
15594
15900
  | `each()` | Keyed list rendering with DOM diffing | Sync | Duplicate keys report `ore:error`; plain `T[]` is a one-time static render |
15595
15901
  | `when()` | Conditional branch rendering | Sync | Getter-fn computed disposed on cleanup; static bool skips subscription |
15596
15902
  | `live(signal)` | One-way binding that skips stale writes during input | Sync | Use for controlled inputs alongside a manual `@input` handler |
15903
+ | `model(signal)` | Two-way form-control binding (value/checked + write-back) | Sync | Plain `value`/`checked` attribute only; pass `get` to keep numeric signals numeric |
15597
15904
  | `onMounted(fn)` | DOM-ready callback | Setup only | Must be called synchronously during `setup()` |
15598
15905
  | `onCleanup(fn)` | Register teardown | Setup only | Called on component disconnect |
15599
15906
  | `onEvent(target, …)` | Scoped event listener with auto-cleanup | Setup only | No-ops on null target; removed on disconnect |
@@ -15708,12 +16015,13 @@ setup(_props) {
15708
16015
  | `prop.string(defaultValue?)` | `PropDef` | Reflects by default |
15709
16016
  | `prop.bool(defaultValue?)` | `PropDef` | Any non-null attribute value other than `"false"` parses as `true`; `"false"` or absent attribute is `false` |
15710
16017
  | `prop.number(defaultValue?)` | `PropDef` | Returns default (not NaN) and warns in dev when attribute is not a valid number |
15711
- | `prop.oneOf(allowed, defaultValue)` | `PropDef` | Restricts to provided string union |
16018
+ | `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 |
15712
16019
  | `prop.json(defaultValue)` | `PropDef` | JSON.parse from attribute; `reflect: false` |
15713
16020
  | `prop.data(defaultValue?)` | `PropDef` | JS-only: never reads/writes an attribute; use for objects, arrays, callbacks, or any non-serialisable value |
15714
16021
 
15715
16022
  > **Choosing the right prop helper:**
15716
16023
  >
16024
+ > - **`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.
15717
16025
  > - **`prop.json`**: value can be declared in HTML (``); attribute string is `JSON.parse`d.
15718
16026
  > - **`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.
15719
16027
 
@@ -15746,7 +16054,16 @@ define('data-grid', {
15746
16054
  ### `html`
15747
16055
 
15748
16056
  Tagged template literal that returns an `HTMLResult`. Supports text interpolation, ordinary attributes (`attr=`),
15749
- boolean attributes (`?attr=`), events (`@event=`), refs (`ref=`), and nested templates.
16057
+ boolean attributes (`?attr=`), explicit property bindings (`.prop=`), events (`@event=` with optional
16058
+ `.stop`/`.prevent`/`.once`/`.capture` modifiers), refs (`ref=`), and nested templates.
16059
+
16060
+ `.prop=${value}` always writes the element property (never an attribute): the explicit channel for structured
16061
+ values (arrays, objects, callbacks). Prototype-polluting names (`__proto__`, `constructor`, `prototype`) are
16062
+ rejected at compile time.
16063
+
16064
+ `@event.stop`, `.prevent`, `.once`, and `.capture` are shorthand for the matching listener behavior
16065
+ (`stopPropagation`/`preventDefault` applied before the handler runs; `once`/`capture` passed as listener
16066
+ options). An unknown modifier throws at compile time.
15750
16067
 
15751
16068
  ### `css`
15752
16069
 
@@ -15761,9 +16078,28 @@ Tagged template literal that returns a `CSSResult` for use in `styles`.
15761
16078
  | `classMap(record)` | Reactive class string from object map |
15762
16079
  | `styleMap(record)` | Reactive inline style string from object map |
15763
16080
  | `live(signal)` | One-way binding that skips stale writes during active user input; use with `@input` handler |
16081
+ | `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 |
15764
16082
  | `unsafeHtml(value)` | HTML rendering sink; sanitize untrusted values before calling |
15765
16083
 
15766
- `each()` treats number and string keys as distinct. A duplicate-key update reports `ore:error` and preserves the last valid list.
16084
+ `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.
16085
+
16086
+ ### `model(signal, options?)`
16087
+
16088
+ 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.
16089
+
16090
+ ```ts
16091
+ const text = signal('');
16092
+ const agree = signal(false);
16093
+ const count = signal(0);
16094
+
16095
+ html`
16096
+
16097
+
16098
+ Number(el.value) })} />
16099
+ `;
16100
+ ```
16101
+
16102
+ `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.
15767
16103
 
15768
16104
  ### `unsafeHtml`
15769
16105
 
@@ -15863,6 +16199,8 @@ Import from `@vielzeug/ore`.
15863
16199
  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.
15864
16200
 
15865
16201
  ```ts
16202
+ type FormFieldValidity = { flags: ValidityStateFlags; message: string };
16203
+
15866
16204
  type FormFieldOptions = {
15867
16205
  disabled?: Readable;
15868
16206
  /**
@@ -15875,8 +16213,13 @@ type FormFieldOptions = {
15875
16213
  /** Called when the ancestor resets (see onFormReset): restore local field state here. */
15876
16214
  onReset?: () => void;
15877
16215
  toFormValue?: (value: T) => File | FormData | string | null;
15878
- /** Recomputed reactively and passed straight to internals.setValidity(). null = always valid. */
15879
- validationMessage?: Readable;
16216
+ /**
16217
+ * Recomputed reactively and passed straight to internals.setValidity().
16218
+ * null (or omitted) = always valid. Flags and message travel together because
16219
+ * the platform's setValidity() contract couples them (flags without a
16220
+ * non-empty message throw); a single { flags, message } | null input makes a
16221
+ * desynced pair unrepresentable.
16222
+ */
15880
16223
  validity?: Readable;
15881
16224
  value: Signal | Readable;
15882
16225
  };
@@ -15890,15 +16233,20 @@ type FormFieldHandle = {
15890
16233
  };
15891
16234
  ```
15892
16235
 
15893
- Pass `validity`/`validationMessage` to make `required`-style constraints participate in native constraint validation
15894
- through `checkValidity()` and `reportValidity()`:
16236
+ Pass a single `validity` signal to make `required`-style constraints participate in native constraint validation
16237
+ through `checkValidity()` and `reportValidity()`. The `{ flags, message }` pair is the one validity input: the old
16238
+ split `validity` + `validationMessage` options are gone, since they could drift apart into a state the platform
16239
+ rejects.
15895
16240
 
15896
16241
  ```ts
15897
16242
  const isBlank = (v: string) => v.trim() === '';
15898
16243
 
15899
16244
  useField({
15900
- validationMessage: computed(() => (required.value && isBlank(value.value) ? 'This field is required.' : '')),
15901
- validity: computed(() => (required.value && isBlank(value.value) ? { valueMissing: true } : null)),
16245
+ validity: computed(() =>
16246
+ required.value && isBlank(value.value)
16247
+ ? { flags: { valueMissing: true }, message: 'This field is required.' }
16248
+ : null,
16249
+ ),
15902
16250
  value,
15903
16251
  });
15904
16252
  ```
@@ -15911,8 +16259,7 @@ Import from `@vielzeug/ore/testing`.
15911
16259
  | ------------------------ | ------------------------------------------------------------------------------------------ |
15912
16260
  | `mount(setup, options?)` | Mount a component and return a test fixture |
15913
16261
  | `cleanup()` | Remove all mounted elements and reset test state |
15914
- | `install(afterEach, options?)` | Register auto-cleanup; pass `{ formInternals: true }` to also install the `ElementInternals`/`FormData`/`.reset()` jsdom polyfill (see below) |
15915
- | `installFormInternalsPolyfill()` | Installs the form-internals polyfill directly (returns an `uninstall()` that restores every patched global). Usually called via `install(afterEach, { formInternals: true })` |
16262
+ | `install(afterEach)` | Register auto-cleanup after each test |
15916
16263
  | `flush(options?)` | Drain reactive updates and animation frames |
15917
16264
  | `renderHook(setup)` | Run lifecycle hooks in isolation; overload accepts `propDefs` as first arg for typed props |
15918
16265
  | `OreTimeoutError` | Error thrown when `flush()` cannot settle tracked Ore work |
@@ -15922,7 +16269,16 @@ Import from `@vielzeug/ore/testing`.
15922
16269
  Import `within`, named dispatchers such as `fireClick`, and waits such as `waitUntil` or `waitForEvent` from
15923
16270
  `@vielzeug/assay`.
15924
16271
 
15925
- > **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.
16272
+ > **Form-associated component testing:** jsdom implements none of the `ElementInternals` form-association API.
16273
+ > `@vielzeug/assay` exports `installFormInternalsPolyfill()` for this: it polyfills
16274
+ > `setFormValue`/`setValidity`/`checkValidity`/`reportValidity`/`validationMessage`/`validity`/`states`, mixes
16275
+ > `checkValidity`/`reportValidity`/`validity`/`validationMessage` onto the host element itself (real browsers do
16276
+ > this for any `formAssociated: true` element), makes `FormData` collect a form-associated element's set value, and
16277
+ > makes `.reset()` invoke `formResetCallback()`. Every patch is a guarded no-op when its target already
16278
+ > exists, and the function returns an `uninstall()` that restores every patched global. Call it directly from a
16279
+ > test setup (alongside `install(afterEach)`) only when the suite tests form-associated components: the patches
16280
+ > are global, so suites without them shouldn't carry them. A downstream package (e.g. a component library built
16281
+ > on `ore`) should rely on it instead of hand-rolling its own copy.
15926
16282
 
15927
16283
  #### `Fixture` interface
15928
16284
 
@@ -16091,18 +16447,26 @@ type CSSResult = {
16091
16447
 
16092
16448
  type LiveBinding = { readonly source: Readable };
16093
16449
 
16450
+ type ModelOptions = {
16451
+ /** Write-back event (default: 'change' for checkbox/radio/select, 'input' otherwise). */
16452
+ event?: string;
16453
+ /** Value reader (default: el.checked for checkbox/radio, el.value otherwise). */
16454
+ get?: (el: HTMLElement) => T;
16455
+ };
16456
+
16094
16457
  type EmitFn> = {
16095
16458
  >(event: K): boolean;
16096
16459
  >>(event: K, detail: T[K]): boolean;
16097
16460
  };
16098
16461
  // KeysWithoutDetail is an internal helper type, not exported.
16099
16462
 
16463
+ type FormFieldValidity = { flags: ValidityStateFlags; message: string };
16464
+
16100
16465
  type FormFieldOptions = {
16101
16466
  disabled?: Readable;
16102
16467
  emptyStringForNull?: boolean;
16103
16468
  onReset?: () => void;
16104
16469
  toFormValue?: (value: T) => File | FormData | string | null;
16105
- validationMessage?: Readable;
16106
16470
  validity?: Readable;
16107
16471
  value: Signal | Readable;
16108
16472
  };
@@ -16256,11 +16620,13 @@ define('x-button', {
16256
16620
 
16257
16621
  ## template bindings
16258
16622
 
16259
- `html` supports text, attributes, booleans, properties, events, refs, and nested templates.
16623
+ `html` supports text, attributes, booleans, properties, events, refs, and nested templates. Event bindings accept
16624
+ `.stop`, `.prevent`, `.once`, and `.capture`: the first two apply before the handler runs, while the latter two use
16625
+ native listener options. Unknown modifiers throw while Ore compiles the template.
16260
16626
 
16261
16627
  ```ts
16262
16628
  import { computed, signal } from '@vielzeug/ripple';
16263
- import { define, html, ref } from '@vielzeug/ore';
16629
+ import { define, html, model, ref } from '@vielzeug/ore';
16264
16630
 
16265
16631
  define('profile-name', {
16266
16632
  setup() {
@@ -16269,10 +16635,7 @@ define('profile-name', {
16269
16635
 
16270
16636
  return html`
16271
16637
  'Current: ' + name.value)}>Name
16272
- 'Current name ' + name.value}
16273
- @input=${(event: Event) => {
16274
- name.value = (event.target as HTMLInputElement).value;
16275
- }} />
16638
+ 'Current name ' + name.value} />
16276
16639
  Hello ${name}
16277
16640
  `;
16278
16641
  },
@@ -16281,8 +16644,7 @@ define('profile-name', {
16281
16644
 
16282
16645
  ## directives
16283
16646
 
16284
- Ore exports `each`, `classMap`, `styleMap`, `when`, `live`, and `unsafeHtml` from `@vielzeug/ore`. Use ordinary
16285
- attribute bindings plus native event handlers for two-way input state; no special model directive is required.
16647
+ Ore exports `each`, `classMap`, `styleMap`, `when`, `live`, `model`, and `unsafeHtml` from `@vielzeug/ore`.
16286
16648
 
16287
16649
  ```ts
16288
16650
  import { signal } from '@vielzeug/ripple';
@@ -16330,9 +16692,50 @@ each(
16330
16692
  );
16331
16693
  ```
16332
16694
 
16333
- ## live form bindings
16695
+ Each reactive reconciliation is transactional. Ore creates missing entries before committing updates or removing stale
16696
+ entries; if a render callback throws, entries created during that pass are rolled back and the last successful list
16697
+ remains on screen. Ore reports the failure through `ore:error` with phase `each-reconcile`.
16334
16698
 
16335
- Use `live(signal)` for inputs that should preserve in-progress user edits instead of overwriting the DOM on stale writes.
16699
+ ## model: two-way form bindings
16700
+
16701
+ Use `model(signal)` for a controlled input: it writes the signal to the control's `value`/`checked` property and
16702
+ writes user input back on the control's change event (`'change'` for checkboxes, radios, and selects; `'input'`
16703
+ otherwise). This replaces the `live()` + manual `@input` handler pair with one binding.
16704
+
16705
+ ```ts
16706
+ import { signal } from '@vielzeug/ripple';
16707
+ import { define, html, model } from '@vielzeug/ore';
16708
+
16709
+ define('live-search', {
16710
+ setup() {
16711
+ const query = signal('');
16712
+ const agree = signal(false);
16713
+
16714
+ return html`
16715
+
16716
+
16717
+ `;
16718
+ },
16719
+ });
16720
+ ```
16721
+
16722
+ A native text input's `value` is always a string, so pass a `get` reader to keep a numeric signal numeric, and an
16723
+ `event` name for custom controls that emit their own change event:
16724
+
16725
+ ```ts
16726
+ const count = signal(0);
16727
+
16728
+ html`
16729
+ Number(el.value) })} />
16730
+ (el as MyPicker).selection })} />
16731
+ `;
16732
+ ```
16733
+
16734
+ ## live: one-way bindings that preserve user edits
16735
+
16736
+ Use `live(signal)` when a binding must skip a stale app-state write while the user is mid-edit, without writing
16737
+ user input back. `model()` covers the common controlled-input case; `live()` is for one-way bindings that only
16738
+ need the stale-write guard.
16336
16739
 
16337
16740
  ```ts
16338
16741
  import { signal } from '@vielzeug/ripple';
@@ -16696,7 +17099,7 @@ define('signup-form', {
16696
17099
  - Bind host attributes and classes via `bind()` rather than mutating the element directly.
16697
17100
  - Provide context at the nearest ancestor: avoid global context singletons.
16698
17101
  - Call `onCleanup()` for every resource allocated in `setup()` (WebSockets, intervals, external subscriptions).
16699
- - Use `live(signal)` for form inputs to prevent clobbering user-in-progress edits.
17102
+ - Use `model(signal)` for two-way form inputs; use `live(signal)` for a one-way binding that must not clobber user-in-progress edits.
16700
17103
  - 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.
16701
17104
  - Test component mounting and lifecycle with `@vielzeug/ore/testing`; import generic DOM events, queries, and waits
16702
17105
  from `@vielzeug/assay`.
@@ -20415,6 +20818,7 @@ Classification guides discovery and bundle selection. It is not a reason to remo
20415
20818
  - **Framework agnostic**: works anywhere HTML can be rendered
20416
20819
  - **Tree-shakeable**: import only the component entry points you register
20417
20820
  - **Comprehensive surface**: inputs, content, disclosure, feedback, layout, and overlay primitives
20821
+ - **Native form integration**: form-associated controls submit values, validate supported required constraints, and reset with their form
20418
20822
  - **Typed framework declarations**: DOM, React, and Vue tags derive from the component property contracts
20419
20823
 
20420
20824
  ### Prerequisites
@@ -20518,6 +20922,14 @@ The declarations cover every supported tag and derive component properties from
20518
20922
 
20519
20923
  Form controls expose `.value` or `.checked` and dispatch standard `input` and `change` events. Read the property from `event.currentTarget`.
20520
20924
 
20925
+ Finite-choice properties use Ore's `prop.oneOf()` parsing: values outside a component's documented options resolve to
20926
+ the declared default, or `undefined` when no default is declared.
20927
+
20928
+ Form-associated controls participate in native form submission, validity checks, and reset. A named control's value
20929
+ is available through `new FormData(form)`, and supported `required` constraints affect `form.checkValidity()`,
20930
+ `form.reportValidity()`, and submit blocking. The `error` property renders and announces feedback; it does not by
20931
+ itself set native constraint validity.
20932
+
20521
20933
  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.
20522
20934
 
20523
20935
  ## Utilities
@@ -20623,6 +21035,20 @@ grid.rows = [
20623
21035
  ];
20624
21036
  ```
20625
21037
 
21038
+ Enumerated properties such as `variant`, `size`, and `color` accept the values listed on each component's reference
21039
+ page. An unsupported value resolves to the property's declared default (or `undefined` when it has no default).
21040
+
21041
+ In an Ore template, `model()` can bind a Refine field's `value` to a Ripple signal and write user input back:
21042
+
21043
+ ```ts
21044
+ import { signal } from '@vielzeug/ripple';
21045
+ import { html, model } from '@vielzeug/ore';
21046
+ import '@vielzeug/refine/input';
21047
+
21048
+ const email = signal('');
21049
+ const field = html``;
21050
+ ```
21051
+
20626
21052
  ## Handle Events
20627
21053
 
20628
21054
  Form controls dispatch standard `input` and `change` events. Read their current property from `event.currentTarget`.
@@ -20646,6 +21072,37 @@ dialog?.addEventListener('open-change', (event: CustomEvent) => {
20646
21072
  });
20647
21073
  ```
20648
21074
 
21075
+ ## Native Form Validation
21076
+
21077
+ Refine's form-associated controls integrate with native form submission, `FormData`, validity checks, and reset.
21078
+ Use `required` for a required-value constraint; setting `error` displays and announces feedback but does not, by
21079
+ itself, invalidate the form.
21080
+
21081
+ ```html
21082
+
21083
+
21084
+
21085
+ Choose a role…
21086
+ Editor
21087
+ Admin
21088
+
21089
+ Save
21090
+
21091
+ import '@vielzeug/refine/input';
21092
+ import '@vielzeug/refine/select';
21093
+ import '@vielzeug/refine/button';
21094
+
21095
+ const form = document.querySelector('#profile-form');
21096
+ form.addEventListener('submit', (event) => {
21097
+ event.preventDefault();
21098
+ console.log(new FormData(form).get('email'));
21099
+ });
21100
+
21101
+ ```
21102
+
21103
+ The browser runs native validation before dispatching `submit`. Call `form.reportValidity()` to show validation
21104
+ feedback directly, or `form.checkValidity()` for a boolean check without the browser's validation UI.
21105
+
20649
21106
  ## Compose Slots
20650
21107
 
20651
21108
  Default slots hold primary content. Named slots identify component-owned regions.
@@ -20840,16 +21297,16 @@ ripple.dispose();
20840
21297
 
20841
21298
  ## Features
20842
21299
 
20843
- - `createRipple()` creates an isolated graph and lifetime boundary.
21300
+ - `createRipple({ onError })` creates an isolated graph with one runtime failure callback.
20844
21301
  - `signal()` stores writable values with configurable equality.
20845
21302
  - `computed()` derives lazy read-only values.
20846
21303
  - `effect()` reacts to dependency changes with cleanup support.
20847
21304
  - `batch()` coalesces synchronous writes and notifications.
20848
21305
  - `createScope()` groups owned reactive work.
20849
21306
  - `watch()` observes one selected source transition.
20850
- - `tap()` provides runtime observability for all graph events.
20851
21307
  - `resource()` owns focused latest-request async state.
20852
21308
  - `fromSubscribable()` creates a disposable bridge for external `{ getSnapshot, subscribe }` sources.
21309
+ - `RippleError`, `RippleCycleError`, and `RippleDisposedError` distinguish package-originated failures.
20853
21310
 
20854
21311
  ## Documentation
20855
21312
 
@@ -20878,15 +21335,18 @@ ripple.dispose();
20878
21335
  | `createScope()` | Group owned reactive work | Sync | Call `run()` to activate it |
20879
21336
  | `untrack()` | Read without tracking | Sync | Read still happens immediately |
20880
21337
  | `watch()` | Observe selected output | Sync | Use `effect()` for broad reads |
20881
- | `tap()` | Observe runtime events | Sync | Handler errors are swallowed |
21338
+ | `resource()` | Load from tracked reactive input | Sync factory / Async loader | `reload()` returns `void`; completion is observable state |
20882
21339
  | `fromSubscribable()` | Bridge external source | Sync | Returned bridge must be disposed |
20883
21340
  | `isReactive()` | Test Ripple's marked readable contract | Sync | Not a security or provenance check |
21341
+ | `RippleError` | Base package error | Sync | Runtime callback failures retain their original value |
21342
+ | `RippleCycleError` | Computed cycle or runaway flush | Sync | Flush failures reach `onError`, not the write site |
21343
+ | `RippleDisposedError` | Work after disposal | Sync | Cached reads remain available |
20884
21344
 
20885
21345
  ## Package Entry Point
20886
21346
 
20887
21347
  | Import | Purpose |
20888
21348
  | --- | --- |
20889
- | `@vielzeug/ripple` | All primitives, types, and errors: signals, computed, effects, scopes, watch, tap, and the isolated graph factory |
21349
+ | `@vielzeug/ripple` | All primitives, types, and errors: signals, computed, effects, scopes, watch, and the isolated graph factory |
20890
21350
 
20891
21351
  ## Graph Creation
20892
21352
 
@@ -20896,11 +21356,14 @@ ripple.dispose();
20896
21356
  function createRipple(options?: RippleOptions): Ripple;
20897
21357
  ```
20898
21358
 
20899
- 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.
21359
+ 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.
20900
21360
 
20901
21361
  | Parameter | Type | Description |
20902
21362
  | --- | --- | --- |
20903
- | `options.errorPolicy` | `'throw' \| 'swallow'` | Controls whether computed refresh, effect, cleanup, and listener failures rethrow. Default `'throw'`. Error events are always emitted through `tap()`. |
21363
+ | `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. |
21364
+
21365
+ `onError` is the only runtime option. `RippleErrorContext.kind` is `cleanup`, `computed`,
21366
+ `effect`, `flush`, or `listener`; `name` is optional. There is no event stream or error-policy option.
20904
21367
 
20905
21368
  **Returns:** `Ripple`.
20906
21369
 
@@ -20939,7 +21402,7 @@ console.log(isReactive(signal(0)));
20939
21402
 
20940
21403
  ## Default Graph Functions
20941
21404
 
20942
- 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()`.
21405
+ 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()`.
20943
21406
 
20944
21407
  ### `signal(initial, options?)`
20945
21408
 
@@ -20971,7 +21434,7 @@ cart.update((state) => ({ ...state, items: state.items + 1 }));
20971
21434
  function computed(derive: () => T, options?: ComputedOptions): Readable;
20972
21435
  ```
20973
21436
 
20974
- 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.
21437
+ 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.
20975
21438
 
20976
21439
  **Returns:** `Readable`.
20977
21440
 
@@ -20995,6 +21458,12 @@ function effect(callback: () => Cleanup | undefined, options?: EffectOptions): E
20995
21458
 
20996
21459
  Runs immediately and reruns when its tracked reads change. A returned cleanup runs before the next callback or disposal.
20997
21460
 
21461
+ | Parameter | Type | Description |
21462
+ | --- | --- | --- |
21463
+ | `options.name` | `string` | Optional label in error context |
21464
+
21465
+ Effects flush synchronously; `EffectOptions` has no scheduler option. Use `batch()` to coalesce writes.
21466
+
20998
21467
  **Returns:** `EffectHandle`.
20999
21468
 
21000
21469
  **Example:**
@@ -21078,16 +21547,31 @@ function resource(
21078
21547
 
21079
21548
  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`.
21080
21549
 
21550
+ | Parameter | Type | Description |
21551
+ | --- | --- | --- |
21552
+ | `source` | `() => Source` | Tracks reactive input reads |
21553
+ | `loader` | `(source, context) => Promise` | Receives input and a request-owned cancellation signal |
21554
+ | `options.name` | `string` | Optional resource label |
21555
+
21556
+ **Returns:** `Resource`. Source and loader failures become error state, not `onError` reports.
21557
+
21558
+ **Example:**
21559
+
21081
21560
  ```ts
21561
+ import { resource, signal } from '@vielzeug/ripple';
21562
+
21563
+ const userId = signal('42');
21082
21564
  const user = resource(
21083
21565
  () => userId.value,
21084
21566
  (id, { signal }) => fetch(`/users/${id}`, { signal }).then((response) => response.json()),
21085
21567
  );
21568
+ console.log(user.value);
21569
+ user.dispose();
21086
21570
  ```
21087
21571
 
21088
- 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`.
21572
+ 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`.
21089
21573
 
21090
- ## Watch and Observability
21574
+ ## Watch
21091
21575
 
21092
21576
  ### `watch(source, callback, options?)`
21093
21577
 
@@ -21115,41 +21599,6 @@ stop.dispose();
21115
21599
 
21116
21600
  ---
21117
21601
 
21118
- ### `ripple.tap(handler, options?)`
21119
-
21120
- ```ts
21121
- function tap(
21122
- handler: (event: RippleEvent) => void,
21123
- options?: { signal?: AbortSignal },
21124
- ): Unsubscribe;
21125
- ```
21126
-
21127
- 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.
21128
-
21129
- The `Ripple.tap()` method is available on instances created with `createRipple()`. After runtime disposal it returns a no-op unsubscribe.
21130
-
21131
- **Returns:** `Unsubscribe`.
21132
-
21133
- **Example:**
21134
-
21135
- ```ts
21136
- import { createRipple } from '@vielzeug/ripple';
21137
-
21138
- const ripple = createRipple({ errorPolicy: 'swallow' });
21139
- const stop = ripple.tap((event) => {
21140
- if (event.type === 'error') console.error(event.context.kind, event.error);
21141
- if (event.type === 'write') console.log(`${event.name}: ${event.previous} → ${event.next}`);
21142
- });
21143
-
21144
- const count = ripple.signal(0);
21145
- count.value = 1;
21146
-
21147
- stop();
21148
- ripple.dispose();
21149
- ```
21150
-
21151
- ---
21152
-
21153
21602
  ### `fromSubscribable(source, options?)`
21154
21603
 
21155
21604
  ```ts
@@ -21165,12 +21614,20 @@ Bridges an external `{ getSnapshot, subscribe }` source into a disposable reacti
21165
21614
  ```ts
21166
21615
  import { fromSubscribable } from '@vielzeug/ripple';
21167
21616
 
21168
- const routerState = fromSubscribable({
21169
- getSnapshot: () => router.getSnapshot(),
21170
- subscribe: (cb) => router.subscribe(cb),
21617
+ let current = 'idle';
21618
+ const listeners = new Set void>();
21619
+ const state = fromSubscribable({
21620
+ getSnapshot: () => current,
21621
+ subscribe: (listener) => {
21622
+ listeners.add(listener);
21623
+ return () => { listeners.delete(listener); };
21624
+ },
21171
21625
  });
21172
21626
 
21173
- routerState.dispose();
21627
+ current = 'ready';
21628
+ for (const listener of listeners) listener();
21629
+ console.log(state.value);
21630
+ state.dispose();
21174
21631
  ```
21175
21632
 
21176
21633
  ## Types
@@ -21185,20 +21642,13 @@ type ComputedOptions = { equals?: Equality; name?: string };
21185
21642
  type EffectOptions = { name?: string };
21186
21643
  type WatchOptions = { equals?: Equality; immediate?: boolean; name?: string };
21187
21644
 
21188
- type RippleErrorContext = { readonly kind: 'cleanup' | 'computed' | 'effect' | 'listener'; readonly name?: string };
21189
- type RippleErrorPolicy = 'throw' | 'swallow';
21190
- type RippleOptions = { errorPolicy?: RippleErrorPolicy };
21191
-
21192
- type RippleEvent =
21193
- | { readonly type: 'compute'; readonly name?: string }
21194
- | { readonly type: 'effect'; readonly name?: string }
21195
- | { readonly type: 'write'; readonly name?: string; readonly next: unknown; readonly previous: unknown }
21196
- | { readonly type: 'dispose'; readonly name?: string; readonly node: 'effect' | 'graph' | 'scope' }
21197
- | { readonly type: 'error'; readonly error: unknown; readonly context: RippleErrorContext };
21645
+ type RippleErrorContext = { readonly kind: 'cleanup' | 'computed' | 'effect' | 'flush' | 'listener'; readonly name?: string };
21646
+ type RippleOptions = { onError?: (error: unknown, context: RippleErrorContext) => void };
21198
21647
 
21199
21648
  interface Readable {
21200
21649
  readonly name?: string;
21201
21650
  peek(): T;
21651
+ /** Change-only: the listener is not called on subscribe, only after a tracked source changes. */
21202
21652
  subscribe(listener: () => void): Unsubscribe;
21203
21653
  readonly value: T;
21204
21654
  }
@@ -21239,7 +21689,6 @@ interface Ripple extends Disposable {
21239
21689
  options?: ResourceOptions,
21240
21690
  ): Resource;
21241
21691
  signal(initial: T, options?: SignalOptions): Signal;
21242
- tap(handler: (event: RippleEvent) => void, options?: { signal?: AbortSignal }): Unsubscribe;
21243
21692
  untrack(fn: () => T): T;
21244
21693
  watch(source: Readable | (() => T), callback: (value: T, previous: T | undefined) => void, options?: WatchOptions): EffectHandle;
21245
21694
  }
@@ -21249,12 +21698,13 @@ interface Ripple extends Disposable {
21249
21698
 
21250
21699
  | Error | Trigger | Notable properties |
21251
21700
  | --- | --- | --- |
21252
- | `RippleError` | Base Ripple error | Use `instanceof RippleError` to narrow unknown values. |
21253
- | `RippleComputedCycleError` | Computed dependency reads itself through a cycle | Extends `RippleError`. |
21254
- | `RippleDisposedResourceError` | `reload()` or `subscribe()` used after resource disposal | Extends `RippleError`. |
21255
- | `RippleDisposedRuntimeError` | Factory or execution API used after `ripple.dispose()` | Extends `RippleError`. |
21256
- | `RippleDisposedScopeError` | `scope.run()` after scope disposal | Extends `RippleError`. |
21257
- | `RippleInfiniteLoopError` | Effect flush exceeds graph iteration limit | Extends `RippleError`. |
21701
+ | `RippleError` | Base Ripple error; a new subscription to a disposed external-source bridge | Use `instanceof RippleError` to narrow unknown values. |
21702
+ | `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. |
21703
+ | `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. |
21704
+
21705
+ These are the three exported error classes. User callback failures are reported as their original
21706
+ values through `onError`; they are not wrapped in `RippleError`. A direct computed read can throw
21707
+ its derivation error to the caller.
21258
21708
 
21259
21709
  ### Usage Guide
21260
21710
 
@@ -21280,10 +21730,8 @@ Use `createRipple()` for tests, SSR requests, embedded applications, or independ
21280
21730
  ```ts
21281
21731
  import { createRipple } from '@vielzeug/ripple';
21282
21732
 
21283
- const ripple = createRipple({ errorPolicy: 'swallow' });
21284
-
21285
- ripple.tap((event) => {
21286
- if (event.type === 'error') console.log(event.context.kind, event.error);
21733
+ const ripple = createRipple({
21734
+ onError: (error, context) => console.log(context.kind, error),
21287
21735
  });
21288
21736
 
21289
21737
  const count = ripple.signal(0);
@@ -21369,26 +21817,28 @@ const stopWatch = ripple.watch(
21369
21817
  stopWatch.dispose();
21370
21818
  ```
21371
21819
 
21372
- ## Observability
21820
+ ## Error Handling
21373
21821
 
21374
- `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.
21822
+ 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:
21375
21823
 
21376
21824
  ```ts
21377
- const ripple = createRipple({ errorPolicy: 'swallow' });
21378
-
21379
- ripple.tap((event) => {
21380
- switch (event.type) {
21381
- case 'write':
21382
- console.log(`${event.name}: ${event.previous} → ${event.next}`);
21383
- break;
21384
- case 'error':
21385
- console.error(`${event.context.kind}:`, event.error);
21386
- break;
21387
- }
21825
+ const ripple = createRipple({
21826
+ onError: (error, context) => {
21827
+ // context.kind: 'cleanup' | 'computed' | 'effect' | 'flush' | 'listener'
21828
+ console.error(`${context.kind}:`, error);
21829
+ },
21388
21830
  });
21389
21831
  ```
21390
21832
 
21391
- `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.
21833
+ `onError` is the only runtime option. Effect options accept only a `name`; effects do not have a
21834
+ microtask scheduler. Use `batch()` to coalesce related writes.
21835
+
21836
+ A direct computed read can throw its derivation error to the caller. `onError` handles computed
21837
+ refresh failures during propagation, not every possible synchronous exception. The exported
21838
+ hierarchy is `RippleError`, `RippleCycleError`, and `RippleDisposedError`; callback failures retain
21839
+ their original values.
21840
+
21841
+ 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.
21392
21842
 
21393
21843
  ## Bridging External Sources
21394
21844
 
@@ -21424,15 +21874,19 @@ const user = ripple.resource(
21424
21874
 
21425
21875
  const stop = ripple.effect(() => {
21426
21876
  const state = user.value;
21427
- if (state.status === 'success') renderUser(state.value);
21428
- if (state.status === 'error') renderError(state.error);
21877
+ if (state.status === 'success') console.log(state.value);
21878
+ if (state.status === 'error') console.error(state.error);
21429
21879
  });
21430
21880
 
21431
21881
  stop.dispose();
21432
21882
  user.dispose();
21433
21883
  ```
21434
21884
 
21435
- 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.
21885
+ Source and loader failures become resource error state rather than `onError` reports.
21886
+ `reload()` returns `void`; observe state to know when loading finishes. Disposed resources retain
21887
+ readable state but throw `RippleDisposedError` from `reload()` and new subscriptions. Use Sourcerer
21888
+ for paginated collection state and a dedicated server-state cache for shared keyed reads, retries,
21889
+ and invalidation.
21436
21890
 
21437
21891
  ## Object State
21438
21892
 
@@ -21552,13 +22006,21 @@ ripple.dispose();
21552
22006
 
21553
22007
  ## Gotchas
21554
22008
 
22009
+ ### `subscribe()` is change-only
22010
+
22011
+ `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.
22012
+
21555
22013
  ### `subscribe()` forces computed evaluation
21556
22014
 
21557
22015
  `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.
21558
22016
 
22017
+ ### Diamonds recompute each path once per write
22018
+
22019
+ 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.
22020
+
21559
22021
  ### Computed first-run failure is recoverable
21560
22022
 
21561
- 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.
22023
+ 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.
21562
22024
 
21563
22025
  ## Best Practices
21564
22026
 
@@ -21569,8 +22031,7 @@ If a computed's `derive` throws on its first run (e.g., a source is `null`), the
21569
22031
  - Batch related synchronous writes.
21570
22032
  - Use `watch()` only for selected source transitions; callback-only reads are untracked.
21571
22033
  - Dispose external-source bridges when their owner ends.
21572
- - Use `tap()` for runtime observability; set `errorPolicy: 'swallow'` to silence rethrow.
21573
- - Use `resource()` for focused async state and Sourcerer for paginated collection workflows.
22034
+ - Route runtime failures with `onError` and observe async failures through resource state.
21574
22035
 
21575
22036
  ### Examples
21576
22037
 
@@ -21589,7 +22050,7 @@ If a computed's `derive` throws on its first run (e.g., a source is `null`), the
21589
22050
  - Async Resource (id: `async-resource`)
21590
22051
  - Create Graph, Signal, Computed & Effect (id: `basic-signal`)
21591
22052
  - Batch & Untrack (id: `batch-untrack`)
21592
- - Microtask Effect (id: `effect-options`)
22053
+ - Named Effect and onError (id: `effect-options`)
21593
22054
  - Nested Effect Ownership (id: `scope-ownership`)
21594
22055
  - Immutable State (id: `store-basics`)
21595
22056
  - Watch Selected Value (id: `watch-selected-value`)
@@ -28348,7 +28809,9 @@ try {
28348
28809
  - `createPageSource()`: loads numbered pages and exposes direct navigation commands
28349
28810
  - `createCursorSource()`: follows opaque cursors returned by the loader
28350
28811
  - `createInfiniteSource()`: appends pages while preserving loaded items
28812
+ - `createResource()`: starts loading one value immediately, or awaits `reload()` with `start: false`
28351
28813
  - `setParams()`: replaces consumer-owned loader parameters and resets pagination
28814
+ - `PageSource.apply()`: reconciles page and structurally compared params in at most one request
28352
28815
  - `subscribe()`: publishes complete immutable state replacements
28353
28816
 
28354
28817
  ## Documentation
@@ -28375,7 +28838,10 @@ try {
28375
28838
  | `createPageSource()` | Load numbered pages | Async | Construction is inert |
28376
28839
  | `createCursorSource()` | Load server-issued cursor pages | Async | `after` and `before` cannot coexist initially |
28377
28840
  | `createInfiniteSource()` | Append numbered pages | Async | `loadMore()` is a no-op while loading or exhausted |
28841
+ | `createResource()` | Load one reactive value | Async | Starts loading during construction; `start: false` opts out |
28378
28842
  | `SourcererError` | Base package error | Sync | Loader errors retain their original type |
28843
+ | `SourcererConfigurationError` | Invalid pagination configuration | Sync | Pages and page sizes must be positive integers |
28844
+ | `SourcererDisposedError` | Command after source disposal | Sync | In-flight work instead settles without committing |
28379
28845
 
28380
28846
  ## Package Entry Point
28381
28847
 
@@ -28460,6 +28926,7 @@ await source.reload();
28460
28926
 
28461
28927
  | Method | Returns | Description |
28462
28928
  | --- | --- | --- |
28929
+ | `apply(update)` | `Promise` | Reconcile `{ params, page }` in at most one load; params compare structurally |
28463
28930
  | `first()` | `Promise` | Load page one |
28464
28931
  | `goTo(page)` | `Promise` | Load a positive page |
28465
28932
  | `last()` | `Promise` | Load the last known page |
@@ -28471,6 +28938,14 @@ await source.reload();
28471
28938
  | `subscribe(listener)` | `() => void` | Register a state listener and return its unsubscribe function |
28472
28939
  | `dispose()` | `void` | Abort work and stop notifications |
28473
28940
 
28941
+ `apply(update: PageSourceUpdate): Promise` compares params structurally with
28942
+ `isEqual`. Changed params use the supplied positive page without clamping, or page one when omitted.
28943
+ Unchanged params with a page use `goTo()` and clamp after the first successful load.
28944
+ An unchanged request does not refetch a committed result or duplicate an active request; it loads
28945
+ an inert source or retries an uncommitted request after failure. Omitted or `undefined` params
28946
+ preserve the requested value; use `setParams(undefined)` when `undefined` is a valid replacement.
28947
+ The update does not include page size; use `setPageSize()` separately.
28948
+
28474
28949
  ---
28475
28950
 
28476
28951
  ### `createCursorSource()`
@@ -28548,6 +29023,54 @@ await source.loadMore();
28548
29023
  | `subscribe(listener)` | `() => void` | Register a state listener and return its unsubscribe function |
28549
29024
  | `dispose()` | `void` | Abort work and stop notifications |
28550
29025
 
29026
+ ---
29027
+
29028
+ ### `createResource()`
29029
+
29030
+ ```ts
29031
+ function createResource(config: ResourceConfig): Resource
29032
+ ```
29033
+
29034
+ Returns observable state for one asynchronously loaded value. It does not suspend rendering,
29035
+ cache by key, or track external reactive inputs.
29036
+
29037
+ | Parameter | Type | Description |
29038
+ | --- | --- | --- |
29039
+ | `config.load` | `(context: ResourceLoadContext) => Promise` | Loader; receives a signal aborted on disposal and on the next `reload()` |
29040
+ | `config.start` | `boolean` | Start the initial load during construction; defaults to `true` |
29041
+
29042
+ **Returns:** `Resource`.
29043
+
29044
+ ```ts
29045
+ import { createResource } from '@vielzeug/sourcerer';
29046
+
29047
+ const profile = createResource({
29048
+ load: ({ signal }) => fetch('/me', { signal }).then((response) => response.json()),
29049
+ start: false,
29050
+ });
29051
+
29052
+ const stop = profile.subscribe((state) => console.log(state.data, state.loading, state.error));
29053
+ console.log(profile.state);
29054
+ try {
29055
+ await profile.reload();
29056
+ } finally {
29057
+ stop();
29058
+ profile.dispose();
29059
+ }
29060
+ ```
29061
+
29062
+ | Method | Returns | Description |
29063
+ | --- | --- | --- |
29064
+ | `reload()` | `Promise` | Refetch with a fresh signal; rejects so callers can await it |
29065
+ | `subscribe(listener)` | `() => void` | Register a state listener and return its unsubscribe function |
29066
+ | `dispose()` | `void` | Abort the in-flight load and stop notifications |
29067
+
29068
+ Construction starts the initial load unless `start: false`. Its failure surfaces through
29069
+ `state.error`; a later `reload()` rejects and retains the previous `data` on failure.
29070
+ A superseded or disposed request settles without touching state. Subscriptions are change-only:
29071
+ read `state` for the current snapshot, including the initial loading state. `dispose()` is idempotent,
29072
+ aborts `disposalSignal`, and leaves the last snapshot readable; subscriptions after disposal are inert.
29073
+
28551
29074
  ## Types
28552
29075
 
28553
29076
  ### Page source types
@@ -28566,8 +29089,11 @@ type PageSourceConfig = Readonly): Promise>;
28566
29089
  } & (undefined extends TParams ? { params?: TParams } : { params: TParams })
28567
29090
  >;
28568
29091
 
29092
+ type PageSourceUpdate = Readonly;
29093
+
28569
29094
  type PageSource = {
28570
29095
  [Symbol.dispose](): void;
29096
+ apply(update: PageSourceUpdate): Promise;
28571
29097
  readonly disposalSignal: AbortSignal;
28572
29098
  dispose(): void;
28573
29099
  readonly disposed: boolean;
@@ -28586,6 +29112,28 @@ type PageSource = {
28586
29112
 
28587
29113
  The conditional config field makes `params` optional only when `TParams` includes `undefined`.
28588
29114
 
29115
+ ### Resource types
29116
+
29117
+ ```ts
29118
+ type ResourceLoadContext = Readonly;
29119
+
29120
+ type ResourceState = Readonly;
29121
+
29122
+ type ResourceConfig = Readonly;
29123
+ start?: boolean;
29124
+ }>;
29125
+
29126
+ type Resource = {
29127
+ [Symbol.dispose](): void;
29128
+ readonly disposalSignal: AbortSignal;
29129
+ dispose(): void;
29130
+ readonly disposed: boolean;
29131
+ reload(): Promise;
29132
+ readonly state: ResourceState;
29133
+ subscribe(listener: (state: ResourceState) => void): () => void;
29134
+ };
29135
+ ```
29136
+
28589
29137
  ### Local source types
28590
29138
 
28591
29139
  ```ts
@@ -28734,7 +29282,7 @@ console.log(source.state.params);
28734
29282
  source.dispose();
28735
29283
  ```
28736
29284
 
28737
- 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.
29285
+ 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).
28738
29286
 
28739
29287
  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`.
28740
29288
 
@@ -28754,6 +29302,47 @@ await source.setPageSize(50);
28754
29302
 
28755
29303
  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.
28756
29304
 
29305
+ ## Apply Page and Filter Together
29306
+
29307
+ 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.
29308
+
29309
+ ```ts
29310
+ import { createPageSource } from '@vielzeug/sourcerer';
29311
+
29312
+ const names = ['Ada', 'Grace', 'Linus', 'Ada Byron'];
29313
+ const source = createPageSource({
29314
+ load: async ({ page, pageSize, params }) => {
29315
+ const matching = names.filter((name) => name.includes(params.search));
29316
+ const start = (page - 1) * pageSize;
29317
+ return { items: matching.slice(start, start + pageSize), totalItems: matching.length };
29318
+ },
29319
+ pageSize: 1,
29320
+ params: { search: '' },
29321
+ });
29322
+
29323
+ try {
29324
+ await source.apply({ page: 2, params: { search: 'Ada' } });
29325
+ console.log(source.state.items); // ['Ada Byron']
29326
+ await source.apply({ params: { search: 'Ada' } });
29327
+ console.log(source.state.pagination.page); // 2; no extra request
29328
+ await source.apply({ params: { search: 'Grace' } });
29329
+ console.log(source.state.pagination.page); // 1
29330
+ } finally {
29331
+ source.dispose();
29332
+ }
29333
+ ```
29334
+
29335
+ Parameters are compared structurally, so a freshly built object with the same contents does not
29336
+ refetch a committed result or duplicate active work. An inert source still loads, and an unchanged
29337
+ request can retry after failure. When parameters change,
29338
+ `page` is validated but never clamped: the page count still describes the previous filter set, so
29339
+ clamping against it would silently move the reader elsewhere. Omit `page` in that case and the
29340
+ source returns to page one. When parameters are unchanged, `apply()` routes through `goTo()` and
29341
+ clamps to the known page count.
29342
+
29343
+ Omitted or `undefined` params keep the requested value. Use `setParams(undefined)` when
29344
+ your parameter type allows `undefined` and you want to replace the value with it.
29345
+
28757
29346
  ## Filter Local Collections
28758
29347
 
28759
29348
  Use a local source when data is already in memory. Its commands are synchronous.
@@ -28829,6 +29418,44 @@ source.dispose();
28829
29418
 
28830
29419
  `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.
28831
29420
 
29421
+ ## Load a Single Value
29422
+
29423
+ Use a resource for one reactive value: a profile, a settings document, a pricing summary. It loads once and refetches on demand.
29424
+
29425
+ ```ts
29426
+ import { createResource } from '@vielzeug/sourcerer';
29427
+
29428
+ type Profile = { name: string };
29429
+ const profile = createResource({
29430
+ load: async ({ signal }) => {
29431
+ const response = await fetch('/me', { signal });
29432
+ if (!response.ok) throw new Error(`Profile request failed: ${response.status}`);
29433
+ return response.json();
29434
+ },
29435
+ start: false,
29436
+ });
29437
+
29438
+ const stop = profile.subscribe((state) => console.log(state.data, state.loading, state.error));
29439
+ console.log(profile.state);
29440
+ try {
29441
+ await profile.reload();
29442
+ } catch (error) {
29443
+ console.error(error);
29444
+ } finally {
29445
+ stop();
29446
+ profile.dispose();
29447
+ }
29448
+ ```
29449
+
29450
+ A resource starts loading during construction by default; the example uses `start: false` to await
29451
+ the first load explicitly. Automatic initial-load failures land in `state.error` without an
29452
+ unhandled rejection. Explicit `reload()` calls reject current failures and preserve previous `data`.
29453
+ Reloading aborts older work, whose promise settles without committing.
29454
+
29455
+ Read `state` before subscribing when you need the initial snapshot: subscriptions notify only
29456
+ changes. A resource does not track Ripple signals or cache values by key; call `reload()` when
29457
+ external inputs change. Disposal aborts work and retains the last readable snapshot.
29458
+
28832
29459
  ## Handle Requests and Errors
28833
29460
 
28834
29461
  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.
@@ -28961,6 +29588,7 @@ Use Scout to compute indexed local matches, Ripple to project state into reactiv
28961
29588
  - [Page Params with URL State](./examples/remote-search-with-url-state.md)
28962
29589
  - [Cursor-Based Pagination](./examples/cursor-based-pagination.md)
28963
29590
  - [Infinite Scroll](./examples/infinite-scroll.md)
29591
+ - [Load a Single Value](./examples/single-value-resource.md)
28964
29592
  - [Framework Integration](./examples/framework-integration.md)
28965
29593
  - [Remote Data with Courier](./examples/sourcerer-with-courier.md)
28966
29594
  - [Reactive Controls with Ripple](./examples/sourcerer-with-ripple.md)
@@ -28968,12 +29596,14 @@ Use Scout to compute indexed local matches, Ripple to project state into reactiv
28968
29596
 
28969
29597
  ### REPL Examples
28970
29598
 
29599
+ - apply - Page and params in one call (id: `apply`)
28971
29600
  - Cursor Source (id: `cursor-source`)
28972
29601
  - Error Handling (id: `error-handling`)
28973
29602
  - Infinite Source (id: `infinite-source`)
28974
29603
  - Source Lifecycle (id: `lifecycle`)
28975
29604
  - Local Source (id: `local-source`)
28976
29605
  - Page Source (id: `page-source`)
29606
+ - createResource - One reactive value (id: `resource`)
28977
29607
 
28978
29608
  ---
28979
29609