@vielzeug/codex 26.10.7 → 26.10.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/data/search.json CHANGED
@@ -3,9 +3,9 @@
3
3
  "category": "utilities",
4
4
  "description": "community content publishing for cloudflare workers + d1: a hardened worker core over per collection payload schemas",
5
5
  "docs": {
6
- "index": " \ntitle: archive\ndescription: \"community content publishing for cloudflare workers + d1: a hardened worker core over per collection payload schemas\"\npackage: archive\ncategory: utilities\nkeywords: [cloudflare, worker, d1, community, publishing, content, catalog, gdpr]\nrelated: [spell, courier, rune]\nexports: [definearchivecollection, createarchiveworker, createauthworker, archiveauthhealthschema, archivederivationerror]\nenvironments: [browser, node]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"archive\" />\n\n## why archive?\n\nyou can share content entries with ranked browsing, filters, likes and account\nerasure on cloudflare workers + d1. `definearchivecollection` supplies your json\npayload schema and indexing rules; the worker supplies sessions, guards,\nidempotent publishing, cursors and rate limits.\n\n```ts\n// before: each deployment repeats validation, indexing and route plumbing.\n\n// after: the deployment is one collection definition plus one factory call.\nimport { definearchivecollection } from '@vielzeug/archive';\nimport { createarchiveworker } from '@vielzeug/archive/worker';\nimport { s } from '@vielzeug/spell';\n\nconst notes = definearchivecollection({\n name: 'notes', contentversion: 1,\n payloadschema: s.object({ title: s.string().min(1).max(128) }),\n index: ({ title }) => ({ title, filter: null, tags: [] }),\n});\nexport default createarchiveworker([notes]);\n```\n\n| feature | archive | hand rolled worker | baas (firebase/supabase) |\n| | | | |\n| bundle size (core) | <packageinfo package=\"archive\" type=\"size\" /> | depends on deployment code | depends on selected sdk |\n| zero dependencies | no — spell and jose | depends on implementation | no — client sdk |\n| content knowledge in the core | <ore icon name=\"check\" size=\"16\"></ore icon> none — one collection seam | <ore icon name=\"x\" size=\"16\"></ore icon> baked in | <ore icon name=\"x\" size=\"16\"></ore icon> rules everywhere |\n| canonical fresh database d1 schema | <ore icon name=\"check\" size=\"16\"></ore icon> in the package | deployment owned | not a d1 backend |\n| idempotent publish w/ tombstones | <ore icon name=\"check\" size=\"16\"></ore icon> built in | <ore icon name=\"x\" size=\"16\"></ore icon> rebuild it | <ore icon name=\"x\" size=\"16\"></ore icon> rebuild it |\n| gdpr erasure / export endpoints | <ore icon name=\"check\" size=\"16\"></ore icon> built in | <ore icon name=\"x\" size=\"16\"></ore icon> rebuild it | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> partial |\n| strict dto re validation on read | <ore icon name=\"check\" size=\"16\"></ore icon> every entry | <ore icon name=\"x\" size=\"16\"></ore icon> optional | <ore icon name=\"x\" size=\"16\"></ore icon> optional |\n\n<div class=\"decision callout\">\n\n**use archive when** an app needs a shareable, ranked, liked community space for\ncontent entries on cloudflare workers + d1, and can express its content as one strict\npayload schema.\n**consider alternatives when** you need rich queries beyond filter/tag/search, live\ncollaboration, or a backend you do not control — archive is deliberately one narrow,\nhardened surface.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/archive @vielzeug/spell\n```\n\n```sh [npm]\nnpm install @vielzeug/archive @vielzeug/spell\n```\n\n```sh [yarn]\nyarn add @vielzeug/archive @vielzeug/spell\n```\n\n:::\n\n## quick start\n\na deployment is one collection definition plus one factory call. the definition\nbrings the payload schema and what to index; everything else — the emitted entry\nenvelope, the content hash, the wire contract, the routes — is composed for you.\n\n```ts\nimport { definearchivecollection } from '@vielzeug/archive';\nimport { createarchiveworker } from '@vielzeug/archive/worker';\nimport { s } from '@vielzeug/spell';\n\n// the collection's payload: one strict schema is the whole content contract.\nconst payloadschema = s.object({\n cuisine: s.enum(['italian', 'japanese']),\n ingredients: s.array(s.string().trim().min(1).max(64)).min(1).max(20),\n name: s.string().trim().min(1).max(32),\n});\n\nconst recipescollection = definearchivecollection({\n canonicalize: (payload) => ({ ...payload, ingredients: [...payload.ingredients].sort() }),\n contentversion: 1,\n filters: ['italian', 'japanese'],\n index: (payload) => ({\n filter: payload.cuisine,\n tags: [payload.ingredients.length <= 5 ? 'quick' : 'involved'],\n title: payload.name,\n }),\n name: 'recipes',\n payloadschema,\n tags: ['quick', 'involved'],\n});\n\n// the whole worker: routing, auth, guards, limits, sql and dto disciplines included.\nexport default createarchiveworker([recipescollection]);\n```\n\nthe browser side never imports worker machinery. the same collection's composed\nschemas parse what the routes emit:\n\n```ts\nimport { definearchivecollection } from '@vielzeug/archive';\nimport { s } from '@vielzeug/spell';\n\nconst notes = definearchivecollection({\n name: 'notes', contentversion: 1,\n payloadschema: s.object({ title: s.string().min(1).max(128) }),\n index: ({ title }) => ({ title, filter: null, tags: [] }),\n});\nconst response = await fetch('/api/v1/collections/notes/entries?sort=liked');\nif (!response.ok) throw new error('archive unavailable');\nconst parsed = notes.contract.page.parse(await response.json());\nconsole.log(parsed.entries);\n```\n\n## features\n\n<div class=\"features grid\">\n\n `definearchivecollection` — the one content seam: payload schema, canonicalization, filter/tag/title derivation, union typed vocabularies\n `createarchiveworker` — the whole routes/auth/limits/sql stack from registered collections\n `collection.contract` — entry, page, publish/update input schemas shared by client and worker\n `archive_limits` + `archivehealthschema` — the application bounds, advertised by health and validated on both sides\n `archiveauthhealthschema` + `auth_schema_version` — typed issuer readiness checked against the issuer ddl stamp\n `archiveerrorresponseschema` — the one error envelope, code typed across the wire\n `createauthworker` — scanner safe confirmation, live session version enforcement, durable budgets, metadata export and coordinated erasure\n `auth_issuer_service` — required uncached issuer check after local catalog jwt verification\n `archivederivationerror` — distinguishes faulty collection code from invalid submitted content\n moderation — reader reports, the operator soft hide and the report queue, keyed on a verified `roles` claim\n `worker.tap()` / `issuer.tap()` — typed sanitized runtime events; neither core logs by default\n `migrations/` — canonical fresh database ddl with the storage stamp health reads back; no upgrade framework\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md) — stand up a collection, deploy the worker, wire the client.\n [api reference](./api.md) — every export, all three entry points, the full route table.\n [examples](./examples.md) — recipes from first deployment to gdpr erasure.\n [migration](./migration.md) — breaking changes after the first release and\n fresh database scope.\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [spell](/spell/) — every payload schema here is a spell schema; the core composes envelopes from it.\n [courier](/courier/) — the http client shape an app's frontend mirrors to consume these routes.\n [rune](/rune/) — the structured logger a deployment points `worker.tap()` events at.\n\n</div>\n\n<! markdownlint enable md060 md033 md025 >\n",
7
- "api": " \ntitle: \"archive: api reference\"\ndescription: \"collection definitions, browser safe wire schemas, worker deployment, and identity issuance.\"\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `definearchivecollection` | compose validation, indexing, identity and wire schemas | sync | invalid content derives to `null`; callback faults throw |\n| `createarchiveworker` | build the catalog worker | sync | collection names must be unique |\n| `createauthworker` | build the passwordless issuer | sync | `auth_archive` is required |\n| `worker.tap`, `issuer.tap` | observe sanitized runtime outcomes | sync | neither core logs by default; observer exceptions are swallowed |\n| `archivederivationerror` | identify a collection implementation/runtime fault | sync | faults become sanitized 500s, not content validation 422s |\n| `archive_limits` | shared application bounds | sync | page bounds do not cap total exports |\n| `archiveerrorresponseschema`, `archiveauthhealthschema` | validate error and issuer health responses | sync | unknown error codes and health states reject |\n| `archiveerror`, `archivedefinitionerror` | public base and invalid definition errors | sync | thrown locally, not transmitted as error objects |\n| `sessioncookievalue` | compose shared session cookie attributes | sync | deployments must choose `secure` from their environment |\n| `iswellformedtext` | check control characters and lone surrogates | sync | does not trim or impose a length bound |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/archive` | browser safe collections, constants, schemas and errors |\n| `@vielzeug/archive/worker` | catalog factory, issuer session service binding and event types |\n| `@vielzeug/archive/auth` | issuer factory and binding types |\n\n## collections\n\n### `definearchivecollection`\n\ncreates a frozen collection and its contract; malformed names, versions or vocabularies throw `archivedefinitionerror`.\n\n```ts\nfunction definearchivecollection<\n p extends object, const c extends number, const f extends string = never, const t extends string = never,\n>(definition: archivecollectiondefinition<p, c, f, t>): archivecollection<p, c>;\n```\n\n| parameter | contract |\n| | |\n| `name` | lowercase `[a z0 9 ]{1,32}` |\n| `contentversion` | positive safe integer |\n| `payloadschema` | spell schema whose output is a stable json object |\n| `filters`, `tags` | optional readonly arrays; omitted means empty vocabulary |\n| `canonicalize` | optional pure, idempotent, schema valid normalization |\n| `index` | derive `filter`, `tags` and searchable `title` without mutating the payload |\n\n**returns:** `archivecollection<p, c>`.\n\n**example:** define and derive one collection without worker bindings.\n\n```ts\nimport { definearchivecollection } from '@vielzeug/archive';\nimport { s } from '@vielzeug/spell';\n\nconst recipes = definearchivecollection({\n name: 'recipes',\n contentversion: 1,\n payloadschema: s.object({ name: s.string().min(1).max(32) }),\n index: (payload) => ({ filter: null, tags: [], title: payload.name }),\n});\nconst derived = await recipes.derive({ name: 'rice' });\nif (derived === null) throw new error('invalid recipe');\nconsole.log(derived.contenthash);\n```\n\n| method / property | result |\n| | |\n| `derive(raw)` | `promise<archivederivation \\| null>` |\n| `contract.entry` | public entry schema |\n| `contract.ownedentry`, `ownedentries` | owner view with `clientkey` and `hidden` |\n| `contract.page` | entries and the forward cursor |\n| `contract.publishinput`, `updateinput` | publish and revision guarded update request schemas |\n| `filters`, `tags` | frozen vocabulary snapshots |\n\n`derive` returns `null` for invalid json objects, schema invalid input, invalid titles, too many tags and oversized canonical payloads. unexpected schema, callback or hashing failures throw `archivederivationerror`. non idempotent or schema invalid canonicalization, unstable schema transformations, index mutation and out of vocabulary projections are also derivation faults. routes map `null` to 422 `invalid payload` and faults to a sanitized 500 `internal`.\nschema output that is lossy or no longer json is a derivation fault, not invalid\nsubmitted content. invalid index result shapes also throw.\n\nconst 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.\n\nobjects 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.\n\n`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.\n\n## worker and maintenance\n\n### `createarchiveworker`\n\nreturns the catalog request/scheduled handler and its observation side channel.\n\n```ts\nfunction createarchiveworker<e extends archiveworkerenv>(\n collections: readonly archivecollection[],\n): archiveworkerhandler<e>;\n```\n\n**returns:** `archiveworkerhandler<e>`; duplicate names throw `archivedefinitionerror` at registration.\n\n**example:** export a deployable worker using the package's structural bindings.\n\n```ts\nimport { definearchivecollection } from '@vielzeug/archive';\nimport { createarchiveworker, type archiveworkerenv } from '@vielzeug/archive/worker';\nimport { s } from '@vielzeug/spell';\n\nconst notes = definearchivecollection({\n name: 'notes', contentversion: 1,\n payloadschema: s.object({ title: s.string().min(1).max(128) }),\n index: ({ title }) => ({ title, filter: null, tags: [] }),\n});\nexport default createarchiveworker<archiveworkerenv>([notes]);\n```\n\n| method | contract |\n| | |\n| `fetch(request, env, ctx)` | returns `promise<response>` |\n| `scheduled(controller, env, ctx)` | sweeps expired retry keys and revocation tombstones; emits failures and rethrows |\n| `tap(handler, { signal? }?)` | returns unsubscribe; a pre aborted signal registers nothing |\n\n`auth_issuer_service` is a required `fetch` shaped service binding to the issuer.\nthe catalog verifies the signed jwt locally, then asks the issuer to validate its\ncurrent account session version. it does not cache successful issuer verdicts.\nif the service exchange fails or exceeds its fixed 5000 ms timeout (body decoding\nincluded), the catalog returns `503 auth unavailable`; the timeout does not cancel\nwork already started by the issuer.\n\nonly anonymous entry lists can use the 30 second edge cache. any `cookie` or `authorization` header disables caching, even if invalid or empty. detail, metadata, private and error responses are `no store`. public reads do not verify sessions.\n\n \n\n### `worker.tap`\n\nobserves typed request, scheduled and cache failure events without affecting request behavior.\n\n```ts\ntap(handler: (event: archiveevent) => void, options?: { readonly signal?: abortsignal }): () => void;\n```\n\n**returns:** an unsubscribe function; abort detaches the observer. diagnostics contain categories and trusted configuration field names, never raw errors, sql, subjects, aliases, tokens or urls.\n\n**example:** observe a worker and detach explicitly.\n\n```ts\nimport { createarchiveworker, type archiveworkerenv } from '@vielzeug/archive/worker';\n\nconst worker = createarchiveworker<archiveworkerenv>([]);\nconst stop = worker.tap((event) => console.log(json.stringify(event)));\nstop();\n```\n\n \n\n## identity issuer\n\n### `createauthworker`\n\nreturns the passwordless email issuer with account export and coordinated erasure.\n\n```ts\nfunction createauthworker<e extends authworkerenv>(): authworkerhandler<e>;\n```\n\n**returns:** `authworkerhandler<e>`.\n\n**example:** export an issuer configured through deployment bindings.\n\n```ts\nimport { createauthworker, type authworkerenv } from '@vielzeug/archive/auth';\n\nexport default createauthworker<authworkerenv>();\n```\n\n| method | contract |\n| | |\n| `fetch(request, env, ctx?)` | returns `promise<response>` |\n| `scheduled(controller, env, ctx?)` | removes expired links, closed send windows and unverified unreferenced accounts |\n| `tap(handler, { signal? }?)` | observes typed `authevent`; returns unsubscribe; pre aborted signals are inert |\n\nmagic links expire after 15 minutes and work once. the durable per email budget allows three sends per hour; consumption, expiration and send failure do not refund it. ordinary sessions last at most 30 days. operator sessions use `operator_session_max_age_seconds` (900 seconds). `operator_emails` controls newly minted role claims.\n\nthe issuer global admission budget is durable d1 state in `auth_sign_in_budget`:\n20 requests per 60 second window before account creation or email delivery.\nthis supplements, rather than replaces, the durable per email budget. no external\nrate limiter binding is required.\nadmission spends are not refunded after later database or email failures.\nrequest emails are trimmed and lowercased before validation. `sender_address`\nmust already be canonical; `operator_emails` entries are trimmed and lowercased\nbefore validation.\n\nsign in requests create pending account rows with `lastsigninat: null`. successful\nredemption atomically claims the one use link and stamps the account's sign in\ntime. the sweep deletes expired links and closed per user send windows, then\nunverified accounts with neither remaining links nor send budget references.\nverified accounts are retained until explicit account deletion.\n\n`operator_session_max_age_seconds = 900` is part of the browser safe root\ncontract; `/auth` exports the factory and issuer binding types.\n\n`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.\n\nopening a magic link performs a safe get that displays a click confirmation page,\nnot redemption. only a marked post from an exact allowed origin or\n`public_origin` consumes `{ token }` and returns 204 with the session cookie. the\nconfirmation page navigates to `/signed in` after success; the post is not a\nredirect. confirmation responses are `no store` with no referrer and restrictive\ncsp; the page removes the token from the address bar and loads no external assets.\nget displays confirmation for any syntactically valid token, including a used\ntoken; only post establishes whether the link is still redeemable. post accepts\nexactly `{ token }` and rejects query parameters.\n\nthe issuer never logs directly. `tap` emits sanitized request and scheduled\noutcomes; observer failures are swallowed, abort detaches the observer and failed\nscheduled work emits then rethrows. `authdiagnostic` reports only a category.\n\n## http routes\n\nevery route below is mounted under `/api/v1`. mutation guards require an exact allowed `origin` and `x archive request: 1`. registered publishers pin `x archive publisher` to the identity captured when preparing private requests. the header is a freshness precondition, not authority.\n\n| route | contract |\n| | |\n| `get /health` | public catalog readiness; 200 `ok` or 503 `degraded` |\n| `post /sign out` | catalog local sign out; `{ ok: true }` and expired session cookie, without issuer access |\n| `get /collections/:collection` | collection metadata |\n| `get /collections/:collection/entries` | `author`, `filter`, `tag`, `search`, `sort`, `cursor`, `limit`; forward entry page |\n| `post /collections/:collection/entries` | `{ clientkey, payload }`; 201 create or 200 replay |\n| `get /collections/:collection/entries/:id` | public entry; always `no store` |\n| `put /collections/:collection/entries/:id` | owner only `{ expectedrevision, payload }` |\n| `delete /collections/:collection/entries/:id` | owner or operator removal; 204 |\n| `put` / `delete /collections/:collection/entries/:id/like` | like / unlike visible entries only; guarded write and response; like state response |\n| `post /collections/:collection/entries/:id/report` | `{ reason }`; idempotent per entry/reporter; 204 |\n| `put` / `delete /collections/:collection/entries/:id/hidden` | operator hide / unhide; 204 |\n| `get /collections/:collection/reports` | operator page; `status=unresolved` (default) or `resolved`, `cursor`, `limit` |\n| `put /collections/:collection/reports/:id/resolved` | operator resolves report by its unique report id; idempotent 204 |\n| `delete /publishers/:id` | operator erases archive profile; not issuer account |\n| `get /moderation log` | operator forward page; `cursor`, `limit` |\n| `get` / `post` / `put /me` | identity, register with optional `{ name }`, rename with required `{ name }` |\n| `delete /me` | profile only cascade; 204; does not clear cookie |\n| `delete /me/account` | revoke issuer/subject and atomically erase profile; 204; coordinated issuer erasure seam |\n| `get /me/publications?collection=…` | owner entries including hidden state and retry keys |\n| `get /me/likes` | forward page `{ ids, cursor }`; `cursor`, `limit` |\n| `get /me/likes?ids=…` | subset lookup `{ ids }`; at most `likestateids` input ids |\n| `get /me/reports` | own forward report page across collections; `cursor`, `limit` |\n| `get /auth/health` | public issuer readiness; typed `authhealth`, 200 `ok` or 503 `degraded`; no email or peer probe |\n| `get /auth/session` | internal issuer session check for the catalog service binding; exact `{ ok: true }`, `no store`, no pii; 401 for an invalid, stale or absent account session |\n| `post /auth/sign out all` | requires marker, exact origin, session and captured `account_header`; atomically increments `session_version`, deletes outstanding links, preserves account and catalog data; 200 `{ ok: true }` with an expired cookie only on durable success |\n| `get /auth/jwks.json` | active and configured retired public verification keys |\n| `post /auth/sign in` | `{ email }` (trimmed and lowercased); 202 `{ ok: true }` |\n| `get /auth/verify?token=…` | safe no store click confirmation page; no token consumption or session change |\n| `post /auth/verify` | json `{ token }`, marker and exact allowed origin or `public_origin`; consume once; 204 with session cookie |\n| `get /auth/account` | `{ id, email, createdat, lastsigninat }`; no tokens or budget data |\n| `delete /auth/account` | requires `account_header` with the captured issuer account uuid; catalog `/me/account` via `auth_archive`, require exact 204, then issuer user and cookie clear; 204 |\n\nforward pages are newest first with opaque context bound keyset cursors. keep the same query context when following a cursor; end at `cursor: null`. `maxpagesize` bounds each page, not the total number of liked ids, reports or audit actions you can export. the subset like query is not a page and cannot combine `ids` with `cursor` or `limit`.\n\nfull erasure creates an issuer/subject revocation tombstone for 30 days plus ten\nseconds and deletes the profile in the same transaction. catalog authentication\nrejects that subject with 401 on ordinary private routes; registration also checks\nrevocation in sql to prevent resurrection races. only `delete /me/account` may\nbypass the issuer session lookup, and only to retry erasure with the original\nsigned session of the same principal while its existing revocation tombstone is\nvalid; jwt signature and claims are still verified. it is not a general\nstale session exception: initial erasure validates the issuer session. the\nissuer facing `delete /auth/account` always checks the current session version\nbefore calling the catalog, so retrying it after completed erasure returns 401\nbecause the account is absent.\ndirect profile only `delete /me` allows later registration using the same session.\nthe sweep removes expired tombstones. re enrollment through the reference issuer\ncreates a fresh account uuid subject; an external issuer must issue a new subject\nto re enroll during the revocation window.\n\nthe issuer stores `auth_users.session_version` as a safe non negative integer,\ndefaulting to `0`. every session jwt carries the current value as the required\n`sessionversion` claim. each ordinary issuer authenticated route compares that\nclaim to the current account row; a mismatch is unauthenticated. `post\n/api/v1/auth/sign out all` checks the captured issuer account uuid against the\nverified subject before atomically incrementing the version and deleting pending\nlinks. a changed account answers 409 `account changed`; a failed request does not\nclear the cookie. this revokes all sessions, including the caller's, after a\nsuccessful response. requests authorized before revocation may finish; there is\nno cross database transactional or in flight cancellation guarantee.\n\nthe catalog requires `auth_issuer_service`, a service binding to the issuer. after\nlocal jwt signature/claims verification, it requests\n`get /api/v1/auth/session` through that binding on every private request. only a\nstrictly validated 401 `unauthenticated` remains a 401. malformed, unexpected,\nprovider and 5xx responses become 503 `auth unavailable`; there is no authority\ncache. both service directions use a fixed 5000 ms timeout that includes body\ndecoding. the issuer's `/api/v1/auth/session` is an internal\nservice contract, not a frontend session api.\n\nissuer readiness is independent of catalog readiness. `get\n/api/v1/auth/health` returns `archiveauthhealthschema` as `authhealth`; its\nread only probe checks configuration, a signing and verification key proof,\nbinding shapes, the singleton `auth_schema_meta` stamp against\n`auth_schema_version`, and readability of expected tables, columns and triggers.\nit does not write, send email or probe the catalog service. responses are\nsanitized.\nconfiguration failure, database failure and schema mismatch return http 503\nwith `status: 'degraded'` and the corresponding reason.\n\neffective hide, unhide, operator removal and publisher erasure append audit rows atomically with the state transition. concurrent idempotent no ops append nothing. audit rows contain opaque ids, not names or content, and have no foreign keys so they outlive erasure. resolving a report sets `resolvedat`; it is not one of the four audit action kinds.\n\n## constants and schemas\n\n| export | value or parsed output |\n| | |\n| `archive_contract_version`, `archive_schema_version` | both debut at `1`; wire and storage versions are independent |\n| `auth_schema_version` | `1`; issuer ddl stamp checked by `/auth/health` |\n| `archive_collection_name_pattern` | `/^[a z0 9 ]{1,32}$/` |\n| `archive_operator_role` | `'archive operator'` |\n| `session_cookie_name`, `session_cookie_path` | `'archive_session'`, `'/api/v1'` |\n| `session_max_age_seconds` | `2592000` |\n| `operator_session_max_age_seconds` | `900`; shared root contract |\n| `account_header` | `'x archive account'`; required issuer account uuid on coordinated erasure |\n| `publisher_header`, `request_marker_header` | `'x archive publisher'`, `'x archive request'` |\n| `ill_formed_text`, `iswellformedtext(value: string): boolean` | reject control characters and lone utf 16 surrogates |\n| `uuidschema`, `isotimestampschema`, `hex64schema` | uuid, iso timestamp, lowercase sha 256 hex strings |\n| `archivealiasschema` | trimmed, well formed alias of at most 24 characters |\n| `archiveauthorschema` | `{ id: string; name: string }` |\n| `archiveidentityschema` | `archiveidentity` |\n| `archiveaccountschema` | `archiveaccount`; issuer metadata, excluding private fields |\n| `archivelimitsschema` | all numeric `archive_limits` fields |\n| `archivehealthschema` | `archivehealth` |\n| `archiveauthhealthschema` | `authhealth` |\n| `archiveerrorresponseschema` | `archiveerrorresponse` |\n| `archiveokschema` | `{ ok: true }` |\n| `archivecollectionmetaschema` | `archivecollectionmeta` |\n| `archivelikedidsschema` | `{ ids: string[] }` subset response |\n| `archivelikespageschema` | `archivelikespage` |\n| `archivelikestateschema` | `{ id: string; likecount: number; liked: boolean }` |\n| `archivereportsschema`, `archiveownreportsschema` | `archivereports`, `archiveownreports` |\n| `archivemoderationlogschema` | `archivemoderationlog` |\n| `archivesortschema` | `archivesort` |\n| `archive_error_codes` | values of `archiveerrorcode` below |\n| `archive_moderation_actions` | `['hide', 'unhide', 'remove', 'erase publisher']` |\n\n### `sessioncookievalue`\n\ncomposes a session cookie using the shared security attributes.\n\n```ts\nfunction sessioncookievalue(token: string, options: { maxageseconds: number; secure: boolean }): string;\n```\n\n| parameter | contract |\n| | |\n| `token` | session token, or empty string when clearing |\n| `options.maxageseconds` | cookie lifetime; `0` clears it |\n| `options.secure` | include `secure` for production https |\n\n**returns:** the `set cookie` value with `httponly`, `samesite=strict`, the shared path and optional `secure`.\n\n**example:** clear the production cookie.\n\n```ts\nimport { sessioncookievalue } from '@vielzeug/archive';\n\nconst expiredcookie = sessioncookievalue('', { maxageseconds: 0, secure: true });\nconsole.log(expiredcookie);\n```\n\n \n\n### `iswellformedtext`\n\nchecks text for control characters and lone utf 16 surrogates.\n\n```ts\nfunction iswellformedtext(value: string): boolean;\n```\n\n**returns:** `true` when `ill_formed_text` does not match; it does not trim or impose bounds.\n\n**example:** validate text independently of the alias schema.\n\n```ts\nimport { iswellformedtext } from '@vielzeug/archive';\n\nconsole.log(iswellformedtext('rice'), iswellformedtext('\\u0000'));\n```\n\n### `archive_limits`\n\n`archive_limits` is frozen with these values:\n\n```ts\nconst limits = {\n aliaschars: 24, cursorchars: 1024, idempotencyretentionms: 172800000,\n likestateids: 50, maxpagesize: 50, maxpublications: 50, pagesize: 10,\n payloadbytes: 8192, publiccacheseconds: 30, reportreasonchars: 256,\n requestbytes: 12288, searchchars: 64, searchterms: 6, tagsperentry: 16,\n titlechars: 128, vocabchars: 64,\n};\n```\n\n## types\n\nthe following definitions use `schema` from `@vielzeug/spell`. schema derived aliases show their complete output shape.\n\n```ts\ninterface archiveindexing<f extends string = string, t extends string = string> {\n filter: f | null;\n tags: readonly t[];\n title: string;\n}\ninterface archivecollectiondefinition<\n p extends object, c extends number, f extends string = never, t extends string = never,\n> {\n canonicalize?(payload: p): p;\n contentversion: c;\n filters?: readonly f[];\n index(payload: p): archiveindexing<noinfer<f>, noinfer<t>>;\n name: string;\n payloadschema: schema<p, unknown>;\n tags?: readonly t[];\n}\ninterface archivederivation {\n canonicaljson: string;\n contenthash: string;\n contentversion: number;\n filter: string | null;\n tags: readonly string[];\n title: string;\n}\ninterface archiveentry<p = unknown, c extends number = number> {\n author: { id: string; name: string };\n contenthash: string;\n contentversion: c;\n id: string;\n likecount: number;\n payload: p;\n publishedat: string;\n revision: number;\n schemaversion: typeof archive_contract_version;\n updatedat: string;\n}\ninterface archiveownedentry<p = unknown, c extends number = number> extends archiveentry<p, c> {\n clientkey: string;\n hidden: boolean;\n}\ninterface archivecontract<p = unknown, c extends number = number> {\n entry: schema<archiveentry<p, c>>;\n ownedentries: schema<{ entries: archiveownedentry<p, c>[] }>;\n ownedentry: schema<archiveownedentry<p, c>>;\n page: schema<archivepage<archiveentry<p, c>>>;\n publishinput: schema<{ clientkey: string; payload: p }, unknown>;\n updateinput: schema<{ expectedrevision: number; payload: p }, unknown>;\n}\ninterface archivecollection<p extends object = object, c extends number = number> {\n readonly contentversion: c;\n readonly contract: archivecontract<p, c>;\n readonly derive: (raw: unknown) => promise<archivederivation | null>;\n readonly filters: readonly string[];\n readonly name: string;\n readonly tags: readonly string[];\n}\ninterface archivepage<t> {\n cursor: string | null;\n entries: readonly t[];\n}\ntype archivesort = 'liked' | 'recent';\ntype archivederivationstage = 'canonicalize' | 'hash' | 'index' | 'schema';\ntype archiveidentity = { id: string; name: string; operator: boolean } | null;\ntype archiveaccount = {\n id: string; email: string; createdat: string; lastsigninat: string | null;\n};\ntype archivecollectionmeta = { filters: string[]; name: string; tags: string[] };\ntype archivelikespage = { cursor: string | null; ids: string[] };\ntype archiveerrorcode =\n | 'account changed' | 'auth unavailable' | 'bad request' | 'conflict' | 'forbidden' | 'gone'\n | 'internal' | 'invalid payload' | 'likes disabled' | 'method not allowed'\n | 'not found' | 'publisher changed' | 'publishing disabled' | 'quota'\n | 'rate limited' | 'record unavailable' | 'timeout' | 'too large'\n | 'unauthenticated' | 'unavailable' | 'unsupported media';\ntype archiveerrorresponse = {\n error: { code: archiveerrorcode; message: string; requestid: string };\n};\ntype archivehealth = {\n dbschemaversion: number | null;\n features: { likes: boolean; publishing: boolean };\n limits: {\n aliaschars: number; cursorchars: number; idempotencyretentionms: number;\n likestateids: number; maxpagesize: number; maxpublications: number;\n pagesize: number; payloadbytes: number; publiccacheseconds: number;\n reportreasonchars: number; requestbytes: number; searchchars: number;\n searchterms: number; tagsperentry: number; titlechars: number; vocabchars: number;\n };\n schemaversion: 1;\n status: 'ok' | 'degraded';\n reason: 'database unavailable' | 'schema mismatch' | null;\n};\ntype authhealth = {\n dbschemaversion: number | null;\n schemaversion: 1;\n status: 'ok' | 'degraded';\n reason: 'configuration unavailable' | 'database unavailable' | 'schema mismatch' | null;\n};\ntype archivereports = {\n cursor: string | null;\n reports: {\n id: string; createdat: string; entryid: string; entrytitle: string;\n reason: string; reporter: { id: string; name: string }; resolvedat: string | null;\n }[];\n};\ntype archiveownreports = {\n cursor: string | null;\n reports: {\n collection: string; createdat: string; entryid: string; id: string;\n reason: string; resolvedat: string | null;\n }[];\n};\ntype archivemoderationaction = 'hide' | 'unhide' | 'remove' | 'erase publisher';\ntype archivemoderationlog = {\n cursor: string | null;\n actions: {\n id: string; action: archivemoderationaction; collection: string | null;\n createdat: string; operatorid: string; targetid: string;\n }[];\n};\n```\n\nworker subpath types model only the binding methods the catalog calls.\n\n```ts\ninterface d1statement {\n all<t>(): promise<{ results?: t[] }>;\n bind(...params: (string | number | null)[]): d1statement;\n first<t>(): promise<t | null>;\n run(): promise<{ meta?: { changes?: number } }>;\n}\ninterface d1like {\n batch(statements: d1statement[]): promise<{ meta?: { changes?: number } }[]>;\n prepare(sql: string): d1statement;\n}\ninterface ratelimiterbinding {\n limit(input: { key: string }): promise<{ success: boolean }>;\n}\ninterface archiveworkerenv {\n auth_issuer_service: authissuerservicebinding;\n allowed_origins: string;\n app_env: string;\n db: d1like;\n jwt_audience: string;\n jwt_issuer: string;\n jwt_jwks_url: string;\n likes_enabled: string;\n publishing_enabled: string;\n rate_limit_key_secret: string;\n read_limit: ratelimiterbinding;\n write_limit: ratelimiterbinding;\n}\ninterface authissuerservicebinding {\n fetch(request: request): promise<response>;\n}\ninterface archiveexecutioncontext {\n waituntil(promise: promise<unknown>): void;\n}\ntype archivediagnostic =\n | { readonly kind: 'configuration'; readonly field: string }\n | { readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection' };\ninterface archiverequestevent {\n code: archiveerrorcode | 'ok';\n diagnostic?: archivediagnostic;\n ms: number;\n requestid: string;\n route: string;\n status: number;\n type: 'request';\n}\ntype archivescheduledevent =\n | { removed: number; type: 'scheduled'; status: 'ok' }\n | { type: 'scheduled'; status: 'failed'; diagnostic: archivediagnostic };\ntype archiveevent =\n | archiverequestevent\n | archivescheduledevent\n | { readonly type: 'cache error'; readonly diagnostic: archivediagnostic };\ninterface archiveworkerhandler<e extends archiveworkerenv = archiveworkerenv> {\n fetch(request: request, env: e, ctx: archiveexecutioncontext): promise<response>;\n scheduled?(controller: unknown, env: e, ctx: archiveexecutioncontext): void | promise<void>;\n tap(handler: (event: archiveevent) => void, options?: { readonly signal?: abortsignal }): () => void;\n}\n```\n\nscheduled `removed` counts the combined expired retry key and revocation rows.\n\nauth subpath types describe the sibling issuer and its required service binding.\n\n```ts\ninterface sendemail {\n send(message: { from: string; html?: string; subject: string; text: string; to: string }): promise<unknown>;\n}\ninterface autharchivebinding {\n fetch(request: request): promise<response>;\n}\ninterface authworkerenv {\n allowed_origins: string;\n app_env: string;\n app_name: string;\n audience: string;\n auth_archive: autharchivebinding;\n auth_db: d1like;\n auth_email: sendemail;\n auth_issuer: string;\n auth_jwt_previous_public_jwks?: string;\n auth_jwt_private_jwk: string;\n operator_emails: string;\n public_origin: string;\n sender_address: string;\n}\ninterface authworkerhandler<e extends authworkerenv = authworkerenv> {\n fetch(request: request, env: e, ctx?: archiveexecutioncontext): promise<response>;\n scheduled(controller: unknown, env: e, ctx?: archiveexecutioncontext): promise<void>;\n tap(handler: (event: authevent) => void, options?: { readonly signal?: abortsignal }): () => void;\n}\ntype authdiagnostic = {\n readonly kind: 'configuration' | 'database' | 'email' | 'catalog' | 'internal';\n};\ntype authevent =\n | {\n readonly type: 'request';\n readonly requestid: string;\n readonly route:\n | '/auth/account'\n | '/auth/health'\n | '/auth/jwks.json'\n | '/auth/session'\n | '/auth/sign in'\n | '/auth/sign out all'\n | '/auth/verify'\n | 'unmatched';\n readonly status: number;\n readonly code: archiveerrorcode | 'ok';\n readonly ms: number;\n readonly diagnostic?: authdiagnostic;\n }\n | { readonly type: 'scheduled'; readonly status: 'ok'; readonly removed: number }\n | { readonly type: 'scheduled'; readonly status: 'failed'; readonly diagnostic: authdiagnostic };\n```\n\nissuer scheduled `removed` counts expired token rows, closed per user send windows\nand deleted unverified accounts. the singleton admission row is retained and its\nwindow resets atomically on a later sign in request.\n\n## errors\n\n| exported class | trigger | properties |\n| | | |\n| `archiveerror` | base for public package errors | `name`, `message`, optional `cause` |\n| `archivedefinitionerror` | invalid collection definition or duplicate registered collection names | extends `archiveerror` |\n| `archivederivationerror` | unexpected schema/canonicalization/index/hash fault or violated collection invariant | extends `archiveerror`; readonly `stage: archivederivationstage`; optional `cause` |\n\n`archiveerror` and `archivedefinitionerror` accept `(message: string, opts?: erroroptions)`.\n`archivederivationerror` accepts `(stage: archivederivationstage, message: string, opts?: erroroptions)`.\nits message and cause are local diagnostics, not response content. worker request\nfailures use the wire envelope, not these thrown classes. internal `apierror` and\n`authoperationerror` are not exported.\n\nstored entries that fail the current wire schema return 503 `record unavailable`.\nstored payloads are canonical by construction, so the emitted entry schemas embed\nthe plain payload schema: parsing a response re runs that schema, and an\nexception from one of its callbacks becomes a sanitized 500.\n\nthe complete public error signatures are:\n\n```ts\nclass archiveerror extends error {\n constructor(message: string, opts?: erroroptions);\n}\nclass archivedefinitionerror extends archiveerror {}\nclass archivederivationerror extends archiveerror {\n readonly stage: archivederivationstage;\n constructor(stage: archivederivationstage, message: string, opts?: erroroptions);\n}\n```\n\ncatalog rate limit responses use `retry after: 60`; configure catalog limiter periods to 60 seconds. issuer admission/send budget responses instead carry seconds until their durable window reopens. health's degraded response is the health dto, not an error envelope: schema mismatch reports the observed version; database unavailability reports `dbschemaversion: null`.\n",
8
- "usage": " \ntitle: \"archive: usage guide\"\ndescription: \"define a collection, initialize fresh d1 databases, deploy the workers, and wire your client and observation policy.\"\n \n\n[[toc]]\n\n## basic usage\n\na deployment is a collection definition plus one factory call. put the definition in a\nmodule both sides import — the browser needs the composed schemas, the worker needs\nthe collection itself.\n\n```ts\n// recipes collection.ts — the one content contract, shared by both sides.\nimport { definearchivecollection } from '@vielzeug/archive';\nimport { s } from '@vielzeug/spell';\n\nconst payloadschema = s.object({\n cuisine: s.enum(['italian', 'japanese']),\n ingredients: s.array(s.string().trim().min(1).max(64)).min(1).max(20),\n name: s.string().trim().min(1).max(32),\n});\n\nexport const recipescollection = definearchivecollection({\n // ingredient order is not identity: the canonical form sorts, so reordered\n // content hashes identically and updates as a no op.\n canonicalize: (payload) => ({ ...payload, ingredients: [...payload.ingredients].sort() }),\n contentversion: 1,\n filters: ['italian', 'japanese'],\n index: (payload) => ({\n filter: payload.cuisine,\n tags: [payload.ingredients.length <= 5 ? 'quick' : 'involved'],\n title: payload.name,\n }),\n name: 'recipes',\n payloadschema,\n tags: ['quick', 'involved'],\n});\n```\n\nthe worker entry imports the collection and exports the built handler:\n\n```ts\n// backend/src/index.ts — the whole deployment.\nimport { createarchiveworker } from '@vielzeug/archive/worker';\nimport { recipescollection } from './recipes collection';\n\nconst worker = createarchiveworker([recipescollection]);\n\n// one sanitized log line per request: ids, codes and route classes only.\nworker.tap((event) => console.log(json.stringify(event)));\n\nexport default worker;\n```\n\nthe browser imports the same collection for its schemas — never the worker subpath:\n\n```ts\nimport { recipescollection } from './recipes collection';\nimport { archiveidentityschema, publisher_header } from '@vielzeug/archive';\n\nconst response = await fetch('/api/v1/collections/recipes/entries');\nif (!response.ok) throw new error('archive unavailable');\nconst page = recipescollection.contract.page.parse(await response.json());\nconst meresponse = await fetch('/api/v1/me', { credentials: 'same origin' });\nif (!meresponse.ok) throw new error('identity unavailable');\nconst me = archiveidentityschema.parse(await meresponse.json());\nif (me === null) throw new error('register a publisher first');\nconst mineresponse = await fetch('/api/v1/me/publications?collection=recipes', {\n credentials: 'same origin', headers: { [publisher_header]: me.id },\n});\nif (!mineresponse.ok) throw new error('own publications unavailable');\nconst owned = recipescollection.contract.ownedentries.parse(await mineresponse.json());\nconsole.log(page.entries, owned.entries);\n```\n\n## the collection contract\n\n`definearchivecollection` is the only place content knowledge lives. the definition is\nvalidated at registration — a bad `name`, `contentversion`, or any vocabulary value\n(well formed text, 1..`vocabchars` chars) throws there, never mid request — and every\nordinary invalid content returns `null` → `422 invalid payload`. collection\nimplementation faults instead throw `archivederivationerror` → sanitized\n`500 internal`:\n\n **parse** — accept stable json objects only; schema outputs must remain json.\n **canonicalize** — optional; a result that leaves the payload schema throws, so a\n broken normalizer can never store invalid content.\n **index** — the returned `filter` must be declared in `filters`, every `tag` in\n `tags` (duplicates collapse), and the `title` is trimmed and bounded\n (`titlechars`, well formed text). a derivation outside the declared vocabulary\n would store a row no list query can reach — it throws instead.\n **size** — the canonical json must fit `payloadbytes`.\n **identity** — the content hash is sha 256 over `{contentversion, payload}`:\n identical content hashes identically, a real edit always re identifies, and the\n core owns the envelope so collections cannot drift it.\n\ndeclare vocabularies with readonly arrays (`['fire', 'ice']`) for automatic\nliteral union inference. const generics retain the version literal and vocabulary\nunions; `noinfer` prevents the index callback from widening them. omitted arrays\nmean empty vocabularies: return `null` for\nthe filter and no tags. object keys are recursively sorted by the core; schema\ntransformations must stabilize and `canonicalize` must be pure and idempotent.\nnon json input returns `null`. unexpected callback, schema or hashing failures,\nunstable transformations, non idempotent canonicalization and index mutation throw\n`archivederivationerror`; its `stage` identifies the failing phase.\nlossy or non json schema output and invalid index result shapes also throw.\n\n`contentversion` participates in content identity. request handling validates\nonly the current collection definition; it does not interpret historical schemas.\narchive provides no content upgrade apis: a new definition never reinterprets\nstored rows.\n\nfilters, tags and searchable titles are persisted data derived at publish/update\ntime. editing `index` or its vocabularies does not recompute existing projections.\nkeep the active definition and dataset consistent. for a development\ndeployment, deliberately reset and reseed a fresh database when you change payload\nor projection semantics; the package does not supply a migration framework.\nresetting destroys existing data. perform it only as an explicit, deliberate\ndevelopment operation, not automatically during startup or deployment.\n\n**payloads carry decisions, not content.** the payload schema should accept\nuser authored choices expressed as ids plus user written text — never the content's\nown copyrighted expression (card text, artwork, rules), never more than the game's\nown identifiers. for games with paid or dlc content this is the whole answer:\nownership resolves on each viewer's device against their own library, the service\nnever learns what anyone owns, and the strict schema is the enforcement seam —\nuse a strict schema that rejects unknown fields, so a collection that only models ids cannot\naccidentally start shipping paid content. for example, published game builds can\nreference cards, equipment and potions by id plus a user written name. the client\nrenders pieces the viewer does not own as unavailable. keep the browser safe\npublication contract in a shared domain module consumed by both the frontend and\nits backend, without importing frontend application code into the worker.\n\n## the worker and its environment\n\n`createarchiveworker(collections)` returns the full handler: anonymous public reads,\ncookie authenticated mutations behind exact origin and request marker guards, local\nrs256/jwks verification followed by an uncached issuer session version check,\nper ip read limits and per subject write limits keyed by peppered hmacs, the\n30 second anonymous cache on public entry lists only, operator moderation, and a\nscheduled expiry sweep for retired publish retry keys and revocation tombstones.\nhealth sits outside the rate limit and session pipelines: probes never burn the\nanonymous read budget or call the issuer.\n\nthe worker reads its environment fail closed — every binding must be present and\nwell formed, or the route answers 503 without naming what is missing:\n\n| variable | meaning |\n| | |\n| `app_env` | `local` or `production` (transport rules and the pepper policy) |\n| `allowed_origins` | exact canonical origins; never wildcards |\n| `jwt_issuer` / `jwt_audience` / `jwt_jwks_url` | your identity provider |\n| `auth_issuer_service` | required issuer service binding for live session version checks |\n| `likes_enabled` / `publishing_enabled` | feature flags |\n| `rate_limit_key_secret` | hmac pepper. required ≥ 32 chars in production; local may fall back |\n| `db` / `read_limit` / `write_limit` | d1 + rate limit bindings |\n\nboth limiter bindings must run 60 second windows (`simple` limiters:\n`period: 60`): the platform binding does not expose its period at runtime, so the\nworker's 429 `retry after` header is fixed at 60 seconds — a longer window would\nmake the header lie.\n\ngenerated env types (wrangler's `worker configuration.d.ts`) satisfy the package's\nstructural `archiveworkerenv` — no `@cloudflare/workers types` dependency, no casts.\n\n## the auth issuer\n\n`createauthworker()` from `@vielzeug/archive/auth` is the reference passwordless\nidentity issuer a deployment runs beside the catalog worker: one post with an email\nsends a single use magic link, and explicitly confirming it sets the `archive_session`\ncookie. the catalog verifies the jwt locally, then calls the issuer through\n`auth_issuer_service` to check the account's current session version. requests create\npending account rows; explicit link redemption atomically stamps the account's\nsign in time and claims the token. the issuer's d1 schema ships at\n`node_modules/@vielzeug/archive/migrations/auth/`; point the issuer's\n`migrations_dir` there. the catalog's `jwt_issuer` and `jwt_jwks_url` must identify\nthe issuer, and its audience must match the issuer's `audience`.\n\n| variable | meaning |\n| | |\n| `app_name` | the app's display name: the magic link email's subject and copy name it |\n| `allowed_origins` | exact origins allowed to trigger unsafe routes (the app origin(s)) |\n| `app_env` | `local` or `production` — production requires https and sets secure cookies |\n| `audience` / `auth_issuer` | the claims every minted session carries; the catalog verifies the same pair |\n| `auth_db` / `auth_email` | the issuer's d1 and send email bindings |\n| `auth_archive` | required service binding to the catalog worker; deletion calls its profile erasure route |\n| `auth_jwt_private_jwk` | the rs256 private key as a jwk json string: a wrangler secret in production |\n| `auth_jwt_previous_public_jwks` | optional retired public rs256 jwks; publish during rotation until old sessions expire |\n| `operator_emails` | comma separated emails whose newly minted sessions carry the operator role |\n| `public_origin` | app origin for links, confirmation post origin checks and client navigation after sign in |\n| `sender_address` | the address magic links come from: a domain onboarded to cloudflare email service |\n\nunder `wrangler dev` the send email binding is simulated — the link prints to the\nissuer's console, so that console is the inbox. demo only alice/bob routes are\ndeployment wrappers, not package apis.\n\nthe issuer first admits sign in requests through its singleton durable\n`auth_sign_in_budget` table: 20 requests per 60 seconds before creating accounts\nor delivering email. no rate limiter binding is required. it then\npermits three sign in emails per email in an hourly durable window.\nlater database or delivery failures do not refund global admission.\nusing or expiring a link never refunds sends; failed delivery also spends the\nallowance. links expire after 15 minutes. operator sessions expire after 900\nseconds, ordinary sessions after at most 30 days. retain retired public keys during\nrotation; the active private key signs every new session. sign in input trims and\nlowercases email before validation. `sender_address` must already be canonical;\n`operator_emails` entries are trimmed and lowercased before validation.\n\nthe scheduled sweep deletes expired links and closed per user send windows, then\npending accounts only when neither a link nor send budget row still references\nthem. failed delivery deletes the unusable token but retains the spent allowance;\nit does not immediately remove the pending account. verified accounts stay until\nexplicit erasure. the singleton admission row remains and resets its window on a\nlater admitted request.\n\nopening `get /auth/verify?token=…` renders a no store confirmation page and does\nnot consume the link or change the session, so email scanners cannot sign in by\nfetching it. a deliberate click sends `post /auth/verify` with json `{ token }`,\nthe request marker and an exact allowed origin or `public_origin`.\nsuccess is 204 with set cookie; the page then navigates to `/signed in`. the\nredemption update and account sign in stamp are trigger backed and atomic.\nthe page removes the token from the address bar, loads no external assets, and\nsets a restrictive csp and `referrer policy: no referrer`.\n\n`get /auth/account` exports account metadata only. for full erasure, call\n`delete /auth/account` with `account_header` (`x archive account`) set to the\nissuer account uuid captured from `get /auth/account` before confirmation.\nthe issuer rejects missing or invalid uuids with 400 and a different signed in\naccount with 409 `account changed`, before any erasure. this guard applies even\nbefore publisher registration and must stay pinned across retries.\nthe issuer forwards the caller's session and publisher\nprecondition through `auth_archive` to `delete /me/account`, requires exactly 204,\nthen deletes its\nown user and clears the cookie. failure preserves the session for retry. direct\n`delete /me` removes only the archive profile and leaves the cookie intact.\n\nseveral game frontends can share one issuer and catalog. keep the cookie\nhost only on the central api host, use `credentials: 'include'` from same site\nhttps game subdomains, and allow each exact frontend origin in both workers.\ndo not share cookies through a parent domain `domain` attribute. the\n[multi collection recipe](./examples/multi collection deployment.md) describes\nthe topology, session revalidation and platform wide account boundary.\n\n`delete /me/account` atomically revokes the issuer/subject and cascades profile\ndeletion. its tombstone lasts 30 days plus ten seconds; old sessions cannot read\nprivate data or register again, including a racing registration. the dedicated\naccount erasure route alone may bypass the issuer lookup, and only to retry with\nthe same principal's original signed session while its existing tombstone is\nvalid; signature and claims are still verified, and initial erasure validates the\nissuer. the issuer's `delete /auth/account` checks the current version before\nstarting erasure; after completed erasure a retry there returns 401. a fresh\nissuer account gets a new uuid subject. external issuers must use a new subject to\nre enroll during the revocation window. profile only deletion does not revoke the\nsubject and permits re registration with existing credentials.\n\neach jwt also carries the issuer account's required safe integer `sessionversion`.\nthe issuer compares it with `auth_users.session_version` on every ordinary\nauthenticated route. a jwt with an old version is unauthenticated even before its\nexpiry; the catalog asks the issuer on every private request and does not cache a\nsuccessful verdict. a valid local jwt with an unavailable, malformed, 5xx or\ntimed out (fixed 5000 ms, including body decoding) issuer response becomes\n`503 auth unavailable`, not anonymous identity.\n\n## the database\n\nthe package ships the canonical catalog migration:\n`node_modules/@vielzeug/archive/migrations/0001_community_archive.sql` — the whole schema,\nmoderation included, as one designed file. the runtime sql in the package is\nwritten against exactly this schema, and the package's own integration suite runs\nagainst the same file. point wrangler at the directory:\n\n```jsonc\n// wrangler.jsonc — the deployment owns its d1 database, not its ddl.\n{\n \"d1_databases\": [\n {\n \"binding\": \"db\",\n \"database_name\": \"my app\",\n \"migrations_dir\": \"./node_modules/@vielzeug/archive/migrations\"\n }\n ]\n}\n```\n\nthe issuer points to\n`node_modules/@vielzeug/archive/migrations/auth/0001_auth.sql`. both files are\nfresh database initialization definitions, not upgrades. the issuer ddl initializes\n`auth_users.session_version` to `0` and stamps `auth_schema_meta.applied` as\n`auth_schema_version` (`1`). neither file automatically deletes or resets data.\narchive's canonical initial schemas are edited directly. reset and reseed a\ndevelopment database deliberately when its schema or collection semantics no\nlonger match; reapplying the initial file does not update an existing dataset.\nthe initialization file also stamps the storage schema\n(`archive_schema_meta`) at `archive_schema_version`, and health reads it back: a\ndatabase with a mismatched stamp answers `status: 'degraded'` with the\nobserved `dbschemaversion` and http 503 (`reason: 'schema mismatch'`).\nan unavailable database produces http 503 with `dbschemaversion: null` and\n`reason: 'database unavailable'`. the debut storage stamp is `1`. a unit\ntest pins the shipped ddl's `check` bounds to `archive_limits`, so the two can never\ndrift silently. the stamp does not detect changes in your collection's payload\nschema, vocabulary or indexing function; you own that dataset consistency.\n\n## routes and wire names\n\n```\nget /api/v1/health\npost /api/v1/sign out\nget /api/v1/auth/health\nget /api/v1/auth/session (internal issuer check)\npost /api/v1/auth/sign out all (revoke every session)\nget /api/v1/collections/:collection (self description)\nget /api/v1/collections/:collection/entries (author, filter, tag, search, sort, cursor, limit)\npost /api/v1/collections/:collection/entries\nget /api/v1/collections/:collection/entries/:id\nput /api/v1/collections/:collection/entries/:id (expectedrevision)\ndelete /api/v1/collections/:collection/entries/:id\nput /api/v1/collections/:collection/entries/:id/like\ndelete /api/v1/collections/:collection/entries/:id/like\npost /api/v1/collections/:collection/entries/:id/report (reason)\nput /api/v1/collections/:collection/entries/:id/hidden (operator)\ndelete /api/v1/collections/:collection/entries/:id/hidden (operator)\nget /api/v1/collections/:collection/reports (operator: status, cursor, limit)\nput /api/v1/collections/:collection/reports/:id/resolved (operator: report id)\ndelete /api/v1/publishers/:id (operator: erase any publisher)\nget /api/v1/moderation log (operator: cursor, limit)\nget /api/v1/me post /me put /me delete /me\nget /api/v1/me/publications?collection=:collection get /api/v1/me/likes[?ids=…]\nget /api/v1/me/reports (cursor, limit)\nget /api/v1/auth/account\nget /api/v1/auth/jwks.json\npost /api/v1/auth/sign in (email)\ndelete /api/v1/auth/account (coordinated full erasure)\ndelete /api/v1/me/account (issuer erasure seam; revokes subject)\nget /api/v1/auth/verify?token=… (confirmation only)\npost /api/v1/auth/verify (guarded {token} redemption)\n```\n\npublishing is bounded by `maxpublications` live entries per `(collection, owner)`.\nthe quota check runs inside the guarded insert.\n\nevery list — entry pages, `/me/likes` without `ids`, `/me/reports`, operator\nreports and `/moderation log` — pages forward only: follow `cursor` until `null`,\npreserving query context; a cursor minted for another query is a 400. `limit`\ndefaults to `pagesize` and is at most `maxpagesize`; there is no total export\nbound. `/me/likes?ids=…` remains a subset lookup returning `{ ids }`, not a\npage, and cannot combine `ids` with `cursor` or `limit`.\n\nshared wire names live in the package — `archive_session` cookie,\n`x archive publisher` and `x archive request` headers, `archive_error_codes`, and\n`archive_limits` — so a client and the worker cannot drift. parse failures with the\ncollection's composed schemas; parse errors with `archiveerrorresponseschema`, the\nsign out body with `archiveokschema`, a collection's self description with\n`archivecollectionmetaschema` and the operator queue with `archivereportsschema`.\nevery entry's `schemaversion` is the archive contract's version, not the\ncollection's; the collection's own format version lives in its hash envelope and in\neach entry's `contentversion`.\n\nonly anonymous entry lists carry a 30 second edge cache contract\n(`publiccacheseconds`). detail is always `no store`. any cookie or authorization\nheader disables caching, regardless of validity; metadata, private and error\nresponses also use `no store`.\n\nimplement ui ownership checks in your application by comparing the current\npublisher id to the entry's `author.id`; there is no package action policy helper.\nserver mutations remain authoritative.\n\n`post /api/v1/sign out` is deliberately local: it clears only this browser's\ncookie and does not contact the issuer or revoke copied sessions. to revoke every\nsession, post to `/api/v1/auth/sign out all` with the mutation marker, an exact\nallowed `origin`, and the issuer account uuid captured from\n`get /api/v1/auth/account` in `x archive account`. the issuer returns 409\n`account changed` if the active subject differs. on success it atomically increments\n`session_version`, deletes outstanding links, preserves account and catalog data,\nand returns `{ ok: true }` with an expired cookie. requests authorized before\nrevocation may finish; there is no cross database cancellation guarantee.\n\n`get /api/v1/auth/health` is public and read only. it validates issuer\nconfiguration, key signing/verification, binding shapes, the `auth_schema_meta`\nstamp and expected tables, columns and triggers, returns `authhealth` through\n`archiveauthhealthschema`, and answers 503 `degraded` for configuration,\ndatabase or schema failures. it never writes, sends email or probes the catalog.\n\n## moderation\n\noperator authority is the session token's optional `roles` claim carrying\n`archive_operator_role` (`'archive operator'`) — minted by your issuer, verified with\nthe same signature, audience and issuer checks as any session, and never a separate\ncredential. moderation routes additionally require a registered publisher row: one\naccountable identity per action.\n\n **report** — any registered publisher reports a visible entry with a bounded\n free text reason (`reportreasonchars`). idempotent per (entry, reporter): the same\n report twice is the same 204. missing or hidden entries answer 404 — there is\n nothing left to report.\n **hide** — an operator removes an entry from every public surface (list, detail,\n likes) with a soft hide: the row, its likes and its retry key stay intact. the\n owner still sees the entry in `/me/publications` with `hidden: true`, edits and\n key replay are frozen (403), removal stays allowed, and unhiding restores\n everything. already hidden or already visible answer the same 204.\n **queue** — the operator pages newest reports with entry title and reporter\n identity; `status` is `unresolved` (default) or `resolved`. each report has its\n own `id` and nullable `resolvedat`. resolve it with\n `put /collections/:collection/reports/:id/resolved`; repeating is a 204 no op.\n **log** — every effective moderation action (hide, unhide, removal, publisher\n erasure) appends one accountability row in the same transaction that applies it,\n and `get /api/v1/moderation log` pages the newest actions to\n operators. rows carry only ids, the action kind and the time — no names (they\n change), no content — and no foreign keys: the audit must outlive the erasures it\n records. idempotent repeats (hiding an already hidden entry, erasing an\n already erased publisher) answer the same 204 with no new row, including\n concurrent repeats: audit insertion tests the effective state inside the batch.\n\nlike and unlike both require a visible entry. their sql guards visibility during\nthe mutation, and the response checks it again; hiding or deleting an entry\ncannot turn a racing unlike into a successful response for unavailable content.\n\n## observability\n\nthe core never logs. `worker.tap(handler, { signal? })` emits one `archiveevent` per\nrequest — `{ type: 'request', code, ms, requestid, route, status, diagnostic? }` —\nand successful sweep — `{ type: 'scheduled', status: 'ok', removed }`. failed\nsweeps emit `{ type: 'scheduled', status: 'failed', diagnostic }` and rethrow so\nthe runtime sees failure. cache failures emit `{ type: 'cache error', diagnostic }`.\nthe worker swallows observer errors so\nobservability cannot affect the worker. with no handlers registered there is zero\nwork. route is a finite class like `/collections/:collection/entries`, never a\nraw path; no event carries a url, cookie, subject, alias or token. diagnostics are\ntyped sanitized categories, not raw exceptions. a pre aborted tap signal is inert.\nthe sweep's `removed` counts expired retry keys and revocation tombstones together.\n\nthe issuer also exposes `tap()` with `authevent` from `@vielzeug/archive/auth`.\nit never logs directly. events contain finite route labels, codes, counts,\nduration and sanitized `authdiagnostic` categories, never emails, tokens or raw\nexceptions. scheduled failures emit then rethrow; pre aborted taps are inert.\nissuer sweep counts include expired tokens, closed per user send windows and\nremoved pending accounts; the singleton admission row is not removed.\n\n```ts\nimport { createlogger } from '@vielzeug/rune';\nimport { createarchiveworker } from '@vielzeug/archive/worker';\nimport { recipescollection } from './recipes collection';\n\nconst log = createlogger({ namespace: 'recipes' });\nconst worker = createarchiveworker([recipescollection]);\nworker.tap((event) => log.debug(event, `archive:${event.type}`));\nexport default worker;\n```\n\n## testing\n\nthe package proves itself against real workerd + local d1:\n\n```sh\npnpm vitest run packages/archive/src/__tests__/ # fast unit suite\npnpm filter @vielzeug/archive test:integration # real workerd + local d1 + issuer coverage\npnpm filter @vielzeug/archive test:types # strict types over src and the integration suite\n```\n\nprove your own collection the way the reference deployment does: a slim deployment\nsuite that publishes over your real vocabulary and asserts the derivation, plus the\npackage's generic suite for the core behavior. a collection with an `index` that can\ndrift out of vocabulary should unit test `derive` directly: ordinary invalid input\nreturns `null`, while implementation faults throw `archivederivationerror`.\ndirect ddl tests exercise the shipped schema rather than a duplicate schema.\n\n## framework integration\n\nthe root entry is plain typescript with no framework coupling; vue, react and svelte\napps all consume the composed schemas the same way. a vue 3 composable that pages a\ncollection:\n\n```ts\n// userecipes.ts — one collection's typed client in any component tree.\nimport { recipescollection } from './recipes collection';\nimport { ref, shallowref } from 'vue';\n\nexport function userecipes() {\n const entries = shallowref<returntype<typeof recipescollection.contract.entry.parse>[]>([]);\n const cursor = ref<string | null>(null);\n async function load(sort: 'liked' | 'recent') {\n const params = new urlsearchparams({ sort });\n if (cursor.value !== null) params.set('cursor', cursor.value);\n const response = await fetch(`/api/v1/collections/recipes/entries?${params}`);\n if (!response.ok) throw new error('the archive is unavailable.');\n const page = recipescollection.contract.page.parse(await response.json());\n entries.value = [...entries.value, ...page.entries];\n cursor.value = page.cursor;\n }\n return { cursor, entries, load };\n}\n```\n\n## working with other vielzeug libraries\n\n [spell](/spell/) — the payload schema a collection declares; the core composes the\n envelope schemas from it, so the wire and the types cannot drift.\n [courier](/courier/) — the http client shape an app's frontend mirrors to consume\n these routes: strict response schemas over one typed port.\n [rune](/rune/) — the structured logger a deployment points `tap()` events at.\n\n## best practices\n\n compose client schemas from `collection.contract`; never hand write the envelope.\n declare exactly the vocabulary your `index` derives, with union types where you\n have them; a value outside it rejects loudly.\n keep `canonicalize` pure and schema true: reordered lists in, canonical form out.\n initialize fresh databases with the shipped ddl and deliberately reset/reseed\n development data when collection or storage semantics change.\n treat `archive_error_codes` and `archiveerrorresponseschema` as one tested contract.\n pin the expected publisher header from the identity you captured at sign in, never from storage alone.\n grant the operator role claim only through your issuer, and only to accounts that\n are also registered publishers.\n wire a sanitized `tap()` sink and register the rate limit pepper as a production secret.\n",
6
+ "index": " \ntitle: archive\ndescription: \"community content publishing for cloudflare workers + d1: a hardened worker core over per collection payload schemas\"\npackage: archive\ncategory: utilities\nkeywords: [cloudflare, worker, d1, community, publishing, content, catalog, gdpr]\nrelated: [spell, courier, rune]\nexports: [definearchivecollection, createarchiveworker, createauthworker, archiveauthhealthschema, archivederivationerror]\nenvironments: [browser, node]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"archive\" />\n\n## why archive?\n\nyou can share content entries with ranked browsing, filters, likes and account\nerasure on cloudflare workers + d1. `definearchivecollection` supplies your json\npayload schema and indexing rules; the worker supplies sessions, guards,\nidempotent publishing, cursors and rate limits.\n\n```ts\n// before: each deployment repeats validation, indexing and route plumbing.\n\n// after: the deployment is one collection definition plus one factory call.\nimport { definearchivecollection } from '@vielzeug/archive';\nimport { createarchiveworker } from '@vielzeug/archive/worker';\nimport { s } from '@vielzeug/spell';\n\nconst notes = definearchivecollection({\n name: 'notes', contentversion: 1,\n payloadschema: s.object({ title: s.string().min(1).max(128) }),\n index: ({ title }) => ({ title, filter: null, tags: [] }),\n});\nexport default createarchiveworker([notes]);\n```\n\n| feature | archive | hand rolled worker | baas (firebase/supabase) |\n| | | | |\n| bundle size (core) | <packageinfo package=\"archive\" type=\"size\" /> | depends on deployment code | depends on selected sdk |\n| zero dependencies | no — spell and jose | depends on implementation | no — client sdk |\n| content knowledge in the core | <ore icon name=\"check\" size=\"16\"></ore icon> none — one collection seam | <ore icon name=\"x\" size=\"16\"></ore icon> baked in | <ore icon name=\"x\" size=\"16\"></ore icon> rules everywhere |\n| canonical fresh database d1 schema | <ore icon name=\"check\" size=\"16\"></ore icon> in the package | deployment owned | not a d1 backend |\n| idempotent publish w/ tombstones | <ore icon name=\"check\" size=\"16\"></ore icon> built in | <ore icon name=\"x\" size=\"16\"></ore icon> rebuild it | <ore icon name=\"x\" size=\"16\"></ore icon> rebuild it |\n| gdpr erasure / export endpoints | <ore icon name=\"check\" size=\"16\"></ore icon> built in | <ore icon name=\"x\" size=\"16\"></ore icon> rebuild it | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> partial |\n| strict dto re validation on read | <ore icon name=\"check\" size=\"16\"></ore icon> every entry | <ore icon name=\"x\" size=\"16\"></ore icon> optional | <ore icon name=\"x\" size=\"16\"></ore icon> optional |\n\n<div class=\"decision callout\">\n\n**use archive when** an app needs a shareable, ranked, liked community space for\ncontent entries on cloudflare workers + d1, and can express its content as one strict\npayload schema.\n**consider alternatives when** you need rich queries beyond filter/tag/search, live\ncollaboration, or a backend you do not control — archive is deliberately one narrow,\nhardened surface.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/archive @vielzeug/spell\n```\n\n```sh [npm]\nnpm install @vielzeug/archive @vielzeug/spell\n```\n\n```sh [yarn]\nyarn add @vielzeug/archive @vielzeug/spell\n```\n\n:::\n\n## quick start\n\na deployment is one collection definition plus one factory call. the definition\nbrings the payload schema and what to index; everything else — the emitted entry\nenvelope, the content hash, the wire contract, the routes — is composed for you.\n\n```ts\nimport { definearchivecollection } from '@vielzeug/archive';\nimport { createarchiveworker } from '@vielzeug/archive/worker';\nimport { s } from '@vielzeug/spell';\n\n// the collection's payload: one strict schema is the whole content contract.\nconst payloadschema = s.object({\n cuisine: s.enum(['italian', 'japanese']),\n ingredients: s.array(s.string().trim().min(1).max(64)).min(1).max(20),\n name: s.string().trim().min(1).max(32),\n});\n\nconst recipescollection = definearchivecollection({\n canonicalize: (payload) => ({ ...payload, ingredients: [...payload.ingredients].sort() }),\n contentversion: 1,\n filters: ['italian', 'japanese'],\n index: (payload) => ({\n filter: payload.cuisine,\n tags: [payload.ingredients.length <= 5 ? 'quick' : 'involved'],\n title: payload.name,\n }),\n name: 'recipes',\n payloadschema,\n tags: ['quick', 'involved'],\n});\n\n// the whole worker: routing, auth, guards, limits, sql and dto disciplines included.\nexport default createarchiveworker([recipescollection]);\n```\n\nthe browser side never imports worker machinery. the same collection's composed\nschemas parse what the routes emit:\n\n```ts\nimport { definearchivecollection } from '@vielzeug/archive';\nimport { s } from '@vielzeug/spell';\n\nconst notes = definearchivecollection({\n name: 'notes', contentversion: 1,\n payloadschema: s.object({ title: s.string().min(1).max(128) }),\n index: ({ title }) => ({ title, filter: null, tags: [] }),\n});\nconst response = await fetch('/api/v1/collections/notes/entries?sort=liked');\nif (!response.ok) throw new error('archive unavailable');\nconst parsed = notes.contract.page.parse(await response.json());\nconsole.log(parsed.entries);\n```\n\n## features\n\n<div class=\"features grid\">\n\n `definearchivecollection` — the one content seam: payload schema, canonicalization, filter/tag/title derivation, union typed vocabularies\n `createarchiveworker` — the whole routes/auth/limits/sql stack from registered collections\n `collection.contract` — entry, page, publish/update input schemas shared by client and worker\n `archive_limits` + `archivehealthschema` — the application bounds, advertised by health and validated on both sides\n `archiveauthhealthschema` + `auth_schema_version` — typed issuer readiness checked against the issuer ddl stamp\n `archiveerrorresponseschema` — the one error envelope, code typed across the wire\n `createauthworker` — scanner safe confirmation, live session version enforcement, durable budgets, metadata export and coordinated erasure\n `auth_issuer_service` — signing key retrieval and an uncached issuer check after local catalog jwt verification\n `archivederivationerror` — distinguishes faulty collection code from invalid submitted content\n moderation — reader reports, the operator soft hide and the report queue, keyed on a verified `roles` claim\n `worker.tap()` / `issuer.tap()` — typed sanitized runtime events; neither core logs by default\n `migrations/` — canonical fresh database ddl with the storage stamp health reads back; no upgrade framework\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md) — stand up a collection, deploy the worker, wire the client.\n [api reference](./api.md) — every export, all three entry points, the full route table.\n [examples](./examples.md) — recipes from first deployment to gdpr erasure.\n [migration](./migration.md) — breaking changes after the first release and\n fresh database scope.\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [spell](/spell/) — every payload schema here is a spell schema; the core composes envelopes from it.\n [courier](/courier/) — the http client shape an app's frontend mirrors to consume these routes.\n [rune](/rune/) — the structured logger a deployment points `worker.tap()` events at.\n\n</div>\n\n<! markdownlint enable md060 md033 md025 >\n",
7
+ "api": " \ntitle: 'archive: api reference'\ndescription: 'collection definitions, browser safe wire schemas, worker deployment, and identity issuance.'\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `definearchivecollection` | compose validation, indexing, identity and wire schemas | sync | invalid content derives to `null`; callback faults throw |\n| `createarchiveworker` | build the catalog worker | sync | collection names must be unique |\n| `createauthworker` | build the passwordless issuer | sync | `auth_archive` is required |\n| `worker.tap`, `issuer.tap` | observe sanitized runtime outcomes | sync | neither core logs by default; observer exceptions are swallowed |\n| `archivederivationerror` | identify a collection implementation/runtime fault | sync | faults become sanitized 500s, not content validation 422s |\n| `archive_limits` | shared application bounds | sync | page bounds do not cap total exports |\n| `archiveerrorresponseschema`, `archiveauthhealthschema` | validate error and issuer health responses | sync | unknown error codes and health states reject |\n| `archiveerror`, `archivedefinitionerror` | public base and invalid definition errors | sync | thrown locally, not transmitted as error objects |\n| `sessioncookievalue` | compose shared session cookie attributes | sync | deployments must choose `secure` from their environment |\n| `iswellformedtext` | check control characters and lone surrogates | sync | does not trim or impose a length bound |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/archive` | browser safe collections, constants, schemas and errors |\n| `@vielzeug/archive/worker` | catalog factory, issuer signing key/session service binding and event types |\n| `@vielzeug/archive/auth` | issuer factory and binding types |\n\n## collections\n\n### `definearchivecollection`\n\ncreates a frozen collection and its contract; malformed names, versions or vocabularies throw `archivedefinitionerror`.\n\n```ts\nfunction definearchivecollection<\n p extends object,\n const c extends number,\n const f extends string = never,\n const t extends string = never,\n>(definition: archivecollectiondefinition<p, c, f, t>): archivecollection<p, c>;\n```\n\n| parameter | contract |\n| | |\n| `name` | lowercase `[a z0 9 ]{1,32}` |\n| `contentversion` | positive safe integer |\n| `payloadschema` | spell schema whose output is a stable json object |\n| `filters`, `tags` | optional readonly arrays; omitted means empty vocabulary |\n| `canonicalize` | optional pure, idempotent, schema valid normalization |\n| `index` | derive `filter`, `tags` and searchable `title` without mutating the payload |\n\n**returns:** `archivecollection<p, c>`.\n\n**example:** define and derive one collection without worker bindings.\n\n```ts\nimport { definearchivecollection } from '@vielzeug/archive';\nimport { s } from '@vielzeug/spell';\n\nconst recipes = definearchivecollection({\n name: 'recipes',\n contentversion: 1,\n payloadschema: s.object({ name: s.string().min(1).max(32) }),\n index: (payload) => ({ filter: null, tags: [], title: payload.name }),\n});\nconst derived = await recipes.derive({ name: 'rice' });\nif (derived === null) throw new error('invalid recipe');\nconsole.log(derived.contenthash);\n```\n\n| method / property | result |\n| | |\n| `derive(raw)` | `promise<archivederivation \\| null>` |\n| `contract.entry` | public entry schema |\n| `contract.ownedentry`, `ownedentries` | owner view with `clientkey` and `hidden` |\n| `contract.page` | entries and the forward cursor |\n| `contract.publishinput`, `updateinput` | publish and revision guarded update request schemas |\n| `filters`, `tags` | frozen vocabulary snapshots |\n\n`derive` returns `null` for invalid json objects, schema invalid input, invalid titles, too many tags and oversized canonical payloads. unexpected schema, callback or hashing failures throw `archivederivationerror`. non idempotent or schema invalid canonicalization, unstable schema transformations, index mutation and out of vocabulary projections are also derivation faults. routes map `null` to 422 `invalid payload` and faults to a sanitized 500 `internal`.\nschema output that is lossy or no longer json is a derivation fault, not invalid\nsubmitted content. invalid index result shapes also throw.\n\nconst 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.\n\nobjects 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.\n\n`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.\n\n## worker and maintenance\n\n### `createarchiveworker`\n\nreturns the catalog request/scheduled handler and its observation side channel.\n\n```ts\nfunction createarchiveworker<e extends archiveworkerenv>(\n collections: readonly archivecollection[],\n): archiveworkerhandler<e>;\n```\n\n**returns:** `archiveworkerhandler<e>`; duplicate names throw `archivedefinitionerror` at registration.\n\n**example:** export a deployable worker using the package's structural bindings.\n\n```ts\nimport { definearchivecollection } from '@vielzeug/archive';\nimport { createarchiveworker, type archiveworkerenv } from '@vielzeug/archive/worker';\nimport { s } from '@vielzeug/spell';\n\nconst notes = definearchivecollection({\n name: 'notes',\n contentversion: 1,\n payloadschema: s.object({ title: s.string().min(1).max(128) }),\n index: ({ title }) => ({ title, filter: null, tags: [] }),\n});\nexport default createarchiveworker<archiveworkerenv>([notes]);\n```\n\n| method | contract |\n| | |\n| `fetch(request, env, ctx)` | returns `promise<response>` |\n| `scheduled(controller, env, ctx)` | sweeps expired retry keys and revocation tombstones; emits failures and rethrows |\n| `tap(handler, { signal? }?)` | returns unsubscribe; a pre aborted signal registers nothing |\n\n`auth_issuer_service` is a required `fetch` shaped service binding to the issuer.\nthe catalog fetches signing keys from the configured `jwt_jwks_url` through that\nbinding, not through global `fetch`. the url still selects the issuer's jwks\nroute and must satisfy the environment's transport rules. key requests use get\nwithout a session cookie and do not follow redirects.\nthe catalog verifies the signed jwt locally, then asks the issuer to validate its\ncurrent account session version. jose caches signing keys; the catalog does not\ncache successful issuer session verdicts.\nif the service exchange fails or exceeds its fixed 5000 ms timeout (body decoding\nincluded), the catalog returns `503 auth unavailable`; the timeout does not cancel\nwork already started by the issuer.\n\nonly anonymous entry lists can use the 30 second edge cache. any `cookie` or `authorization` header disables caching, even if invalid or empty. detail, metadata, private and error responses are `no store`. public reads do not verify sessions.\n\n \n\n### `worker.tap`\n\nobserves typed request, scheduled and cache failure events without affecting request behavior.\n\n```ts\ntap(handler: (event: archiveevent) => void, options?: { readonly signal?: abortsignal }): () => void;\n```\n\n**returns:** an unsubscribe function; abort detaches the observer. diagnostics contain categories and trusted configuration field names, never raw errors, sql, subjects, aliases, tokens or urls.\n\n**example:** observe a worker and detach explicitly.\n\n```ts\nimport { createarchiveworker, type archiveworkerenv } from '@vielzeug/archive/worker';\n\nconst worker = createarchiveworker<archiveworkerenv>([]);\nconst stop = worker.tap((event) => console.log(json.stringify(event)));\nstop();\n```\n\n \n\n## identity issuer\n\n### `createauthworker`\n\nreturns the passwordless email issuer with account export and coordinated erasure.\n\n```ts\nfunction createauthworker<e extends authworkerenv>(): authworkerhandler<e>;\n```\n\n**returns:** `authworkerhandler<e>`.\n\n**example:** export an issuer configured through deployment bindings.\n\n```ts\nimport { createauthworker, type authworkerenv } from '@vielzeug/archive/auth';\n\nexport default createauthworker<authworkerenv>();\n```\n\n| method | contract |\n| | |\n| `fetch(request, env, ctx?)` | returns `promise<response>` |\n| `scheduled(controller, env, ctx?)` | removes expired links, closed send windows and unverified unreferenced accounts |\n| `tap(handler, { signal? }?)` | observes typed `authevent`; returns unsubscribe; pre aborted signals are inert |\n\nmagic links expire after 15 minutes and work once. the durable per email budget allows three sends per hour; consumption, expiration and send failure do not refund it. ordinary sessions last at most 30 days. operator sessions use `operator_session_max_age_seconds` (900 seconds). `operator_emails` controls newly minted role claims.\n\nthe issuer global admission budget is durable d1 state in `auth_sign_in_budget`:\n20 requests per 60 second window before account creation or email delivery.\nthis supplements, rather than replaces, the durable per email budget. no external\nrate limiter binding is required.\nadmission spends are not refunded after later database or email failures.\nrequest emails are trimmed and lowercased before validation. `sender_address`\nmust already be canonical; `operator_emails` entries are trimmed and lowercased\nbefore validation.\n\nsign in requests create pending account rows with `lastsigninat: null`. successful\nredemption atomically claims the one use link and stamps the account's sign in\ntime. the sweep deletes expired links and closed per user send windows, then\nunverified accounts with neither remaining links nor send budget references.\nverified accounts are retained until explicit account deletion.\n\n`operator_session_max_age_seconds = 900` is part of the browser safe root\ncontract; `/auth` exports the factory and issuer binding types.\n\n`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.\n\nopening a magic link performs a safe get that displays a click confirmation page,\nnot redemption. only a marked post from an exact allowed origin or\n`public_origin` consumes `{ token }` and returns 204 with the session cookie. the\nconfirmation page navigates to the fixed `${public_origin}/signed in` destination;\nyour application must serve that page because the issuer serves only the\nconfirmation endpoint. the post is not a redirect. confirmation responses are\n`no store` with no referrer and a restrictive csp; inline script and styles\nrequire a fresh response nonce, with no\n`unsafe inline`. the page removes the token from the address bar before rendering\nthe controls and loads no external assets. `app_name` supplies escaped page and\nemail branding; the confirmation has keyboard focus, live status announcements,\n44 pixel minimum action heights, a warm near white paper and copper light scheme,\na deep neutral dark scheme, serif headings, sans serif body text and reduced motion\nstyles. without javascript, it links to `${public_origin}/account` as “request a\nnew sign in link” but cannot redeem a link.\nget displays confirmation for any syntactically valid token, including a used\ntoken; only post establishes whether the link is still redeemable. post accepts\nexactly `{ token }` and rejects query parameters. with valid issuer configuration,\ninvalid or missing get tokens, duplicate token parameters and unsupported query\nparameters return http 400 with branded html recovery, not a json error. post\nerrors retain the json `archiveerrorresponse` schema and their http status.\n\nthe button announces pending sign in and blocks duplicate submissions. http 410\nreplaces it with a new link action; http 400 also disables resubmission. every\nfailure offers the fixed `${public_origin}/account` recovery link labelled\n“request a new sign in link”. like `/signed in`, `/account` is a fixed reference\nissuer convention: the application must serve its sign in entry there, not the issuer.\nrecovery is secondary while retry remains possible, and becomes the primary action\nfor invalid or expired links. it replaces the footer exit link to avoid duplicate\nnavigation. the initial “back to app” footer links to `${public_origin}/`.\nhttp 429 delays manual retry using unsigned integer seconds or a syntactically valid\nhttp date `retry after`, rounded up\nand bounded to 1 300 seconds (30 seconds for missing or invalid values). http 503\nand network failures offer explicit retry; no failure starts an automatic request.\nall returned response bodies, including discarded stale responses, are cancelled\nwithout delaying recovery; cancellation failures are ignored. each attempt aborts\nits controller on completion. stalled requests abort after 15 seconds with a\ntimeout specific message, distinct from connection failures. leaving the page aborts its request and\nclears timers; bfcache restoration resumes only the remaining cooldown, not a\nrequest. navigation after exact 204 success is fixed at\n`${public_origin}/signed in`; query input never controls either destination.\n\nthe issuer never logs directly. `tap` emits sanitized request and scheduled\noutcomes; observer failures are swallowed, abort detaches the observer and failed\nscheduled work emits then rethrows. `authdiagnostic` reports only a category.\n\n## http routes\n\nevery route below is mounted under `/api/v1`. mutation guards require an exact allowed `origin` and `x archive request: 1`. registered publishers pin `x archive publisher` to the identity captured when preparing private requests. the header is a freshness precondition, not authority.\n\n| route | contract |\n| | |\n| `get /health` | public catalog readiness; 200 `ok` or 503 `degraded` |\n| `post /sign out` | catalog local sign out; `{ ok: true }` and expired session cookie, without issuer access |\n| `get /collections/:collection` | collection metadata |\n| `get /collections/:collection/entries` | `author`, `filter`, `tag`, `search`, `sort`, `cursor`, `limit`; forward entry page |\n| `post /collections/:collection/entries` | `{ clientkey, payload }`; 201 create or 200 replay |\n| `get /collections/:collection/entries/:id` | public entry; always `no store` |\n| `put /collections/:collection/entries/:id` | owner only `{ expectedrevision, payload }` |\n| `delete /collections/:collection/entries/:id` | owner or operator removal; 204 |\n| `put` / `delete /collections/:collection/entries/:id/like` | like / unlike visible entries only; guarded write and response; like state response |\n| `post /collections/:collection/entries/:id/report` | `{ reason }`; idempotent per entry/reporter; 204 |\n| `put` / `delete /collections/:collection/entries/:id/hidden` | operator hide / unhide; 204 |\n| `get /collections/:collection/reports` | operator page; `status=unresolved` (default) or `resolved`, `cursor`, `limit` |\n| `put /collections/:collection/reports/:id/resolved` | operator resolves report by its unique report id; idempotent 204 |\n| `delete /publishers/:id` | operator erases archive profile; not issuer account |\n| `get /moderation log` | operator forward page; `cursor`, `limit` |\n| `get` / `post` / `put /me` | identity, register with optional `{ name }`, rename with required `{ name }` |\n| `delete /me` | profile only cascade; 204; does not clear cookie |\n| `delete /me/account` | revoke issuer/subject and atomically erase profile; 204; coordinated issuer erasure seam |\n| `get /me/publications?collection=…` | owner entries including hidden state and retry keys |\n| `get /me/likes` | forward page `{ ids, cursor }`; `cursor`, `limit` |\n| `get /me/likes?ids=…` | subset lookup `{ ids }`; at most `likestateids` input ids |\n| `get /me/reports` | own forward report page across collections; `cursor`, `limit` |\n| `get /auth/health` | public issuer readiness; typed `authhealth`, 200 `ok` or 503 `degraded`; no email or peer probe |\n| `get /auth/session` | internal issuer session check for the catalog service binding; exact `{ ok: true }`, `no store`, no pii; 401 for an invalid, stale or absent account session |\n| `post /auth/sign out all` | requires marker, exact origin, session and captured `account_header`; atomically increments `session_version`, deletes outstanding links, preserves account and catalog data; 200 `{ ok: true }` with an expired cookie only on durable success |\n| `get /auth/jwks.json` | active and configured retired public verification keys |\n| `post /auth/sign in` | `{ email }` (trimmed and lowercased); 202 `{ ok: true }` |\n| `get /auth/verify?token=…` | safe no store click confirmation html; with valid issuer configuration, invalid queries return 400 recovery html; no token consumption or session change |\n| `post /auth/verify` | json `{ token }`, marker and exact allowed origin or `public_origin`; consume once; 204 with session cookie |\n| `get /auth/account` | `{ id, email, createdat, lastsigninat }`; no tokens or budget data |\n| `delete /auth/account` | requires `account_header` with the captured issuer account uuid; catalog `/me/account` via `auth_archive`, require exact 204, then issuer user and cookie clear; 204 |\n\nforward pages are newest first with opaque context bound keyset cursors. keep the same query context when following a cursor; end at `cursor: null`. `maxpagesize` bounds each page, not the total number of liked ids, reports or audit actions you can export. the subset like query is not a page and cannot combine `ids` with `cursor` or `limit`.\n\nfull erasure creates an issuer/subject revocation tombstone for 30 days plus ten\nseconds and deletes the profile in the same transaction. catalog authentication\nrejects that subject with 401 on ordinary private routes; registration also checks\nrevocation in sql to prevent resurrection races. only `delete /me/account` may\nbypass the issuer session lookup, and only to retry erasure with the original\nsigned session of the same principal while its existing revocation tombstone is\nvalid; jwt signature and claims are still verified. it is not a general\nstale session exception: initial erasure validates the issuer session. the\nissuer facing `delete /auth/account` always checks the current session version\nbefore calling the catalog, so retrying it after completed erasure returns 401\nbecause the account is absent.\ndirect profile only `delete /me` allows later registration using the same session.\nthe sweep removes expired tombstones. re enrollment through the reference issuer\ncreates a fresh account uuid subject; an external issuer must issue a new subject\nto re enroll during the revocation window.\n\nthe issuer stores `auth_users.session_version` as a safe non negative integer,\ndefaulting to `0`. every session jwt carries the current value as the required\n`sessionversion` claim. each ordinary issuer authenticated route compares that\nclaim to the current account row; a mismatch is unauthenticated. `post\n/api/v1/auth/sign out all` checks the captured issuer account uuid against the\nverified subject before atomically incrementing the version and deleting pending\nlinks. a changed account answers 409 `account changed`; a failed request does not\nclear the cookie. this revokes all sessions, including the caller's, after a\nsuccessful response. requests authorized before revocation may finish; there is\nno cross database transactional or in flight cancellation guarantee.\n\nthe catalog requires `auth_issuer_service`, a service binding to the issuer for\nboth jwks retrieval and live session validation. signing key retrieval uses\n`jwt_jwks_url` through the binding, so it does not depend on a public same zone\nworker to worker fetch. after local jwt signature/claims verification, it requests\n`get /api/v1/auth/session` through that binding on every private request. only a\nstrictly validated 401 `unauthenticated` remains a 401. malformed, unexpected,\nprovider and 5xx responses become 503 `auth unavailable`; there is no authority\ncache. both service directions use a fixed 5000 ms timeout that includes body\ndecoding. the issuer's `/api/v1/auth/session` is an internal\nservice contract, not a frontend session api.\n\nissuer readiness is independent of catalog readiness. `get\n/api/v1/auth/health` returns `archiveauthhealthschema` as `authhealth`; its\nread only probe checks configuration, a signing and verification key proof,\nbinding shapes, the singleton `auth_schema_meta` stamp against\n`auth_schema_version`, and readability of expected tables, columns and triggers.\nit does not write, send email or probe the catalog service. responses are\nsanitized.\nconfiguration failure, database failure and schema mismatch return http 503\nwith `status: 'degraded'` and the corresponding reason.\n\neffective hide, unhide, operator removal and publisher erasure append audit rows atomically with the state transition. concurrent idempotent no ops append nothing. audit rows contain opaque ids, not names or content, and have no foreign keys so they outlive erasure. resolving a report sets `resolvedat`; it is not one of the four audit action kinds.\n\n## constants and schemas\n\n| export | value or parsed output |\n| | |\n| `archive_contract_version`, `archive_schema_version` | both debut at `1`; wire and storage versions are independent |\n| `auth_schema_version` | `1`; issuer ddl stamp checked by `/auth/health` |\n| `archive_collection_name_pattern` | `/^[a z0 9 ]{1,32}$/` |\n| `archive_operator_role` | `'archive operator'` |\n| `session_cookie_name`, `session_cookie_path` | `'archive_session'`, `'/api/v1'` |\n| `session_max_age_seconds` | `2592000` |\n| `operator_session_max_age_seconds` | `900`; shared root contract |\n| `account_header` | `'x archive account'`; required issuer account uuid on coordinated erasure |\n| `publisher_header`, `request_marker_header` | `'x archive publisher'`, `'x archive request'` |\n| `ill_formed_text`, `iswellformedtext(value: string): boolean` | reject control characters and lone utf 16 surrogates |\n| `uuidschema`, `isotimestampschema`, `hex64schema` | uuid, iso timestamp, lowercase sha 256 hex strings |\n| `archivealiasschema` | trimmed, well formed alias of at most 24 characters |\n| `archiveauthorschema` | `{ id: string; name: string }` |\n| `archiveidentityschema` | `archiveidentity` |\n| `archiveaccountschema` | `archiveaccount`; issuer metadata, excluding private fields |\n| `archivelimitsschema` | all numeric `archive_limits` fields |\n| `archivehealthschema` | `archivehealth` |\n| `archiveauthhealthschema` | `authhealth` |\n| `archiveerrorresponseschema` | `archiveerrorresponse` |\n| `archiveokschema` | `{ ok: true }` |\n| `archivecollectionmetaschema` | `archivecollectionmeta` |\n| `archivelikedidsschema` | `{ ids: string[] }` subset response |\n| `archivelikespageschema` | `archivelikespage` |\n| `archivelikestateschema` | `{ id: string; likecount: number; liked: boolean }` |\n| `archivereportsschema`, `archiveownreportsschema` | `archivereports`, `archiveownreports` |\n| `archivemoderationlogschema` | `archivemoderationlog` |\n| `archivesortschema` | `archivesort` |\n| `archive_error_codes` | values of `archiveerrorcode` below |\n| `archive_moderation_actions` | `['hide', 'unhide', 'remove', 'erase publisher']` |\n\n### `sessioncookievalue`\n\ncomposes a session cookie using the shared security attributes.\n\n```ts\nfunction sessioncookievalue(token: string, options: { maxageseconds: number; secure: boolean }): string;\n```\n\n| parameter | contract |\n| | |\n| `token` | session token, or empty string when clearing |\n| `options.maxageseconds` | cookie lifetime; `0` clears it |\n| `options.secure` | include `secure` for production https |\n\n**returns:** the `set cookie` value with `httponly`, `samesite=strict`, the shared path and optional `secure`.\n\n**example:** clear the production cookie.\n\n```ts\nimport { sessioncookievalue } from '@vielzeug/archive';\n\nconst expiredcookie = sessioncookievalue('', { maxageseconds: 0, secure: true });\nconsole.log(expiredcookie);\n```\n\n \n\n### `iswellformedtext`\n\nchecks text for control characters and lone utf 16 surrogates.\n\n```ts\nfunction iswellformedtext(value: string): boolean;\n```\n\n**returns:** `true` when `ill_formed_text` does not match; it does not trim or impose bounds.\n\n**example:** validate text independently of the alias schema.\n\n```ts\nimport { iswellformedtext } from '@vielzeug/archive';\n\nconsole.log(iswellformedtext('rice'), iswellformedtext('\\u0000'));\n```\n\n### `archive_limits`\n\n`archive_limits` is frozen with these values:\n\n```ts\nconst limits = {\n aliaschars: 24,\n cursorchars: 1024,\n idempotencyretentionms: 172800000,\n likestateids: 50,\n maxpagesize: 50,\n maxpublications: 50,\n pagesize: 10,\n payloadbytes: 8192,\n publiccacheseconds: 30,\n reportreasonchars: 256,\n requestbytes: 12288,\n searchchars: 64,\n searchterms: 6,\n tagsperentry: 16,\n titlechars: 128,\n vocabchars: 64,\n};\n```\n\n## types\n\nthe following definitions use `schema` from `@vielzeug/spell`. schema derived aliases show their complete output shape.\n\n```ts\ninterface archiveindexing<f extends string = string, t extends string = string> {\n filter: f | null;\n tags: readonly t[];\n title: string;\n}\ninterface archivecollectiondefinition<\n p extends object,\n c extends number,\n f extends string = never,\n t extends string = never,\n> {\n canonicalize?(payload: p): p;\n contentversion: c;\n filters?: readonly f[];\n index(payload: p): archiveindexing<noinfer<f>, noinfer<t>>;\n name: string;\n payloadschema: schema<p, unknown>;\n tags?: readonly t[];\n}\ninterface archivederivation {\n canonicaljson: string;\n contenthash: string;\n contentversion: number;\n filter: string | null;\n tags: readonly string[];\n title: string;\n}\ninterface archiveentry<p = unknown, c extends number = number> {\n author: { id: string; name: string };\n contenthash: string;\n contentversion: c;\n id: string;\n likecount: number;\n payload: p;\n publishedat: string;\n revision: number;\n schemaversion: typeof archive_contract_version;\n updatedat: string;\n}\ninterface archiveownedentry<p = unknown, c extends number = number> extends archiveentry<p, c> {\n clientkey: string;\n hidden: boolean;\n}\ninterface archivecontract<p = unknown, c extends number = number> {\n entry: schema<archiveentry<p, c>>;\n ownedentries: schema<{ entries: archiveownedentry<p, c>[] }>;\n ownedentry: schema<archiveownedentry<p, c>>;\n page: schema<archivepage<archiveentry<p, c>>>;\n publishinput: schema<{ clientkey: string; payload: p }, unknown>;\n updateinput: schema<{ expectedrevision: number; payload: p }, unknown>;\n}\ninterface archivecollection<p extends object = object, c extends number = number> {\n readonly contentversion: c;\n readonly contract: archivecontract<p, c>;\n readonly derive: (raw: unknown) => promise<archivederivation | null>;\n readonly filters: readonly string[];\n readonly name: string;\n readonly tags: readonly string[];\n}\ninterface archivepage<t> {\n cursor: string | null;\n entries: readonly t[];\n}\ntype archivesort = 'liked' | 'recent';\ntype archivederivationstage = 'canonicalize' | 'hash' | 'index' | 'schema';\ntype archiveidentity = { id: string; name: string; operator: boolean } | null;\ntype archiveaccount = {\n id: string;\n email: string;\n createdat: string;\n lastsigninat: string | null;\n};\ntype archivecollectionmeta = { filters: string[]; name: string; tags: string[] };\ntype archivelikespage = { cursor: string | null; ids: string[] };\ntype archiveerrorcode =\n | 'account changed'\n | 'auth unavailable'\n | 'bad request'\n | 'conflict'\n | 'forbidden'\n | 'gone'\n | 'internal'\n | 'invalid payload'\n | 'likes disabled'\n | 'method not allowed'\n | 'not found'\n | 'publisher changed'\n | 'publishing disabled'\n | 'quota'\n | 'rate limited'\n | 'record unavailable'\n | 'timeout'\n | 'too large'\n | 'unauthenticated'\n | 'unavailable'\n | 'unsupported media';\ntype archiveerrorresponse = {\n error: { code: archiveerrorcode; message: string; requestid: string };\n};\ntype archivehealth = {\n dbschemaversion: number | null;\n features: { likes: boolean; publishing: boolean };\n limits: {\n aliaschars: number;\n cursorchars: number;\n idempotencyretentionms: number;\n likestateids: number;\n maxpagesize: number;\n maxpublications: number;\n pagesize: number;\n payloadbytes: number;\n publiccacheseconds: number;\n reportreasonchars: number;\n requestbytes: number;\n searchchars: number;\n searchterms: number;\n tagsperentry: number;\n titlechars: number;\n vocabchars: number;\n };\n schemaversion: 1;\n status: 'ok' | 'degraded';\n reason: 'database unavailable' | 'schema mismatch' | null;\n};\ntype authhealth = {\n dbschemaversion: number | null;\n schemaversion: 1;\n status: 'ok' | 'degraded';\n reason: 'configuration unavailable' | 'database unavailable' | 'schema mismatch' | null;\n};\ntype archivereports = {\n cursor: string | null;\n reports: {\n id: string;\n createdat: string;\n entryid: string;\n entrytitle: string;\n reason: string;\n reporter: { id: string; name: string };\n resolvedat: string | null;\n }[];\n};\ntype archiveownreports = {\n cursor: string | null;\n reports: {\n collection: string;\n createdat: string;\n entryid: string;\n id: string;\n reason: string;\n resolvedat: string | null;\n }[];\n};\ntype archivemoderationaction = 'hide' | 'unhide' | 'remove' | 'erase publisher';\ntype archivemoderationlog = {\n cursor: string | null;\n actions: {\n id: string;\n action: archivemoderationaction;\n collection: string | null;\n createdat: string;\n operatorid: string;\n targetid: string;\n }[];\n};\n```\n\nworker subpath types model only the binding methods the catalog calls.\n\n```ts\ninterface d1statement {\n all<t>(): promise<{ results?: t[] }>;\n bind(...params: (string | number | null)[]): d1statement;\n first<t>(): promise<t | null>;\n run(): promise<{ meta?: { changes?: number } }>;\n}\ninterface d1like {\n batch(statements: d1statement[]): promise<{ meta?: { changes?: number } }[]>;\n prepare(sql: string): d1statement;\n}\ninterface ratelimiterbinding {\n limit(input: { key: string }): promise<{ success: boolean }>;\n}\ninterface archiveworkerenv {\n auth_issuer_service: authissuerservicebinding;\n allowed_origins: string;\n app_env: string;\n db: d1like;\n jwt_audience: string;\n jwt_issuer: string;\n jwt_jwks_url: string;\n likes_enabled: string;\n publishing_enabled: string;\n rate_limit_key_secret: string;\n read_limit: ratelimiterbinding;\n write_limit: ratelimiterbinding;\n}\ninterface authissuerservicebinding {\n fetch(request: request): promise<response>;\n}\ninterface archiveexecutioncontext {\n waituntil(promise: promise<unknown>): void;\n}\ntype archivediagnostic =\n | { readonly kind: 'configuration'; readonly field: string }\n | { readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection' };\ninterface archiverequestevent {\n code: archiveerrorcode | 'ok';\n diagnostic?: archivediagnostic;\n ms: number;\n requestid: string;\n route: string;\n status: number;\n type: 'request';\n}\ntype archivescheduledevent =\n | { removed: number; type: 'scheduled'; status: 'ok' }\n | { type: 'scheduled'; status: 'failed'; diagnostic: archivediagnostic };\ntype archiveevent =\n | archiverequestevent\n | archivescheduledevent\n | { readonly type: 'cache error'; readonly diagnostic: archivediagnostic };\ninterface archiveworkerhandler<e extends archiveworkerenv = archiveworkerenv> {\n fetch(request: request, env: e, ctx: archiveexecutioncontext): promise<response>;\n scheduled?(controller: unknown, env: e, ctx: archiveexecutioncontext): void | promise<void>;\n tap(handler: (event: archiveevent) => void, options?: { readonly signal?: abortsignal }): () => void;\n}\n```\n\nscheduled `removed` counts the combined expired retry key and revocation rows.\n\nauth subpath types describe the sibling issuer and its required service binding.\n\n```ts\ninterface sendemail {\n send(message: { from: string; html?: string; subject: string; text: string; to: string }): promise<unknown>;\n}\ninterface autharchivebinding {\n fetch(request: request): promise<response>;\n}\ninterface authworkerenv {\n allowed_origins: string;\n app_env: string;\n app_name: string;\n audience: string;\n auth_archive: autharchivebinding;\n auth_db: d1like;\n auth_email: sendemail;\n auth_issuer: string;\n auth_jwt_previous_public_jwks?: string;\n auth_jwt_private_jwk: string;\n operator_emails: string;\n public_origin: string;\n sender_address: string;\n}\ninterface authworkerhandler<e extends authworkerenv = authworkerenv> {\n fetch(request: request, env: e, ctx?: archiveexecutioncontext): promise<response>;\n scheduled(controller: unknown, env: e, ctx?: archiveexecutioncontext): promise<void>;\n tap(handler: (event: authevent) => void, options?: { readonly signal?: abortsignal }): () => void;\n}\ntype authdiagnostic = {\n readonly kind: 'configuration' | 'database' | 'schema' | 'email' | 'catalog' | 'internal';\n};\ntype authevent =\n | {\n readonly type: 'request';\n readonly requestid: string;\n readonly route:\n | '/auth/account'\n | '/auth/health'\n | '/auth/jwks.json'\n | '/auth/session'\n | '/auth/sign in'\n | '/auth/sign out all'\n | '/auth/verify'\n | 'unmatched';\n readonly status: number;\n readonly code: archiveerrorcode | 'ok';\n readonly ms: number;\n readonly diagnostic?: authdiagnostic;\n }\n | { readonly type: 'scheduled'; readonly status: 'ok'; readonly removed: number }\n | { readonly type: 'scheduled'; readonly status: 'failed'; readonly diagnostic: authdiagnostic };\n```\n\nissuer scheduled `removed` counts expired token rows, closed per user send windows\nand deleted unverified accounts. the singleton admission row is retained and its\nwindow resets atomically on a later sign in request.\n\n## errors\n\n| exported class | trigger | properties |\n| | | |\n| `archiveerror` | base for public package errors | `name`, `message`, optional `cause` |\n| `archivedefinitionerror` | invalid collection definition or duplicate registered collection names | extends `archiveerror` |\n| `archivederivationerror` | unexpected schema/canonicalization/index/hash fault or violated collection invariant | extends `archiveerror`; readonly `stage: archivederivationstage`; optional `cause` |\n\n`archiveerror` and `archivedefinitionerror` accept `(message: string, opts?: erroroptions)`.\n`archivederivationerror` accepts `(stage: archivederivationstage, message: string, opts?: erroroptions)`.\nits message and cause are local diagnostics, not response content. worker request\nfailures use the wire envelope, not these thrown classes. internal `apierror` and\n`authoperationerror` are not exported.\n\nstored entries that fail the current wire schema return 503 `record unavailable`.\nstored payloads are canonical by construction, so the emitted entry schemas embed\nthe plain payload schema: parsing a response re runs that schema, and an\nexception from one of its callbacks becomes a sanitized 500.\n\nthe complete public error signatures are:\n\n```ts\nclass archiveerror extends error {\n constructor(message: string, opts?: erroroptions);\n}\nclass archivedefinitionerror extends archiveerror {}\nclass archivederivationerror extends archiveerror {\n readonly stage: archivederivationstage;\n constructor(stage: archivederivationstage, message: string, opts?: erroroptions);\n}\n```\n\ncatalog rate limit responses use `retry after: 60`; configure catalog limiter periods to 60 seconds. issuer admission/send budget responses instead carry seconds until their durable window reopens. health's degraded response is the health dto, not an error envelope: schema mismatch reports the observed version; database unavailability reports `dbschemaversion: null`.\n",
8
+ "usage": " \ntitle: 'archive: usage guide'\ndescription: 'define a collection, initialize fresh d1 databases, deploy the workers, and wire your client and observation policy.'\n \n\n[[toc]]\n\n## basic usage\n\na deployment is a collection definition plus one factory call. put the definition in a\nmodule both sides import — the browser needs the composed schemas, the worker needs\nthe collection itself.\n\n```ts\n// recipes collection.ts — the one content contract, shared by both sides.\nimport { definearchivecollection } from '@vielzeug/archive';\nimport { s } from '@vielzeug/spell';\n\nconst payloadschema = s.object({\n cuisine: s.enum(['italian', 'japanese']),\n ingredients: s.array(s.string().trim().min(1).max(64)).min(1).max(20),\n name: s.string().trim().min(1).max(32),\n});\n\nexport const recipescollection = definearchivecollection({\n // ingredient order is not identity: the canonical form sorts, so reordered\n // content hashes identically and updates as a no op.\n canonicalize: (payload) => ({ ...payload, ingredients: [...payload.ingredients].sort() }),\n contentversion: 1,\n filters: ['italian', 'japanese'],\n index: (payload) => ({\n filter: payload.cuisine,\n tags: [payload.ingredients.length <= 5 ? 'quick' : 'involved'],\n title: payload.name,\n }),\n name: 'recipes',\n payloadschema,\n tags: ['quick', 'involved'],\n});\n```\n\nthe worker entry imports the collection and exports the built handler:\n\n```ts\n// backend/src/index.ts — the whole deployment.\nimport { createarchiveworker } from '@vielzeug/archive/worker';\nimport { recipescollection } from './recipes collection';\n\nconst worker = createarchiveworker([recipescollection]);\n\n// one sanitized log line per request: ids, codes and route classes only.\nworker.tap((event) => console.log(json.stringify(event)));\n\nexport default worker;\n```\n\nthe browser imports the same collection for its schemas — never the worker subpath:\n\n```ts\nimport { recipescollection } from './recipes collection';\nimport { archiveidentityschema, publisher_header } from '@vielzeug/archive';\n\nconst response = await fetch('/api/v1/collections/recipes/entries');\nif (!response.ok) throw new error('archive unavailable');\nconst page = recipescollection.contract.page.parse(await response.json());\nconst meresponse = await fetch('/api/v1/me', { credentials: 'same origin' });\nif (!meresponse.ok) throw new error('identity unavailable');\nconst me = archiveidentityschema.parse(await meresponse.json());\nif (me === null) throw new error('register a publisher first');\nconst mineresponse = await fetch('/api/v1/me/publications?collection=recipes', {\n credentials: 'same origin',\n headers: { [publisher_header]: me.id },\n});\nif (!mineresponse.ok) throw new error('own publications unavailable');\nconst owned = recipescollection.contract.ownedentries.parse(await mineresponse.json());\nconsole.log(page.entries, owned.entries);\n```\n\n## the collection contract\n\n`definearchivecollection` is the only place content knowledge lives. the definition is\nvalidated at registration — a bad `name`, `contentversion`, or any vocabulary value\n(well formed text, 1..`vocabchars` chars) throws there, never mid request — and every\nordinary invalid content returns `null` → `422 invalid payload`. collection\nimplementation faults instead throw `archivederivationerror` → sanitized\n`500 internal`:\n\n **parse** — accept stable json objects only; schema outputs must remain json.\n **canonicalize** — optional; a result that leaves the payload schema throws, so a\n broken normalizer can never store invalid content.\n **index** — the returned `filter` must be declared in `filters`, every `tag` in\n `tags` (duplicates collapse), and the `title` is trimmed and bounded\n (`titlechars`, well formed text). a derivation outside the declared vocabulary\n would store a row no list query can reach — it throws instead.\n **size** — the canonical json must fit `payloadbytes`.\n **identity** — the content hash is sha 256 over `{contentversion, payload}`:\n identical content hashes identically, a real edit always re identifies, and the\n core owns the envelope so collections cannot drift it.\n\ndeclare vocabularies with readonly arrays (`['fire', 'ice']`) for automatic\nliteral union inference. const generics retain the version literal and vocabulary\nunions; `noinfer` prevents the index callback from widening them. omitted arrays\nmean empty vocabularies: return `null` for\nthe filter and no tags. object keys are recursively sorted by the core; schema\ntransformations must stabilize and `canonicalize` must be pure and idempotent.\nnon json input returns `null`. unexpected callback, schema or hashing failures,\nunstable transformations, non idempotent canonicalization and index mutation throw\n`archivederivationerror`; its `stage` identifies the failing phase.\nlossy or non json schema output and invalid index result shapes also throw.\n\n`contentversion` participates in content identity. request handling validates\nonly the current collection definition; it does not interpret historical schemas.\narchive provides no content upgrade apis: a new definition never reinterprets\nstored rows.\n\nfilters, tags and searchable titles are persisted data derived at publish/update\ntime. editing `index` or its vocabularies does not recompute existing projections.\nkeep the active definition and dataset consistent. for a development\ndeployment, deliberately reset and reseed a fresh database when you change payload\nor projection semantics; the package does not supply a migration framework.\nresetting destroys existing data. perform it only as an explicit, deliberate\ndevelopment operation, not automatically during startup or deployment.\n\n**payloads carry decisions, not content.** the payload schema should accept\nuser authored choices expressed as ids plus user written text — never the content's\nown copyrighted expression (card text, artwork, rules), never more than the game's\nown identifiers. for games with paid or dlc content this is the whole answer:\nownership resolves on each viewer's device against their own library, the service\nnever learns what anyone owns, and the strict schema is the enforcement seam —\nuse a strict schema that rejects unknown fields, so a collection that only models ids cannot\naccidentally start shipping paid content. for example, published game builds can\nreference cards, equipment and potions by id plus a user written name. the client\nrenders pieces the viewer does not own as unavailable. keep the browser safe\npublication contract in a shared domain module consumed by both the frontend and\nits backend, without importing frontend application code into the worker.\n\n## the worker and its environment\n\n`createarchiveworker(collections)` returns the full handler: anonymous public reads,\ncookie authenticated mutations behind exact origin and request marker guards, local\nrs256/jwks verification followed by an uncached issuer session version check,\nper ip read limits and per subject write limits keyed by peppered hmacs, the\n30 second anonymous cache on public entry lists only, operator moderation, and a\nscheduled expiry sweep for retired publish retry keys and revocation tombstones.\nhealth sits outside the rate limit and session pipelines: probes never burn the\nanonymous read budget or call the issuer.\n\nthe worker reads its environment fail closed — every binding must be present and\nwell formed, or the route answers 503 without naming what is missing:\n\n| variable | meaning |\n| | |\n| `app_env` | `local` or `production` (transport rules and the pepper policy) |\n| `allowed_origins` | exact canonical origins; never wildcards |\n| `jwt_issuer` / `jwt_audience` / `jwt_jwks_url` | exact issuer claim, audience and issuer jwks endpoint url |\n| `auth_issuer_service` | required issuer service binding for signing key retrieval and live session version checks |\n| `likes_enabled` / `publishing_enabled` | feature flags |\n| `rate_limit_key_secret` | hmac pepper. required ≥ 32 chars in production; local may fall back |\n| `db` / `read_limit` / `write_limit` | d1 + rate limit bindings |\n\nboth limiter bindings must run 60 second windows (`simple` limiters:\n`period: 60`): the platform binding does not expose its period at runtime, so the\nworker's 429 `retry after` header is fixed at 60 seconds — a longer window would\nmake the header lie.\n\ngenerated env types (wrangler's `worker configuration.d.ts`) satisfy the package's\nstructural `archiveworkerenv` — no `@cloudflare/workers types` dependency, no casts.\n\nsigning keys are fetched from `jwt_jwks_url` through `auth_issuer_service`, not\nglobal `fetch`. point the url at the bound issuer's `/api/v1/auth/jwks.json`\nroute; retain the https production or loopback http local configuration rules.\nthis transport supports workers on the same cloudflare zone without a public\nworker to worker fetch. jose caches signing keys, but every private request still\nchecks the current issuer session version. a signing key provider failure is\n`503 auth unavailable`, not a reason to clear the browser's signed in state.\n\n## the auth issuer\n\n`createauthworker()` from `@vielzeug/archive/auth` is the reference passwordless\nidentity issuer a deployment runs beside the catalog worker: one post with an email\nsends a single use magic link, and explicitly confirming it sets the `archive_session`\ncookie. the catalog verifies the jwt locally, then calls the issuer through\n`auth_issuer_service` to check the account's current session version. requests create\npending account rows; explicit link redemption atomically stamps the account's\nsign in time and claims the token. the issuer's d1 schema ships at\n`node_modules/@vielzeug/archive/migrations/auth/`; point the issuer's\n`migrations_dir` there. the catalog's `jwt_issuer` and `jwt_jwks_url` must identify\nthe issuer, and its audience must match the issuer's `audience`.\n\n| variable | meaning |\n| | |\n| `app_name` | the app's display name in magic link email and escaped confirmation page branding |\n| `allowed_origins` | exact origins allowed to trigger unsafe routes (the app origin(s)) |\n| `app_env` | `local` or `production` — production requires https and sets secure cookies |\n| `audience` / `auth_issuer` | the claims every minted session carries; the catalog verifies the same pair |\n| `auth_db` / `auth_email` | the issuer's d1 and send email bindings |\n| `auth_archive` | required service binding to the catalog worker; deletion calls its profile erasure route |\n| `auth_jwt_private_jwk` | the rs256 private key as a jwk json string: a wrangler secret in production |\n| `auth_jwt_previous_public_jwks` | optional retired public rs256 jwks; publish during rotation until old sessions expire |\n| `operator_emails` | comma separated emails whose newly minted sessions carry the operator role |\n| `public_origin` | app origin for links, confirmation post origin checks and client navigation after sign in |\n| `sender_address` | the address magic links come from: a domain onboarded to cloudflare email service |\n\nunder `wrangler dev` the send email binding is simulated — the link prints to the\nissuer's console, so that console is the inbox. demo only alice/bob routes are\ndeployment wrappers, not package apis.\n\nthe issuer first admits sign in requests through its singleton durable\n`auth_sign_in_budget` table: 20 requests per 60 seconds before creating accounts\nor delivering email. no rate limiter binding is required. it then\npermits three sign in emails per email in an hourly durable window.\nlater database or delivery failures do not refund global admission.\nusing or expiring a link never refunds sends; failed delivery also spends the\nallowance. links expire after 15 minutes. operator sessions expire after 900\nseconds, ordinary sessions after at most 30 days. retain retired public keys during\nrotation; the active private key signs every new session. sign in input trims and\nlowercases email before validation. `sender_address` must already be canonical;\n`operator_emails` entries are trimmed and lowercased before validation.\n\nthe scheduled sweep deletes expired links and closed per user send windows, then\npending accounts only when neither a link nor send budget row still references\nthem. failed delivery deletes the unusable token but retains the spent allowance;\nit does not immediately remove the pending account. verified accounts stay until\nexplicit erasure. the singleton admission row remains and resets its window on a\nlater admitted request.\n\nopening `get /auth/verify?token=…` renders a no store confirmation page and does\nnot consume the link or change the session, so email scanners cannot sign in by\nfetching it. a deliberate click sends `post /auth/verify` with json `{ token }`,\nthe request marker and an exact allowed origin or `public_origin`.\nsuccess is 204 with set cookie; the page then navigates to the fixed\n`${public_origin}/signed in` destination. your application must serve that page;\nthe issuer only serves the confirmation endpoint. the redemption update and\naccount sign in stamp are trigger backed and atomic.\nthe page removes the token from the address bar, loads no external assets, and\nsets a restrictive csp and `referrer policy: no referrer`. its inline css and\nscript use a fresh nonce, never `unsafe inline`. the page follows light/dark\npreferences, supports reduced motion, focuses the confirmation action and\nannounces pending, success and recovery states. without javascript it links to\n`${public_origin}/account` as “request a new sign in link” and does not pretend\nto complete sign in.\n\narchive only serves the confirmation endpoint. your application must serve its\nsign in entry at the fixed `${public_origin}/account` recovery destination and\nthe success page at `${public_origin}/signed in`. these are reference issuer\nroute conventions, not configurable destinations. the initial “back to app”\nexit links to `${public_origin}/`. no query parameter supplies a destination.\nexpired or already used links (post 410) require a new link rather than a retry.\nhttp 429 enables manual retry after a `retry after` cooldown bounded to 1 300\nseconds, accepting unsigned integer seconds or valid http date syntax and\ndefaulting to 30 seconds for missing or invalid values, including negative or\nfractional numbers. http 503 and network failures offer a retry button and a\nsecondary “request a new sign in link” action; invalid or expired links make\nthat recovery action primary. the page never\nautomatically retries, aborts stalled requests after 15 seconds and releases\nrequests/timers when you leave it. response bodies, including stale responses,\nare cancelled without waiting, and completed attempts abort their controllers.\n\nwith valid issuer configuration, malformed get links return http 400 with branded\nrecovery html rather than the json error envelope. post errors retain the json\nerror schema. a well formed expired token still gets get 200 confirmation: its\nredemption status is determined only by the deliberate post.\n\n`get /auth/account` exports account metadata only. for full erasure, call\n`delete /auth/account` with `account_header` (`x archive account`) set to the\nissuer account uuid captured from `get /auth/account` before confirmation.\nthe issuer rejects missing or invalid uuids with 400 and a different signed in\naccount with 409 `account changed`, before any erasure. this guard applies even\nbefore publisher registration and must stay pinned across retries.\nthe issuer forwards the caller's session and publisher\nprecondition through `auth_archive` to `delete /me/account`, requires exactly 204,\nthen deletes its\nown user and clears the cookie. failure preserves the session for retry. direct\n`delete /me` removes only the archive profile and leaves the cookie intact.\n\nseveral game frontends can share one issuer and catalog. keep the cookie\nhost only on the central api host, use `credentials: 'include'` from same site\nhttps game subdomains, and allow each exact frontend origin in both workers.\ndo not share cookies through a parent domain `domain` attribute. the\n[multi collection recipe](./examples/multi collection deployment.md) describes\nthe topology, session revalidation and platform wide account boundary.\n\n`delete /me/account` atomically revokes the issuer/subject and cascades profile\ndeletion. its tombstone lasts 30 days plus ten seconds; old sessions cannot read\nprivate data or register again, including a racing registration. the dedicated\naccount erasure route alone may bypass the issuer lookup, and only to retry with\nthe same principal's original signed session while its existing tombstone is\nvalid; signature and claims are still verified, and initial erasure validates the\nissuer. the issuer's `delete /auth/account` checks the current version before\nstarting erasure; after completed erasure a retry there returns 401. a fresh\nissuer account gets a new uuid subject. external issuers must use a new subject to\nre enroll during the revocation window. profile only deletion does not revoke the\nsubject and permits re registration with existing credentials.\n\neach jwt also carries the issuer account's required safe integer `sessionversion`.\nthe issuer compares it with `auth_users.session_version` on every ordinary\nauthenticated route. a jwt with an old version is unauthenticated even before its\nexpiry; the catalog asks the issuer on every private request and does not cache a\nsuccessful verdict. a valid local jwt with an unavailable, malformed, 5xx or\ntimed out (fixed 5000 ms, including body decoding) issuer response becomes\n`503 auth unavailable`, not anonymous identity.\n\n## the database\n\nthe package ships the canonical catalog migration:\n`node_modules/@vielzeug/archive/migrations/0001_community_archive.sql` — the whole schema,\nmoderation included, as one designed file. the runtime sql in the package is\nwritten against exactly this schema, and the package's own integration suite runs\nagainst the same file. point wrangler at the directory:\n\n```jsonc\n// wrangler.jsonc — the deployment owns its d1 database, not its ddl.\n{\n \"d1_databases\": [\n {\n \"binding\": \"db\",\n \"database_name\": \"my app\",\n \"migrations_dir\": \"./node_modules/@vielzeug/archive/migrations\",\n },\n ],\n}\n```\n\nthe issuer points to\n`node_modules/@vielzeug/archive/migrations/auth/0001_auth.sql`. both files are\nfresh database initialization definitions, not upgrades. the issuer ddl initializes\n`auth_users.session_version` to `0` and stamps `auth_schema_meta.applied` as\n`auth_schema_version` (`1`). neither file automatically deletes or resets data.\narchive's canonical initial schemas are edited directly. reset and reseed a\ndevelopment database deliberately when its schema or collection semantics no\nlonger match; reapplying the initial file does not update an existing dataset.\nthe initialization file also stamps the storage schema\n(`archive_schema_meta`) at `archive_schema_version`, and health reads it back: a\ndatabase with a mismatched stamp answers `status: 'degraded'` with the\nobserved `dbschemaversion` and http 503 (`reason: 'schema mismatch'`).\nan unavailable database produces http 503 with `dbschemaversion: null` and\n`reason: 'database unavailable'`. the debut storage stamp is `1`. a unit\ntest pins the shipped ddl's `check` bounds to `archive_limits`, so the two can never\ndrift silently. the stamp does not detect changes in your collection's payload\nschema, vocabulary or indexing function; you own that dataset consistency.\n\n## routes and wire names\n\n```\nget /api/v1/health\npost /api/v1/sign out\nget /api/v1/auth/health\nget /api/v1/auth/session (internal issuer check)\npost /api/v1/auth/sign out all (revoke every session)\nget /api/v1/collections/:collection (self description)\nget /api/v1/collections/:collection/entries (author, filter, tag, search, sort, cursor, limit)\npost /api/v1/collections/:collection/entries\nget /api/v1/collections/:collection/entries/:id\nput /api/v1/collections/:collection/entries/:id (expectedrevision)\ndelete /api/v1/collections/:collection/entries/:id\nput /api/v1/collections/:collection/entries/:id/like\ndelete /api/v1/collections/:collection/entries/:id/like\npost /api/v1/collections/:collection/entries/:id/report (reason)\nput /api/v1/collections/:collection/entries/:id/hidden (operator)\ndelete /api/v1/collections/:collection/entries/:id/hidden (operator)\nget /api/v1/collections/:collection/reports (operator: status, cursor, limit)\nput /api/v1/collections/:collection/reports/:id/resolved (operator: report id)\ndelete /api/v1/publishers/:id (operator: erase any publisher)\nget /api/v1/moderation log (operator: cursor, limit)\nget /api/v1/me post /me put /me delete /me\nget /api/v1/me/publications?collection=:collection get /api/v1/me/likes[?ids=…]\nget /api/v1/me/reports (cursor, limit)\nget /api/v1/auth/account\nget /api/v1/auth/jwks.json\npost /api/v1/auth/sign in (email)\ndelete /api/v1/auth/account (coordinated full erasure)\ndelete /api/v1/me/account (issuer erasure seam; revokes subject)\nget /api/v1/auth/verify?token=… (confirmation only)\npost /api/v1/auth/verify (guarded {token} redemption)\n```\n\npublishing is bounded by `maxpublications` live entries per `(collection, owner)`.\nthe quota check runs inside the guarded insert.\n\nevery list — entry pages, `/me/likes` without `ids`, `/me/reports`, operator\nreports and `/moderation log` — pages forward only: follow `cursor` until `null`,\npreserving query context; a cursor minted for another query is a 400. `limit`\ndefaults to `pagesize` and is at most `maxpagesize`; there is no total export\nbound. `/me/likes?ids=…` remains a subset lookup returning `{ ids }`, not a\npage, and cannot combine `ids` with `cursor` or `limit`.\n\nshared wire names live in the package — `archive_session` cookie,\n`x archive publisher` and `x archive request` headers, `archive_error_codes`, and\n`archive_limits` — so a client and the worker cannot drift. parse failures with the\ncollection's composed schemas; parse errors with `archiveerrorresponseschema`, the\nsign out body with `archiveokschema`, a collection's self description with\n`archivecollectionmetaschema` and the operator queue with `archivereportsschema`.\nevery entry's `schemaversion` is the archive contract's version, not the\ncollection's; the collection's own format version lives in its hash envelope and in\neach entry's `contentversion`.\n\nonly anonymous entry lists carry a 30 second edge cache contract\n(`publiccacheseconds`). detail is always `no store`. any cookie or authorization\nheader disables caching, regardless of validity; metadata, private and error\nresponses also use `no store`.\n\nimplement ui ownership checks in your application by comparing the current\npublisher id to the entry's `author.id`; there is no package action policy helper.\nserver mutations remain authoritative.\n\n`post /api/v1/sign out` is deliberately local: it clears only this browser's\ncookie and does not contact the issuer or revoke copied sessions. to revoke every\nsession, post to `/api/v1/auth/sign out all` with the mutation marker, an exact\nallowed `origin`, and the issuer account uuid captured from\n`get /api/v1/auth/account` in `x archive account`. the issuer returns 409\n`account changed` if the active subject differs. on success it atomically increments\n`session_version`, deletes outstanding links, preserves account and catalog data,\nand returns `{ ok: true }` with an expired cookie. requests authorized before\nrevocation may finish; there is no cross database cancellation guarantee.\n\n`get /api/v1/auth/health` is public and read only. it validates issuer\nconfiguration, key signing/verification, binding shapes, the `auth_schema_meta`\nstamp and expected tables, columns and triggers, returns `authhealth` through\n`archiveauthhealthschema`, and answers 503 `degraded` for configuration,\ndatabase or schema failures. it never writes, sends email or probes the catalog.\n\n## moderation\n\noperator authority is the session token's optional `roles` claim carrying\n`archive_operator_role` (`'archive operator'`) — minted by your issuer, verified with\nthe same signature, audience and issuer checks as any session, and never a separate\ncredential. moderation routes additionally require a registered publisher row: one\naccountable identity per action.\n\n **report** — any registered publisher reports a visible entry with a bounded\n free text reason (`reportreasonchars`). idempotent per (entry, reporter): the same\n report twice is the same 204. missing or hidden entries answer 404 — there is\n nothing left to report.\n **hide** — an operator removes an entry from every public surface (list, detail,\n likes) with a soft hide: the row, its likes and its retry key stay intact. the\n owner still sees the entry in `/me/publications` with `hidden: true`, edits and\n key replay are frozen (403), removal stays allowed, and unhiding restores\n everything. already hidden or already visible answer the same 204.\n **queue** — the operator pages newest reports with entry title and reporter\n identity; `status` is `unresolved` (default) or `resolved`. each report has its\n own `id` and nullable `resolvedat`. resolve it with\n `put /collections/:collection/reports/:id/resolved`; repeating is a 204 no op.\n **log** — every effective moderation action (hide, unhide, removal, publisher\n erasure) appends one accountability row in the same transaction that applies it,\n and `get /api/v1/moderation log` pages the newest actions to\n operators. rows carry only ids, the action kind and the time — no names (they\n change), no content — and no foreign keys: the audit must outlive the erasures it\n records. idempotent repeats (hiding an already hidden entry, erasing an\n already erased publisher) answer the same 204 with no new row, including\n concurrent repeats: audit insertion tests the effective state inside the batch.\n\nlike and unlike both require a visible entry. their sql guards visibility during\nthe mutation, and the response checks it again; hiding or deleting an entry\ncannot turn a racing unlike into a successful response for unavailable content.\n\n## observability\n\nthe core never logs. `worker.tap(handler, { signal? })` emits one `archiveevent` per\nrequest — `{ type: 'request', code, ms, requestid, route, status, diagnostic? }` —\nand successful sweep — `{ type: 'scheduled', status: 'ok', removed }`. failed\nsweeps emit `{ type: 'scheduled', status: 'failed', diagnostic }` and rethrow so\nthe runtime sees failure. cache failures emit `{ type: 'cache error', diagnostic }`.\nthe worker swallows observer errors so\nobservability cannot affect the worker. with no handlers registered there is zero\nwork. route is a finite class like `/collections/:collection/entries`, never a\nraw path; no event carries a url, cookie, subject, alias or token. diagnostics are\ntyped sanitized categories, not raw exceptions. a pre aborted tap signal is inert.\nthe sweep's `removed` counts expired retry keys and revocation tombstones together.\n\nthe issuer also exposes `tap()` with `authevent` from `@vielzeug/archive/auth`.\nit never logs directly. events contain finite route labels, codes, counts,\nduration and sanitized `authdiagnostic` categories, never emails, tokens or raw\nexceptions. scheduled failures emit then rethrow; pre aborted taps are inert.\nissuer sweep counts include expired tokens, closed per user send windows and\nremoved pending accounts; the singleton admission row is not removed.\n\n```ts\nimport { createlogger } from '@vielzeug/rune';\nimport { createarchiveworker } from '@vielzeug/archive/worker';\nimport { recipescollection } from './recipes collection';\n\nconst log = createlogger({ namespace: 'recipes' });\nconst worker = createarchiveworker([recipescollection]);\nworker.tap((event) => log.debug(event, `archive:${event.type}`));\nexport default worker;\n```\n\n## testing\n\nthe package proves itself against real workerd + local d1:\n\n```sh\npnpm vitest run packages/archive/src/__tests__/ # fast unit suite\npnpm filter @vielzeug/archive test:integration # real workerd + local d1 + issuer coverage\npnpm filter @vielzeug/archive test:types # strict types over src and the integration suite\n```\n\nprove your own collection the way the reference deployment does: a slim deployment\nsuite that publishes over your real vocabulary and asserts the derivation, plus the\npackage's generic suite for the core behavior. a collection with an `index` that can\ndrift out of vocabulary should unit test `derive` directly: ordinary invalid input\nreturns `null`, while implementation faults throw `archivederivationerror`.\ndirect ddl tests exercise the shipped schema rather than a duplicate schema.\n\n## framework integration\n\nthe root entry is plain typescript with no framework coupling; vue, react and svelte\napps all consume the composed schemas the same way. a vue 3 composable that pages a\ncollection:\n\n```ts\n// userecipes.ts — one collection's typed client in any component tree.\nimport { recipescollection } from './recipes collection';\nimport { ref, shallowref } from 'vue';\n\nexport function userecipes() {\n const entries = shallowref<returntype<typeof recipescollection.contract.entry.parse>[]>([]);\n const cursor = ref<string | null>(null);\n async function load(sort: 'liked' | 'recent') {\n const params = new urlsearchparams({ sort });\n if (cursor.value !== null) params.set('cursor', cursor.value);\n const response = await fetch(`/api/v1/collections/recipes/entries?${params}`);\n if (!response.ok) throw new error('the archive is unavailable.');\n const page = recipescollection.contract.page.parse(await response.json());\n entries.value = [...entries.value, ...page.entries];\n cursor.value = page.cursor;\n }\n return { cursor, entries, load };\n}\n```\n\n## working with other vielzeug libraries\n\n [spell](/spell/) — the payload schema a collection declares; the core composes the\n envelope schemas from it, so the wire and the types cannot drift.\n [courier](/courier/) — the http client shape an app's frontend mirrors to consume\n these routes: strict response schemas over one typed port.\n [rune](/rune/) — the structured logger a deployment points `tap()` events at.\n\n## best practices\n\n compose client schemas from `collection.contract`; never hand write the envelope.\n declare exactly the vocabulary your `index` derives, with union types where you\n have them; a value outside it rejects loudly.\n keep `canonicalize` pure and schema true: reordered lists in, canonical form out.\n initialize fresh databases with the shipped ddl and deliberately reset/reseed\n development data when collection or storage semantics change.\n treat `archive_error_codes` and `archiveerrorresponseschema` as one tested contract.\n pin the expected publisher header from the identity you captured at sign in, never from storage alone.\n grant the operator role claim only through your issuer, and only to accounts that\n are also registered publishers.\n wire a sanitized `tap()` sink and register the rate limit pepper as a production secret.\n",
9
9
  "examples": " \ntitle: \"archive: examples\"\ndescription: \"task oriented recipes for @vielzeug/archive, from first deployment to erasure.\"\n \n\n [stand up a content archive](./examples/stand up a content archive.md)\n [content identity and versions](./examples/content identity and versions.md)\n [multi collection deployment](./examples/multi collection deployment.md)\n [moderate content](./examples/moderate content.md)\n [client error mapping](./examples/client error mapping.md)\n [gdpr erasure and export](./examples/gdpr erasure and export.md)\n"
10
10
  },
11
11
  "examples": [],
@@ -20,9 +20,9 @@
20
20
  "category": "utilities",
21
21
  "description": "tree shakeable typescript utilities with focused category entry points for arrays, async work, caching, objects, strings, math, and guards.",
22
22
  "docs": {
23
- "index": " \ntitle: 'arsenal: utility library for typescript'\ndescription: tree shakeable typescript utilities with focused category entry points for arrays, async work, caching, objects, strings, math, and guards.\npackage: arsenal\ncategory: utilities\nkeywords: [utility, array, string, object, math, async, debounce, throttle, cache]\nexports: [groupby, retry, debounce, isequal, taskpool, cache, fuzzyfilter, tryparsejson, allocate, hash]\nrelated: [tempo, sourcerer, spell, coins]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"arsenal\" />\n\n## why arsenal?\n\narsenal provides typed, zero dependency utilities that reduce repeated application code: collection transforms, numeric operations, retry and cancellation, caching, safe path traversal, and deterministic serialization. the root stays curated while category entry points expose the broader toolkit. exact platform aliases remain excluded.\n\n```ts\n// before\nconst users = json.parse(raw).filter((user) => user.name.includes(query));\n\n// after\nimport { fuzzyfilter } from '@vielzeug/arsenal/array';\nimport { tryparsejson } from '@vielzeug/arsenal/object';\n\nconst parsed = tryparsejson(raw);\nconst users = parsed.ok ? fuzzyfilter(parsed.value as user[], query, { select: (user) => user.name }) : [];\n```\n\n| feature | arsenal | lodash es | remeda |\n| | | | |\n| bundle size | <packageinfo package=\"arsenal\" type=\"size\" /> | ~72 kb | ~18 kb |\n| typed root utilities | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| category entry points | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | partial |\n| async task pool and cache | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use arsenal when** you need one typed utility dependency with focused subpaths for specialized behavior.\n\n**consider narrower alternatives when** you need only platform apis or a small functional subset.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/arsenal\n```\n\n```sh [npm]\nnpm install @vielzeug/arsenal\n```\n\n```sh [yarn]\nyarn add @vielzeug/arsenal\n```\n\n:::\n\n## quick start\n\n```ts\nimport { groupby, retry } from '@vielzeug/arsenal';\nimport { cache } from '@vielzeug/arsenal/cache';\nimport { taskpool } from '@vielzeug/arsenal/async';\n\nconst byrole = groupby([{ role: 'admin' }, { role: 'user' }], (user) => user.role);\n\nconst pool = taskpool({ concurrency: 2 });\nconst health = await pool.run((signal) => retry(() => fetch('/health', { signal }).then((response) => response.json())));\n\nconst responses = cache<string, unknown>({ ttlms: 60_000 });\nconst profile = await responses.getorload('/profile', () => fetch('/profile').then((response) => response.json()));\n\npool.dispose();\nconsole.log(byrole, health, profile);\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`retry`**: retry async work with cancellation support from package root\n **`taskpool`**: bounded, disposable concurrent work from `/async`\n **`cache`**: identity keyed ttl cache with async load deduplication from `/cache`\n **`fuzzyfilter`**: explicit field fuzzy filtering from `/array`\n **`tryparsejson`**: preserve json syntax failures from `/object`\n **`allocate`**: lossless proportional distribution from `/math`\n **`chunk`, `zip`, `compact`**: typed collection transforms from `/array`\n **`range`, `average`, `median`**: common numeric operations from `/math`\n **`assert`, `tap`**: typed control flow helpers from `/function`\n **`isequal`**: structural equality from package root\n **`hash`**: deterministic serialization for stable cache keys from `/object`\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [spell](/spell/): validate `unknown` json data after `tryparsejson`.\n [vault](/vault/): persistent storage; arsenal cache is in memory only.\n [tempo](/tempo/): date/time utilities kept outside arsenal.\n [coins](/coins/): money formatting and currency conversion.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
24
- "api": " \ntitle: 'arsenal: api reference'\ndescription: reference for arsenal root utilities and category entry points.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution | common gotcha |\n| | | | |\n| `groupby` | group values by key (prototype pollution guarded) | sync | root export |\n| `chunk` / `zip` / `compact` | typed collection transforms | sync | available from `/array` |\n| `range` / `average` / `median` | numeric generation and aggregation | sync | available from `/math` |\n| `assert` | assert and narrow a condition | sync | available from `/function` |\n| `retry` | retry async work | async | rethrows final error |\n| `taskpool` | bound concurrent tasks | async | available from `/async` |\n| `cache` | in memory identity keyed cache | async | available from `/cache` |\n| `fuzzyfilter` | filter string or selected object fields | sync | object collections require `select` |\n| `fuzzyscore` | rank string or selected object fields | sync | object collections require `select` |\n| `tryparsejson` | preserve json syntax result | sync | returns `unknown` on success |\n| `getpath` | optional object lookup (safe path guarded) | sync | available from `/object` |\n| `allocate` | lossless proportional distribution | sync | available from `/math` |\n| `isequal` | structural equality | sync | root export |\n| `hash` | deterministic serialization for cache keys | sync | available from `/object` |\n| `compressbytes` / `decompressbytes` | native byte compression with bounded decompression | async | requires native compression streams; decoding requires an output limit |\n| `pack` / `unpack` | versioned, compressed, url safe payload envelope | async | both sides bound the payload with `maxbytes`; compression falls back to raw |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/arsenal` | curated common utilities |\n| `@vielzeug/arsenal/array` | immutable transforms, set operations, sorting, fuzzy search |\n| `@vielzeug/arsenal/async` | retry, cancellation, task pool, timing |\n| `@vielzeug/arsenal/cache` | in memory cache and memoization |\n| `@vielzeug/arsenal/encoding` | base64url and native byte compression |\n| `@vielzeug/arsenal/function` | assertions, composition, timing, teardown |\n| `@vielzeug/arsenal/guards` | type guards, predicate combinators, equality guards |\n| `@vielzeug/arsenal/math` | ranges, aggregation, statistics, interpolation, exact allocation |\n| `@vielzeug/arsenal/object` | paths, transforms, hash, json parse result |\n| `@vielzeug/arsenal/random` | cryptographic entropy selection and shuffle |\n| `@vielzeug/arsenal/string` | unicode aware case transforms and similarity |\n\n## array\n\n### typed transforms\n\n```ts\nchunk<t>(input: readonly t[], size?: number): t[][]\ncompact<t>(array: readonly t[]): array<exclude<t, false | '' | 0 | 0n | null | undefined>>\nfirst<t>(array: readonly t[], fallback?: t): t | undefined\nlast<t>(array: readonly t[], fallback?: t): t | undefined\nreplace<t>(array: readonly t[], predicate: (value: t) => boolean, value: t): t[]\nrotate<t>(array: readonly t[], positions: number, options?: { wrap?: boolean }): t[]\nzip(...arrays): array<tuple>\nunzip(rows): tupleofarrays\n```\n\n`take`, `takelast`, `drop`, and `droplast` return new arrays and normalize negative counts to zero. set operations accept optional selectors for object identity.\n\n### fuzzyfilter / fuzzyscore\n\n```ts\nfuzzyfilter(strings: readonly string[], query: string, options?: fuzzyoptions): string[]\nfuzzyfilter<t>(items: readonly t[], query: string, options: fuzzyselection<t>): t[]\nfuzzyscore(strings: readonly string[], query: string, options?: fuzzyoptions): scoredresult<string>[]\nfuzzyscore<t>(items: readonly t[], query: string, options: fuzzyselection<t>): scoredresult<t>[]\n```\n\n`fuzzyfilter` preserves input order. `fuzzyscore` orders results by descending score.\n\n```ts\nimport { fuzzyfilter } from '@vielzeug/arsenal/array';\n\nconst users = [{ email: 'alice@example.com', name: 'alice' }];\nconst matches = fuzzyfilter(users, 'alice', { select: (user) => [user.name, user.email] });\n```\n\n \n\n## async\n\n### taskpool\n\n```ts\ninterface taskpool {\n run<t>(task: (signal: abortsignal) => promise<t>): promise<t>;\n idle(): promise<void>;\n dispose(reason?: unknown): void;\n readonly active: number;\n readonly pending: number;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n}\n\ntaskpool(options?: { concurrency?: number }): taskpool\n```\n\n`dispose()` aborts running cooperative tasks and rejects pending tasks.\n\n```ts\nimport { taskpool } from '@vielzeug/arsenal/async';\n\nconst pool = taskpool({ concurrency: 2 });\nconst user = await pool.run((signal) => fetch('/user', { signal }).then((response) => response.json()));\npool.dispose();\n```\n\n \n\n## cache\n\n### cache\n\n```ts\ninterface cache<k, t> {\n entries(): readonlyarray<readonly [k, t]>;\n get(key: k): t | undefined;\n set(key: k, value: t, options?: { ttlms?: number }): void;\n getorload(key: k, load: () => promise<t>): promise<t>;\n delete(key: k): boolean;\n clear(): void;\n readonly size: number;\n}\n\ncache<k, t>(options?: cacheoptions): cache<k, t>\n```\n\nkeys use native `map` identity. expiry is lazy, evaluated by `get`, `getorload`, `set`, and `entries`. `entries()` trims expired values and returns a new insertion ordered snapshot without exposing internal storage. the `size` getter does not evict expired entries.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\nconst profiles = cache<string, profile>({ ttlms: 60_000 });\nconst profile = await profiles.getorload('me', loadprofile);\n```\n\n \n\n## encoding\n\nurl safe base64 (`base64url`) codecs without `btoa`/`buffer`, so the same code runs in browsers, workers, and node.\n\n```ts\nimport { base64urltobytes, base64urltotext, bytestobase64url, texttobase64url, utf8bytes } from '@vielzeug/arsenal';\n\ntexttobase64url('{\"build\":\"hero\"}'); // never contains '+', '/', or '='\nbase64urltotext(texttobase64url('league of legends')); // round trips any utf 8 text\nbytestobase64url(uint8array.from([0, 1, 250])); // byte level codec for binary payloads\nbase64urltobytes(code); // throws on characters outside the alphabet\nutf8bytes('hunter äöü'); // 11: byte length of a utf 8 string\n```\n\n### compressbytes\n\n```ts\ncompressbytes(bytes: uint8array, options?: compressionoptions): promise<uint8array>\n```\n\ncompresses bytes using the native `compressionstream` api without modifying the input.\n\n| option | type | default | meaning |\n| | | | |\n| `format` | `compressionformat` | `'deflate raw'` | compression format; the receiver must use the same format |\n\n**returns:** compressed bytes. rejects with `arsenalserializationerror` when the format or capability is unavailable, or stream processing fails. does not fall back to another format.\n\n**example:** encode compressed bytes for a url.\n\n```ts\nimport { bytestobase64url, compressbytes } from '@vielzeug/arsenal';\n\nconst bytes = new textencoder().encode(json.stringify({ name: 'hunters' }));\nconst code = bytestobase64url(await compressbytes(bytes));\n```\n\n \n\n### decompressbytes\n\n```ts\ndecompressbytes(bytes: uint8array, options: decompressionoptions): promise<uint8array>\n```\n\ndecompresses bytes using the native `decompressionstream` api, enforcing the output limit while reading chunks.\n\n| option | type | default | meaning |\n| | | | |\n| `format` | `compressionformat` | `'deflate raw'` | format used by the sender |\n| `maxoutputbytes` | `number` | required | non negative safe integer; maximum decoded byte length |\n\n**returns:** decompressed bytes. rejects with `arsenalserializationerror` for invalid limits, corrupt or truncated data, unavailable formats/capabilities, or output exceeding the limit. a failed stream is canceled and its reader lock released. bound encoded input separately before calling this function.\n\n**example:** decode text within an application defined byte limit.\n\n```ts\nimport { compressbytes, decompressbytes } from '@vielzeug/arsenal';\n\nconst compressed = await compressbytes(new textencoder().encode('hunters'));\nconst bytes = await decompressbytes(compressed, { maxoutputbytes: 8192 });\nconst text = new textdecoder('utf 8', { fatal: true }).decode(bytes);\n```\n\n \n\n### pack\n\n```ts\npack(payload: unknown, options: sharecodeoptions): promise<string>\n```\n\nserializes `payload` as json and encodes it into a compact share code: `prefix` + mode marker + base64url bytes, compressed with raw deflate when the compressed form is shorter and the runtime supports the streams.\n\n| option | type | meaning |\n| | | |\n| `maxbytes` | `number` | positive safe integer; maximum serialized payload size |\n| `prefix` | `string` | 1–8 alphanumeric characters identifying the code family and version |\n\n**returns:** the shorter of the raw (`prefix` + `r`) and compressed (`prefix` + `d`) representations. throws `sharecodeerror` (`too large`, `invalid`) for oversized payloads and misconfigured options.\n\n**example:** encode a shareable build as a code small enough for a url.\n\n```ts\nimport { pack } from '@vielzeug/arsenal';\n\nconst code = await pack({ cards: ['slash', 'guard'] }, { maxbytes: 8192, prefix: 'ab1' });\n```\n\n \n\n### unpack\n\n```ts\nunpack(code: string, options: sharecodeoptions): promise<unknown>\n```\n\ndecodes a share code produced by `pack` with the same options. both sides are bounded: input longer than `maxbytes` could carry is refused up front, streamed decompression stops at `maxbytes`, and non canonical base64url is rejected by round trip.\n\n**returns:** the parsed json payload. throws `sharecodeerror` (`invalid` for foreign or malformed codes, `too large` for budget overruns, `compression unavailable` when a compressed code arrives on a runtime without `decompressionstream`). underlying compression failures surface as `arsenalserializationerror`.\n\n**example:** decode untrusted input within the byte budget.\n\n```ts\nimport { unpack } from '@vielzeug/arsenal';\n\nconst payload = await unpack(code, { maxbytes: 8192, prefix: 'ab1' });\n```\n\n## object\n\n### tryparsejson\n\n```ts\ntype jsonparseresult = { ok: true; value: unknown } | { error: syntaxerror; ok: false };\n\ntryparsejson(text: string): jsonparseresult\n```\n\nuse a schema validator after success to refine `unknown` data.\n\n```ts\nimport { tryparsejson } from '@vielzeug/arsenal/object';\n\nconst result = tryparsejson(raw);\nif (!result.ok) throw result.error;\n```\n\n### getpath\n\n```ts\ngetpath<t extends record<string, unknown>, p extends string>(item: t, path: p): pathvalue<t, p> | undefined\ngetpathor<t extends record<string, unknown>, p extends string, f>(item: t, path: p, fallback: f): pathvalue<t, p> | f\nrequirepath<t extends record<string, unknown>, p extends string>(item: t, path: p): exclude<pathvalue<t, p>, undefined>\n```\n\n### hash\n\n```ts\nhash(value: unknown, options?: hashoptions): string\n```\n\nproduces a deterministic, order independent serialization. handles `date`, `regexp`, `set`, `map`, and `bigint`. circular references produce a sentinel. useful for stable cache keys.\n\n \n\n## math\n\n### numeric toolkit\n\n```ts\nrange(stop: number): number[]\nrange(start: number, stop: number, step?: number): number[]\nclamp(value: number, min?: number, max?: number): number\nsum<t>(values: readonly t[], select?: (value: t) => number): number\naverage<t>(values: readonly t[], select?: (value: t) => number): number | undefined\nmedian<t>(values: readonly t[], select?: (value: t) => number): number | undefined\nvariance<t>(values: readonly t[], select?: (value: t) => number): number\nstandarddeviation<t>(values: readonly t[], select?: (value: t) => number): number\n```\n\n`sum` and `average` reject non finite values. `gcd` and `lcm` require safe integers. `linspace` requires finite bounds and a positive integer point count. `variance` and `standarddeviation` compute population statistics.\n\n### allocate\n\n```ts\nallocate(amount: number, ratios: number[] | number): number[]\nallocate(amount: bigint, ratios: number[] | number): bigint[]\n```\n\ndistributes an amount proportionally across ratios. the indivisible remainder is applied to the last bucket so the sum equals the original amount exactly: critical for financial operations.\n\n## types\n\n```ts\ntype fuzzyoptions = {\n normalize?: boolean;\n threshold?: number;\n};\n\ntype fuzzyselection<t> = fuzzyoptions & {\n select: (item: t) => string | readonly string[];\n};\n\ntype scoredresult<t> = { item: t; score: number };\n\ntype cacheoptions = {\n capacity?: number;\n now?: () => number;\n ttlms?: number;\n};\n\ninterface compressionoptions {\n format?: compressionformat;\n}\n\ninterface decompressionoptions extends compressionoptions {\n maxoutputbytes: number;\n}\n```\n\n## errors\n\n `rangeerror`: invalid numeric bounds, capacity, concurrency, or retry count.\n `typeerror`: invalid value types, unsupported comparison, or required path missing.\n `arsenalserializationerror`: memo or hash cannot serialize supplied input; compression fails, is unavailable, receives an invalid output limit, or exceeds that limit. stream failures retain the original error as `cause`.\n `sharecodeerror`: a share code cannot be encoded or decoded; the `reason` field is `invalid`, `too large`, or `compression unavailable`.\n",
25
- "usage": " \ntitle: 'arsenal: usage guide'\ndescription: use arsenal root utilities for common work and category entry points for specialized collection, async, cache, object, and string behavior.\n \n\n[[toc]]\n\n## basic usage\n\nstart at package root for common utilities. move to a category entry point for the broader typed toolkit. arsenal keeps helpers that improve readability, narrowing, immutable updates, or edge case consistency; exact platform aliases stay out.\n\n```ts\nimport { groupby, retry } from '@vielzeug/arsenal';\n\nconst users = [\n { id: 'a1', role: 'admin' },\n { id: 'u1', role: 'user' },\n { id: 'u2', role: 'user' },\n];\n\nconst byrole = groupby(users, (user) => user.role);\nconst health = await retry(() => fetch('/health').then((response) => response.json()));\n\nconsole.log(byrole, health);\n```\n\nuse category imports for apis absent from root:\n\n```ts\nimport { fuzzyfilter } from '@vielzeug/arsenal/array';\nimport { taskpool } from '@vielzeug/arsenal/async';\nimport { cache } from '@vielzeug/arsenal/cache';\nimport { tryparsejson } from '@vielzeug/arsenal/object';\n```\n\n## transform collections\n\nuse `/array` for transforms that preserve input immutability. `filtermap` combines mapping and omission; `indexby` and `groupby` build lookup structures without mutation.\n\n```ts\nimport { filtermap, indexby, sort } from '@vielzeug/arsenal/array';\n\nconst products = [\n { id: 'p1', price: 20, published: true },\n { id: 'p2', price: 10, published: false },\n { id: 'p3', price: 15, published: true },\n];\n\nconst publishedlabels = filtermap(products, (product) =>\n product.published ? `${product.id}: ${product.price}` : undefined,\n);\nconst byid = indexby(products, (product) => product.id);\nconst byprice = sort(products, (product) => product.price);\n\nconsole.log(publishedlabels, byid, byprice);\n```\n\n## search explicit fields\n\nsearch string arrays directly. object collections require `select`, so callers define exactly what can match.\n\n```ts\nimport { fuzzyfilter, fuzzyscore } from '@vielzeug/arsenal/array';\n\nconst users = [\n { email: 'alice@example.com', name: 'alice' },\n { email: 'bob@example.com', name: 'bob' },\n];\n\nconst matches = fuzzyfilter(users, 'alice', { select: (user) => [user.name, user.email] });\nconst ranked = fuzzyscore(users, 'ali', { select: (user) => user.name });\n```\n\n## work with object data\n\nuse `/object` for paths, key selection, stable cache keys, and object transforms.\n\n```ts\nimport { getpathor, hash, omit, pick } from '@vielzeug/arsenal/object';\n\nconst config = { api: { host: 'localhost', port: 3000 }, debug: true };\nconst port = getpathor(config, 'api.port', 8080);\nconst publicconfig = pick(config, ['api']);\nconst productionconfig = omit(config, ['debug']);\nconst key = hash({ port, productionconfig });\n\nconsole.log(publicconfig, key);\n```\n\n## parse and validate json\n\n`tryparsejson` distinguishes syntax failure from schema failure. treat successful values as `unknown`, then validate with spell or application code.\n\n```ts\nimport { tryparsejson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst user = s.object({ id: s.string(), name: s.string() });\nconst parsed = tryparsejson(raw);\n\nif (!parsed.ok) throw parsed.error;\n\nconst user = user.parse(parsed.value);\n```\n\n## bound concurrent work\n\nuse `parallel` for one finite collection. use `taskpool` when tasks arrive over time or need disposal.\n\n```ts\nimport { parallel, taskpool } from '@vielzeug/arsenal/async';\n\nconst metadata = await parallel(urls, (url) => fetch(url).then((response) => response.json()), { limit: 4 });\n\nconst pool = taskpool({ concurrency: 2 });\nconst profile = await pool.run((signal) => fetch('/profile', { signal }).then((response) => response.json()));\n\nawait pool.idle();\npool.dispose();\n\nconsole.log(metadata, profile);\n```\n\n## cache loaded values\n\nuse `cache` for process local values. keys retain native `map` identity. `getorload` deduplicates concurrent loads for one key.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\ntype profile = { id: string; name: string };\n\nconst profiles = cache<string, profile>({ capacity: 100, ttlms: 60_000 });\nconst profile = await profiles.getorload('me', () => fetch('/profile').then((response) => response.json()));\n\nconsole.log(profiles.entries());\nprofiles.delete('me');\nconst freshprofile = await profiles.getorload('me', () => fetch('/profile').then((response) => response.json()));\n\nconsole.log(profile, freshprofile);\n```\n\n## test deterministic randomness\n\nrandom helpers use cryptographic entropy by default. pass `randomsource` in tests when output must be deterministic.\n\n```ts\nimport { random, randomfloat, type randomsource } from '@vielzeug/arsenal/random';\n\nconst source: randomsource = { next: () => 0.5 };\n\nrandomfloat(source); // 0.5\nrandom(1, 4, source); // 3\n```\n\n## encode text for urls and qr codes\n\nshare codes ride in urls and qr payloads, where `+`, `/`, and padding `=` break consumers. the base64url codecs are environment independent: no `btoa`, no `buffer`, so the same encode/decode pair works in the browser, in workers, and in node.\n\n```ts\nimport { base64urltotext, texttobase64url } from '@vielzeug/arsenal';\n\nconst code = texttobase64url(json.stringify({ seat: 4, version: 2 }));\n// eyjzzwf0ijo0lcj2zxjzaw9uijoyfq: safe in a query string or qr\n\nconst payload = json.parse(base64urltotext(code));\n```\n\ndecoding throws on characters outside the base64url alphabet, so corrupted input fails loudly instead of decoding to garbage.\n\n## compress share payloads\n\ncompress utf 8 bytes before base64url encoding. set a decoded byte limit before accepting compressed input from another device.\n\n```ts\nimport { base64urltobytes, bytestobase64url, compressbytes, decompressbytes } from '@vielzeug/arsenal';\n\nconst payload = json.stringify({ name: 'hunters', notes: 'an expedition through alborea. '.repeat(20) });\nconst compressed = await compressbytes(new textencoder().encode(payload));\nconst code = bytestobase64url(compressed);\nconst bytes = await decompressbytes(base64urltobytes(code), { maxoutputbytes: 8192 });\nconst restored = new textdecoder('utf 8', { fatal: true }).decode(bytes);\nconsole.log(restored === payload);\n```\n\nboth helpers default to `deflate raw`. you can explicitly select `gzip` or `deflate`; use the same format at both ends. the helpers reject when native streams do not support the selected format. they do not choose a fallback or define a wire protocol.\n\nfor a complete versioned envelope over these primitives — prefix, compression mode marker, byte budgets on both sides — see [`pack` / `unpack`](./api.md#pack).\n\ncompression can enlarge small inputs. compare the final encoded strings, including any format marker, before choosing a representation. bound input size before decoding base64url and validate decompressed json against your schema. the decompression limit bounds retained output, not native stream working memory.\n\n## working with other vielzeug libraries\n\nuse spell after `tryparsejson` for typed external data. use vault instead of `cache` when data must survive reloads or process restart.\n\n```ts\nimport { tryparsejson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst settings = s.object({ theme: s.string() });\nconst parsed = tryparsejson(rawsettings);\nconst settings = parsed.ok ? settings.parse(parsed.value) : { theme: 'system' };\n```\n\n## best practices\n\n import common utilities from package root.\n import the broader toolkit from category entry points.\n prefer arsenal when a helper improves typing, intent, immutable updates, or edge case behavior.\n use the platform directly for exact aliases such as `crypto.randomuuid()` and `array.prototype.flat()`.\n pass `select` for every fuzzy search over objects.\n validate parsed json before using it as application data.\n use `parallel` for finite batches and `taskpool` for ongoing work.\n dispose task pools when their owner ends.\n use `cache` only for in memory data.\n inject `randomsource` in deterministic tests.\n",
23
+ "index": " \ntitle: 'arsenal: utility library for typescript'\ndescription: tree shakeable typescript utilities with focused category entry points for arrays, async work, caching, objects, strings, math, and guards.\npackage: arsenal\ncategory: utilities\nkeywords: [utility, array, pagination, sorting, string, object, math, async, cache]\nexports: [paginate, sort, retry, taskpool, cache]\nrelated: [tempo, sourcerer, spell, coins]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"arsenal\" />\n\n## why arsenal?\n\narsenal provides typed, zero dependency utilities that reduce repeated application code: collection transforms, numeric operations, retry and cancellation, caching, safe path traversal, and deterministic serialization. the root stays curated while category entry points expose the broader toolkit. exact platform aliases remain excluded.\n\n```ts\n// before\nconst users = json.parse(raw).filter((user) => user.name.includes(query));\n\n// after\nimport { fuzzyfilter } from '@vielzeug/arsenal/array';\nimport { tryparsejson } from '@vielzeug/arsenal/object';\n\nconst parsed = tryparsejson(raw);\nconst users = parsed.ok ? fuzzyfilter(parsed.value as user[], query, { select: (user) => user.name }) : [];\n```\n\n| feature | arsenal | lodash es | remeda |\n| | | | |\n| bundle size | <packageinfo package=\"arsenal\" type=\"size\" /> | ~72 kb | ~18 kb |\n| typed root utilities | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| category entry points | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | partial |\n| async task pool and cache | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use arsenal when** you need one typed utility dependency with focused subpaths for specialized behavior.\n\n**consider narrower alternatives when** you need only platform apis or a small functional subset.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/arsenal\n```\n\n```sh [npm]\nnpm install @vielzeug/arsenal\n```\n\n```sh [yarn]\nyarn add @vielzeug/arsenal\n```\n\n:::\n\n## quick start\n\n```ts\nimport { groupby, retry } from '@vielzeug/arsenal';\nimport { cache } from '@vielzeug/arsenal/cache';\nimport { taskpool } from '@vielzeug/arsenal/async';\n\nconst byrole = groupby([{ role: 'admin' }, { role: 'user' }], (user) => user.role);\n\nconst pool = taskpool({ concurrency: 2 });\nconst health = await pool.run((signal) => retry(() => fetch('/health', { signal }).then((response) => response.json())));\n\nconst responses = cache<string, unknown>({ ttlms: 60_000 });\nconst profile = await responses.getorload('/profile', () => fetch('/profile').then((response) => response.json()));\n\npool.dispose();\nconsole.log(byrole, health, profile);\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`retry`**: retry async work with cancellation support from package root\n **`taskpool`**: bounded, disposable concurrent work from `/async`\n **`cache`**: identity keyed ttl cache with async load deduplication from `/cache`\n **`fuzzyfilter`**: explicit field fuzzy filtering from `/array`\n **`tryparsejson`**: preserve json syntax failures from `/object`\n **`allocate`**: lossless proportional distribution from `/math`\n **`chunk`, `zip`, `compact`**: typed collection transforms from `/array`\n **`paginate`**: one page plus its totals from an in memory list, in a one or zero based page base\n **`sort`**: selector, `{ key: direction }`, or ordered grid criteria from one entry point\n **`range`, `average`, `median`**: common numeric operations from `/math`\n **`assert`, `tap`**: typed control flow helpers from `/function`\n **`isequal`**: structural equality from package root\n **`hash`**: deterministic serialization for stable cache keys from `/object`\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [spell](/spell/): validate `unknown` json data after `tryparsejson`.\n [vault](/vault/): persistent storage; arsenal cache is in memory only.\n [tempo](/tempo/): date/time utilities kept outside arsenal.\n [coins](/coins/): money formatting and currency conversion.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
24
+ "api": " \ntitle: 'arsenal: api reference'\ndescription: reference for arsenal root utilities and category entry points.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution | common gotcha |\n| | | | |\n| `groupby` | group values by key (prototype pollution guarded) | sync | root export |\n| `chunk` / `zip` / `compact` | typed collection transforms | sync | available from `/array` |\n| `paginate` | cut one page and its totals out of a list | sync | pages past the last return empty `items`, unclamped |\n| `sort` | sort by a selector, keyed fields, or ordered path criteria | sync | criteria paths are resolved safely; mixed value types throw |\n| `range` / `average` / `median` | numeric generation and aggregation | sync | available from `/math` |\n| `assert` | assert and narrow a condition | sync | available from `/function` |\n| `retry` | retry async work | async | rethrows final error |\n| `taskpool` | bound concurrent tasks | async | available from `/async` |\n| `cache` | in memory identity keyed cache | async | available from `/cache` |\n| `fuzzyfilter` | filter string or selected object fields | sync | object collections require `select` |\n| `fuzzyscore` | rank string or selected object fields | sync | object collections require `select` |\n| `tryparsejson` | preserve json syntax result | sync | returns `unknown` on success |\n| `getpath` | optional object lookup (safe path guarded) | sync | available from `/object` |\n| `allocate` | lossless proportional distribution | sync | available from `/math` |\n| `isequal` | structural equality | sync | root export |\n| `hash` | deterministic serialization for cache keys | sync | available from `/object` |\n| `compressbytes` / `decompressbytes` | native byte compression with bounded decompression | async | requires native compression streams; decoding requires an output limit |\n| `pack` / `unpack` | versioned, compressed, url safe payload envelope | async | both sides bound the payload with `maxbytes`; compression falls back to raw |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/arsenal` | curated common utilities |\n| `@vielzeug/arsenal/array` | immutable transforms, pagination, set operations, sorting, fuzzy search |\n| `@vielzeug/arsenal/async` | retry, cancellation, task pool, timing |\n| `@vielzeug/arsenal/cache` | in memory cache and memoization |\n| `@vielzeug/arsenal/encoding` | base64url and native byte compression |\n| `@vielzeug/arsenal/function` | assertions, composition, timing, teardown |\n| `@vielzeug/arsenal/guards` | type guards, predicate combinators, equality guards |\n| `@vielzeug/arsenal/math` | ranges, aggregation, statistics, interpolation, exact allocation |\n| `@vielzeug/arsenal/object` | paths, transforms, hash, json parse result |\n| `@vielzeug/arsenal/random` | cryptographic entropy selection and shuffle |\n| `@vielzeug/arsenal/string` | unicode aware case transforms and similarity |\n\n## array\n\n### typed transforms\n\n```ts\nchunk<t>(input: readonly t[], size?: number): t[][]\ncompact<t>(array: readonly t[]): array<exclude<t, false | '' | 0 | 0n | null | undefined>>\nfirst<t>(array: readonly t[], fallback?: t): t | undefined\nlast<t>(array: readonly t[], fallback?: t): t | undefined\nreplace<t>(array: readonly t[], predicate: (value: t) => boolean, value: t): t[]\nrotate<t>(array: readonly t[], positions: number, options?: { wrap?: boolean }): t[]\nzip(...arrays): array<tuple>\nunzip(rows): tupleofarrays\n```\n\n`take`, `takelast`, `drop`, and `droplast` return new arrays and normalize negative counts to zero. set operations accept optional selectors for object identity.\n\n### paginate\n\n```ts\npaginate<t>(items: readonly t[], options?: paginateoptions): pageenvelope<t>\n```\n\ncuts 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 < totalpages` never underflows. pages past the last yield empty `items` and echo the requested page rather than clamping it. throws `typeerror` for a non array and `rangeerror` when `page` is not an integer in the selected base or `pagesize` is not a positive integer.\n\n| option | type | default | description |\n| | | | |\n| `page` | `number` | first page in `pageoffset`'s base | requested page; must be an integer greater than or equal to `pageoffset` |\n| `pagesize` | `number` | `20` | number of items per page; must be a positive integer |\n| `pageoffset` | `0 \\| 1` | `1` | page number base: `0` for zero based callers, `1` for one based callers |\n\n```ts\nimport { paginate } from '@vielzeug/arsenal/array';\n\npaginate([1, 2, 3, 4, 5], { page: 2, pagesize: 2 });\n// { items: [3, 4], page: 2, pagesize: 2, totalitems: 5, totalpages: 3 }\n\nconst springpage = 0;\npaginate([1, 2, 3, 4, 5], { page: springpage, pageoffset: 0 });\n// { items: [1, 2, 3, 4, 5], page: 0, pagesize: 20, totalitems: 5, totalpages: 1 }\n```\n\nthe envelope is server neutral: it models no http dialect, so adapters map their response onto it and map it back out.\n\n### sort\n\n```ts\nsort<t>(array: readonly t[], selector: (item: t) => unknown, direction?: sortdirection): t[];\nsort<t>(array: readonly t[], selectors: sortselectors<t>): t[];\nsort<t>(array: readonly t[], criteria: readonlyarray<sortcriterion<t>>): t[];\n```\n\nthree 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.\n\n```ts\nimport { sort } from '@vielzeug/arsenal/array';\n\nconst users = [{ name: 'ada', age: 41 }, { name: 'grace', age: 30 }];\nsort(users, (user) => user.name);\nsort(users, { name: 'asc', age: 'desc' });\n\nconst rows = [\n { address: { city: 'berlin' }, age: 41 },\n { address: { city: 'aachen' }, age: 30 },\n];\nsort(rows, [{ key: 'address.city' }, { key: 'age', direction: 'desc' }]);\n```\n\ncriteria 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.\n\n### fuzzyfilter / fuzzyscore\n\n```ts\nfuzzyfilter(strings: readonly string[], query: string, options?: fuzzyoptions): string[]\nfuzzyfilter<t>(items: readonly t[], query: string, options: fuzzyselection<t>): t[]\nfuzzyscore(strings: readonly string[], query: string, options?: fuzzyoptions): scoredresult<string>[]\nfuzzyscore<t>(items: readonly t[], query: string, options: fuzzyselection<t>): scoredresult<t>[]\n```\n\n`fuzzyfilter` preserves input order. `fuzzyscore` orders results by descending score.\n\n```ts\nimport { fuzzyfilter } from '@vielzeug/arsenal/array';\n\nconst users = [{ email: 'alice@example.com', name: 'alice' }];\nconst matches = fuzzyfilter(users, 'alice', { select: (user) => [user.name, user.email] });\n```\n\n \n\n## async\n\n### taskpool\n\n```ts\ninterface taskpool {\n run<t>(task: (signal: abortsignal) => promise<t>): promise<t>;\n idle(): promise<void>;\n dispose(reason?: unknown): void;\n readonly active: number;\n readonly pending: number;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n}\n\ntaskpool(options?: { concurrency?: number }): taskpool\n```\n\n`dispose()` aborts running cooperative tasks and rejects pending tasks.\n\n```ts\nimport { taskpool } from '@vielzeug/arsenal/async';\n\nconst pool = taskpool({ concurrency: 2 });\nconst user = await pool.run((signal) => fetch('/user', { signal }).then((response) => response.json()));\npool.dispose();\n```\n\n \n\n## cache\n\n### cache\n\n```ts\ninterface cache<k, t> {\n entries(): readonlyarray<readonly [k, t]>;\n get(key: k): t | undefined;\n set(key: k, value: t, options?: { ttlms?: number }): void;\n getorload(key: k, load: () => promise<t>): promise<t>;\n delete(key: k): boolean;\n clear(): void;\n readonly size: number;\n}\n\ncache<k, t>(options?: cacheoptions): cache<k, t>\n```\n\nkeys use native `map` identity. expiry is lazy, evaluated by `get`, `getorload`, `set`, and `entries`. `entries()` trims expired values and returns a new insertion ordered snapshot without exposing internal storage. the `size` getter does not evict expired entries.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\nconst profiles = cache<string, profile>({ ttlms: 60_000 });\nconst profile = await profiles.getorload('me', loadprofile);\n```\n\n \n\n## encoding\n\nurl safe base64 (`base64url`) codecs without `btoa`/`buffer`, so the same code runs in browsers, workers, and node.\n\n```ts\nimport { base64urltobytes, base64urltotext, bytestobase64url, texttobase64url, utf8bytes } from '@vielzeug/arsenal';\n\ntexttobase64url('{\"build\":\"hero\"}'); // never contains '+', '/', or '='\nbase64urltotext(texttobase64url('league of legends')); // round trips any utf 8 text\nbytestobase64url(uint8array.from([0, 1, 250])); // byte level codec for binary payloads\nbase64urltobytes(code); // throws on characters outside the alphabet\nutf8bytes('hunter äöü'); // 11: byte length of a utf 8 string\n```\n\n### compressbytes\n\n```ts\ncompressbytes(bytes: uint8array, options?: compressionoptions): promise<uint8array>\n```\n\ncompresses bytes using the native `compressionstream` api without modifying the input.\n\n| option | type | default | meaning |\n| | | | |\n| `format` | `compressionformat` | `'deflate raw'` | compression format; the receiver must use the same format |\n\n**returns:** compressed bytes. rejects with `arsenalserializationerror` when the format or capability is unavailable, or stream processing fails. does not fall back to another format.\n\n**example:** encode compressed bytes for a url.\n\n```ts\nimport { bytestobase64url, compressbytes } from '@vielzeug/arsenal';\n\nconst bytes = new textencoder().encode(json.stringify({ name: 'hunters' }));\nconst code = bytestobase64url(await compressbytes(bytes));\n```\n\n \n\n### decompressbytes\n\n```ts\ndecompressbytes(bytes: uint8array, options: decompressionoptions): promise<uint8array>\n```\n\ndecompresses bytes using the native `decompressionstream` api, enforcing the output limit while reading chunks.\n\n| option | type | default | meaning |\n| | | | |\n| `format` | `compressionformat` | `'deflate raw'` | format used by the sender |\n| `maxoutputbytes` | `number` | required | non negative safe integer; maximum decoded byte length |\n\n**returns:** decompressed bytes. rejects with `arsenalserializationerror` for invalid limits, corrupt or truncated data, unavailable formats/capabilities, or output exceeding the limit. a failed stream is canceled and its reader lock released. bound encoded input separately before calling this function.\n\n**example:** decode text within an application defined byte limit.\n\n```ts\nimport { compressbytes, decompressbytes } from '@vielzeug/arsenal';\n\nconst compressed = await compressbytes(new textencoder().encode('hunters'));\nconst bytes = await decompressbytes(compressed, { maxoutputbytes: 8192 });\nconst text = new textdecoder('utf 8', { fatal: true }).decode(bytes);\n```\n\n \n\n### pack\n\n```ts\npack(payload: unknown, options: sharecodeoptions): promise<string>\n```\n\nserializes `payload` as json and encodes it into a compact share code: `prefix` + mode marker + base64url bytes, compressed with raw deflate when the compressed form is shorter and the runtime supports the streams.\n\n| option | type | meaning |\n| | | |\n| `maxbytes` | `number` | positive safe integer; maximum serialized payload size |\n| `prefix` | `string` | 1–8 alphanumeric characters identifying the code family and version |\n\n**returns:** the shorter of the raw (`prefix` + `r`) and compressed (`prefix` + `d`) representations. throws `sharecodeerror` (`too large`, `invalid`) for oversized payloads and misconfigured options.\n\n**example:** encode a shareable build as a code small enough for a url.\n\n```ts\nimport { pack } from '@vielzeug/arsenal';\n\nconst code = await pack({ cards: ['slash', 'guard'] }, { maxbytes: 8192, prefix: 'ab1' });\n```\n\n \n\n### unpack\n\n```ts\nunpack(code: string, options: sharecodeoptions): promise<unknown>\n```\n\ndecodes a share code produced by `pack` with the same options. both sides are bounded: input longer than `maxbytes` could carry is refused up front, streamed decompression stops at `maxbytes`, and non canonical base64url is rejected by round trip.\n\n**returns:** the parsed json payload. throws `sharecodeerror` (`invalid` for foreign or malformed codes, `too large` for budget overruns, `compression unavailable` when a compressed code arrives on a runtime without `decompressionstream`). underlying compression failures surface as `arsenalserializationerror`.\n\n**example:** decode untrusted input within the byte budget.\n\n```ts\nimport { unpack } from '@vielzeug/arsenal';\n\nconst payload = await unpack(code, { maxbytes: 8192, prefix: 'ab1' });\n```\n\n## object\n\n### tryparsejson\n\n```ts\ntype jsonparseresult = { ok: true; value: unknown } | { error: syntaxerror; ok: false };\n\ntryparsejson(text: string): jsonparseresult\n```\n\nuse a schema validator after success to refine `unknown` data.\n\n```ts\nimport { tryparsejson } from '@vielzeug/arsenal/object';\n\nconst result = tryparsejson(raw);\nif (!result.ok) throw result.error;\n```\n\n### getpath\n\n```ts\ngetpath<t extends record<string, unknown>, p extends string>(item: t, path: p): pathvalue<t, p> | undefined\ngetpathor<t extends record<string, unknown>, p extends string, f>(item: t, path: p, fallback: f): pathvalue<t, p> | f\nrequirepath<t extends record<string, unknown>, p extends string>(item: t, path: p): exclude<pathvalue<t, p>, undefined>\n```\n\n### hash\n\n```ts\nhash(value: unknown, options?: hashoptions): string\n```\n\nproduces a deterministic, order independent serialization. handles `date`, `regexp`, `set`, `map`, and `bigint`. circular references produce a sentinel. useful for stable cache keys.\n\n \n\n## math\n\n### numeric toolkit\n\n```ts\nrange(stop: number): number[]\nrange(start: number, stop: number, step?: number): number[]\nclamp(value: number, min?: number, max?: number): number\nsum<t>(values: readonly t[], select?: (value: t) => number): number\naverage<t>(values: readonly t[], select?: (value: t) => number): number | undefined\nmedian<t>(values: readonly t[], select?: (value: t) => number): number | undefined\nvariance<t>(values: readonly t[], select?: (value: t) => number): number\nstandarddeviation<t>(values: readonly t[], select?: (value: t) => number): number\n```\n\n`sum` and `average` reject non finite values. `gcd` and `lcm` require safe integers. `linspace` requires finite bounds and a positive integer point count. `variance` and `standarddeviation` compute population statistics.\n\n### allocate\n\n```ts\nallocate(amount: number, ratios: number[] | number): number[]\nallocate(amount: bigint, ratios: number[] | number): bigint[]\n```\n\ndistributes an amount proportionally across ratios. the indivisible remainder is applied to the last bucket so the sum equals the original amount exactly: critical for financial operations.\n\n## types\n\n```ts\ntype fuzzyoptions = {\n normalize?: boolean;\n threshold?: number;\n};\n\ntype fuzzyselection<t> = fuzzyoptions & {\n select: (item: t) => string | readonly string[];\n};\n\ntype scoredresult<t> = { item: t; score: number };\n\ntype pageenvelope<t> = readonly<{\n items: readonly t[];\n page: number;\n pagesize: number;\n totalitems: number;\n totalpages: number;\n}>;\n\ntype paginateoptions = readonly<{\n page?: number;\n pagesize?: number;\n pageoffset?: 0 | 1;\n}>;\n\ntype sortdirection = 'asc' | 'desc';\n\ntype sortselectors<t> = partial<record<keyof t, sortdirection>>;\n\ntype sortcriterion<t> = readonly<{\n key: keyof t | (string & {});\n direction?: sortdirection;\n}>;\n\ntype cacheoptions = {\n capacity?: number;\n now?: () => number;\n ttlms?: number;\n};\n\ninterface compressionoptions {\n format?: compressionformat;\n}\n\ninterface decompressionoptions extends compressionoptions {\n maxoutputbytes: number;\n}\n```\n\n## errors\n\n `rangeerror`: invalid numeric bounds, capacity, concurrency, or retry count.\n `typeerror`: invalid value types, unsupported comparison, or required path missing.\n `arsenalserializationerror`: memo or hash cannot serialize supplied input; compression fails, is unavailable, receives an invalid output limit, or exceeds that limit. stream failures retain the original error as `cause`.\n `sharecodeerror`: a share code cannot be encoded or decoded; the `reason` field is `invalid`, `too large`, or `compression unavailable`.\n",
25
+ "usage": " \ntitle: 'arsenal: usage guide'\ndescription: use arsenal root utilities for common work and category entry points for specialized collection, async, cache, object, and string behavior.\n \n\n[[toc]]\n\n## basic usage\n\nstart at package root for common utilities. move to a category entry point for the broader typed toolkit. arsenal keeps helpers that improve readability, narrowing, immutable updates, or edge case consistency; exact platform aliases stay out.\n\n```ts\nimport { groupby, retry } from '@vielzeug/arsenal';\n\nconst users = [\n { id: 'a1', role: 'admin' },\n { id: 'u1', role: 'user' },\n { id: 'u2', role: 'user' },\n];\n\nconst byrole = groupby(users, (user) => user.role);\nconst health = await retry(() => fetch('/health').then((response) => response.json()));\n\nconsole.log(byrole, health);\n```\n\nuse category imports for apis absent from root:\n\n```ts\nimport { fuzzyfilter } from '@vielzeug/arsenal/array';\nimport { taskpool } from '@vielzeug/arsenal/async';\nimport { cache } from '@vielzeug/arsenal/cache';\nimport { tryparsejson } from '@vielzeug/arsenal/object';\n```\n\n## transform collections\n\nuse `/array` for transforms that preserve input immutability. `filtermap` combines mapping and omission; `indexby` and `groupby` build lookup structures without mutation.\n\n```ts\nimport { filtermap, indexby, sort } from '@vielzeug/arsenal/array';\n\nconst products = [\n { id: 'p1', price: 20, published: true },\n { id: 'p2', price: 10, published: false },\n { id: 'p3', price: 15, published: true },\n];\n\nconst publishedlabels = filtermap(products, (product) =>\n product.published ? `${product.id}: ${product.price}` : undefined,\n);\nconst byid = indexby(products, (product) => product.id);\nconst byprice = sort(products, (product) => product.price);\n\nconsole.log(publishedlabels, byid, byprice);\n```\n\n## page an in memory list\n\nuse `paginate` when the whole list is local and a view needs one page plus its totals.\n\n```ts\nimport { paginate } from '@vielzeug/arsenal/array';\n\nconst rows = array.from({ length: 45 }, (_, index) => ({ id: index + 1 }));\n\nconst first = paginate(rows, { pagesize: 10 });\nconst past = paginate(rows, { page: 9, pagesize: 10 });\n\nconsole.log(first.totalpages, past.items); // 5 []\n```\n\n`page` is echoed rather than clamped, so a stale page number reads as an empty page instead of\nsilently jumping elsewhere. pass `{ pageoffset: 0 }` for a zero based caller and the envelope comes\nback in that same base.\n\n## sort by grid criteria\n\n`sort` accepts the ordering a data grid already holds: an ordered list of column paths, each with a direction.\n\n```ts\nimport { sort } from '@vielzeug/arsenal/array';\n\nconst rows = [\n { address: { city: 'berlin' }, age: 41 },\n { address: { city: 'aachen' }, age: 30 },\n { address: { city: 'berlin' }, age: 27 },\n];\n\nconst ordered = sort(rows, [{ key: 'address.city' }, { key: 'age', direction: 'desc' }]);\n```\n\npaths are resolved safely, so nested and indexed columns work and prototype access is refused. the\nfirst criterion that differs decides the order and ties keep their input order. keep each column\ntyped: a column mixing numbers and strings throws a `typeerror` rather than guessing an order.\n\n## search explicit fields\n\nsearch string arrays directly. object collections require `select`, so callers define exactly what can match.\n\n```ts\nimport { fuzzyfilter, fuzzyscore } from '@vielzeug/arsenal/array';\n\nconst users = [\n { email: 'alice@example.com', name: 'alice' },\n { email: 'bob@example.com', name: 'bob' },\n];\n\nconst matches = fuzzyfilter(users, 'alice', { select: (user) => [user.name, user.email] });\nconst ranked = fuzzyscore(users, 'ali', { select: (user) => user.name });\n```\n\n## work with object data\n\nuse `/object` for paths, key selection, stable cache keys, and object transforms.\n\n```ts\nimport { getpathor, hash, omit, pick } from '@vielzeug/arsenal/object';\n\nconst config = { api: { host: 'localhost', port: 3000 }, debug: true };\nconst port = getpathor(config, 'api.port', 8080);\nconst publicconfig = pick(config, ['api']);\nconst productionconfig = omit(config, ['debug']);\nconst key = hash({ port, productionconfig });\n\nconsole.log(publicconfig, key);\n```\n\n## parse and validate json\n\n`tryparsejson` distinguishes syntax failure from schema failure. treat successful values as `unknown`, then validate with spell or application code.\n\n```ts\nimport { tryparsejson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst user = s.object({ id: s.string(), name: s.string() });\nconst parsed = tryparsejson(raw);\n\nif (!parsed.ok) throw parsed.error;\n\nconst user = user.parse(parsed.value);\n```\n\n## bound concurrent work\n\nuse `parallel` for one finite collection. use `taskpool` when tasks arrive over time or need disposal.\n\n```ts\nimport { parallel, taskpool } from '@vielzeug/arsenal/async';\n\nconst metadata = await parallel(urls, (url) => fetch(url).then((response) => response.json()), { limit: 4 });\n\nconst pool = taskpool({ concurrency: 2 });\nconst profile = await pool.run((signal) => fetch('/profile', { signal }).then((response) => response.json()));\n\nawait pool.idle();\npool.dispose();\n\nconsole.log(metadata, profile);\n```\n\n## cache loaded values\n\nuse `cache` for process local values. keys retain native `map` identity. `getorload` deduplicates concurrent loads for one key.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\ntype profile = { id: string; name: string };\n\nconst profiles = cache<string, profile>({ capacity: 100, ttlms: 60_000 });\nconst profile = await profiles.getorload('me', () => fetch('/profile').then((response) => response.json()));\n\nconsole.log(profiles.entries());\nprofiles.delete('me');\nconst freshprofile = await profiles.getorload('me', () => fetch('/profile').then((response) => response.json()));\n\nconsole.log(profile, freshprofile);\n```\n\n## test deterministic randomness\n\nrandom helpers use cryptographic entropy by default. pass `randomsource` in tests when output must be deterministic.\n\n```ts\nimport { random, randomfloat, type randomsource } from '@vielzeug/arsenal/random';\n\nconst source: randomsource = { next: () => 0.5 };\n\nrandomfloat(source); // 0.5\nrandom(1, 4, source); // 3\n```\n\n## encode text for urls and qr codes\n\nshare codes ride in urls and qr payloads, where `+`, `/`, and padding `=` break consumers. the base64url codecs are environment independent: no `btoa`, no `buffer`, so the same encode/decode pair works in the browser, in workers, and in node.\n\n```ts\nimport { base64urltotext, texttobase64url } from '@vielzeug/arsenal';\n\nconst code = texttobase64url(json.stringify({ seat: 4, version: 2 }));\n// eyjzzwf0ijo0lcj2zxjzaw9uijoyfq: safe in a query string or qr\n\nconst payload = json.parse(base64urltotext(code));\n```\n\ndecoding throws on characters outside the base64url alphabet, so corrupted input fails loudly instead of decoding to garbage.\n\n## compress share payloads\n\ncompress utf 8 bytes before base64url encoding. set a decoded byte limit before accepting compressed input from another device.\n\n```ts\nimport { base64urltobytes, bytestobase64url, compressbytes, decompressbytes } from '@vielzeug/arsenal';\n\nconst payload = json.stringify({ name: 'hunters', notes: 'an expedition through alborea. '.repeat(20) });\nconst compressed = await compressbytes(new textencoder().encode(payload));\nconst code = bytestobase64url(compressed);\nconst bytes = await decompressbytes(base64urltobytes(code), { maxoutputbytes: 8192 });\nconst restored = new textdecoder('utf 8', { fatal: true }).decode(bytes);\nconsole.log(restored === payload);\n```\n\nboth helpers default to `deflate raw`. you can explicitly select `gzip` or `deflate`; use the same format at both ends. the helpers reject when native streams do not support the selected format. they do not choose a fallback or define a wire protocol.\n\nfor a complete versioned envelope over these primitives — prefix, compression mode marker, byte budgets on both sides — see [`pack` / `unpack`](./api.md#pack).\n\ncompression can enlarge small inputs. compare the final encoded strings, including any format marker, before choosing a representation. bound input size before decoding base64url and validate decompressed json against your schema. the decompression limit bounds retained output, not native stream working memory.\n\n## working with other vielzeug libraries\n\nuse spell after `tryparsejson` for typed external data. use vault instead of `cache` when data must survive reloads or process restart.\n\n```ts\nimport { tryparsejson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst settings = s.object({ theme: s.string() });\nconst parsed = tryparsejson(rawsettings);\nconst settings = parsed.ok ? settings.parse(parsed.value) : { theme: 'system' };\n```\n\n## best practices\n\n import common utilities from package root.\n import the broader toolkit from category entry points.\n prefer arsenal when a helper improves typing, intent, immutable updates, or edge case behavior.\n use the platform directly for exact aliases such as `crypto.randomuuid()` and `array.prototype.flat()`.\n pass `select` for every fuzzy search over objects.\n validate parsed json before using it as application data.\n use `parallel` for finite batches and `taskpool` for ongoing work.\n dispose task pools when their owner ends.\n use `cache` only for in memory data.\n inject `randomsource` in deterministic tests.\n",
26
26
  "examples": " \ntitle: 'arsenal: examples'\ndescription: practical examples and recipes for arsenal.\n \n\n## examples\n\n [array utilities](./examples/array.md)\n [async utilities](./examples/async.md)\n [cache utilities](./examples/cache.md)\n [encoding utilities](./examples/encoding.md)\n [function utilities](./examples/function.md)\n [guards / typed predicates](./examples/typed.md)\n [math utilities](./examples/math.md)\n [object utilities](./examples/object.md): includes `getpath`, `tryparsejson`, `hash`, `diff`, `deepmerge`\n [random utilities](./examples/random.md)\n [string utilities](./examples/string.md)\n"
27
27
  },
28
28
  "examples": [
@@ -42,6 +42,10 @@
42
42
  "id": "array-map",
43
43
  "text": "filtermap transform array elements import { filtermap } from '@vielzeug/arsenal'\n\nconst numbers = [1, 2, 3, 4, 5]\n\nconst doubled = filtermap(numbers, number => number * 2)\nconsole.log('doubled:', doubled)\n\nconst strings = filtermap(numbers, number => `number: ${number}`)\nconsole.log('formatted:', strings)\n\nconst evendoubled = filtermap(numbers, number =>\n number % 2 === 0 ? number * 2 : undefined\n)\nconsole.log('even numbers doubled:', evendoubled)"
44
44
  },
45
+ {
46
+ "id": "array-paginate",
47
+ "text": "paginate page an array import { paginate } from '@vielzeug/arsenal'\n\nconst rows = array.from({ length: 45 }, (_, index) => ({ id: index + 1 }))\n\nconst first = paginate(rows, { pagesize: 10 })\nconsole.log('page:', first.page, 'of', first.totalpages)\nconsole.log('items:', first.items.map((row) => row.id))\n\n// a zero based caller (spring style) gets its own base back\nconst zerobased = paginate(rows, { page: 0, pagesize: 10, pageoffset: 0 })\nconsole.log('zero based page:', zerobased.page, zerobased.items.length)\n\n// pages past the last echo the request instead of clamping it\nconst past = paginate(rows, { page: 9, pagesize: 10 })\nconsole.log('past the end:', past.items, 'of', past.totalitems)"
48
+ },
45
49
  {
46
50
  "id": "array-search",
47
51
  "text": "fuzzyfilter explicit searchable fields import { fuzzyfilter, fuzzyscore } from '@vielzeug/arsenal/array'\n\nconst users = [\n { name: 'alice johnson', role: 'admin' },\n { name: 'bob smith', role: 'user' },\n { name: 'charlie brown', role: 'user' },\n]\n\nconst byname = fuzzyfilter(users, 'alice', { select: user => user.name })\nconsole.log('filtered:', byname)\n\nconst ranked = fuzzyscore(users, 'smith', { select: user => [user.name, user.role] })\nconsole.log('ranked:', ranked)"
@@ -50,6 +54,10 @@
50
54
  "id": "array-search-normalize",
51
55
  "text": "fuzzyfilter unicode normalization import { fuzzyfilter, fuzzyscore } from '@vielzeug/arsenal/array'\n\nconst names = ['josé', 'élise', 'café', 'naïve', 'resume']\n\nconst nonorm = fuzzyfilter(names, 'jose', { threshold: 0.9 })\nconsole.log('normalize:false:', nonorm)\n\nconst withnorm = fuzzyfilter(names, 'jose', { normalize: true, threshold: 0.9 })\nconsole.log('normalize:true:', withnorm)\n\nconst scored = fuzzyscore(names, 'elise', { normalize: true, threshold: 0.5 })\nconsole.log('scored:', scored)"
52
56
  },
57
+ {
58
+ "id": "array-sort-criteria",
59
+ "text": "sort selector, selectors, or grid criteria import { sort } from '@vielzeug/arsenal'\n\nconst rows = [\n { address: { city: 'berlin' }, age: 41, name: 'ada' },\n { address: { city: 'aachen' }, age: 30, name: 'grace' },\n { address: { city: 'berlin' }, age: 27, name: 'linus' },\n]\n\n// a grid hands over ordered column paths, each with a direction\nconst ordered = sort(rows, [{ key: 'address.city' }, { key: 'age', direction: 'desc' }])\nconsole.log('city asc, then age desc:', ordered.map((row) => `${row.name} (${row.address.city})`))\n\n// selector form for one extracted value\nconsole.log('by name:', sort(rows, (row) => row.name).map((row) => row.name))\n\n// selectors form for a keyed object\nconsole.log('by age desc:', sort(rows, { age: 'desc' }).map((row) => row.age))"
60
+ },
53
61
  {
54
62
  "id": "array-uniq",
55
63
  "text": "uniq remove duplicates import { uniq } from '@vielzeug/arsenal'\n\nconst numbers = [1, 2, 2, 3, 3, 3, 4, 5, 5]\nconsole.log('unique numbers:', uniq(numbers))\n\nconst tags = ['javascript', 'react', 'vue', 'react', 'angular', 'vue']\nconsole.log('unique tags:', uniq(tags))\n\n// works with objects too (by reference)\nconst obj1 = { id: 1 }\nconst obj2 = { id: 2 }\nconst objects = [obj1, obj2, obj1, obj2]\nconsole.log('unique objects:', uniq(objects))"
@@ -151,29 +159,29 @@
151
159
  "text": "guards, equality, and predicate combinators import { allof, anyof, isempty, isequal, ismatch, isplainobject, noneof, shallowequal } from '@vielzeug/arsenal/guards'\n\nconst values = ['hello', 42, [1, 2, 3], {}, null]\n\nvalues.foreach(value => {\n console.log({\n empty: isempty(value),\n plainobject: isplainobject(value),\n })\n})\n\n// deep structural equality (handles nested objects, arrays, dates, maps, sets)\nconsole.log('deep equal:', isequal({ a: [1, 2] }, { a: [1, 2] })) // true\n\n// shallow equality (one level, uses object.is so nan === nan)\nconsole.log('shallow equal:', shallowequal([1, 2, 3], [1, 2, 3])) // true\n\n// partial deep match: source properties must be present and equal\nconsole.log('is match:', ismatch({ a: 1, b: 2, c: 3 }, { a: 1 })) // true\n\n// predicate combinators with vacuous truth semantics\nconst isworkingage = allof<number>(\n (n) => n >= 18,\n (n) => n < 65,\n)\nconst isspecial = anyof<number>(\n (n) => n === 0,\n (n) => n === 100,\n)\nconst isodd = noneof((n) => n % 2 === 0)\n\nconsole.log('working age:', isworkingage(30)) // true\nconsole.log('special:', isspecial(100)) // true\nconsole.log('odd:', [1, 2, 3, 4].filter(isodd)) // [1, 3]"
152
160
  }
153
161
  ],
154
- "exports": "groupby retry debounce isequal taskpool cache fuzzyfilter tryparsejson allocate hash",
155
- "keywords": "utility array string object math async debounce throttle cache",
162
+ "exports": "paginate sort retry taskpool cache",
163
+ "keywords": "utility array pagination sorting string object math async cache",
156
164
  "name": "@vielzeug/arsenal",
157
165
  "related": "tempo sourcerer spell coins",
158
166
  "slug": "arsenal",
159
- "source": "export * from './array/chunk';\nexport * from './array/filtermap';\nexport * from './array/groupby';\nexport * from './array/indexby';\nexport * from './array/partition';\nexport * from './array/sort';\nexport * from './array/uniq';\nexport * from './async/abortable';\nexport * from './async/aborterror';\nexport * from './async/attempt';\nexport * from './async/backoff';\nexport * from './async/parallel';\nexport * from './async/retry';\nexport * from './async/sleep';\nexport * from './async/taskpool';\nexport * from './async/waitfor';\nexport * from './cache/cache';\nexport * from './cache/memo';\nexport * from './encoding/base64url';\nexport * from './encoding/compression';\nexport * from './encoding/share code';\nexport * from './errors';\nexport * from './function/debounce';\nexport * from './function/once';\nexport * from './function/pipe';\nexport * from './function/tapper';\nexport * from './function/throttle';\nexport * from './guards/combinators';\nexport * from './guards/isdefined';\nexport * from './guards/isequal';\nexport * from './guards/isnil';\nexport * from './guards/isplainobject';\nexport * from './math/allocate';\nexport * from './math/clamp';\nexport * from './math/range';\nexport * from './object/getpath';\nexport * from './object/hash';\nexport * from './object/omit';\nexport * from './object/pick';\nexport * from './random/random';\nexport * from './random/source';\nexport * from './string/camelcase';\nexport * from './types';\n"
167
+ "source": "export * from './array/chunk';\nexport * from './array/filtermap';\nexport * from './array/groupby';\nexport * from './array/indexby';\nexport * from './array/paginate';\nexport * from './array/partition';\nexport * from './array/sort';\nexport * from './array/uniq';\nexport * from './async/abortable';\nexport * from './async/aborterror';\nexport * from './async/attempt';\nexport * from './async/backoff';\nexport * from './async/parallel';\nexport * from './async/retry';\nexport * from './async/sleep';\nexport * from './async/taskpool';\nexport * from './async/waitfor';\nexport * from './cache/cache';\nexport * from './cache/memo';\nexport * from './encoding/base64url';\nexport * from './encoding/compression';\nexport * from './encoding/share code';\nexport * from './errors';\nexport * from './function/debounce';\nexport * from './function/once';\nexport * from './function/pipe';\nexport * from './function/tapper';\nexport * from './function/throttle';\nexport * from './guards/combinators';\nexport * from './guards/isdefined';\nexport * from './guards/isequal';\nexport * from './guards/isnil';\nexport * from './guards/isplainobject';\nexport * from './math/allocate';\nexport * from './math/clamp';\nexport * from './math/range';\nexport * from './object/getpath';\nexport * from './object/hash';\nexport * from './object/omit';\nexport * from './object/pick';\nexport * from './random/random';\nexport * from './random/source';\nexport * from './string/camelcase';\nexport * from './types';\n"
160
168
  },
161
169
  {
162
170
  "category": "testing",
163
171
  "description": "scoped dom queries, exact event dispatch, and cancellable async waiting for browser tests.",
164
172
  "docs": {
165
- "index": " \ntitle: 'assay: framework agnostic dom testing primitives'\ndescription: scoped dom queries, exact event dispatch, and cancellable async waiting for browser tests.\npackage: assay\ncategory: testing\nkeywords: [testing, dom, events, queries, custom elements]\nrelated: [ore, refine]\nexports:\n [\n within,\n queryinshadow,\n queryallinshadow,\n querypart,\n getslotted,\n dispatch,\n fireblur,\n firechange,\n fireclick,\n firecustom,\n firefocus,\n fireinput,\n firekeydown,\n firekeyup,\n firesubmit,\n waituntil,\n eventually,\n waitforevent,\n delay,\n assayerror,\n assayqueryerror,\n assaytimeouterror,\n ]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"assay\" />\n\n## why assay?\n\nassay provides focused dom test primitives that work with vanilla elements, custom elements, and framework rendered output. it scopes queries, dispatches exact browser event classes, and waits on explicit conditions without imposing a renderer or browser automation stack.\n\n```ts\n// before\nbutton.dispatchevent(new mouseevent('click', { bubbles: true }));\nawait new promise((resolve) => settimeout(resolve, 100));\n\n// after\nfireclick(view.get('button.submit'));\nawait waituntil(() => view.querybytext('saved') !== null);\n```\n\n| feature | assay | testing library dom | browser automation |\n| | | | |\n| bundle size | <packageinfo package=\"assay\" type=\"size\" /> | larger query layer | browser runtime required |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| scoped dom queries | `within()` and shadow helpers | renderer oriented queries | manual selectors |\n| deterministic waits | `waituntil()` and `waitforevent()` | framework dependent | full browser timing |\n\n<div class=\"decision callout\">\n\n**use assay when** a dom unit test needs readable queries, dispatched events, or a bounded async wait without adopting a rendering framework.\n\n**consider browser integration tests when** correctness depends on browser default actions, focus behavior, pointer capture, layout, or accessibility tree behavior.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add d @vielzeug/assay\n```\n\n```sh [npm]\nnpm install d @vielzeug/assay\n```\n\n```sh [yarn]\nyarn add d @vielzeug/assay\n```\n\n:::\n\n## quick start\n\nscope a test fixture, dispatch an event, and wait for resulting dom state.\n\n```ts\nimport { fireclick, waituntil, within } from '@vielzeug/assay';\n\nconst panel = document.createelement('section');\npanel.innerhtml = '<button>save</button><output></output>';\npanel.queryselector('button')!.addeventlistener('click', () => {\n panel.queryselector('output')!.textcontent = 'saved';\n});\n\nconst view = within(panel);\nfireclick(view.get('button'));\nawait waituntil(() => view.querybytext('saved') !== null);\n```\n\n## features\n\n<div class=\"features grid\">\n\n `within(root)` scopes nullable and required dom queries.\n `queryinshadow`, `querypart`, and `getslotted` cross custom element boundaries explicitly.\n `fireclick`, `fireinput`, `firekeydown`, and peers dispatch exact synchronous events.\n `waituntil`, `eventually`, and `waitforevent` provide bounded, abortable async waiting.\n `delay` models explicit cancellable timer scheduling.\n `assayerror`, `assayqueryerror`, and `assaytimeouterror` provide typed failures.\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [ore](/ore/): component authoring and test fixtures that pair with assay dom helpers.\n [refine](/refine/): accessible components with component specific test assertions.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
166
- "api": " \ntitle: 'assay: api reference'\ndescription: api reference for @vielzeug/assay queries, event dispatch, and async waiting.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `within` | creates scoped query api | sync | required `get*` methods throw `assayqueryerror` |\n| `queryinshadow` / `querypart` / `getslotted` | crosses custom element boundaries | sync | open shadow roots are required |\n\n| `fire*` / `dispatch` | dispatches platform event instances | sync | does not reproduce browser default behavior |\n| `waituntil` / `eventually` / `waitforevent` | waits for conditions, assertions, or events | async | use a signal or timeout for bounded waits |\n| `delay` | schedules a cancellable timer | async | pass a signal to cancel a pending wait |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/assay` | dom queries, events, wait helpers, errors, and types |\n\n## queries\n\n### `within(root)`\n\ncreates a `queryscope` for an `element`, `shadowroot`, `document`, or `documentfragment`.\n\n| method | returns | use |\n| | | |\n| `get(selector)` | `element` | required css match; throws `assayqueryerror` |\n| `query(selector)` | `element \\| null` | optional css match |\n| `queryall(selector)` | `element[]` | all css matches |\n| `getbytext(text, selector?)` | `element` | required exact trimmed text match |\n| `querybytext(text, selector?)` | `element \\| null` | optional exact trimmed text match |\n| `queryallbytext(text, selector?)` | `element[]` | all exact trimmed text matches |\n| `getbytestid(id)` | `element` | required `data testid` match |\n| `querybytestid(id)` | `element \\| null` | optional `data testid` match |\n| `queryallbytestid(id)` | `element[]` | all `data testid` matches |\n\ntext selectors default to `'*'`. required query failures include the lookup and a bounded view of the scoped dom.\n\n### shadow and slot helpers\n\n| function | returns | description |\n| | | |\n| `queryinshadow(host, selector)` | `element \\| null` | first match in an open shadow root |\n| `queryallinshadow(host, selector)` | `element[]` | all matches in an open shadow root |\n| `querypart(host, part)` | `element \\| null` | first shadow element whose `part` token matches |\n| `getslotted(host, slotname?)` | `element[]` | direct light dom children in a named or default slot |\n\nthese helpers return `null` or `[]` when there is no shadow root. dynamic test ids, parts, and slot names are matched\nas attribute values rather than interpolated into css selectors.\n\n## event dispatch\n\nall event helpers synchronously return `dispatchevent()`'s boolean result.\n\n```ts\nimport {\n dispatch,\n fireblur,\n firechange,\n fireclick,\n firecustom,\n firefocus,\n fireinput,\n firekeydown,\n firekeyup,\n firesubmit,\n} from '@vielzeug/assay';\n\nfireclick(button, { clientx: 20 });\nfireinput(input);\nfirekeydown(input, { key: 'enter' });\nfirecustom(element, 'item added', { detail: { id: '42' } });\ndispatch(element, new event('ready'));\n```\n\n| function | event class | defaults |\n| | | |\n| `fireblur` / `firefocus` | `focusevent` | platform defaults (`bubbles: false`) |\n| `firechange` | `event` | `bubbles: true` |\n| `fireinput` | `inputevent` | `bubbles: true` |\n| `fireclick` | `mouseevent` | `bubbles: true`, `cancelable: true` |\n| `firekeydown` / `firekeyup` | `keyboardevent` | `bubbles: true`, `cancelable: true` |\n| `firesubmit` | `submitevent` | `bubbles: true`, `cancelable: true` |\n| `firecustom` | `customevent` | `bubbles: true`, `cancelable: true`, `composed: false` |\n\n`firecustom(target, type, init?)` dispatches a `customevent` with the given type. assay intentionally does not\nprovide browser default or fallback pointer/touch simulation.\n\n## async waiting\n\n```ts\nawait waituntil(() => ready, { interval: 20, signal, timeout: 1000 });\nawait eventually(() => expect(spy).tohavebeencalled(), { signal, timeout: 1000 });\nawait waitforevent(target, 'ready', { signal, timeout: 1000 });\nawait delay(100, { signal });\n```\n\n| function | success condition | options |\n| | | |\n| `waituntil(predicate, options?)` | predicate returns `true` | `timeout`, `interval`, `signal` |\n| `eventually(assertion, options?)` | assertion stops throwing | `timeout`, `interval`, `signal`, `message` |\n| `waitforevent(target, type, options?)` | target emits `type` | `timeout`, `signal` |\n| `delay(ms?, options?)` | timer elapses | `signal` |\n\n\n`waituntil()`, `eventually()`, and `waitforevent()` reject with `assaytimeouterror` when their shared deadline expires. `waituntil()` and `eventually()` remain bounded while an asynchronous callback is pending. abort rejects with the signal reason and removes assay owned timers and listeners. callback work itself must cooperate with application cancellation.\n\n`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.\n\n## types\n\n```ts\nexport interface queryscope {\n get<e extends element = element>(selector: string): e;\n getbytestid<e extends element = element>(testid: string): e;\n getbytext<e extends element = element>(text: string, selector?: string): e;\n query<e extends element = element>(selector: string): e | null;\n queryall<e extends element = element>(selector: string): e[];\n queryallbytestid<e extends element = element>(testid: string): e[];\n queryallbytext<e extends element = element>(text: string, selector?: string): e[];\n querybytestid<e extends element = element>(testid: string): e | null;\n querybytext<e extends element = element>(text: string, selector?: string): e | null;\n}\n```\n\nscoped query helpers returned by `within(root)`.\n\n```ts\nexport interface waitoptions {\n /** polling interval in ms (default: 50). */\n interval?: number;\n /** cancel the pending wait. */\n signal?: abortsignal;\n /** maximum wait time in ms (default: 1000). */\n timeout?: number;\n}\n\nexport interface eventuallyoptions extends waitoptions {\n /** context included in the timeout error. */\n message?: string;\n}\n\nexport interface delayoptions {\n /** cancel the pending delay. */\n signal?: abortsignal;\n}\n```\n\n`waitoptions` configures `waituntil()`. `eventuallyoptions` configures `eventually()`. `delayoptions` configures `delay()`.\n\n## errors\n\n| error | meaning |\n| | |\n| `assayerror` | base class for assay originated errors |\n| `assayqueryerror` | a required `get*` query had no match |\n| `assaytimeouterror` | a wait operation reached its timeout |\n\nuse `instanceof assayerror` to narrow any value to the assay error hierarchy.\n",
167
- "usage": " \ntitle: 'assay: usage guide'\ndescription: scoped dom queries, exact event dispatch, and cancellable waiting with @vielzeug/assay.\n \n\n[[toc]]\n\n## basic usage\n\n`within(root)` is assay's query api. it accepts an element, document fragment, or shadow root and keeps every lookup\nin that scope. use `get*` when a match is required and nullable `query*` methods when absence is part of the assertion.\n\n```ts\nimport { within } from '@vielzeug/assay';\n\nconst view = within(panel.shadowroot!);\n\nconst save = view.get<htmlbuttonelement>('button.save');\nconst status = view.querybytext('saved');\n\nexpect(view.query('.error')).tobenull();\nexpect(view.getbytestid('summary').textcontent).tocontain('complete');\n```\n\n`get()`, `getbytext()`, and `getbytestid()` throw `assayqueryerror` with the lookup and a bounded rendering of the\nscoped dom. this keeps a failed required lookup diagnosable without non null assertions.\n\nuse the cross boundary helpers for custom elements:\n\n```ts\nimport { getslotted, queryallinshadow, querypart } from '@vielzeug/assay';\n\nconst trigger = querypart(menu, 'trigger');\nconst options = queryallinshadow(menu, '[role=\"option\"]');\nconst footeractions = getslotted(dialog, 'footer');\n```\n\n## exact event dispatch\n\nassay dispatches the platform event class named by each helper. it does not model browser activation, focus\nmanagement, or form defaults. `firefocus()` and `fireblur()` use their non bubbling platform defaults; use\n`focusin`/`focusout` events when testing delegated focus listeners. use `element.click()` when native activation is\nthe behavior under test; use assay when testing an event listener or a controlled state transition.\n\n```ts\nimport { fireclick, firecustom, fireinput, firekeydown } from '@vielzeug/assay';\n\ninput.value = 'ada';\nfireinput(input);\n\nfirekeydown(input, { key: 'enter' });\nfireclick(savebutton);\nfirecustom(panel, 'value change', { detail: { value: 42 } });\n```\n\nevery helper returns `dispatchevent()`'s boolean result. `dispatch(target, event)` is available when an existing\nevent instance is the clearest expression of the test.\n\n## waiting\n\nchoose the waiting primitive by the test's assertion shape:\n\n```ts\nimport { delay, eventually, waitforevent, waituntil } from '@vielzeug/assay';\n\nawait waituntil(() => panel.queryselector('.status')?.textcontent === 'ready');\n\nawait eventually(() => {\n expect(onsave).tohavebeencalledonce();\n}, { message: 'save callback did not run' });\n\nconst completed = waitforevent<customevent<{ id: string }>>(panel, 'save complete', {\n signal: abortsignal.timeout(1000),\n});\nfireclick(savebutton);\nexpect((await completed).detail.id).tobedefined();\n\nawait delay(100); // real timer dependency, such as a debounce\n```\n\n`waituntil()` polls a boolean predicate. `eventually()` retries a throwing assertion and can include a diagnostic `message`. both enforce one hard deadline while asynchronous callbacks are pending. abort rejects immediately with the signal reason, but cannot stop callback owned work; forward an application signal into that work when needed.\n\ntimeouts reject with `assaytimeouterror`. timeout and delay values must be finite and non negative; polling intervals must be finite and positive. all durations must fit the platform timer limit of 2,147,483,647 ms.\n\n## testing custom elements\n\nuse ore for component mounting and assay for generic dom concerns:\n\n```ts\nimport { fireclick } from '@vielzeug/assay';\nimport { html } from '@vielzeug/ore';\nimport { mount } from '@vielzeug/ore/testing';\n\nconst fixture = await mount(\n () => html`\n <button @click=${onsave}>save</button>\n `,\n);\n\nfireclick(fixture.get('button'));\nawait fixture.flush();\n\nexpect(onsave).tohavebeencalledonce();\n```\n\nrefine's testing entry point contains only refine specific assertions and typed mount wrappers. import assay helpers\ndirectly instead of routing generic dom operations through another package.\n\n## best practices\n\n scope multiple assertions with `within()` rather than repeatedly querying the document.\n prefer `get*` for required controls and `query*` for intentional absence checks.\n make form state and event boundaries explicit: assign `.value`, then call `fireinput()` or `firechange()`.\n use browser integration tests for focus, disabled activation, pointer capture, and other browser default behavior.\n use `waitforevent()` for an emitted event, `waituntil()` for a condition, and `eventually()` for assertions.\n add a diagnostic `message` to waits whose final assertion does not identify the tested workflow.\n",
173
+ "index": " \ntitle: 'assay: framework agnostic dom testing primitives'\ndescription: scoped dom queries, exact event dispatch, and cancellable async waiting for browser tests.\npackage: assay\ncategory: testing\nkeywords: [testing, dom, events, queries, custom elements]\nrelated: [ore, refine]\nexports:\n [\n within,\n queryinshadow,\n queryallinshadow,\n querypart,\n getslotted,\n dispatch,\n fireblur,\n firechange,\n fireclick,\n firecustom,\n firefocus,\n fireinput,\n firekeydown,\n firekeyup,\n firesubmit,\n waituntil,\n eventually,\n waitforevent,\n delay,\n walkflattree,\n installforminternalspolyfill,\n assayerror,\n assayqueryerror,\n assaytimeouterror,\n ]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"assay\" />\n\n## why assay?\n\nassay provides focused dom test primitives that work with vanilla elements, custom elements, and framework rendered output. it scopes queries, dispatches exact browser event classes, and waits on explicit conditions without imposing a renderer or browser automation stack.\n\n```ts\n// before\nbutton.dispatchevent(new mouseevent('click', { bubbles: true }));\nawait new promise((resolve) => settimeout(resolve, 100));\n\n// after\nfireclick(view.get('button.submit'));\nawait waituntil(() => view.querybytext('saved') !== null);\n```\n\n| feature | assay | testing library dom | browser automation |\n| | | | |\n| bundle size | <packageinfo package=\"assay\" type=\"size\" /> | larger query layer | browser runtime required |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| scoped dom queries | `within()` and shadow helpers | renderer oriented queries | manual selectors |\n| deterministic waits | `waituntil()` and `waitforevent()` | framework dependent | full browser timing |\n\n<div class=\"decision callout\">\n\n**use assay when** a dom unit test needs readable queries, dispatched events, or a bounded async wait without adopting a rendering framework.\n\n**consider browser integration tests when** correctness depends on browser default actions, focus behavior, pointer capture, layout, or accessibility tree behavior.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add d @vielzeug/assay\n```\n\n```sh [npm]\nnpm install d @vielzeug/assay\n```\n\n```sh [yarn]\nyarn add d @vielzeug/assay\n```\n\n:::\n\n## quick start\n\nscope a test fixture, dispatch an event, and wait for resulting dom state.\n\n```ts\nimport { fireclick, waituntil, within } from '@vielzeug/assay';\n\nconst panel = document.createelement('section');\npanel.innerhtml = '<button>save</button><output></output>';\npanel.queryselector('button')!.addeventlistener('click', () => {\n panel.queryselector('output')!.textcontent = 'saved';\n});\n\nconst view = within(panel);\nfireclick(view.get('button'));\nawait waituntil(() => view.querybytext('saved') !== null);\n```\n\n## features\n\n<div class=\"features grid\">\n\n `within(root)` scopes nullable and required dom queries.\n `queryinshadow`, `querypart`, and `getslotted` cross custom element boundaries explicitly.\n `fireclick`, `fireinput`, `firekeydown`, and peers dispatch exact synchronous events.\n `waituntil`, `eventually`, and `waitforevent` provide bounded, abortable async waiting.\n `delay` models explicit cancellable timer scheduling.\n `walkflattree` traverses the flat tree across slot boundaries jsdom's light dom queries miss.\n `installforminternalspolyfill` gives jsdom the `elementinternals` form association api.\n `assayerror`, `assayqueryerror`, and `assaytimeouterror` provide typed failures.\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [ore](/ore/): component authoring and test fixtures that pair with assay dom helpers.\n [refine](/refine/): accessible components with component specific test assertions.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
174
+ "api": " \ntitle: 'assay: api reference'\ndescription: api reference for @vielzeug/assay queries, event dispatch, async waiting, and test environment helpers.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `within` | creates scoped query api | sync | required `get*` methods throw `assayqueryerror` |\n| `queryinshadow` / `querypart` / `getslotted` | crosses custom element boundaries | sync | open shadow roots are required |\n\n| `fire*` / `dispatch` | dispatches platform event instances | sync | does not reproduce browser default behavior |\n| `waituntil` / `eventually` / `waitforevent` | waits for conditions, assertions, or events | async | use a signal or timeout for bounded waits |\n| `delay` | schedules a cancellable timer | async | pass a signal to cancel a pending wait |\n| `walkflattree` | walks the flat tree across slot boundaries | sync | jsdom only need: browsers already use the flat tree |\n| `installforminternalspolyfill` | polyfills `elementinternals` form association for jsdom | sync | global patches: only install in suites that need them |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/assay` | dom queries, events, wait helpers, test environment helpers, errors, and types |\n\n## queries\n\n### `within(root)`\n\ncreates a `queryscope` for an `element`, `shadowroot`, `document`, or `documentfragment`.\n\n| method | returns | use |\n| | | |\n| `get(selector)` | `element` | required css match; throws `assayqueryerror` |\n| `query(selector)` | `element \\| null` | optional css match |\n| `queryall(selector)` | `element[]` | all css matches |\n| `getbytext(text, selector?)` | `element` | required exact trimmed text match |\n| `querybytext(text, selector?)` | `element \\| null` | optional exact trimmed text match |\n| `queryallbytext(text, selector?)` | `element[]` | all exact trimmed text matches |\n| `getbytestid(id)` | `element` | required `data testid` match |\n| `querybytestid(id)` | `element \\| null` | optional `data testid` match |\n| `queryallbytestid(id)` | `element[]` | all `data testid` matches |\n\ntext selectors default to `'*'`. required query failures include the lookup and a bounded view of the scoped dom.\n\n### shadow and slot helpers\n\n| function | returns | description |\n| | | |\n| `queryinshadow(host, selector)` | `element \\| null` | first match in an open shadow root |\n| `queryallinshadow(host, selector)` | `element[]` | all matches in an open shadow root |\n| `querypart(host, part)` | `element \\| null` | first shadow element whose `part` token matches |\n| `getslotted(host, slotname?)` | `element[]` | direct light dom children in a named or default slot |\n\nthese helpers return `null` or `[]` when there is no shadow root. dynamic test ids, parts, and slot names are matched\nas attribute values rather than interpolated into css selectors.\n\n## event dispatch\n\nall event helpers synchronously return `dispatchevent()`'s boolean result.\n\n```ts\nimport {\n dispatch,\n fireblur,\n firechange,\n fireclick,\n firecustom,\n firefocus,\n fireinput,\n firekeydown,\n firekeyup,\n firesubmit,\n} from '@vielzeug/assay';\n\nfireclick(button, { clientx: 20 });\nfireinput(input);\nfirekeydown(input, { key: 'enter' });\nfirecustom(element, 'item added', { detail: { id: '42' } });\ndispatch(element, new event('ready'));\n```\n\n| function | event class | defaults |\n| | | |\n| `fireblur` / `firefocus` | `focusevent` | platform defaults (`bubbles: false`) |\n| `firechange` | `event` | `bubbles: true` |\n| `fireinput` | `inputevent` | `bubbles: true` |\n| `fireclick` | `mouseevent` | `bubbles: true`, `cancelable: true` |\n| `firekeydown` / `firekeyup` | `keyboardevent` | `bubbles: true`, `cancelable: true` |\n| `firesubmit` | `submitevent` | `bubbles: true`, `cancelable: true` |\n| `firecustom` | `customevent` | `bubbles: true`, `cancelable: true`, `composed: false` |\n\n`firecustom(target, type, init?)` dispatches a `customevent` with the given type. assay intentionally does not\nprovide browser default or fallback pointer/touch simulation.\n\n## async waiting\n\n```ts\nawait waituntil(() => ready, { interval: 20, signal, timeout: 1000 });\nawait eventually(() => expect(spy).tohavebeencalled(), { signal, timeout: 1000 });\nawait waitforevent(target, 'ready', { signal, timeout: 1000 });\nawait delay(100, { signal });\n```\n\n| function | success condition | options |\n| | | |\n| `waituntil(predicate, options?)` | predicate returns `true` | `timeout`, `interval`, `signal` |\n| `eventually(assertion, options?)` | assertion stops throwing | `timeout`, `interval`, `signal`, `message` |\n| `waitforevent(target, type, options?)` | target emits `type` | `timeout`, `signal` |\n| `delay(ms?, options?)` | timer elapses | `signal` |\n\n\n`waituntil()`, `eventually()`, and `waitforevent()` reject with `assaytimeouterror` when their shared deadline expires. `waituntil()` and `eventually()` remain bounded while an asynchronous callback is pending. abort rejects with the signal reason and removes assay owned timers and listeners. callback work itself must cooperate with application cancellation.\n\n`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.\n\n## test environment helpers\n\n### `walkflattree(root, visit)`\n\n```ts\nfunction walkflattree(root: element, visit: (element: htmlelement) => void): void;\n```\n\nwalks the flat tree below `root`, calling `visit` for each assigned or ordinary descendant (not for `root` itself).\nat each `<slot>` it expands `assignedelements()` instead of descending into its (always empty, in jsdom) literal\nchildren. jsdom only\nimplements the light dom tree, so `queryselectorall()` never crosses a slot into its assigned elements; real\nbrowsers use the flat tree for that. this helper closes the gap for tests that must find form associated (or\notherwise slotted) elements through shadow boundaries.\n\n```ts\nconst named: htmlelement[] = [];\n\nwalkflattree(form, (el) => {\n if (el.hasattribute('name')) named.push(el);\n});\n```\n\n### `installforminternalspolyfill()`\n\n```ts\nfunction installforminternalspolyfill(): () => void;\n```\n\njsdom implements none of the `elementinternals` form association api: not `setformvalue`, not `setvalidity`, not\nthe `checkvalidity`/`reportvalidity`/`validity`/`validationmessage` mixin real browsers put on every\n`formassociated: true` element, not even `formdata` collecting a form associated element's set value, or\n`<form>.reset()` invoking `formresetcallback()`. every one of those gaps is exercised by `usefield()` from\n`@vielzeug/ore`, so any package testing a form associated component needs all of them.\n\ncall it from a test setup when the suite tests form associated components; it returns an `uninstall()` that\nrestores every patched global. the patches are global monkey patches and skip apis already implemented by the\nenvironment, so suites without form associated components shouldn't install them. if `elementinternals` is absent,\nthe installer is a no op. a downstream package should\nrely on this instead of hand rolling its own copy.\n\n```ts\n// vitest.setup.ts\nimport { installforminternalspolyfill } from '@vielzeug/assay';\nimport { install } from '@vielzeug/ore/testing';\nimport { afterall, aftereach } from 'vitest';\n\ninstall(aftereach);\nafterall(installforminternalspolyfill());\n```\n\n## types\n\n```ts\nexport interface queryscope {\n get<e extends element = element>(selector: string): e;\n getbytestid<e extends element = element>(testid: string): e;\n getbytext<e extends element = element>(text: string, selector?: string): e;\n query<e extends element = element>(selector: string): e | null;\n queryall<e extends element = element>(selector: string): e[];\n queryallbytestid<e extends element = element>(testid: string): e[];\n queryallbytext<e extends element = element>(text: string, selector?: string): e[];\n querybytestid<e extends element = element>(testid: string): e | null;\n querybytext<e extends element = element>(text: string, selector?: string): e | null;\n}\n```\n\nscoped query helpers returned by `within(root)`.\n\n```ts\nexport interface waitoptions {\n /** polling interval in ms (default: 50). */\n interval?: number;\n /** cancel the pending wait. */\n signal?: abortsignal;\n /** maximum wait time in ms (default: 1000). */\n timeout?: number;\n}\n\nexport interface eventuallyoptions extends waitoptions {\n /** context included in the timeout error. */\n message?: string;\n}\n\nexport interface delayoptions {\n /** cancel the pending delay. */\n signal?: abortsignal;\n}\n```\n\n`waitoptions` configures `waituntil()`. `eventuallyoptions` configures `eventually()`. `delayoptions` configures `delay()`.\n\n## errors\n\n| error | meaning |\n| | |\n| `assayerror` | base class for assay originated errors |\n| `assayqueryerror` | a required `get*` query had no match |\n| `assaytimeouterror` | a wait operation reached its timeout |\n\nuse `instanceof assayerror` to narrow any value to the assay error hierarchy.\n",
175
+ "usage": " \ntitle: 'assay: usage guide'\ndescription: scoped dom queries, exact event dispatch, and cancellable waiting with @vielzeug/assay.\n \n\n[[toc]]\n\n## basic usage\n\n`within(root)` is assay's query api. it accepts an element, document fragment, or shadow root and keeps every lookup\nin that scope. use `get*` when a match is required and nullable `query*` methods when absence is part of the assertion.\n\n```ts\nimport { within } from '@vielzeug/assay';\n\nconst view = within(panel.shadowroot!);\n\nconst save = view.get<htmlbuttonelement>('button.save');\nconst status = view.querybytext('saved');\n\nexpect(view.query('.error')).tobenull();\nexpect(view.getbytestid('summary').textcontent).tocontain('complete');\n```\n\n`get()`, `getbytext()`, and `getbytestid()` throw `assayqueryerror` with the lookup and a bounded rendering of the\nscoped dom. this keeps a failed required lookup diagnosable without non null assertions.\n\nuse the cross boundary helpers for custom elements:\n\n```ts\nimport { getslotted, queryallinshadow, querypart } from '@vielzeug/assay';\n\nconst trigger = querypart(menu, 'trigger');\nconst options = queryallinshadow(menu, '[role=\"option\"]');\nconst footeractions = getslotted(dialog, 'footer');\n```\n\n## exact event dispatch\n\nassay dispatches the platform event class named by each helper. it does not model browser activation, focus\nmanagement, or form defaults. `firefocus()` and `fireblur()` use their non bubbling platform defaults; use\n`focusin`/`focusout` events when testing delegated focus listeners. use `element.click()` when native activation is\nthe behavior under test; use assay when testing an event listener or a controlled state transition.\n\n```ts\nimport { fireclick, firecustom, fireinput, firekeydown } from '@vielzeug/assay';\n\ninput.value = 'ada';\nfireinput(input);\n\nfirekeydown(input, { key: 'enter' });\nfireclick(savebutton);\nfirecustom(panel, 'value change', { detail: { value: 42 } });\n```\n\nevery helper returns `dispatchevent()`'s boolean result. `dispatch(target, event)` is available when an existing\nevent instance is the clearest expression of the test.\n\n## waiting\n\nchoose the waiting primitive by the test's assertion shape:\n\n```ts\nimport { delay, eventually, waitforevent, waituntil } from '@vielzeug/assay';\n\nawait waituntil(() => panel.queryselector('.status')?.textcontent === 'ready');\n\nawait eventually(() => {\n expect(onsave).tohavebeencalledonce();\n}, { message: 'save callback did not run' });\n\nconst completed = waitforevent<customevent<{ id: string }>>(panel, 'save complete', {\n signal: abortsignal.timeout(1000),\n});\nfireclick(savebutton);\nexpect((await completed).detail.id).tobedefined();\n\nawait delay(100); // real timer dependency, such as a debounce\n```\n\n`waituntil()` polls a boolean predicate. `eventually()` retries a throwing assertion and can include a diagnostic `message`. both enforce one hard deadline while asynchronous callbacks are pending. abort rejects immediately with the signal reason, but cannot stop callback owned work; forward an application signal into that work when needed.\n\ntimeouts reject with `assaytimeouterror`. timeout and delay values must be finite and non negative; polling intervals must be finite and positive. all durations must fit the platform timer limit of 2,147,483,647 ms.\n\n## testing custom elements\n\nuse ore for component mounting and assay for generic dom concerns:\n\n```ts\nimport { fireclick } from '@vielzeug/assay';\nimport { html } from '@vielzeug/ore';\nimport { mount } from '@vielzeug/ore/testing';\n\nconst fixture = await mount(\n () => html`\n <button @click=${onsave}>save</button>\n `,\n);\n\nfireclick(fixture.get('button'));\nawait fixture.flush();\n\nexpect(onsave).tohavebeencalledonce();\n```\n\nrefine's testing entry point contains only refine specific assertions and typed mount wrappers. import assay helpers\ndirectly instead of routing generic dom operations through another package.\n\nwhen a suite tests a form associated custom element (one built on `usefield()` from `@vielzeug/ore`), install the\nform internals polyfill in the setup file so jsdom gains the `elementinternals` api it lacks:\n\n```ts\n// vitest.setup.ts\nimport { installforminternalspolyfill } from '@vielzeug/assay';\nimport { install } from '@vielzeug/ore/testing';\nimport { afterall, aftereach } from 'vitest';\n\ninstall(aftereach);\nafterall(installforminternalspolyfill());\n```\n\n## traverse slotted content in jsdom\n\nuse `walkflattree()` when a test must visit descendants assigned through slots. it expands each slot into its assigned\nelements, including nested slots, instead of relying on jsdom's light dom only `queryselectorall()`.\n\n```ts\nimport { walkflattree } from '@vielzeug/assay';\n\nconst named: htmlelement[] = [];\nwalkflattree(form, (element) => {\n if (element.hasattribute('name')) named.push(element);\n});\n```\n\nthe callback receives descendants in the flat tree, not the root element or slot elements themselves. browsers already\nuse the flat tree for dom algorithms; this helper is for test code that needs equivalent traversal in jsdom.\n\n## best practices\n\n scope multiple assertions with `within()` rather than repeatedly querying the document.\n prefer `get*` for required controls and `query*` for intentional absence checks.\n make form state and event boundaries explicit: assign `.value`, then call `fireinput()` or `firechange()`.\n use browser integration tests for focus, disabled activation, pointer capture, and other browser default behavior.\n use `waitforevent()` for an emitted event, `waituntil()` for a condition, and `eventually()` for assertions.\n add a diagnostic `message` to waits whose final assertion does not identify the tested workflow.\n",
168
176
  "examples": " \ntitle: 'assay: examples'\ndescription: practical examples and recipes for assay.\n \n\n## examples\n\n [custom element interaction](./examples/custom element interaction.md)\n [waiting for async updates](./examples/waiting for async updates.md)\n"
169
177
  },
170
178
  "examples": [],
171
- "exports": "within queryinshadow queryallinshadow querypart getslotted dispatch fireblur firechange fireclick firecustom firefocus fireinput firekeydown firekeyup firesubmit waituntil eventually waitforevent delay assayerror assayqueryerror assaytimeouterror",
179
+ "exports": "within queryinshadow queryallinshadow querypart getslotted dispatch fireblur firechange fireclick firecustom firefocus fireinput firekeydown firekeyup firesubmit waituntil eventually waitforevent delay walkflattree installforminternalspolyfill assayerror assayqueryerror assaytimeouterror",
172
180
  "keywords": "testing dom events queries custom elements",
173
181
  "name": "@vielzeug/assay",
174
182
  "related": "ore refine",
175
183
  "slug": "assay",
176
- "source": "export { assayerror, assayqueryerror, assaytimeouterror } from './errors';\n\nexport {\n dispatch,\n fireblur,\n firechange,\n fireclick,\n firecustom,\n firefocus,\n fireinput,\n firekeydown,\n firekeyup,\n firesubmit,\n} from './events';\nexport { getslotted, type queryscope, queryallinshadow, queryinshadow, querypart, within } from './query';\nexport {\n type delayoptions,\n delay,\n type eventuallyoptions,\n eventually,\n type waitoptions,\n waitforevent,\n waituntil,\n} from './wait';\n"
184
+ "source": "export { walkflattree } from './dom';\nexport { assayerror, assayqueryerror, assaytimeouterror } from './errors';\nexport {\n dispatch,\n fireblur,\n firechange,\n fireclick,\n firecustom,\n firefocus,\n fireinput,\n firekeydown,\n firekeyup,\n firesubmit,\n} from './events';\nexport { installforminternalspolyfill } from './form internals';\nexport { getslotted, type queryscope, queryallinshadow, queryinshadow, querypart, within } from './query';\nexport {\n type delayoptions,\n delay,\n type eventuallyoptions,\n eventually,\n type waitoptions,\n waitforevent,\n waituntil,\n} from './wait';\n"
177
185
  },
178
186
  {
179
187
  "category": "state",
@@ -334,9 +342,9 @@
334
342
  "category": "http",
335
343
  "description": "a framework neutral http client with explicit cached reads, prefetching, immutable middleware, typed paths, and structured errors.",
336
344
  "docs": {
337
- "index": " \ntitle: 'courier: http client'\ndescription: a framework neutral http client with explicit cached reads, prefetching, immutable middleware, typed paths, and structured errors.\npackage: courier\ncategory: http\nkeywords: [http client, fetch, cache, prefetch, middleware, interceptors, errors]\nrelated: [flux, spell, postmaster]\nexports:\n [\n createcourier,\n couriererror,\n courierhttperror,\n couriernetworkerror,\n couriertimeouterror,\n courieraborterror,\n courierschemavalidationerror,\n withbearerauth,\n withrequestid,\n withlogging,\n ]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"courier\" />\n\n## why courier?\n\nnative `fetch` leaves request policy, error classification, middleware composition, and repeated read coordination to each application. courier keeps those concerns in one client, including explicit cached gets and warm cache prefetching, while observable query and mutation state remain consumer owned.\n\n```ts\n// before\nconst response = await fetch(`/api/users/${userid}`);\nif (!response.ok) throw new error(`http ${response.status}`);\nconst user = await response.json();\n\n// after\nconst user = await courier.get<user>('/users/{id}', { params: { id: userid } });\n```\n\n| feature | courier | ky | ofetch |\n| | | | |\n| bundle size | <packageinfo package=\"courier\" type=\"size\" /> | separate package | separate package |\n| zero runtime dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| native fetch transport | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| explicit parsed value cache and prefetch | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| immutable middleware at construction | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| structured error taxonomy | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use courier when** one client should own typed http, explicit cached gets and prefetching, immutable middleware, and structured errors, while a separate state layer owns observable queries and mutations.\n\n**consider ky when** you only need a compact fetch wrapper. **consider ofetch when** you want a bundled fetch utility with its own retry and caching conventions.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/courier\n```\n\n```sh [npm]\nnpm install @vielzeug/courier\n```\n\n```sh [yarn]\nyarn add @vielzeug/courier\n```\n\n:::\n\n## quick start\n\ncreate one transport client for an application or request scope, then use method conveniences for standard http methods and `request()` for custom methods.\n\n```ts\nimport { courierhttperror, createcourier } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\n\ntry {\n const user = await courier.get<user>('/users/{id}', { params: { id: 42 } });\n console.log(user.name);\n} catch (error) {\n if (courierhttperror.is(error, 404)) console.log('user not found');\n else throw error;\n} finally {\n courier.dispose();\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`createcourier()`**: one lifecycle, immutable middleware pipeline, header defaults, and cancellation boundary.\n **`request()`**: one contract for every http method; pass `method` in the config.\n **cached `get()`**: opt in with a structured key and optional per read ttl; successful parsed values share bounded storage and in flight work.\n **`prefetch()`**: await cache warming while request failures remain observable through `tap()`.\n **`invalidatecache()` / `clearcache()`**: invalidate structured key prefixes without query state.\n **`get()` / `post()` / `put()` / `patch()` / `delete()`**: conveniences for standard methods.\n **`withbearerauth()` / `withrequestid()` / `withlogging()`**: composable middleware configured at construction.\n **`tap()`**: signal owned structured transport observation.\n **error taxonomy**: http, network, timeout, abort, parse, and schema failures are distinct, actionable classes.\n **`cancelall()` / `dispose()`**: abort active requests and tear down the transport.\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [flux](/flux/): composes async streams; pair with courier transport calls in the owning state layer.\n [spell](/spell/): validates parsed http payloads through courier's `schema` option.\n [postmaster](/postmaster/): coordinates durable delivery of courier requests through a job outbox.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
338
- "api": " \ntitle: 'courier: api reference'\ndescription: reference for courier http, cached read, prefetch, middleware, and error apis.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createcourier()` | creates transport client | sync | dispose only when whole scope ends |\n| `request()` | sends one http request of any method | async | direct calls never deduplicate |\n| `get()` | sends a get, optionally through the explicit cache | async | one key must identify one representation |\n| `prefetch()` | warms a cached get | async | request failures resolve after surfacing through `tap()` |\n| `invalidatecache()` / `clearcache()` | removes cached values | sync | invalidation matches structured key prefixes |\n| `post()` / `put()` / `patch()` / `delete()` | common write method conveniences | async | use `request()` for custom methods |\n| `withbearerauth()` | adds authorization middleware | sync | token provider runs per request |\n| `withrequestid()` | adds request identifier middleware | sync | default generator uses `crypto.randomuuid()` |\n| `withlogging()` | logs request result metadata | sync | requires explicit logger; urls may contain sensitive query values |\n| `tap()` | observe structured transport events | sync | observation only; optional signal owns lifetime |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/courier` | client factory, errors, middleware helpers, and public types |\n\n## client\n\n### `createcourier()`\n\n```ts\ncreatecourier(options?: courieroptions): courier;\n```\n\nreturns an http client sharing base url, default headers, immutable middleware, optional parsed value cache, timeout, and cancellation lifecycle.\n\n| `courieroptions` field | type | default | description |\n| | | | |\n| `baseurl` | `string` | `''` | prefix for relative request paths |\n| `cache.capacity` | `number` | `100` | maximum cached entries; positive integer or `infinity` |\n| `cache.ttlms` | `number` | `30_000` | default cache freshness; non negative milliseconds or `infinity` |\n| `fetch` | `typeof globalthis.fetch` | `globalthis.fetch` | fetch implementation |\n| `headers` | `headersinit` | `{}` | default request headers (immutable; merged lowercase) |\n| `middleware` | `readonly middleware[]` | `[]` | immutable middleware chain configured at construction |\n| `timeout` | `number` | `30_000` | integer milliseconds from 1 to 2,147,483,647, or `infinity` |\n\n**returns:** `courier`.\n\n```ts\nimport { createcourier, withbearerauth } from '@vielzeug/courier';\n\nconst courier = createcourier({\n baseurl: 'https://api.example.com',\n middleware: [withbearerauth('token')],\n});\n```\n\n| `courier` member | signature | description |\n| | | |\n| `request` | `<t, p>(path, config?) => promise<t>` | sends one http request; `config.method` defaults to `get` |\n| `get` | `<t, p>(path, config?) => promise<t>` | sends get; `cache` opts parsed responses into bounded caching |\n| `prefetch` | `<t, p>(path, config) => promise<void>` | warms one required structured cache key; failures are tap observed |\n| `invalidatecache` | `(prefix: couriercachekey) => void` | invalidates matching settled and pending key prefixes |\n| `clearcache` | `() => void` | removes every cached and pending entry |\n| `post` | `<t, p>(path, config?) => promise<t>` | sends post |\n| `put` | `<t, p>(path, config?) => promise<t>` | sends put |\n| `patch` | `<t, p>(path, config?) => promise<t>` | sends patch |\n| `delete` | `<t, p>(path, config?) => promise<t>` | sends delete |\n| `tap` | `(handler, options?: { signal?: abortsignal }) => () => void` | observes structured request and disposal events |\n| `cancelall` | `() => void` | aborts active requests; client remains usable |\n| `dispose` | `() => void` | final teardown; aborts work and marks client unusable |\n| `disposed` | `boolean` | whether final disposal occurred |\n| `disposalsignal` | `abortsignal` | aborts on final disposal |\n\n \n\n## requests\n\n### `request()`\n\n```ts\nrequest<t, p extends string>(path: p, config?: requestconfig<p, t> & { method?: string }): promise<t>;\n```\n\nsends one http request. `config.method` defaults to `get`; pass any method (including custom verbs) to support it consistently.\n\n| `requestconfig<p, t>` field | type | description |\n| | | |\n| `method` | `string` | http method; defaults to `get` |\n| `params` | `record<string, string \\| number \\| boolean>` | path parameters for `{param}` placeholders |\n| `query` | `params` | query string parameters |\n| `body` | `unknown` | bodyinit values pass through; streams use node duplex; get/head reject bodies; other values encode as json |\n| `headers` | `headersinit` | per request headers merged with (and overriding) defaults |\n| `responsetype` | `responsetype` | response parsing strategy |\n| `schema` | `{ parse(data: unknown): t }` | optional parsed response validator; incompatible with `responsetype: 'raw'` |\n| `signal` | `abortsignal` | external abort signal merged with internal cancellation |\n| `timeout` | `number` | integer milliseconds from 1 to 2,147,483,647, or `infinity`; overrides default |\n| `fetchinit` | `omit<requestinit, 'body' \\| 'headers' \\| 'method' \\| 'signal'>` | raw fetch options for advanced use |\n\n**returns:** parsed response body (or `undefined` for empty bodies). with\n`responsetype: 'raw'` the resolved value is the `response` itself; a `204`/`205`\nanswer whose transport carried an unexpected empty body stream is normalized to\n`body: null` — the stray stream is cancelled and headers, status, statustext,\nurl, redirect and type metadata are preserved (on the response and its clones).\na null body raw response owns no pending resource, so no consumption is required\nto release the request.\n\n```ts\nconst created = await courier.request<user>('/users', {\n method: 'post',\n body: { name: 'ada' },\n});\n```\n\n### `get()`\n\n```ts\nget<t, p extends string>(path: p, config?: getrequestconfig<p, t>): promise<t>;\n```\n\nconvenience for `request(path, { ...config, method: 'get' })`. a `cache` descriptor opts a parsed response into the bounded cache. it requires a structured key and may override the default ttl. raw responses cannot be cached.\n\n```ts\nconst user = await courier.get<user>('/users/{id}', {\n cache: { key: ['users', 1], ttlms: 10_000 },\n params: { id: 1 },\n});\n```\n\n## cache\n\n### `prefetch()`\n\n```ts\nprefetch<t, p extends string>(path: p, config: prefetchconfig<p, t>): promise<void>;\n```\n\nstarts the same cached get used by `get()`. `config.cache` is required; `signal`, per call `timeout`, and `responsetype: 'raw'` are unavailable. fresh values and pending loads are reused. the promise settles when the attempt finishes. request failures emit `request error` through `tap()` and resolve without caching data.\n\n### `invalidatecache()` and `clearcache()`\n\n```ts\ninvalidatecache(prefix: couriercachekey): void;\nclearcache(): void;\n```\n\n`invalidatecache()` removes every settled entry whose structured key starts with `prefix` and prevents matching pending loads from being stored. `clearcache()` applies that behavior to all entries. existing waiters may still receive an invalidated in flight result; `cancelall()` or `dispose()` aborts physical work.\n\n \n\n## middleware\n\nmiddleware is an immutable chain configured at construction. each middleware receives an immutable `fetchcontext` and a `next` function.\n\n```ts\ntype middleware = (ctx: fetchcontext, next: (ctx: fetchcontext) => promise<response>) => promise<response>;\n```\n\n### middleware helpers\n\n```ts\nwithbearerauth(token: string | (() => string | null | undefined | promise<string | null | undefined>)): middleware;\nwithrequestid(options?: { generate?: () => string; header?: string }): middleware;\nwithlogging(options: {\n logger: (message: string, meta: { duration: number; method: string; status: number; url: string }) => void;\n}): middleware;\n```\n\neach helper returns a `middleware` passed in the `middleware` option. `withlogging` requires an explicit `logger` function, isolates logger failures from requests, and has no default console output.\n\n```ts\nconst courier = createcourier({\n middleware: [\n withbearerauth(() => tokenstore.getaccesstoken()),\n withrequestid(),\n withlogging({ logger: (msg) => console.log(msg) }),\n ],\n});\n```\n\n## types\n\n```ts\ntype transportoptions = {\n baseurl?: string;\n fetch?: typeof globalthis.fetch;\n headers?: headersinit;\n middleware?: readonly middleware[];\n timeout?: number;\n};\n\ntype couriercachekeyatom = string | number | boolean | null;\ntype couriercachekey = readonly [couriercachekeyatom, ...couriercachekeyatom[]];\n\ntype couriercacheoptions = {\n capacity?: number;\n ttlms?: number;\n};\n\ntype courierreadcache = {\n key: couriercachekey;\n ttlms?: number;\n};\n\ntype courieroptions = transportoptions & {\n cache?: couriercacheoptions;\n};\n\ntype fetchcontext = {\n readonly headers: readonly<record<string, string>>;\n readonly init: readonly<omit<requestinit, 'headers'>>;\n readonly url: string;\n withheaders(updates: record<string, string | undefined>): fetchcontext;\n withinit(updates: partial<omit<requestinit, 'body' | 'headers' | 'method' | 'signal'>>): fetchcontext;\n};\n\ntype middleware = (ctx: fetchcontext, next: (ctx: fetchcontext) => promise<response>) => promise<response>;\n\ntype courierevent =\n | { type: 'request start'; method: string; url: string }\n | { type: 'request success'; method: string; url: string; status: number; duration: number }\n | { type: 'request error'; method: string; url: string; error: unknown }\n | { type: 'dispose' };\n```\n\n```ts\ntype paramvalue = string | number | boolean | null | readonly (string | number | boolean | null)[] | undefined;\ntype params = record<string, paramvalue>;\ntype responsetype = 'auto' | 'json' | 'text' | 'blob' | 'arraybuffer' | 'raw';\ntype requestconfig<p extends string = string, t = unknown> = {\n body?: unknown;\n method?: string;\n fetchinit?: omit<requestinit, 'body' | 'headers' | 'method' | 'signal'>;\n headers?: headersinit;\n params?: record<string, string | number | boolean>;\n query?: params;\n responsetype?: responsetype;\n schema?: { parse(data: unknown): t };\n signal?: abortsignal;\n timeout?: number;\n};\n\ntype getrequestconfig<p extends string = string, t = unknown> = omit<\n requestconfig<p, t>,\n 'body' | 'method' | 'responsetype'\n> &\n ({ cache?: courierreadcache; responsetype?: exclude<responsetype, 'raw'> } | { cache?: never; responsetype: 'raw' }) & {\n body?: never;\n };\n\ntype prefetchconfig<p extends string = string, t = unknown> = omit<\n getrequestconfig<p, t>,\n 'cache' | 'responsetype' | 'signal' | 'timeout'\n> & {\n cache: courierreadcache;\n responsetype?: exclude<responsetype, 'raw'>;\n signal?: never;\n timeout?: never;\n};\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `couriererror` | base class for all courier errors | use `instanceof` to narrow |\n| `courierhttperror` | non 2xx http response | `status`, `data`, `headers`, `method`, `url`; `courierhttperror.is(e, status?)` narrows by status |\n| `couriernetworkerror` | request failure without response | `method`, `url`, `cause` |\n| `couriertimeouterror` | timeout signal aborts request | `method`, `url`, `cause` |\n| `courieraborterror` | caller or client cancellation | `method`, `url`, `cause` |\n| `courierschemavalidationerror` | response schema fails | `data`, `cause` |\n| `courierparseerror` | response body cannot parse | n/a |\n| `courierconfigerror` | invalid request config (get/head body, raw + schema) | n/a |\n| `courierdisposederror` | work starts after disposal | n/a |\n\nerrors thrown directly by custom middleware are preserved rather than reclassified.\n",
339
- "usage": " \ntitle: 'courier: usage guide'\ndescription: use one courier client for typed http, explicit cached reads, prefetching, immutable middleware, and structured errors.\n \n\n[[toc]]\n\n## basic usage\n\ncreate one courier client for an application or request scope. its transport policy, middleware, and disposal lifecycle apply to every request.\n\n```ts\nimport { createcourier } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\nconst user = await courier.get<user>('/users/{id}', { params: { id: 1 } });\nconsole.log(user.name);\n```\n\n## http requests\n\nuse `get()`, `post()`, `put()`, `patch()`, and `delete()` for standard methods. use `request()` when the method is custom or selected dynamically. courier preserves root relative paths, encodes path parameters, serializes json compatible bodies, and parses successful responses.\n\n```ts\nconst posts = await courier.get<{ id: number; title: string }[]>('/users/{id}/posts', {\n params: { id: 1 },\n query: { limit: 20, status: 'published' },\n});\n\nawait courier.patch('/posts/{id}', {\n body: { title: 'updated title' },\n params: { id: posts[0].id },\n});\n```\n\n`get()`, `post()`, `put()`, `patch()`, and `delete()` cover common methods. use `request()` for custom verbs or when a method is selected dynamically.\n\n## default headers\n\nset default headers at construction. they are merged (lowercased) into every request and cannot be mutated at runtime: middleware handles dynamic headers.\n\n```ts\nconst courier = createcourier({\n baseurl: 'https://api.example.com',\n headers: { 'x client': 'web' },\n});\n```\n\n## middleware\n\nmiddleware is an immutable chain configured once at construction. each middleware receives an immutable `fetchcontext` and a `next` function. use `ctx.withheaders()` or `ctx.withinit()` to derive a new context: never mutate `ctx` directly.\n\n```ts\nimport { withbearerauth, withrequestid, createcourier } from '@vielzeug/courier';\n\nconst courier = createcourier({\n baseurl: 'https://api.example.com',\n middleware: [\n withbearerauth(() => sessionstorage.getitem('access token') ?? ''),\n withrequestid(),\n ],\n});\n```\n\na bearer token provider may return `null`, `undefined`, or an empty string to omit a stale authorization header. use `withlogging()` with an explicit `logger` function to log requests during local development. `withlogging()` includes full urls, so sanitize query values before persistent logging.\n\n```ts\nimport { withlogging, createcourier } from '@vielzeug/courier';\n\nconst courier = createcourier({\n baseurl: 'https://api.example.com',\n middleware: [withlogging({ logger: (msg) => console.log(msg) })],\n});\n```\n\nuse `tap()` for multiple structured transport observers. it emits request start, request success, request error, and dispose events; observer failures cannot alter requests. pass `{ signal }` to own listener lifetime.\n\n```ts\nconst stop = courier.tap((event) => metrics.record(event), { signal: routesignal });\n```\n\nbecause middleware is immutable, a client's behavior is locally understandable from its construction call. to change policy, create a new client.\n\n## cached reads and prefetching\n\nopt parsed gets into caching with a structured key. courier uses a finite capacity of 100 entries and a 30 second ttl by default; client options change those defaults, and each read may override ttl. successful parsed and schema validated values are cached, while errors are not.\n\n```ts\nconst courier = createcourier({\n baseurl: 'https://api.example.com',\n cache: { capacity: 200, ttlms: 60_000 },\n});\nconst cache = { key: ['accounts', accountid, 'users', userid] as const, ttlms: 10_000 };\n\nawait courier.prefetch<user>('/users/{id}', { cache, params: { id: userid } });\nconst user = await courier.get<user>('/users/{id}', { cache, params: { id: userid } });\n```\n\n`prefetch()` returns a promise that settles when warming finishes. request failures are not cached or rejected from that promise; the physical request emits `request error` through `tap()`. cache, key, and url configuration errors still throw before prefetch starts.\n\ninvalidate a structured prefix after writes. every matching settled or pending key becomes ineligible for caching, while unrelated entries remain available.\n\n```ts\nawait courier.patch('/users/{id}', { body: update, params: { id: userid } });\ncourier.invalidatecache(['accounts', accountid, 'users']);\n```\n\na cache key must identify one response representation, including a stable non secret principal or tenant identifier, schema, and relevant request variants. never place credentials in a key; call `clearcache()` when identity changes. cached objects are shared by reference.\n\nconcurrent callers share physical work but own their own signal and timeout. one cancelled caller does not affect the others. when every caller cancels and no prefetch owns the load, courier aborts the physical request. `cancelall()` always aborts physical work; `dispose()` also clears settled cache data.\n\n## response parsing\n\ncourier parses successful 2xx response bodies automatically based on `content type`, unless you set `responsetype` explicitly (`'json'`, `'text'`, `'blob'`, `'arraybuffer'`, or `'raw'` for the `response` itself). empty json bodies return `undefined` instead of throwing. raw response bodies remain owned by courier until consumed or cancelled, so `cancelall()` and `dispose()` still abort them. raw responses cannot be combined with `schema`. always consume or cancel the body to release request ownership, and set `timeout: infinity` for intentionally long lived streams. courier preserves url, redirect, and type metadata on tracked responses and their clones. a `204`/`205` raw answer is normalized to `body: null` — a stray empty transport stream is cancelled for you — so a null body raw response never carries pending body ownership and needs no consumption.\n\n```ts\nconst buffer = await courier.request('/files/{id}', {\n method: 'get',\n params: { id: 1 },\n responsetype: 'arraybuffer',\n});\n```\n\n## schema validation\n\npass a `schema` with a `parse(data)` method to validate the parsed body. any validator works: a `@vielzeug/spell` schema or a plain function wrapper. a failed parse throws `courierschemavalidationerror`.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst user = await courier.get('/users/1', {\n schema: s.object({ id: s.number(), name: s.string() }),\n});\n```\n\n## cancellation and disposal\n\npass a `signal` to cancel one request. `cancelall()` aborts every active request without disposing the client. `dispose()` aborts active work and marks the client unusable: call it only at the final application or request boundary.\n\n```ts\nconst controller = new abortcontroller();\nvoid courier.get('/slow', { signal: controller.signal });\ncontroller.abort(); // cancels that request only\n\ncourier.cancelall(); // cancels all active requests\ncourier.dispose(); // final teardown\n```\n\n## timeouts\n\nset a client wide `timeout` (default 30,000 ms) or a per request timeout. finite values must be integer milliseconds from 1 through 2,147,483,647. a timed out request rejects with `couriertimeouterror`; use `infinity` for intentionally long lived work.\n\n```ts\nconst courier = createcourier({ timeout: 10_000 });\nawait courier.get('/health', { timeout: 2_000 });\n```\n\n## error handling\n\ncourier classifies url, request construction, fetch, cancellation, http, response parsing, and schema failures into distinct error classes. errors thrown directly by custom middleware are preserved.\n\n```ts\nimport {\n courieraborterror,\n courierhttperror,\n couriernetworkerror,\n courierparseerror,\n couriertimeouterror,\n createcourier,\n} from '@vielzeug/courier';\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\n\nasync function loaduser(): promise<string> {\n try {\n return (await courier.get<{ name: string }>('/users/1', { timeout: 2_000 })).name;\n } catch (error) {\n if (error instanceof courieraborterror) return 'cancelled';\n if (error instanceof couriertimeouterror) return 'timed out; retry.';\n if (courierhttperror.is(error, 404)) return 'user not found.';\n if (error instanceof couriernetworkerror) return 'check your connection.';\n if (error instanceof courierparseerror) return 'the server returned an unreadable response.';\n throw error;\n }\n}\n```\n\n a `courierhttperror` has a response (`status`, `data`, `headers`); a `couriernetworkerror` does not.\n do not display cancellation as an application error during navigation.\n `courierschemavalidationerror` carries the raw pre validation `data`.\n\n## composing with a state layer\n\ncourier 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.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst users = createpagesource<user>({\n load: async ({ page, pagesize, signal }) => {\n const result = await courier.get<{ data: user[]; total: number }>('/users', {\n query: { page, pagesize },\n signal,\n });\n return { items: result.data, totalitems: result.total };\n },\n});\n\nawait users.reload();\n```\n\n## framework integration\n\ncreate courier at an application or route boundary. inject the client where requests are issued; let the framework own rendering and cache state.\n\n::: code group\n\n```tsx [react]\nimport { createcontext, usecontext } from 'react';\nimport { createcourier } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\ntype courierclient = returntype<typeof createcourier>;\nconst couriercontext = createcontext<courierclient | null>(null);\n\nexport function courierprovider({ children, courier }: { children: react.reactnode; courier: courierclient }) {\n return <couriercontext.provider value={courier}>{children}</couriercontext.provider>;\n}\n\nexport function usecourier() {\n const courier = usecontext(couriercontext);\n if (!courier) throw new error('courierprovider missing');\n return courier;\n}\n\n// in a component:\n// const user = await usecourier().get<user>('/users/1');\n```\n\n```ts [vue 3]\nimport { createcourier } from '@vielzeug/courier';\n\nconst courier = createcourier({ baseurl: '/api' });\n// provide via app.provide('courier', courier) and inject in setup().\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { createcourier } from '@vielzeug/courier';\n export let courier: returntype<typeof createcourier>;\n</script>\n```\n\n:::\n\ncourier exposes no loading, error, or cache store. construct the client at the application or ssr request boundary, pass it into framework providers, and dispose it at that same boundary. render state in the framework that owns the view.\n\n## gotchas\n\n### root relative paths stay root relative\n\nwith no `baseurl`, `'/api/users'` is passed to fetch unchanged while `'api/users'` remains document relative. with a `baseurl`, courier appends either spelling to that base.\n\n### `baseurl` should not include query parameters\n\n`buildurl` joins `baseurl` and path with `/`. a base url like `https://api.example.com?token=abc` produces broken urls (`https://api.example.com?token=abc/users`). pass query parameters per request via `config.query` instead.\n\n### query parameters precede fragments\n\nwhen a path already contains `#fragment`, courier inserts `config.query` before the fragment so values remain part of the http query.\n\n### get and head bodies are rejected\n\nfetch does not support bodies for get or head. courier rejects them as request parse errors before dispatch.\n\n### middleware is immutable\n\nthere is no `use()` method. to change middleware, create a new client. this keeps each client's behavior locally understandable and avoids runtime mutation of the request chain.\n\n### keep credentials out of urls when using logging middleware\n\n`withlogging()` logs the full url, including query parameters. if urls may contain sensitive data, provide a custom `logger` that sanitizes the url first.\n\n## best practices\n\n create one courier client per application or ssr request scope.\n configure middleware once at construction; never mutate it at runtime.\n use method conveniences for get/post/put/patch/delete and `request()` for custom verbs.\n use explicit cache keys only for parsed gets that should share data and in flight work.\n clear cached data when the authenticated principal or tenant changes.\n let the owning state layer own observable queries and mutations.\n dispose only at the final application or request boundary.\n keep credentials out of urls when using logging middleware.\n",
345
+ "index": " \ntitle: 'courier: http client'\ndescription: a framework neutral http client with explicit cached reads, prefetching, immutable middleware, typed paths, and structured errors.\npackage: courier\ncategory: http\nkeywords: [http client, fetch, cache, prefetch, middleware, errors, rfc9457, problem details]\nrelated: [flux, spell, postmaster]\nexports: [createcourier, courierhttperror, problemdetail, problemdetailerror, withbearerauth]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"courier\" />\n\n## why courier?\n\nnative `fetch` leaves request policy, error classification, middleware composition, and repeated read coordination to each application. courier keeps those concerns in one client, including explicit cached gets and warm cache prefetching, while observable query and mutation state remain consumer owned.\n\n```ts\n// before\nconst response = await fetch(`/api/users/${userid}`);\nif (!response.ok) throw new error(`http ${response.status}`);\nconst user = await response.json();\n\n// after\nconst user = await courier.get<user>('/users/{id}', { params: { id: userid } });\n```\n\n| feature | courier | ky | ofetch |\n| | | | |\n| bundle size | <packageinfo package=\"courier\" type=\"size\" /> | separate package | separate package |\n| zero runtime dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| native fetch transport | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| explicit parsed value cache and prefetch | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| immutable middleware at construction | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| structured error taxonomy | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use courier when** one client should own typed http, explicit cached gets and prefetching, immutable middleware, and structured errors, while a separate state layer owns observable queries and mutations.\n\n**consider ky when** you only need a compact fetch wrapper. **consider ofetch when** you want a bundled fetch utility with its own retry and caching conventions.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/courier\n```\n\n```sh [npm]\nnpm install @vielzeug/courier\n```\n\n```sh [yarn]\nyarn add @vielzeug/courier\n```\n\n:::\n\n## quick start\n\ncreate one transport client for an application or request scope, then use method conveniences for standard http methods and `request()` for custom methods.\n\n```ts\nimport { courierhttperror, createcourier } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\n\ntry {\n const user = await courier.get<user>('/users/{id}', { params: { id: 42 } });\n console.log(user.name);\n} catch (error) {\n if (courierhttperror.is(error, 404)) console.log('user not found');\n else throw error;\n} finally {\n courier.dispose();\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`createcourier()`**: one lifecycle, immutable middleware pipeline, header defaults, and cancellation boundary.\n **`request()`**: one contract for every http method; pass `method` in the config.\n **cached `get()`**: opt in with a structured key and optional per read ttl; successful parsed values share bounded storage and in flight work.\n **`prefetch()`**: await cache warming while request failures remain observable through `tap()`.\n **`invalidatecache()` / `clearcache()`**: invalidate structured key prefixes without query state.\n **`get()` / `post()` / `put()` / `patch()` / `delete()`**: conveniences for standard methods.\n **`withbearerauth()` / `withrequestid()` / `withlogging()`**: composable middleware configured at construction.\n **`tap()`**: signal owned structured transport observation.\n **error taxonomy**: http, network, timeout, abort, parse, and schema failures are distinct, actionable classes.\n **rfc 9457 problem details**: an `application/problem+json` error body parses onto `courierhttperror.problem` while the http status stays authoritative.\n **`cancelall()` / `dispose()`**: abort active requests and tear down the transport.\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [flux](/flux/): composes async streams; pair with courier transport calls in the owning state layer.\n [spell](/spell/): validates parsed http payloads through courier's `schema` option.\n [postmaster](/postmaster/): coordinates durable delivery of courier requests through a job outbox.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
346
+ "api": " \ntitle: 'courier: api reference'\ndescription: reference for courier http, cached read, prefetch, middleware, and error apis.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createcourier()` | creates transport client | sync | dispose only when whole scope ends |\n| `request()` | sends one http request of any method | async | direct calls never deduplicate |\n| `get()` | sends a get, optionally through the explicit cache | async | one key must identify one representation |\n| `prefetch()` | warms a cached get | async | request failures resolve after surfacing through `tap()` |\n| `invalidatecache()` / `clearcache()` | removes cached values | sync | invalidation matches structured key prefixes |\n| `post()` / `put()` / `patch()` / `delete()` | common write method conveniences | async | use `request()` for custom methods |\n| `withbearerauth()` | adds authorization middleware | sync | token provider runs per request |\n| `withrequestid()` | adds request identifier middleware | sync | default generator uses `crypto.randomuuid()` |\n| `withlogging()` | logs request result metadata | sync | requires explicit logger; urls may contain sensitive query values |\n| `tap()` | observe structured transport events | sync | observation only; optional signal owns lifetime |\n| `courierhttperror.problem` | parsed rfc 9457 problem details from an error body | sync | present only when the response is `application/problem+json` |\n| `problemdetail` | rfc 9457 problem members and extension members | sync | extension values are `unknown`; courier does not validate their shapes |\n| `problemdetailerror()` | build an http error that carries problem details | sync | intended for mocks and middleware, not for parsing a response |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/courier` | client factory, errors, middleware helpers, and public types |\n\n## client\n\n### `createcourier()`\n\n```ts\ncreatecourier(options?: courieroptions): courier;\n```\n\nreturns an http client sharing base url, default headers, immutable middleware, optional parsed value cache, timeout, and cancellation lifecycle.\n\n| `courieroptions` field | type | default | description |\n| | | | |\n| `baseurl` | `string` | `''` | prefix for relative request paths |\n| `cache.capacity` | `number` | `100` | maximum cached entries; positive integer or `infinity` |\n| `cache.ttlms` | `number` | `30_000` | default cache freshness; non negative milliseconds or `infinity` |\n| `fetch` | `typeof globalthis.fetch` | `globalthis.fetch` | fetch implementation |\n| `headers` | `headersinit` | `{}` | default request headers (immutable; merged lowercase) |\n| `middleware` | `readonly middleware[]` | `[]` | immutable middleware chain configured at construction |\n| `timeout` | `number` | `30_000` | integer milliseconds from 1 to 2,147,483,647, or `infinity` |\n\n**returns:** `courier`.\n\n```ts\nimport { createcourier, withbearerauth } from '@vielzeug/courier';\n\nconst courier = createcourier({\n baseurl: 'https://api.example.com',\n middleware: [withbearerauth('token')],\n});\n```\n\n| `courier` member | signature | description |\n| | | |\n| `request` | `<t, p>(path, config?) => promise<t>` | sends one http request; `config.method` defaults to `get` |\n| `get` | `<t, p>(path, config?) => promise<t>` | sends get; `cache` opts parsed responses into bounded caching |\n| `prefetch` | `<t, p>(path, config) => promise<void>` | warms one required structured cache key; failures are tap observed |\n| `invalidatecache` | `(prefix: couriercachekey) => void` | invalidates matching settled and pending key prefixes |\n| `clearcache` | `() => void` | removes every cached and pending entry |\n| `post` | `<t, p>(path, config?) => promise<t>` | sends post |\n| `put` | `<t, p>(path, config?) => promise<t>` | sends put |\n| `patch` | `<t, p>(path, config?) => promise<t>` | sends patch |\n| `delete` | `<t, p>(path, config?) => promise<t>` | sends delete |\n| `tap` | `(handler, options?: { signal?: abortsignal }) => () => void` | observes structured request and disposal events |\n| `cancelall` | `() => void` | aborts active requests; client remains usable |\n| `dispose` | `() => void` | final teardown; aborts work and marks client unusable |\n| `disposed` | `boolean` | whether final disposal occurred |\n| `disposalsignal` | `abortsignal` | aborts on final disposal |\n\n \n\n## requests\n\n### `request()`\n\n```ts\nrequest<t, p extends string>(path: p, config?: requestconfig<p, t> & { method?: string }): promise<t>;\n```\n\nsends one http request. `config.method` defaults to `get`; pass any method (including custom verbs) to support it consistently.\n\n| `requestconfig<p, t>` field | type | description |\n| | | |\n| `method` | `string` | http method; defaults to `get` |\n| `params` | `record<string, string \\| number \\| boolean>` | path parameters for `{param}` placeholders |\n| `query` | `params` | query string parameters |\n| `body` | `unknown` | bodyinit values pass through; streams use node duplex; get/head reject bodies; other values encode as json |\n| `headers` | `headersinit` | per request headers merged with (and overriding) defaults |\n| `responsetype` | `responsetype` | response parsing strategy |\n| `schema` | `{ parse(data: unknown): t }` | optional parsed response validator; incompatible with `responsetype: 'raw'` |\n| `signal` | `abortsignal` | external abort signal merged with internal cancellation |\n| `timeout` | `number` | integer milliseconds from 1 to 2,147,483,647, or `infinity`; overrides default |\n| `fetchinit` | `omit<requestinit, 'body' \\| 'headers' \\| 'method' \\| 'signal'>` | raw fetch options for advanced use |\n\n**returns:** parsed response body (or `undefined` for empty bodies). with\n`responsetype: 'raw'` the resolved value is the `response` itself; a `204`/`205`\nanswer whose transport carried an unexpected empty body stream is normalized to\n`body: null` — the stray stream is cancelled and headers, status, statustext,\nurl, redirect and type metadata are preserved (on the response and its clones).\na null body raw response owns no pending resource, so no consumption is required\nto release the request.\n\n```ts\nconst created = await courier.request<user>('/users', {\n method: 'post',\n body: { name: 'ada' },\n});\n```\n\n### `get()`\n\n```ts\nget<t, p extends string>(path: p, config?: getrequestconfig<p, t>): promise<t>;\n```\n\nconvenience for `request(path, { ...config, method: 'get' })`. a `cache` descriptor opts a parsed response into the bounded cache. it requires a structured key and may override the default ttl. raw responses cannot be cached.\n\n```ts\nconst user = await courier.get<user>('/users/{id}', {\n cache: { key: ['users', 1], ttlms: 10_000 },\n params: { id: 1 },\n});\n```\n\n## cache\n\n### `prefetch()`\n\n```ts\nprefetch<t, p extends string>(path: p, config: prefetchconfig<p, t>): promise<void>;\n```\n\nstarts the same cached get used by `get()`. `config.cache` is required; `signal`, per call `timeout`, and `responsetype: 'raw'` are unavailable. fresh values and pending loads are reused. the promise settles when the attempt finishes. request failures emit `request error` through `tap()` and resolve without caching data.\n\n### `invalidatecache()` and `clearcache()`\n\n```ts\ninvalidatecache(prefix: couriercachekey): void;\nclearcache(): void;\n```\n\n`invalidatecache()` removes every settled entry whose structured key starts with `prefix` and prevents matching pending loads from being stored. `clearcache()` applies that behavior to all entries. existing waiters may still receive an invalidated in flight result; `cancelall()` or `dispose()` aborts physical work.\n\n \n\n## middleware\n\nmiddleware is an immutable chain configured at construction. each middleware receives an immutable `fetchcontext` and a `next` function.\n\n```ts\ntype middleware = (ctx: fetchcontext, next: (ctx: fetchcontext) => promise<response>) => promise<response>;\n```\n\n### middleware helpers\n\n```ts\nwithbearerauth(token: string | (() => string | null | undefined | promise<string | null | undefined>)): middleware;\nwithrequestid(options?: { generate?: () => string; header?: string }): middleware;\nwithlogging(options: {\n logger: (message: string, meta: { duration: number; method: string; status: number; url: string }) => void;\n}): middleware;\n```\n\neach helper returns a `middleware` passed in the `middleware` option. `withlogging` requires an explicit `logger` function, isolates logger failures from requests, and has no default console output.\n\n```ts\nconst courier = createcourier({\n middleware: [\n withbearerauth(() => tokenstore.getaccesstoken()),\n withrequestid(),\n withlogging({ logger: (msg) => console.log(msg) }),\n ],\n});\n```\n\n## types\n\n```ts\ntype transportoptions = {\n baseurl?: string;\n fetch?: typeof globalthis.fetch;\n headers?: headersinit;\n middleware?: readonly middleware[];\n timeout?: number;\n};\n\ntype couriercachekeyatom = string | number | boolean | null;\ntype couriercachekey = readonly [couriercachekeyatom, ...couriercachekeyatom[]];\n\ntype couriercacheoptions = {\n capacity?: number;\n ttlms?: number;\n};\n\ntype courierreadcache = {\n key: couriercachekey;\n ttlms?: number;\n};\n\ntype courieroptions = transportoptions & {\n cache?: couriercacheoptions;\n};\n\ntype fetchcontext = {\n readonly headers: readonly<record<string, string>>;\n readonly init: readonly<omit<requestinit, 'headers'>>;\n readonly url: string;\n withheaders(updates: record<string, string | undefined>): fetchcontext;\n withinit(updates: partial<omit<requestinit, 'body' | 'headers' | 'method' | 'signal'>>): fetchcontext;\n};\n\ntype middleware = (ctx: fetchcontext, next: (ctx: fetchcontext) => promise<response>) => promise<response>;\n\ntype courierevent =\n | { type: 'request start'; method: string; url: string }\n | { type: 'request success'; method: string; url: string; status: number; duration: number }\n | { type: 'request error'; method: string; url: string; error: unknown }\n | { type: 'dispose' };\n```\n\n```ts\ntype paramvalue = string | number | boolean | null | readonly (string | number | boolean | null)[] | undefined;\ntype params = record<string, paramvalue>;\ntype responsetype = 'auto' | 'json' | 'text' | 'blob' | 'arraybuffer' | 'raw';\ntype requestconfig<p extends string = string, t = unknown> = {\n body?: unknown;\n method?: string;\n fetchinit?: omit<requestinit, 'body' | 'headers' | 'method' | 'signal'>;\n headers?: headersinit;\n params?: record<string, string | number | boolean>;\n query?: params;\n responsetype?: responsetype;\n schema?: { parse(data: unknown): t };\n signal?: abortsignal;\n timeout?: number;\n};\n\ntype getrequestconfig<p extends string = string, t = unknown> = omit<\n requestconfig<p, t>,\n 'body' | 'method' | 'responsetype'\n> &\n ({ cache?: courierreadcache; responsetype?: exclude<responsetype, 'raw'> } | { cache?: never; responsetype: 'raw' }) & {\n body?: never;\n };\n\ntype prefetchconfig<p extends string = string, t = unknown> = omit<\n getrequestconfig<p, t>,\n 'cache' | 'responsetype' | 'signal' | 'timeout'\n> & {\n cache: courierreadcache;\n responsetype?: exclude<responsetype, 'raw'>;\n signal?: never;\n timeout?: never;\n};\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `couriererror` | base class for all courier errors | use `instanceof` to narrow |\n| `courierhttperror` | non 2xx http response | `status`, `data`, `headers`, `method`, `url`, `problem`; `courierhttperror.is(e, status?)` narrows by status |\n| `couriernetworkerror` | request failure without response | `method`, `url`, `cause` |\n| `couriertimeouterror` | timeout signal aborts request | `method`, `url`, `cause` |\n| `courieraborterror` | caller or client cancellation | `method`, `url`, `cause` |\n| `courierschemavalidationerror` | response schema fails | `data`, `cause` |\n| `courierparseerror` | response body cannot parse | n/a |\n| `courierconfigerror` | invalid request config (get/head body, raw + schema) | n/a |\n| `courierdisposederror` | work starts after disposal | n/a |\n\nerrors thrown directly by custom middleware are preserved rather than reclassified.\n\n### problem details\n\n```ts\nexport type problemdetail = readonly<{\n type?: string;\n status?: number;\n title?: string;\n detail?: string;\n instance?: string;\n [extension: string]: unknown;\n}>;\n\nexport const problem_json_media_type = 'application/problem+json';\n\nexport function isproblemmediatype(contenttype: string | null | undefined): boolean;\n\nexport function problemdetailerror(\n problem: problemdetail,\n options?: { message?: string; method?: string; status?: number; url?: string },\n): courierhttperror;\n```\n\nwhen a non 2xx response answers with `application/problem+json` (rfc 9457), `courierhttperror.problem`\nholds a shallow frozen copy of the parsed json object and the error message prefers `detail`, then\n`title`, then the response status text (or `http <status>` when status text is empty). any other error\nbody keeps the shape it already had on `data`, with `problem` left `undefined`. courier checks that\nthe parsed body is an object; it does not validate the types or contents of registered or extension\nmembers. rfc 9457 treats an omitted `type` as `about:blank`, but courier does not insert that value\ninto the object.\n\n| member | purpose | common gotcha |\n| | | |\n| `problemdetail` | the five registered rfc 9457 members plus extensions | every member is optional; extension values have type `unknown` |\n| `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 |\n| `problem_json_media_type` | registered media type constant | compare with `isproblemmediatype()`, not `===`, to allow parameters |\n| `isproblemmediatype()` | match `application/problem+json` and its parameterised forms | plain `application/json` never matches |\n| `problemdetailerror()` | build a problem carrying `courierhttperror` without a server | status defaults to numeric `problem.status`, then `500`; a supplied `options.status` takes precedence |\n\n```ts\nproblemdetailerror(problem, options?)\n```\n\n`problemdetailerror()` returns a `courierhttperror` with the supplied problem object on both\n`error.problem` and `error.data`. its defaults are `method: 'get'`, `url: ''`, and status from\n`problem.status` when numeric or `500` otherwise. the error message uses `options.message`, then\n`problem.detail`, `problem.title`, and finally `http <status>`. unlike a parsed response problem,\nthe supplied object is not copied or frozen.\n\nthe http status line stays authoritative: `error.status` is the response status even when\n`problem.status` disagrees. a json array or primitive leaves `data` as that parsed value; malformed\njson leaves `data` as its raw text. neither produces a partial `problem`.\n\n```ts\nimport { createcourier, courierhttperror, problemdetailerror, type problemdetail } from '@vielzeug/courier';\n\nconst courier = createcourier({ baseurl: '/api' });\n\ntry {\n await courier.post('/invoices');\n} catch (error) {\n if (courierhttperror.is(error)) {\n const problem: problemdetail | undefined = error.problem;\n console.log(error.status, problem?.title, problem?.detail, problem?.type);\n }\n}\n\nconst mockederror = problemdetailerror(\n { title: 'insufficient credit', detail: 'choose a lower amount.', status: 402 },\n { method: 'post', url: '/invoices' },\n);\nconsole.log(mockederror.status, mockederror.problem?.detail);\n```\n",
347
+ "usage": " \ntitle: 'courier: usage guide'\ndescription: use one courier client for typed http, explicit cached reads, prefetching, immutable middleware, and structured errors.\n \n\n[[toc]]\n\n## basic usage\n\ncreate one courier client for an application or request scope. its transport policy, middleware, and disposal lifecycle apply to every request.\n\n```ts\nimport { createcourier } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\nconst user = await courier.get<user>('/users/{id}', { params: { id: 1 } });\nconsole.log(user.name);\n```\n\n## http requests\n\nuse `get()`, `post()`, `put()`, `patch()`, and `delete()` for standard methods. use `request()` when the method is custom or selected dynamically. courier preserves root relative paths, encodes path parameters, serializes json compatible bodies, and parses successful responses.\n\n```ts\nconst posts = await courier.get<{ id: number; title: string }[]>('/users/{id}/posts', {\n params: { id: 1 },\n query: { limit: 20, status: 'published' },\n});\n\nawait courier.patch('/posts/{id}', {\n body: { title: 'updated title' },\n params: { id: posts[0].id },\n});\n```\n\n`get()`, `post()`, `put()`, `patch()`, and `delete()` cover common methods. use `request()` for custom verbs or when a method is selected dynamically.\n\n## default headers\n\nset default headers at construction. they are merged (lowercased) into every request and cannot be mutated at runtime: middleware handles dynamic headers.\n\n```ts\nconst courier = createcourier({\n baseurl: 'https://api.example.com',\n headers: { 'x client': 'web' },\n});\n```\n\n## middleware\n\nmiddleware is an immutable chain configured once at construction. each middleware receives an immutable `fetchcontext` and a `next` function. use `ctx.withheaders()` or `ctx.withinit()` to derive a new context: never mutate `ctx` directly.\n\n```ts\nimport { withbearerauth, withrequestid, createcourier } from '@vielzeug/courier';\n\nconst courier = createcourier({\n baseurl: 'https://api.example.com',\n middleware: [\n withbearerauth(() => sessionstorage.getitem('access token') ?? ''),\n withrequestid(),\n ],\n});\n```\n\na bearer token provider may return `null`, `undefined`, or an empty string to omit a stale authorization header. use `withlogging()` with an explicit `logger` function to log requests during local development. `withlogging()` includes full urls, so sanitize query values before persistent logging.\n\n```ts\nimport { withlogging, createcourier } from '@vielzeug/courier';\n\nconst courier = createcourier({\n baseurl: 'https://api.example.com',\n middleware: [withlogging({ logger: (msg) => console.log(msg) })],\n});\n```\n\nuse `tap()` for multiple structured transport observers. it emits request start, request success, request error, and dispose events; observer failures cannot alter requests. pass `{ signal }` to own listener lifetime.\n\n```ts\nconst stop = courier.tap((event) => metrics.record(event), { signal: routesignal });\n```\n\nbecause middleware is immutable, a client's behavior is locally understandable from its construction call. to change policy, create a new client.\n\n## cached reads and prefetching\n\nopt parsed gets into caching with a structured key. courier uses a finite capacity of 100 entries and a 30 second ttl by default; client options change those defaults, and each read may override ttl. successful parsed and schema validated values are cached, while errors are not.\n\n```ts\nconst courier = createcourier({\n baseurl: 'https://api.example.com',\n cache: { capacity: 200, ttlms: 60_000 },\n});\nconst cache = { key: ['accounts', accountid, 'users', userid] as const, ttlms: 10_000 };\n\nawait courier.prefetch<user>('/users/{id}', { cache, params: { id: userid } });\nconst user = await courier.get<user>('/users/{id}', { cache, params: { id: userid } });\n```\n\n`prefetch()` returns a promise that settles when warming finishes. request failures are not cached or rejected from that promise; the physical request emits `request error` through `tap()`. cache, key, and url configuration errors still throw before prefetch starts.\n\ninvalidate a structured prefix after writes. every matching settled or pending key becomes ineligible for caching, while unrelated entries remain available.\n\n```ts\nawait courier.patch('/users/{id}', { body: update, params: { id: userid } });\ncourier.invalidatecache(['accounts', accountid, 'users']);\n```\n\na cache key must identify one response representation, including a stable non secret principal or tenant identifier, schema, and relevant request variants. never place credentials in a key; call `clearcache()` when identity changes. cached objects are shared by reference.\n\nconcurrent callers share physical work but own their own signal and timeout. one cancelled caller does not affect the others. when every caller cancels and no prefetch owns the load, courier aborts the physical request. `cancelall()` always aborts physical work; `dispose()` also clears settled cache data.\n\n## response parsing\n\ncourier parses successful 2xx response bodies automatically based on `content type`, unless you set `responsetype` explicitly (`'json'`, `'text'`, `'blob'`, `'arraybuffer'`, or `'raw'` for the `response` itself). empty json bodies return `undefined` instead of throwing. raw response bodies remain owned by courier until consumed or cancelled, so `cancelall()` and `dispose()` still abort them. raw responses cannot be combined with `schema`. always consume or cancel the body to release request ownership, and set `timeout: infinity` for intentionally long lived streams. courier preserves url, redirect, and type metadata on tracked responses and their clones. a `204`/`205` raw answer is normalized to `body: null` — a stray empty transport stream is cancelled for you — so a null body raw response never carries pending body ownership and needs no consumption.\n\n```ts\nconst buffer = await courier.request('/files/{id}', {\n method: 'get',\n params: { id: 1 },\n responsetype: 'arraybuffer',\n});\n```\n\n## schema validation\n\npass a `schema` with a `parse(data)` method to validate the parsed body. any validator works: a `@vielzeug/spell` schema or a plain function wrapper. a failed parse throws `courierschemavalidationerror`.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst user = await courier.get('/users/1', {\n schema: s.object({ id: s.number(), name: s.string() }),\n});\n```\n\n## cancellation and disposal\n\npass a `signal` to cancel one request. `cancelall()` aborts every active request without disposing the client. `dispose()` aborts active work and marks the client unusable: call it only at the final application or request boundary.\n\n```ts\nconst controller = new abortcontroller();\nvoid courier.get('/slow', { signal: controller.signal });\ncontroller.abort(); // cancels that request only\n\ncourier.cancelall(); // cancels all active requests\ncourier.dispose(); // final teardown\n```\n\n## timeouts\n\nset a client wide `timeout` (default 30,000 ms) or a per request timeout. finite values must be integer milliseconds from 1 through 2,147,483,647. a timed out request rejects with `couriertimeouterror`; use `infinity` for intentionally long lived work.\n\n```ts\nconst courier = createcourier({ timeout: 10_000 });\nawait courier.get('/health', { timeout: 2_000 });\n```\n\n## error handling\n\ncourier classifies url, request construction, fetch, cancellation, http, response parsing, and schema failures into distinct error classes. errors thrown directly by custom middleware are preserved.\n\n```ts\nimport {\n courieraborterror,\n courierhttperror,\n couriernetworkerror,\n courierparseerror,\n couriertimeouterror,\n createcourier,\n} from '@vielzeug/courier';\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\n\nasync function loaduser(): promise<string> {\n try {\n return (await courier.get<{ name: string }>('/users/1', { timeout: 2_000 })).name;\n } catch (error) {\n if (error instanceof courieraborterror) return 'cancelled';\n if (error instanceof couriertimeouterror) return 'timed out; retry.';\n if (courierhttperror.is(error, 404)) return 'user not found.';\n if (error instanceof couriernetworkerror) return 'check your connection.';\n if (error instanceof courierparseerror) return 'the server returned an unreadable response.';\n throw error;\n }\n}\n```\n\n a `courierhttperror` has a response (`status`, `data`, `headers`); a `couriernetworkerror` does not.\n do not display cancellation as an application error during navigation.\n `courierschemavalidationerror` carries the raw pre validation `data`.\n\n## problem details\n\nwhen a server answers a failure with `application/problem+json` (rfc 9457), courier parses the\njson object onto `courierhttperror.problem` and chooses the message from `detail`, then `title`,\nthen the http status text. the parsed object is shallow frozen; extension members remain `unknown`\nand courier does not validate their contents. nothing is configured.\n\n```ts\nimport { courierhttperror, createcourier, type problemdetail } from '@vielzeug/courier';\n\nconst courier = createcourier({ baseurl: '/api' });\n\nasync function submitinvoice(body: unknown): promise<string> {\n try {\n await courier.post('/invoices', { body });\n return 'saved.';\n } catch (error) {\n if (!courierhttperror.is(error)) throw error;\n\n const problem = error.problem;\n if (problem?.title === 'insufficient credit') return `blocked: ${problem.detail ?? 'no credit'}`;\n\n // extension members are ordinary properties on the same document.\n const field = problem?.['errors'] as record<string, string> | undefined;\n\n return field ? object.values(field).join(', ') : `request failed (${error.status}).`;\n }\n}\n```\n\n`error.status` is always the http status from the response, so a `problem.status` that disagrees\nnever overrides it. `problem` stays `undefined` for any other content type, and a `problem+json`\nbody that is a json array or primitive keeps that parsed value on `data`; malformed json keeps its\nraw text there. neither case creates a partial `problem`. rfc 9457 treats an omitted `type` as\n`about:blank`, but courier does not add a `type` property. use `problemdetailerror()` to raise the\nsame error shape from a mock or middleware; it stores the supplied problem object directly on both\n`problem` and `data`.\n\n```ts\nimport { problemdetailerror } from '@vielzeug/courier';\n\nconst mockfailure = problemdetailerror(\n { title: 'insufficient credit', detail: 'choose a lower amount.', status: 402 },\n { method: 'post', url: '/invoices' },\n);\n\nconsole.log(mockfailure.status, mockfailure.problem?.detail);\n```\n\n## composing with a state layer\n\ncourier 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.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst users = createpagesource<user>({\n load: async ({ page, pagesize, signal }) => {\n const result = await courier.get<{ data: user[]; total: number }>('/users', {\n query: { page, pagesize },\n signal,\n });\n return { items: result.data, totalitems: result.total };\n },\n});\n\nawait users.reload();\n```\n\n## framework integration\n\ncreate courier at an application or route boundary. inject the client where requests are issued; let the framework own rendering and cache state.\n\n::: code group\n\n```tsx [react]\nimport { createcontext, usecontext } from 'react';\nimport { createcourier } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\ntype courierclient = returntype<typeof createcourier>;\nconst couriercontext = createcontext<courierclient | null>(null);\n\nexport function courierprovider({ children, courier }: { children: react.reactnode; courier: courierclient }) {\n return <couriercontext.provider value={courier}>{children}</couriercontext.provider>;\n}\n\nexport function usecourier() {\n const courier = usecontext(couriercontext);\n if (!courier) throw new error('courierprovider missing');\n return courier;\n}\n\n// in a component:\n// const user = await usecourier().get<user>('/users/1');\n```\n\n```ts [vue 3]\nimport { createcourier } from '@vielzeug/courier';\n\nconst courier = createcourier({ baseurl: '/api' });\n// provide via app.provide('courier', courier) and inject in setup().\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { createcourier } from '@vielzeug/courier';\n export let courier: returntype<typeof createcourier>;\n</script>\n```\n\n:::\n\ncourier exposes no loading, error, or cache store. construct the client at the application or ssr request boundary, pass it into framework providers, and dispose it at that same boundary. render state in the framework that owns the view.\n\n## gotchas\n\n### root relative paths stay root relative\n\nwith no `baseurl`, `'/api/users'` is passed to fetch unchanged while `'api/users'` remains document relative. with a `baseurl`, courier appends either spelling to that base.\n\n### `baseurl` should not include query parameters\n\n`buildurl` joins `baseurl` and path with `/`. a base url like `https://api.example.com?token=abc` produces broken urls (`https://api.example.com?token=abc/users`). pass query parameters per request via `config.query` instead.\n\n### query parameters precede fragments\n\nwhen a path already contains `#fragment`, courier inserts `config.query` before the fragment so values remain part of the http query.\n\n### get and head bodies are rejected\n\nfetch does not support bodies for get or head. courier rejects them as request parse errors before dispatch.\n\n### middleware is immutable\n\nthere is no `use()` method. to change middleware, create a new client. this keeps each client's behavior locally understandable and avoids runtime mutation of the request chain.\n\n### keep credentials out of urls when using logging middleware\n\n`withlogging()` logs the full url, including query parameters. if urls may contain sensitive data, provide a custom `logger` that sanitizes the url first.\n\n## best practices\n\n create one courier client per application or ssr request scope.\n configure middleware once at construction; never mutate it at runtime.\n use method conveniences for get/post/put/patch/delete and `request()` for custom verbs.\n use explicit cache keys only for parsed gets that should share data and in flight work.\n clear cached data when the authenticated principal or tenant changes.\n let the owning state layer own observable queries and mutations.\n dispose only at the final application or request boundary.\n keep credentials out of urls when using logging middleware.\n",
340
348
  "examples": " \ntitle: 'courier: examples'\ndescription: practical examples and recipes for courier.\n \n\n## examples\n\n [authentication](./examples/authentication.md)\n [service discovery and custom fetch](./examples/service discovery and custom fetch.md)\n [crud operations](./examples/crud operations.md)\n [disposal](./examples/disposal.md)\n [error handling patterns](./examples/error handling patterns.md)\n [file uploads](./examples/file uploads.md)\n [optimistic updates](./examples/optimistic updates.md)\n [sse events](./examples/sse events.md)\n"
341
349
  },
342
350
  "examples": [
@@ -351,14 +359,18 @@
351
359
  {
352
360
  "id": "middleware",
353
361
  "text": "middleware immutable chain import { createcourier, withbearerauth, withrequestid } from '@vielzeug/courier'\n\nconst fetch: typeof globalthis.fetch = async (_url, init) =>\n new response(json.stringify({ ok: true }), { headers: { 'content type': 'application/json' } })\n\nlet token = 'initial token'\nconst courier = createcourier({\n baseurl: 'https://api.example.com',\n fetch,\n middleware: [withbearerauth(() => token), withrequestid({ generate: () => 'req 1' })],\n})\n\nawait courier.get('/profile')\nconsole.log('first request sent with initial token')\n\ntoken = 'refreshed token'\nawait courier.request('/profile', { method: 'post', body: { update: true } })\nconsole.log('second request sent with refreshed token')\n\ncourier.dispose()\nconsole.log('✓ client disposed')"
362
+ },
363
+ {
364
+ "id": "problem-details",
365
+ "text": "problem+json rfc 9457 error bodies import { courierhttperror, createcourier } from '@vielzeug/courier'\n\nconst fetch: typeof globalthis.fetch = async () =>\n new response(\n json.stringify({\n type: 'https://api.example.com/problems/insufficient credit',\n status: 402,\n title: 'insufficient credit',\n detail: 'account 1 owes 12.50 eur.',\n errors: { amount: 'above the remaining balance' },\n }),\n { status: 402, headers: { 'content type': 'application/problem+json; charset=utf 8' } },\n )\n\nconst courier = createcourier({ baseurl: 'https://api.example.com', fetch })\n\ntry {\n await courier.post('/invoices', { body: { amount: 12.5 } })\n} catch (error) {\n if (courierhttperror.is(error)) {\n console.log('status:', error.status)\n console.log('message:', error.message)\n console.log('title:', error.problem?.title)\n console.log('extension:', error.problem?.errors)\n }\n}\n\ncourier.dispose()\nconsole.log('✓ problem details parsed from the error body')"
354
366
  }
355
367
  ],
356
- "exports": "createcourier couriererror courierhttperror couriernetworkerror couriertimeouterror courieraborterror courierschemavalidationerror withbearerauth withrequestid withlogging",
357
- "keywords": "http client fetch cache prefetch middleware interceptors errors",
368
+ "exports": "createcourier courierhttperror problemdetail problemdetailerror withbearerauth",
369
+ "keywords": "http client fetch cache prefetch middleware errors rfc9457 problem details",
358
370
  "name": "@vielzeug/courier",
359
371
  "related": "flux spell postmaster",
360
372
  "slug": "courier",
361
- "source": "export {\n type courier,\n courieraborterror,\n courierconfigerror,\n courierdisposederror,\n couriererror,\n type courierevent,\n courierhttperror,\n couriernetworkerror,\n type courieroptions,\n courierparseerror,\n courierschemavalidationerror,\n couriertimeouterror,\n createcourier,\n} from './courier.js';\nexport { withbearerauth, withlogging, withrequestid } from './interceptors.js';\nexport type { responsetype } from './response.js';\nexport type { fetchcontext, middleware, transportoptions } from './transport.js';\nexport type {\n couriercachekey,\n couriercachekeyatom,\n couriercacheoptions,\n courierreadcache,\n getrequestconfig,\n params,\n prefetchconfig,\n requestconfig,\n} from './url.js';\n"
373
+ "source": "export { type courier, type courierevent, type courieroptions, createcourier } from './courier.js';\nexport {\n courieraborterror,\n courierconfigerror,\n courierdisposederror,\n couriererror,\n courierhttperror,\n couriernetworkerror,\n courierparseerror,\n courierschemavalidationerror,\n couriertimeouterror,\n problemdetailerror,\n} from './errors.js';\nexport { withbearerauth, withlogging, withrequestid } from './interceptors.js';\nexport { isproblemmediatype, problem_json_media_type, type problemdetail } from './problem.js';\nexport type { responsetype } from './response.js';\nexport type { fetchcontext, middleware, transportoptions } from './transport.js';\nexport type {\n couriercachekey,\n couriercachekeyatom,\n couriercacheoptions,\n courierreadcache,\n getrequestconfig,\n params,\n prefetchconfig,\n requestconfig,\n} from './url.js';\n"
362
374
  },
363
375
  {
364
376
  "category": "ui interaction",
@@ -916,18 +928,18 @@
916
928
  "category": "ui primitives",
917
929
  "description": "functional custom element authoring with typed props, reactive templates, lifecycle helpers, and testing utilities.",
918
930
  "docs": {
919
- "index": " \ntitle: 'ore: web component authoring with signals'\ndescription: functional custom element authoring with typed props, reactive templates, lifecycle helpers, and testing utilities.\npackage: ore\ncategory: ui primitives\nkeywords: [web components, custom elements, reactive, templates, signals, lifecycle]\nrelated: [ripple, refine, orbit]\nexports: [define, prop, html, css, ref, createcontext, inject, injectstrict, provide, onmounted, oncleanup, onevent, onelement, onformreset, watcheffect, useemit, useslots, gethost, bind, each, when, classmap, stylemap, live, unsafehtml, usefield, createid, oreerror, oreapierror, oreinternalerror, orelifecycleerror, attributebindings, bindoptions, maybereactive]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"ore\" />\n\n## why ore?\n\nore keeps custom elements functional and signal driven while giving you direct control over templates, lifecycle hooks, host bindings, and form associated behavior.\n\n```ts\n// before: vanilla custom element boilerplate\nclass mycounter extends htmlelement {\n #count = 0;\n connectedcallback() {\n this.attachshadow({ mode: 'open' });\n this.#render();\n }\n #render() {\n this.shadowroot!.innerhtml = `<button>${this.#count}</button>`;\n this.shadowroot!.queryselector('button')!.onclick = () => {\n this.#count++;\n this.#render();\n };\n }\n}\ncustomelements.define('my counter', mycounter);\n\n// after: ore\nimport { signal } from '@vielzeug/ripple';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('my counter', {\n setup() {\n const count = signal(0);\n return html`<button @click=${() => count.value++}>${count}</button>`;\n },\n});\n```\n\n| feature | ore | lit | stencil |\n| | | | |\n| bundle size | <packageinfo package=\"ore\" type=\"size\" /> | ~12 kb | ~60 kb+ toolchain |\n| signal first runtime | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> (separate signals package) | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| functional component setup | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| typed prop helpers | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| host binding helpers | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | partial |\n| form associated helpers | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | partial |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use ore when** you want typed, signal driven custom elements with minimal runtime overhead and no framework lock in.\n\n**consider lit when** you need a mature ecosystem with wide community adoption and don't need signal based reactivity.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/ore @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/ore @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/ore @vielzeug/ripple\n```\n\n:::\n\n## quick start\n\n```ts\nimport { computed, signal } from '@vielzeug/ripple';\nimport { bind, css, define, html, onmounted, prop } from '@vielzeug/ore';\n\ndefine('my counter', {\n props: {\n label: prop.string('count'),\n step: prop.number(1),\n },\n styles: [\n css`\n :host {\n display: inline grid;\n gap: 0.5rem;\n }\n `,\n ],\n setup(props) {\n const count = signal(0);\n const doubled = computed(() => count.value * 2);\n\n bind({ class: { 'is positive': () => count.value > 0 } });\n\n onmounted(() => console.log('mounted'));\n\n return html`\n <button @click=${() => (count.value += props.step.value)}>${props.label}: ${count}</button>\n <p>doubled: ${doubled}</p>\n `;\n },\n});\n```\n\n## features\n\n<div class=\"features grid\">\n\n signal first runtime with `signal`, `computed`, `watch`, `batch` from `@vielzeug/ripple`: import them directly\n functional component authoring via `define(tag, { props, setup, styles, formassociated })`\n props via `prop.*` helpers (`prop.string`, `prop.number`, `prop.bool`, `prop.oneof`, `prop.json`, `prop.data`) or raw `propdef` objects\n `setup(props)` takes only props and returns an `htmlresult` directly: `return html\\`...\\``\n lifecycle hooks: `onmounted`, `oncleanup`, `onevent`, `onelement`, `watcheffect`: plain functions imported from `@vielzeug/ore`, called directly from `setup()` or any composable it calls\n directives: `each` (keyed reactive list rendering), `classmap`, `stylemap`, `when`, `live`, `unsafehtml`\n host bindings via `bind({ attr, class, style, on })`: pass `{ target: el }` to bind any off host element\n reactive aria sync via explicit `aria *` keys in `bind({ attr }, { target })`, with automatic cleanup on disconnect\n context via `provide(key, value)` / `inject(key)`; typed emit/slots via `useemit<emits>()` / `useslots<slotnames>()`\n form associated `usefield()` and observer helpers are root exports\n testing utilities (`@vielzeug/ore/testing`): `mount`, `renderhook`, `flush`, `cleanup`\n generic testing utilities (scoped queries, named event dispatchers, and async waits) are exported by `@vielzeug/assay`\n\n</div>\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/ore` | all browser runtime apis: components, directives, `usefield`, and observers |\n| `@vielzeug/ore/testing` | ore specific mounting, lifecycle flushing, hooks, cleanup, and form internals |\n| `@vielzeug/assay` | generic dom events, scoped queries, and async waiting |\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [refine](../refine/index.md) for prebuilt accessible components powered by ore.\n [ripple](../ripple/index.md) for reactive state used inside ore components.\n [forge](../forge/index.md) for typed form state that integrates with ore.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
920
- "api": " \ntitle: 'ore: api reference'\ndescription: complete api reference for @vielzeug/ore and @vielzeug/ore/testing.\n \n\n[[toc]]\n\n## api overview\n\nall browser runtime symbols below are imported from `@vielzeug/ore`. lifecycle/context/binding functions (`onmounted`, `oncleanup`, `onevent`, `onelement`, `watcheffect`, `bind`, `provide`, `useemit`, `useslots`, `gethost`) resolve the active component through an implicit \"current component\" context: they work when called synchronously during `setup()`, or from any composable function `setup()` calls (transitively), but throw if called outside that window.\n\n> `watcheffect` is not named `watch`: `@vielzeug/ripple` already exports a `watch(source, callback)` with different semantics (explicit source + old/new value pair), and the two are frequently imported in the same file.\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `define()` | create and register a custom element | sync | tag must contain a hyphen; call before first use |\n| `html` | tagged template literal returning htmlresult | sync | expressions must be signals, functions, or primitives |\n| `prop.*` | typed prop helpers (string, bool, number, …) | sync | prop values are signals: read `.value` |\n| `provide()`/`inject()` | context api for parent to descendant sharing | setup only | must be called synchronously during `setup()` |\n| `ref()` | reactive reference to a dom element | sync | value is null until after first mount |\n| `createcontext()` | create a unique typed injection key | sync | context is scoped to the component tree |\n| `each()` | keyed list rendering with dom diffing | sync | duplicate keys report `ore:error`; plain `t[]` is a one time static render |\n| `when()` | conditional branch rendering | sync | getter fn computed disposed on cleanup; static bool skips subscription |\n| `live(signal)` | one way binding that skips stale writes during input | sync | use for controlled inputs alongside a manual `@input` handler |\n| `onmounted(fn)` | dom ready callback | setup only | must be called synchronously during `setup()` |\n| `oncleanup(fn)` | register teardown | setup only | called on component disconnect |\n| `onevent(target, …)` | scoped event listener with auto cleanup | setup only | no ops on null target; removed on disconnect |\n| `usefield(options)` | wire signal to form `elementinternals` | setup only | requires `formassociated: true` on the component definition |\n| `onformreset(fn)` | run work when the ancestor `<form>` resets | setup only | fires every reset (not one shot); only for `formassociated: true` components |\n| `useemit<emits>()` | typed `emit()` bound to the current host | setup only | call once per component; returns `dispatchevent`'s boolean (`false` if a listener called `preventdefault()`) |\n| `useslots<slotnames>()`| reactive slot presence/element signals | setup only | safe to call more than once: the underlying registry is created once |\n| `gethost()` | the current component's host element | setup only | prefer a higher level helper (`bind`, …) when one exists |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/ore` | all browser runtime apis, including directives, fields, and lifecycle helpers |\n| `@vielzeug/ore/testing` | ore specific mounting, lifecycle, hook, cleanup, and form test support |\n| `@vielzeug/assay` | generic dom events, scoped queries, and async waiting |\n\n## core component api\n\n### `define(tag, definition)`\n\n```ts\ndefine<props>(tag: string, definition: componentdefinition<props>): customelementconstructor;\n```\n\nreturns the registered class (for `instanceof` or subclassing); registration itself is the only global side effect.\n\nthe `setup()` function receives only typed prop signals:\n\n```ts\nsetup(props) {\n return html`<div>${props.label}</div>`;\n}\n```\n\neverything else: lifecycle hooks, host bindings, context, slots, emit: is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls):\n\n```ts\nimport { define, html, onmounted, useemit, useslots } from '@vielzeug/ore';\n\ndefine('my card', {\n setup(_props) {\n const emit = useemit<{ close: undefined }>();\n const slots = useslots<'header' | 'footer'>();\n\n onmounted(() => console.log('mounted'));\n\n // emit() returns dispatchevent's boolean: false if a listener called preventdefault()\n const notcancelled = emit('close');\n\n return html`${when(slots.has('header'), () => html`<slot name=\"header\"></slot>`)}`;\n },\n});\n```\n\n`useemit<emits>()` and `useslots<slotnames>()` are factory hooks: call them once per setup run to get a typed\n`emit`/`slots` bound to the current host. `useslots()` is safe to call more than once within that setup run.\n\n### componentdefinition\n\n```ts\ntype componentdefinition<props> = {\n formassociated?: boolean;\n props?: propsdef<props>;\n setup: (props: inferprops<propsdef<props>>) => htmlresult | null;\n shadow?: partial<shadowrootinit> | false; // false = light dom (no shadow root)\n styles?: (string | cssstylesheet | cssresult)[];\n};\n```\n\n## runtime helpers\n\n`onmounted`, `oncleanup`, `onevent`, `onelement`, and `watcheffect` are plain functions imported from `@vielzeug/ore`. call them directly during `setup()`.\n\n```ts\nimport { html, oncleanup, onevent, onmounted } from '@vielzeug/ore';\n\nsetup(props) {\n onmounted(() => {\n // dom is ready; return a function for mount scoped cleanup\n return () => { /* cleanup on unmount */ };\n });\n\n oncleanup(() => { /* called on disconnect */ });\n\n onevent(window, 'keydown', (e) => { /* auto removed on disconnect */ });\n\n return html`...`;\n}\n```\n\nbecause these resolve the active component through an implicit context (rather than a value threaded through parameters), composable helper functions can call them directly too: no need to pass hooks in as options:\n\n```ts\nimport { oncleanup } from '@vielzeug/ore';\n\nfunction usemyhelper() {\n oncleanup(() => { /* teardown */ });\n}\n\n// in setup:\nsetup(_props) {\n usemyhelper();\n return html`...`;\n}\n```\n\n## props api\n\n| helper | signature | notes |\n| | | |\n| `prop.string(defaultvalue?)` | `propdef<string>` | reflects by default |\n| `prop.bool(defaultvalue?)` | `propdef<boolean>` | any non null attribute value other than `\"false\"` parses as `true`; `\"false\"` or absent attribute is `false` |\n| `prop.number(defaultvalue?)` | `propdef<number>` | returns default (not nan) and warns in dev when attribute is not a valid number |\n| `prop.oneof(allowed, defaultvalue)` | `propdef<t>` | restricts to provided string union |\n| `prop.json(defaultvalue)` | `propdef<t>` | json.parse from attribute; `reflect: false` |\n| `prop.data<t>(defaultvalue?)` | `propdef<t>` | js only: never reads/writes an attribute; use for objects, arrays, callbacks, or any non serialisable value |\n\n> **choosing the right prop helper:**\n>\n> **`prop.json`**: value can be declared in html (`<my el config='{\"x\":1}'>`); attribute string is `json.parse`d.\n> **`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.\n\nwhen you need custom parsing or `reflect: false`, use a raw `propdef` object:\n\n```ts\nprops: {\n items: { default: [], parse: () => [], reflect: false },\n}\n```\n\nuse `prop.data` for props that hold js only values (including callbacks) that cannot be serialised through an html attribute:\n\n```ts\ndefine('data grid', {\n props: {\n getrowkey: prop.data<(row: unknown) => string>(),\n columns: prop.data<datagridcolumn[]>([]),\n onsort: prop.data<(key: string) => void>(),\n },\n setup(props) {\n // set from js: grid.getrowkey = (row) => row.id\n return html`...`;\n },\n});\n```\n\n## template and directives\n\n### `html`\n\ntagged template literal that returns an `htmlresult`. supports text interpolation, ordinary attributes (`attr=`),\nboolean attributes (`?attr=`), events (`@event=`), refs (`ref=`), and nested templates.\n\n### `css`\n\ntagged template literal that returns a `cssresult` for use in `styles`.\n\n### directives\n\n| directive | purpose |\n| | |\n| `each(source, key, render, fallback?)` | keyed reactive list; render receives `readable<t>` and `readable<number>`; plain `t[]` is a one time static snapshot |\n| `when(condition, truthy, falsy?)` | conditional rendering |\n| `classmap(record)` | reactive class string from object map |\n| `stylemap(record)` | reactive inline style string from object map |\n| `live(signal)` | one way binding that skips stale writes during active user input; use with `@input` handler |\n| `unsafehtml(value)` | html rendering sink; sanitize untrusted values before calling |\n\n`each()` treats number and string keys as distinct. a duplicate key update reports `ore:error` and preserves the last valid list.\n\n### `unsafehtml`\n\n`unsafehtml()` is an explicit html injection sink. it has no global sanitizer: sanitize untrusted\ncontent before passing it to the directive, so the trust boundary remains at the call site.\n\n```ts\nimport { unsafehtml } from '@vielzeug/ore';\n\nconst safearticle = sanitize(usersuppliedarticle);\n\nreturn html`<article>${unsafehtml(safearticle)}</article>`;\n```\n\n## host bindings\n\n`bind(config, options?)` is a plain function imported from `@vielzeug/ore`:\n\n```ts\nbind({\n attr: { role: 'button', 'aria expanded': () => string(open.value) },\n class: { 'is open': open },\n style: { ' height': () => height.value + 'px' },\n on: { click: handleclick },\n});\n```\n\n`bind()` auto registers cleanup with the component scope: no manual `oncleanup` needed. returns a cleanup function for early teardown.\n\n### off host bindings\n\npass `{ target: el }` as a second argument to bind to any element other than the host:\n\n```ts\nbind(\n { attr: { 'aria expanded': () => string(isopen.value) } },\n { target: triggerel },\n);\n```\n\nevent listener options (`once`, `capture`, `passive`) are also accepted in the second argument. cleanup is auto registered with the component scope when called during setup.\n\n### reactive aria attributes\n\nuse explicit `aria *` keys in `bind({ attr }, { target })`:\n\n```ts\nconst stoparia = bind(\n {\n attr: {\n 'aria controls': panelid,\n 'aria expanded': () => isopen.value,\n 'aria haspopup': 'listbox',\n },\n },\n { target: triggerel },\n);\n```\n\nstatic values are applied once. getters and signals create reactive effects. calling `stoparia()` stops updates. values of `null`, `undefined`, or `false` remove the attribute.\n\n## slots\n\n `slots.has(name?)`: `readable<boolean>`: whether the named (or default) slot has assigned content\n `slots.elements(name?)`: `readable<element[]>`: the assigned elements for the slot\n\nslot signals update reactively when assigned content changes, including when slots are inserted dynamically (via `when()` or `each()`) after mount.\n\n## context api\n\n `createcontext<t>(description)`: create a unique typed injection key; the description is diagnostic only\n `provide(key, value)`: provide a value to descendants\n `inject(key)`: resolve from nearest ancestor; returns `undefined` if not found\n `inject(key, fallback)`: resolve with a fallback value\n `injectstrict(key)`: resolve or throw if absent\n\n`provide()` and `inject()` must be called synchronously during `setup()`. calling them outside a setup context throws\n`'lifecycle hooks must be called during component setup'`. context resolution walks the ancestor chain including shadow\ndom boundaries. `inject()` resolves and caches its result once per consumer: provide a `readable` (signal/computed)\nrather than a raw value if descendants need to observe later changes; re calling `provide()` with a new raw value\nafterward is not seen by consumers that already resolved it (a dev mode warning fires when a key is provided twice on\nthe same element). `provide()` registers cleanup automatically: context keys are removed from the registry when the\nproviding component disconnects, so reconnecting the same element runs `setup()` fresh without spurious \"overwriting\"\nwarnings or stale keys leaking to descendants.\n\n## utilities\n\n `ref<t>()`: create a `signal<t | null>` element reference. set to the element via `ref=` in templates.\n `createid(prefix = 'id')`: generate a collision resistant id with a semantic prefix, a per runtime tag, and a monotonic counter (for example, `'field a3k21'`).\n\n## form associated api\n\nimport from `@vielzeug/ore`.\n\n### `usefield(options)`\n\nwire 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.\n\n```ts\ntype formfieldoptions<t> = {\n disabled?: readable<boolean>;\n /**\n * when true, a null/undefined value is submitted as '' instead of null,\n * keeping the field's key present in formdata even when the value is absent.\n * only applies to the default toformvalue; ignored if toformvalue is provided.\n * @default false\n */\n emptystringfornull?: boolean;\n /** called when the ancestor <form> resets (see onformreset): restore local field state here. */\n onreset?: () => void;\n toformvalue?: (value: t) => file | formdata | string | null;\n /** recomputed reactively and passed straight to internals.setvalidity(). null = always valid. */\n validationmessage?: readable<string>;\n validity?: readable<validitystateflags | null>;\n value: signal<t> | readable<t>;\n};\n\ntype formfieldhandle = {\n checkvalidity(): boolean;\n readonly internals: elementinternals;\n reportvalidity(): boolean;\n /** set (non empty message) or clear (empty string) a custom validity error. */\n setcustomvalidity(message: string): void;\n};\n```\n\npass `validity`/`validationmessage` to make `required` style constraints participate in native constraint validation\nthrough `checkvalidity()` and `reportvalidity()`:\n\n```ts\nconst isblank = (v: string) => v.trim() === '';\n\nusefield({\n validationmessage: computed(() => (required.value && isblank(value.value) ? 'this field is required.' : '')),\n validity: computed(() => (required.value && isblank(value.value) ? { valuemissing: true } : null)),\n value,\n});\n```\n\n## testing apis\n\nimport from `@vielzeug/ore/testing`.\n\n| api | purpose |\n| | |\n| `mount(setup, options?)` | mount a component and return a test fixture |\n| `cleanup()` | remove all mounted elements and reset test state |\n| `install(aftereach, options?)` | register auto cleanup; pass `{ forminternals: true }` to also install the `elementinternals`/`formdata`/`<form>.reset()` jsdom polyfill (see below) |\n| `installforminternalspolyfill()` | installs the form internals polyfill directly (returns an `uninstall()` that restores every patched global). usually called via `install(aftereach, { forminternals: true })` |\n| `flush(options?)` | drain reactive updates and animation frames |\n| `renderhook(setup)` | run lifecycle hooks in isolation; overload accepts `propdefs` as first arg for typed props |\n| `oretimeouterror` | error thrown when `flush()` cannot settle tracked ore work |\n\n> **test isolation:** `cleanup()` removes mounted elements and resets ore's cross test caches and counters. call it in `aftereach`, or use `install(aftereach)`.\n\nimport `within`, named dispatchers such as `fireclick`, and waits such as `waituntil` or `waitforevent` from\n`@vielzeug/assay`.\n\n> **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 `<form>.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.\n\n#### `fixture` interface\n\n```ts\ninterface fixture<t extends htmlelement = htmlelement> {\n [symbol.dispose](): void; // delegates to dispose(): enables `using` declarations\n element: t;\n readonly disposed: boolean; // true after dispose() has been called\n readonly shadow: shadowroot | null;\n get<e extends element>(selector: string): e;\n query<e extends element>(selector: string): e | null;\n queryall<e extends element>(selector: string): e[];\n getbytext<e extends element>(text: string, selector?: string): e;\n querybytext<e extends element>(text: string, selector?: string): e | null;\n queryallbytext<e extends element>(text: string, selector?: string): e[];\n getbytestid<e extends element>(testid: string): e;\n querybytestid<e extends element>(testid: string): e | null;\n queryallbytestid<e extends element>(testid: string): e[];\n attr(name: string, value: string | number | boolean): promise<void>;\n attrs(record: record<string, string | number | boolean>): promise<void>;\n flush(options?: flushoptions): promise<void>;\n act(fn: () => unknown): promise<void>;\n dispose(): void; // removes the component from the dom: idempotent\n}\n```\n\n#### `renderhook`\n\nuseful for testing composable lifecycle hooks (`onmounted`, `watcheffect`, `inject`, etc.) without a template. `onmounted`/`oncleanup`/`watcheffect`/... work exactly as inside a real `setup()`, since they resolve the same implicit current component context:\n\n```ts\n// without props\nconst { result, flush, dispose } = await renderhook(() => {\n const count = signal(0);\n onmounted(() => {\n count.value = 1;\n });\n return count;\n});\nexpect(result.value).tobe(1);\n\n// with typed props (prop defs overload)\nconst { result } = await renderhook({ label: prop.string('hello'), count: prop.number(0) }, (props) => props.label);\nexpect(result.value).tobe('hello');\n```\n\n## ripple primitives\n\nore does **not** re export reactive primitives. import them directly from `@vielzeug/ripple`:\n\n```ts\nimport { batch, computed, signal, watch } from '@vielzeug/ripple';\n```\n\nsee the [ripple documentation](/ripple/) for the full api.\n\n## lifecycle events\n\n| event | when |\n| | |\n| `ore:error` | when a lifecycle callback fails: bubbles, composed; detail is `orelifecycleerror` |\n\n## types\n\n```ts\ntype propdef<t> = {\n readonly default: t;\n readonly parse: (value: string | null) => t;\n reflect?: boolean;\n};\n\ntype propsdef<t extends record<string, unknown>> = {\n [k in keyof required<t>]: propdef<t[k & keyof t]>;\n};\n\ntype propinputdefs = record<string, propdef<unknown>>;\n\n/**\n * infer reactive props type from a propinputdefs map.\n * each entry becomes readable<t> keyed by prop name.\n */\ntype inferprops<d extends propinputdefs> = {\n readonly [k in keyof d] ?: readable<inferpropvalue<d[k]>>;\n};\n\n// runtime hooks: all plain functions imported from '@vielzeug/ore', not fields on an object.\ntype onmountedcallback = () => cleanup | undefined;\ntype onformresetcallback = () => void;\n\ndeclare function onmounted(fn: onmountedcallback): void; // dom ready callback; runs after each connection's render\ndeclare function oncleanup(fn: cleanup): void; // register teardown; called on disconnect\ndeclare function onelement<t extends htmlelement>(\n ref: readable<t | null>,\n callback: (el: t) => cleanup | undefined,\n): () => void;\ndeclare function onevent<k extends keyof htmlelementeventmap>(\n target: eventtarget | null | undefined,\n event: k,\n listener: (e: htmlelementeventmap[k]) => void,\n options?: addeventlisteneroptions,\n): void;\ndeclare function onevent(\n target: eventtarget | null | undefined,\n event: string,\n listener: eventlistener,\n options?: addeventlisteneroptions,\n): void;\ndeclare function onformreset(fn: onformresetcallback): void; // runs on every ancestor <form> reset; formassociated only\ndeclare function watcheffect(fn: () => cleanup | undefined): () => void; // scoped reactive effect; auto cleaned on disconnect\ndeclare function bind(config: hostbindconfig, options?: bindoptions): () => void; // bindings for host or any target element\ndeclare function provide<t>(key: injectionkey<t>, value: t): void; // register a context value on the host element\ndeclare function inject<t>(key: injectionkey<t>): t | undefined;\ndeclare function inject<t>(key: injectionkey<t>, fallback: t): t;\ndeclare function gethost(): htmlelement; // the current component's host element\ndeclare function useemit<emits extends record<string, unknown> = record<string, never>>(): emitfn<emits>;\ndeclare function useslots<slotnames extends string = string>(): componentslots<slotnames>;\n\ntype componentdefinition<props extends record<string, unknown> = record<never, never>> = {\n formassociated?: boolean;\n props?: propsdef<props>;\n setup: (props: inferprops<propsdef<props>>) => htmlresult | null;\n shadow?: partial<shadowrootinit> | false; // false = light dom\n styles?: (string | cssstylesheet | cssresult)[];\n};\n\ntype maybereactive<t> = t | readable<t> | (() => t); // plain value, signal, or getter: accepted by when(), each(), classmap(), and bind()\n\ntype hostbindingvalue = maybereactive<string | number | boolean | null | undefined>;\n\ntype attributebindings = record<string, hostbindingvalue>;\n\ntype hostbindconfig = {\n attr?: attributebindings;\n class?: (() => record<string, boolean>) | record<string, readable<boolean> | (() => boolean) | boolean>;\n on?: record<string, ((event: event) => void) | undefined>;\n style?: record<string, hostbindingvalue>;\n};\n\ntype bindoptions = addeventlisteneroptions & {\n target?: element;\n};\n\ntype componentslots<s extends string = string> = {\n elements(name?: s): readable<element[]>;\n has(name?: s): readable<boolean>;\n};\n\ntype ref<t extends element> = signal<t | null>;\n\ntype refcallback<t extends element> = (el: t | null) => void;\n\ntype injectionkey<t> = symbol & { readonly __ore_injection_key?: t };\n\ninterface htmlresult {\n mount(\n parent: parentnode,\n anchor: node | null,\n registercleanup: (fn: () => void) => void,\n ): node[];\n}\n\ntype cssresult = {\n content: string;\n tostring(): string;\n};\n\ntype livebinding<t> = { readonly source: readable<t> };\n\ntype emitfn<t extends record<string, unknown>> = {\n <k extends keyswithoutdetail<t>>(event: k): boolean;\n <k extends exclude<keyof t, keyswithoutdetail<t>>>(event: k, detail: t[k]): boolean;\n};\n// keyswithoutdetail is an internal helper type, not exported.\n\ntype formfieldoptions<t = unknown> = {\n disabled?: readable<boolean>;\n emptystringfornull?: boolean;\n onreset?: () => void;\n toformvalue?: (value: t) => file | formdata | string | null;\n validationmessage?: readable<string>;\n validity?: readable<validitystateflags | null>;\n value: signal<t> | readable<t>;\n};\n\ntype formfieldhandle = {\n checkvalidity: () => boolean;\n readonly internals: elementinternals;\n reportvalidity: () => boolean;\n setcustomvalidity: (message: string) => void;\n};\n\n/** phase in which a oreerror occurred. */\ntype oreerrorphase = 'each reconcile' | 'form reset' | 'mounted' | 'setup';\n```\n\n## errors\n\n`oreerror` is the base class for every ore error class: `err instanceof oreerror` catches all of them.\n\n **`oreapierror`**: thrown when the `ore` api itself is misused: calling `define()` with a duplicate tag, calling a lifecycle hook (`inject`, `onmounted`, `oncleanup`, `onevent`, …) outside of `setup()`, or passing an invalid prop definition to `define()`.\n **`oreinternalerror`**: thrown when an ore invariant fails, indicating a package bug rather than invalid application code.\n **`orelifecycleerror`**: reported in the `ore:error` event when component `setup()`, a mounted callback, a form reset callback, or `each()` reconciliation fails. extends `oreerror` with:\n `component: string`: the element's local name\n `phase: oreerrorphase`: `'setup'` | `'mounted'` | `'form reset'` | `'each reconcile'`\n `cause: error`: the original error thrown by `setup()`\n **`oretimeouterror`**: thrown by `flush()` (from `@vielzeug/ore/testing`) when pending ore work does not settle before its timeout.\n\nlifecycle failures dispatch a bubbling, composed `ore:error` event whose `detail` is the `orelifecycleerror`. setup\nfailures still rethrow their original error; mounted and form reset callback failures are reported through the same\nevent so their remaining callbacks can continue.\n",
921
- "usage": " \ntitle: 'ore: usage guide'\ndescription: practical ore usage patterns for components, props, templates, slots, context, forms, sentinel integration, and tests.\n \n\n[[toc]]\n\n## basic usage\n\n`define(tag, definition)` creates and registers a custom element. call it from the module or browser bootstrap responsible for registration.\n\nyour `setup()` function receives typed prop signals and returns an `htmlresult` directly. its state belongs to the\ncurrent connection: disconnect disposes it, and reconnecting the same element runs setup again.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('status chip', {\n setup() {\n const online = signal(true);\n\n return html`\n <button @click=${() => (online.value = !online.value)}>${() => (online.value ? 'online' : 'offline')}</button>\n `;\n },\n});\n```\n\neverything besides `props`: lifecycle hooks, host bindings, context, slots, emit: is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls):\n\n```ts\nimport { define, gethost, html, bind, useemit, useslots } from '@vielzeug/ore';\n\ndefine('my widget', {\n setup(_props) {\n const el = gethost(); // the host htmlelement\n const emit = useemit<{ close: undefined }>(); // typed event emitter\n const slots = useslots<'header'>(); // reactive slot observation\n\n bind({ attr: { role: 'group' } }); // host binding helper (attr, class, style, on)\n\n return html`<slot></slot>`;\n },\n});\n```\n\n## signals and effects\n\nore does not re export ripple primitives: import them directly from `@vielzeug/ripple`.\n\n```ts\nimport { batch, computed, effect, signal, watch } from '@vielzeug/ripple';\n\nconst count = signal(0);\nconst doubled = computed(() => count.value * 2);\n\neffect(() => {\n console.log('doubled =', doubled.value);\n});\n\nwatch(count, (next, prev) => {\n console.log('count changed', prev, ' >', next);\n});\n\nbatch(() => {\n count.value = 1;\n count.value = 2;\n});\n```\n\n## onmounted and lifecycle\n\nuse `onmounted()` for dom dependent initialization that must run after the template is mounted. use `onelement(ref, cb)` for work tied to a specific dom node. `onevent()` attaches a listener that is automatically removed on disconnect.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html, onelement, onevent, onmounted, ref, useslots } from '@vielzeug/ore';\n\ndefine('deferred init', {\n setup(_props) {\n const tabindex = signal(0);\n const inputref = ref<htmlinputelement>();\n const slots = useslots<'items'>();\n\n onmounted(() => {\n const items = slots.elements('items').value;\n console.log('found', items.length, 'items');\n });\n\n onelement(inputref, (input) => {\n input.focus();\n });\n\n onevent(window, 'keydown', (e: keyboardevent) => {\n if (e.key === 'escape') tabindex.value = 0;\n });\n\n return html`<div><slot name=\"items\"></slot><input ref=${inputref} /></div>`;\n },\n});\n```\n\n## prop definitions\n\nuse `prop.*` helpers for common cases, or raw `propdef` objects for custom parsing or `reflect: false`.\n\n```ts\nimport { define, html, prop } from '@vielzeug/ore';\n\ndefine('x button', {\n props: {\n label: prop.string('button'),\n disabled: prop.bool(false),\n variant: prop.oneof(['primary', 'secondary'] as const, 'primary'),\n count: prop.number(0),\n },\n setup(props) {\n return html`\n <button ?disabled=${props.disabled} data variant=${props.variant}>${props.label} (${props.count})</button>\n `;\n },\n});\n```\n\n## template bindings\n\n`html` supports text, attributes, booleans, properties, events, refs, and nested templates.\n\n```ts\nimport { computed, signal } from '@vielzeug/ripple';\nimport { define, html, ref } from '@vielzeug/ore';\n\ndefine('profile name', {\n setup() {\n const name = signal('alice');\n const inputref = ref<htmlinputelement>();\n\n return html`\n <label title=${computed(() => 'current: ' + name.value)}>name</label>\n <input\n ref=${inputref}\n value=${name}\n aria label=${() => 'current name ' + name.value}\n @input=${(event: event) => {\n name.value = (event.target as htmlinputelement).value;\n }} />\n <p>hello ${name}</p>\n `;\n },\n});\n```\n\n## directives\n\nore exports `each`, `classmap`, `stylemap`, `when`, `live`, and `unsafehtml` from `@vielzeug/ore`. use ordinary\nattribute bindings plus native event handlers for two way input state; no special model directive is required.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { classmap, define, each, html, stylemap, when } from '@vielzeug/ore';\n\ndefine('task list', {\n setup() {\n const tasks = signal([{ id: 1, text: 'write tests' }]);\n const active = signal(true);\n\n return html`\n <ul\n class=\"${classmap({ ready: () => tasks.value.length > 0 })}\"\n style=${stylemap({ opacity: () => (active.value ? 1 : 0.5) })}>\n ${when(\n () => active.value,\n () => html`<li>active</li>`,\n () => html`<li>paused</li>`,\n )}\n ${each(\n tasks,\n (task) => task.id,\n (task) => html`<li>${() => task.value.text}</li>`,\n )}\n </ul>\n `;\n },\n});\n```\n\n### each() api\n\n`each(source, key, render, fallback?)` takes positional arguments:\n\n **source**: signal, getter, or plain array\n **key**: function returning a unique string or number per item; number and string keys remain distinct\n **render**: receives reactive `item` and `index` signals\n **fallback**: optional, rendered when the list is empty\n\n```ts\neach(\n items,\n (item) => item.id,\n (item, index) => html`<li>#${index}: ${() => item.value.label}</li>`,\n () => html`<li>no items</li>`,\n);\n```\n\n## live form bindings\n\nuse `live(signal)` for inputs that should preserve in progress user edits instead of overwriting the dom on stale writes.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html, live } from '@vielzeug/ore';\n\ndefine('live search', {\n setup() {\n const query = signal('');\n\n return html`\n <input value=${live(query)} @input=${(e: event) => (query.value = (e.target as htmlinputelement).value)} />\n `;\n },\n});\n```\n\n## host bindings\n\n`bind()` wires reactive attrs, classes, styles, and events to the host element.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { bind, define, html } from '@vielzeug/ore';\n\ndefine('x toggle', {\n setup(_props) {\n const open = signal(false);\n\n bind({\n attr: { 'aria expanded': () => string(open.value), role: 'button', tabindex: 0 },\n class: { 'is open': open },\n on: { click: () => (open.value = !open.value) },\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\nthe `bind` config supports `attr`, `class`, `style`, and `on` sections.\n\n## aria bindings\n\nuse explicit `aria *` keys in `bind({ attr: config }, { target })` to reactively sync aria attributes to any element.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { bind, define, html, onmounted } from '@vielzeug/ore';\n\ndefine('x disclosure', {\n setup(_props) {\n const open = signal(false);\n const panelid = 'disclosure panel';\n\n bind({\n attr: { role: 'button', tabindex: 0 },\n on: { click: () => (open.value = !open.value) },\n });\n\n onmounted(() => {\n const trigger = document.queryselector('#trigger') as htmlelement;\n if (trigger) {\n // bind() registers cleanup automatically when called inside setup\n bind(\n {\n attr: {\n 'aria controls': panelid,\n 'aria expanded': () => string(open.value),\n 'aria haspopup': 'region',\n },\n },\n { target: trigger },\n );\n }\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\nstatic values are applied once. getter functions create reactive effects. setting a value to `null`, `undefined`, or `false` removes the attribute.\n\n`bind()` always returns a cleanup function. use it to stop syncing early when a trigger element can be swapped out:\n\n```ts\nonmounted(() => {\n const trigger = document.queryselector('#trigger') as htmlelement;\n const stoparia = bind({ attr: { 'aria expanded': () => string(open.value) } }, { target: trigger });\n\n // stop syncing when the trigger is replaced\n oncleanup(stoparia);\n});\n```\n\n### binding a non host element with `bind()`\n\npass `{ target: el }` as a second argument to bind attributes, classes, styles, or events to any element:\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { bind, define, html, onmounted, ref } from '@vielzeug/ore';\n\ndefine('button wrapper', {\n setup(_props) {\n const visible = signal(false);\n const btnref = ref<htmlbuttonelement>();\n\n onmounted(() => {\n const btn = btnref.value;\n if (!btn) return;\n\n bind(\n {\n attr: { 'aria pressed': () => string(visible.value) },\n on: { click: () => (visible.value = !visible.value) },\n },\n { target: btn },\n );\n });\n\n return html`<button ref=${btnref}>toggle</button>`;\n },\n});\n```\n\n## slots and emits\n\n```ts\nimport { define, html, useemit, useslots, when } from '@vielzeug/ore';\n\ndefine('card with footer', {\n setup(_props) {\n const slots = useslots<'header' | 'footer'>();\n const emit = useemit<{ action: undefined }>();\n\n return html`\n <div class=\"card\">\n <slot name=\"header\"></slot>\n <slot></slot>\n ${when(slots.has('footer'), () => html`<footer><slot name=\"footer\"></slot></footer>`)}\n </div>\n <button @click=${() => emit('action')}>go</button>\n `;\n },\n});\n```\n\npass a `slotnames` type parameter to `useslots<slotnames>()` to get typed `slots.has()` and `slots.elements()` calls.\n\n## context provide/inject\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { createcontext, define, html, injectstrict, provide } from '@vielzeug/ore';\n\nconst count_ctx = createcontext<returntype<typeof signal<number>>>('count');\n\ndefine('count provider', {\n setup(_props) {\n const count = signal(0);\n provide(count_ctx, count);\n\n return html`<button @click=${() => count.value++}><slot></slot></button>`;\n },\n});\n\ndefine('count consumer', {\n setup() {\n const count = injectstrict(count_ctx);\n\n return html`<p>count: ${count}</p>`;\n },\n});\n```\n\n`provide()` registers cleanup automatically: context keys are removed from the registry when the providing component disconnects. on reconnect, `setup()` runs fresh and `provide()` re registers without spurious \"overwriting\" warnings. provide a `readable` (signal/computed) rather than a raw value if descendants need to observe later changes: `inject()` resolves and caches the value once per consumer connection.\n\n## form associated elements\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html, prop } from '@vielzeug/ore';\nimport { usefield } from '@vielzeug/ore';\n\ndefine('rating input', {\n formassociated: true,\n setup() {\n const value = signal(0);\n const field = usefield({ value });\n\n return html`\n <button @click=${() => (value.value = 1)}>1</button>\n <button @click=${() => (value.value = 2)}>2</button>\n <button @click=${() => (value.value = 3)}>3</button>\n <button @click=${() => field.reportvalidity()}>validate</button>\n <p>current: ${value}</p>\n `;\n },\n});\n```\n\n## sentinel observers\n\nuse `@vielzeug/sentinel` for subscribable browser and dom observations. create element dependent sentinels inside `onmounted()` and dispose them with the component.\n\n```ts\nimport { define, html, oncleanup, onmounted, ref, watcheffect } from '@vielzeug/ore';\nimport { fromsubscribable } from '@vielzeug/ripple';\nimport { createelementsize, sentinelunavailableerror } from '@vielzeug/sentinel';\n\ndefine('x observed', {\n setup(_props) {\n const boxref = ref<htmldivelement>();\n\n onmounted(() => {\n const element = boxref.value;\n if (!element) return;\n\n try {\n const size = createelementsize(element);\n const sizestate = fromsubscribable(size, { signal: size.disposalsignal });\n\n watcheffect(() => {\n console.log(sizestate.value?.width);\n });\n\n oncleanup(() => size.dispose());\n } catch (error) {\n if (!(error instanceof sentinelunavailableerror)) throw error;\n }\n });\n\n return html`<div ref=${boxref}>observe me</div>`;\n },\n});\n```\n\n## testing utilities\n\nimport from `@vielzeug/ore/testing`.\n\n```ts\nimport { aftereach, describe, expect, it } from 'vitest';\nimport { signal } from '@vielzeug/ripple';\nimport { fireclick } from '@vielzeug/assay';\nimport { html } from '@vielzeug/ore';\nimport { cleanup, mount } from '@vielzeug/ore/testing';\n\ndescribe('my counter', () => {\n aftereach(cleanup);\n\n it('increments on click', async () => {\n let count!: returntype<typeof signal<number>>;\n const { query, act } = await mount(() => {\n count = signal(0);\n return html`<button @click=${() => count.value++}>${count}</button>`;\n });\n\n expect(query('button')?.textcontent).tobe('0');\n\n await act(() => fireclick(query('button')!));\n\n expect(query('button')?.textcontent).tobe('1');\n });\n});\n```\n\n## framework integration\n\nore components are standard custom elements and work natively in any framework.\n\n::: code group\n\n```tsx [react]\n// react 19+ supports custom elements natively.\nimport './x toggle'; // wherever define('x toggle', { ... }) is called\n\nfunction app() {\n return <x toggle aria label=\"open menu\" />;\n}\n```\n\n```ts [vue 3]\n<script setup lang=\"ts\">\nimport './x toggle'; // wherever define('x toggle', { ... }) is called\nimport { ref } from 'vue';\n\nconst open = ref(false);\n</script>\n\n<template>\n <x toggle :aria label=\"'open menu'\" @click=\"open = !open\" />\n</template>\n```\n\n```svelte [svelte]\n<script>\n import './x toggle'; // wherever define('x toggle', { ... }) is called\n\n function handleclick() {\n console.log('toggled');\n }\n</script>\n\n<x toggle aria label=\"open menu\" on:click={handleclick} />\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### with ripple\n\nimport ripple primitives directly from `@vielzeug/ripple` for standalone reactive state outside components.\n\n```ts\nimport { signal, computed } from '@vielzeug/ripple';\nimport { define, html } from '@vielzeug/ore';\n\n// shared state created outside any component\nconst theme = signal<'light' | 'dark'>('light');\nconst isdark = computed(() => theme.value === 'dark');\n\ndefine('theme toggle', {\n setup() {\n return html`\n <button @click=${() => (theme.value = isdark.value ? 'light' : 'dark')}>\n ${() =>\n isdark.value ? '<ore icon name=\"sun\" size=\"16\"></ore icon>' : '<ore icon name=\"moon\" size=\"16\"></ore icon>'}\n </button>\n `;\n },\n});\n```\n\n### with forge\n\nuse `@vielzeug/forge` for typed form state. `usefield()` remains intentionally narrow: it connects a form associated\ncustom element to native `elementinternals` without imposing submission, validation, or dirty state policy.\n\n```ts\nimport { createform } from '@vielzeug/forge';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('signup form', {\n setup(_props) {\n const form = createform({ initialvalues: { email: '' } });\n\n return html`\n <form\n @submit=${(event: submitevent) => {\n event.preventdefault();\n void form.submit(async (values) => {\n console.log(values);\n });\n }}>\n <slot></slot>\n </form>\n `;\n },\n});\n```\n\n## best practices\n\n setup returns `html\\`...\\`` directly: not a function wrapping the template.\n use `watcheffect()` for reactive subscriptions tied to component lifetime: it auto registers cleanup on disconnect.\n use `onelement(ref, cb)` instead of `onmounted` when the work is tied to a single dom node.\n bind host attributes and classes via `bind()` rather than mutating the element directly.\n provide context at the nearest ancestor: avoid global context singletons.\n call `oncleanup()` for every resource allocated in `setup()` (websockets, intervals, external subscriptions).\n use `live(signal)` for form inputs to prevent clobbering user in progress edits.\n 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.\n test component mounting and lifecycle with `@vielzeug/ore/testing`; import generic dom events, queries, and waits\n from `@vielzeug/assay`.\n",
931
+ "index": " \ntitle: 'ore: web component authoring with signals'\ndescription: functional custom element authoring with typed props, reactive templates, lifecycle helpers, and testing utilities.\npackage: ore\ncategory: ui primitives\nkeywords: [web components, custom elements, reactive, templates, signals, lifecycle]\nrelated: [ripple, refine, orbit]\nexports: [define, prop, html, css, ref, createcontext, inject, injectstrict, provide, onmounted, oncleanup, onevent, onelement, onformreset, watcheffect, useemit, useslots, gethost, bind, each, when, classmap, stylemap, live, model, unsafehtml, usefield, createid, oreerror, oreapierror, oreinternalerror, orelifecycleerror, attributebindings, bindoptions, maybereactive]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"ore\" />\n\n## why ore?\n\nore keeps custom elements functional and signal driven while giving you direct control over templates, lifecycle hooks, host bindings, and form associated behavior.\n\n```ts\n// before: vanilla custom element boilerplate\nclass mycounter extends htmlelement {\n #count = 0;\n connectedcallback() {\n this.attachshadow({ mode: 'open' });\n this.#render();\n }\n #render() {\n this.shadowroot!.innerhtml = `<button>${this.#count}</button>`;\n this.shadowroot!.queryselector('button')!.onclick = () => {\n this.#count++;\n this.#render();\n };\n }\n}\ncustomelements.define('my counter', mycounter);\n\n// after: ore\nimport { signal } from '@vielzeug/ripple';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('my counter', {\n setup() {\n const count = signal(0);\n return html`<button @click=${() => count.value++}>${count}</button>`;\n },\n});\n```\n\n| feature | ore | lit | stencil |\n| | | | |\n| bundle size | <packageinfo package=\"ore\" type=\"size\" /> | ~12 kb | ~60 kb+ toolchain |\n| signal first runtime | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> (separate signals package) | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| functional component setup | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| typed prop helpers | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| host binding helpers | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | partial |\n| form associated helpers | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | partial |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use ore when** you want typed, signal driven custom elements with minimal runtime overhead and no framework lock in.\n\n**consider lit when** you need a mature ecosystem with wide community adoption and don't need signal based reactivity.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/ore @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/ore @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/ore @vielzeug/ripple\n```\n\n:::\n\n## quick start\n\n```ts\nimport { computed, signal } from '@vielzeug/ripple';\nimport { bind, css, define, html, onmounted, prop } from '@vielzeug/ore';\n\ndefine('my counter', {\n props: {\n label: prop.string('count'),\n step: prop.number(1),\n },\n styles: [\n css`\n :host {\n display: inline grid;\n gap: 0.5rem;\n }\n `,\n ],\n setup(props) {\n const count = signal(0);\n const doubled = computed(() => count.value * 2);\n\n bind({ class: { 'is positive': () => count.value > 0 } });\n\n onmounted(() => console.log('mounted'));\n\n return html`\n <button @click=${() => (count.value += props.step.value)}>${props.label}: ${count}</button>\n <p>doubled: ${doubled}</p>\n `;\n },\n});\n```\n\n## features\n\n<div class=\"features grid\">\n\n signal first runtime with `signal`, `computed`, `watch`, `batch` from `@vielzeug/ripple`: import them directly\n functional component authoring via `define(tag, { props, setup, styles, formassociated })`\n props via `prop.*` helpers (`prop.string`, `prop.number`, `prop.bool`, `prop.oneof`, `prop.json`, `prop.data`) or raw `propdef` objects\n `setup(props)` takes only props and returns an `htmlresult` directly: `return html\\`...\\``\n lifecycle hooks: `onmounted`, `oncleanup`, `onevent`, `onelement`, `watcheffect`: plain functions imported from `@vielzeug/ore`, called directly from `setup()` or any composable it calls\n directives: `each` (transactional keyed list reconciliation), `classmap`, `stylemap`, `when`, `live`, `model`, `unsafehtml`\n host bindings via `bind({ attr, class, style, on })`: pass `{ target: el }` to bind any off host element\n reactive aria sync via explicit `aria *` keys in `bind({ attr }, { target })`, with automatic cleanup on disconnect\n context via `provide(key, value)` / `inject(key)`; typed emit/slots via `useemit<emits>()` / `useslots<slotnames>()`\n form associated `usefield()` and observer helpers are root exports\n testing utilities (`@vielzeug/ore/testing`): `mount`, `renderhook`, `flush`, `cleanup`\n generic testing utilities (scoped queries, named event dispatchers, and async waits) are exported by `@vielzeug/assay`\n\n</div>\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/ore` | all browser runtime apis: components, directives, `usefield`, and observers |\n| `@vielzeug/ore/testing` | ore specific mounting, lifecycle flushing, hooks, cleanup, and form internals |\n| `@vielzeug/assay` | generic dom events, scoped queries, and async waiting |\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [refine](../refine/index.md) for prebuilt accessible components powered by ore.\n [ripple](../ripple/index.md) for reactive state used inside ore components.\n [forge](../forge/index.md) for typed form state that integrates with ore.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
932
+ "api": " \ntitle: 'ore: api reference'\ndescription: complete api reference for @vielzeug/ore and @vielzeug/ore/testing.\n \n\n[[toc]]\n\n## api overview\n\nall browser runtime symbols below are imported from `@vielzeug/ore`. lifecycle/context/binding functions (`onmounted`, `oncleanup`, `onevent`, `onelement`, `watcheffect`, `bind`, `provide`, `useemit`, `useslots`, `gethost`) resolve the active component through an implicit \"current component\" context: they work when called synchronously during `setup()`, or from any composable function `setup()` calls (transitively), but throw if called outside that window.\n\n> `watcheffect` is not named `watch`: `@vielzeug/ripple` already exports a `watch(source, callback)` with different semantics (explicit source + old/new value pair), and the two are frequently imported in the same file.\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `define()` | create and register a custom element | sync | tag must contain a hyphen; call before first use |\n| `html` | tagged template literal returning htmlresult | sync | expressions must be signals, functions, or primitives |\n| `prop.*` | typed prop helpers (string, bool, number, …) | sync | prop values are signals: read `.value` |\n| `provide()`/`inject()` | context api for parent to descendant sharing | setup only | must be called synchronously during `setup()` |\n| `ref()` | reactive reference to a dom element | sync | value is null until after first mount |\n| `createcontext()` | create a unique typed injection key | sync | context is scoped to the component tree |\n| `each()` | keyed list rendering with dom diffing | sync | duplicate keys report `ore:error`; plain `t[]` is a one time static render |\n| `when()` | conditional branch rendering | sync | getter fn computed disposed on cleanup; static bool skips subscription |\n| `live(signal)` | one way binding that skips stale writes during input | sync | use for controlled inputs alongside a manual `@input` handler |\n| `model(signal)` | two way form control binding (value/checked + write back) | sync | plain `value`/`checked` attribute only; pass `get` to keep numeric signals numeric |\n| `onmounted(fn)` | dom ready callback | setup only | must be called synchronously during `setup()` |\n| `oncleanup(fn)` | register teardown | setup only | called on component disconnect |\n| `onevent(target, …)` | scoped event listener with auto cleanup | setup only | no ops on null target; removed on disconnect |\n| `usefield(options)` | wire signal to form `elementinternals` | setup only | requires `formassociated: true` on the component definition |\n| `onformreset(fn)` | run work when the ancestor `<form>` resets | setup only | fires every reset (not one shot); only for `formassociated: true` components |\n| `useemit<emits>()` | typed `emit()` bound to the current host | setup only | call once per component; returns `dispatchevent`'s boolean (`false` if a listener called `preventdefault()`) |\n| `useslots<slotnames>()`| reactive slot presence/element signals | setup only | safe to call more than once: the underlying registry is created once |\n| `gethost()` | the current component's host element | setup only | prefer a higher level helper (`bind`, …) when one exists |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/ore` | all browser runtime apis, including directives, fields, and lifecycle helpers |\n| `@vielzeug/ore/testing` | ore specific mounting, lifecycle, hook, cleanup, and form test support |\n| `@vielzeug/assay` | generic dom events, scoped queries, and async waiting |\n\n## core component api\n\n### `define(tag, definition)`\n\n```ts\ndefine<props>(tag: string, definition: componentdefinition<props>): customelementconstructor;\n```\n\nreturns the registered class (for `instanceof` or subclassing); registration itself is the only global side effect.\n\nthe `setup()` function receives only typed prop signals:\n\n```ts\nsetup(props) {\n return html`<div>${props.label}</div>`;\n}\n```\n\neverything else: lifecycle hooks, host bindings, context, slots, emit: is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls):\n\n```ts\nimport { define, html, onmounted, useemit, useslots } from '@vielzeug/ore';\n\ndefine('my card', {\n setup(_props) {\n const emit = useemit<{ close: undefined }>();\n const slots = useslots<'header' | 'footer'>();\n\n onmounted(() => console.log('mounted'));\n\n // emit() returns dispatchevent's boolean: false if a listener called preventdefault()\n const notcancelled = emit('close');\n\n return html`${when(slots.has('header'), () => html`<slot name=\"header\"></slot>`)}`;\n },\n});\n```\n\n`useemit<emits>()` and `useslots<slotnames>()` are factory hooks: call them once per setup run to get a typed\n`emit`/`slots` bound to the current host. `useslots()` is safe to call more than once within that setup run.\n\n### componentdefinition\n\n```ts\ntype componentdefinition<props> = {\n formassociated?: boolean;\n props?: propsdef<props>;\n setup: (props: inferprops<propsdef<props>>) => htmlresult | null;\n shadow?: partial<shadowrootinit> | false; // false = light dom (no shadow root)\n styles?: (string | cssstylesheet | cssresult)[];\n};\n```\n\n## runtime helpers\n\n`onmounted`, `oncleanup`, `onevent`, `onelement`, and `watcheffect` are plain functions imported from `@vielzeug/ore`. call them directly during `setup()`.\n\n```ts\nimport { html, oncleanup, onevent, onmounted } from '@vielzeug/ore';\n\nsetup(props) {\n onmounted(() => {\n // dom is ready; return a function for mount scoped cleanup\n return () => { /* cleanup on unmount */ };\n });\n\n oncleanup(() => { /* called on disconnect */ });\n\n onevent(window, 'keydown', (e) => { /* auto removed on disconnect */ });\n\n return html`...`;\n}\n```\n\nbecause these resolve the active component through an implicit context (rather than a value threaded through parameters), composable helper functions can call them directly too: no need to pass hooks in as options:\n\n```ts\nimport { oncleanup } from '@vielzeug/ore';\n\nfunction usemyhelper() {\n oncleanup(() => { /* teardown */ });\n}\n\n// in setup:\nsetup(_props) {\n usemyhelper();\n return html`...`;\n}\n```\n\n## props api\n\n| helper | signature | notes |\n| | | |\n| `prop.string(defaultvalue?)` | `propdef<string>` | reflects by default |\n| `prop.bool(defaultvalue?)` | `propdef<boolean>` | any non null attribute value other than `\"false\"` parses as `true`; `\"false\"` or absent attribute is `false` |\n| `prop.number(defaultvalue?)` | `propdef<number>` | returns default (not nan) and warns in dev when attribute is not a valid number |\n| `prop.oneof(allowed, defaultvalue?)` | `propdef<t>` | 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 |\n| `prop.json(defaultvalue)` | `propdef<t>` | json.parse from attribute; `reflect: false` |\n| `prop.data<t>(defaultvalue?)` | `propdef<t>` | js only: never reads/writes an attribute; use for objects, arrays, callbacks, or any non serialisable value |\n\n> **choosing the right prop helper:**\n>\n> **`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.\n> **`prop.json`**: value can be declared in html (`<my el config='{\"x\":1}'>`); attribute string is `json.parse`d.\n> **`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.\n\nwhen you need custom parsing or `reflect: false`, use a raw `propdef` object:\n\n```ts\nprops: {\n items: { default: [], parse: () => [], reflect: false },\n}\n```\n\nuse `prop.data` for props that hold js only values (including callbacks) that cannot be serialised through an html attribute:\n\n```ts\ndefine('data grid', {\n props: {\n getrowkey: prop.data<(row: unknown) => string>(),\n columns: prop.data<datagridcolumn[]>([]),\n onsort: prop.data<(key: string) => void>(),\n },\n setup(props) {\n // set from js: grid.getrowkey = (row) => row.id\n return html`...`;\n },\n});\n```\n\n## template and directives\n\n### `html`\n\ntagged template literal that returns an `htmlresult`. supports text interpolation, ordinary attributes (`attr=`),\nboolean attributes (`?attr=`), explicit property bindings (`.prop=`), events (`@event=` with optional\n`.stop`/`.prevent`/`.once`/`.capture` modifiers), refs (`ref=`), and nested templates.\n\n`.prop=${value}` always writes the element property (never an attribute): the explicit channel for structured\nvalues (arrays, objects, callbacks). prototype polluting names (`__proto__`, `constructor`, `prototype`) are\nrejected at compile time.\n\n`@event.stop`, `.prevent`, `.once`, and `.capture` are shorthand for the matching listener behavior\n(`stoppropagation`/`preventdefault` applied before the handler runs; `once`/`capture` passed as listener\noptions). an unknown modifier throws at compile time.\n\n### `css`\n\ntagged template literal that returns a `cssresult` for use in `styles`.\n\n### directives\n\n| directive | purpose |\n| | |\n| `each(source, key, render, fallback?)` | keyed reactive list; render receives `readable<t>` and `readable<number>`; plain `t[]` is a one time static snapshot |\n| `when(condition, truthy, falsy?)` | conditional rendering |\n| `classmap(record)` | reactive class string from object map |\n| `stylemap(record)` | reactive inline style string from object map |\n| `live(signal)` | one way binding that skips stale writes during active user input; use with `@input` handler |\n| `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 |\n| `unsafehtml(value)` | html rendering sink; sanitize untrusted values before calling |\n\n`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.\n\n### `model(signal, options?)`\n\ntwo 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 `<select>`; `'input'` otherwise). this is the complete controlled input loop that the `live()` + manual `@input` handler pair used to require.\n\n```ts\nconst text = signal('');\nconst agree = signal(false);\nconst count = signal(0);\n\nhtml`\n <input value=${model(text)} />\n <input type=\"checkbox\" checked=${model(agree)} />\n <input type=\"number\" value=${model(count, { get: (el) => number(el.value) })} />\n`;\n```\n\n`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.\n\n### `unsafehtml`\n\n`unsafehtml()` is an explicit html injection sink. it has no global sanitizer: sanitize untrusted\ncontent before passing it to the directive, so the trust boundary remains at the call site.\n\n```ts\nimport { unsafehtml } from '@vielzeug/ore';\n\nconst safearticle = sanitize(usersuppliedarticle);\n\nreturn html`<article>${unsafehtml(safearticle)}</article>`;\n```\n\n## host bindings\n\n`bind(config, options?)` is a plain function imported from `@vielzeug/ore`:\n\n```ts\nbind({\n attr: { role: 'button', 'aria expanded': () => string(open.value) },\n class: { 'is open': open },\n style: { ' height': () => height.value + 'px' },\n on: { click: handleclick },\n});\n```\n\n`bind()` auto registers cleanup with the component scope: no manual `oncleanup` needed. returns a cleanup function for early teardown.\n\n### off host bindings\n\npass `{ target: el }` as a second argument to bind to any element other than the host:\n\n```ts\nbind(\n { attr: { 'aria expanded': () => string(isopen.value) } },\n { target: triggerel },\n);\n```\n\nevent listener options (`once`, `capture`, `passive`) are also accepted in the second argument. cleanup is auto registered with the component scope when called during setup.\n\n### reactive aria attributes\n\nuse explicit `aria *` keys in `bind({ attr }, { target })`:\n\n```ts\nconst stoparia = bind(\n {\n attr: {\n 'aria controls': panelid,\n 'aria expanded': () => isopen.value,\n 'aria haspopup': 'listbox',\n },\n },\n { target: triggerel },\n);\n```\n\nstatic values are applied once. getters and signals create reactive effects. calling `stoparia()` stops updates. values of `null`, `undefined`, or `false` remove the attribute.\n\n## slots\n\n `slots.has(name?)`: `readable<boolean>`: whether the named (or default) slot has assigned content\n `slots.elements(name?)`: `readable<element[]>`: the assigned elements for the slot\n\nslot signals update reactively when assigned content changes, including when slots are inserted dynamically (via `when()` or `each()`) after mount.\n\n## context api\n\n `createcontext<t>(description)`: create a unique typed injection key; the description is diagnostic only\n `provide(key, value)`: provide a value to descendants\n `inject(key)`: resolve from nearest ancestor; returns `undefined` if not found\n `inject(key, fallback)`: resolve with a fallback value\n `injectstrict(key)`: resolve or throw if absent\n\n`provide()` and `inject()` must be called synchronously during `setup()`. calling them outside a setup context throws\n`'lifecycle hooks must be called during component setup'`. context resolution walks the ancestor chain including shadow\ndom boundaries. `inject()` resolves and caches its result once per consumer: provide a `readable` (signal/computed)\nrather than a raw value if descendants need to observe later changes; re calling `provide()` with a new raw value\nafterward is not seen by consumers that already resolved it (a dev mode warning fires when a key is provided twice on\nthe same element). `provide()` registers cleanup automatically: context keys are removed from the registry when the\nproviding component disconnects, so reconnecting the same element runs `setup()` fresh without spurious \"overwriting\"\nwarnings or stale keys leaking to descendants.\n\n## utilities\n\n `ref<t>()`: create a `signal<t | null>` element reference. set to the element via `ref=` in templates.\n `createid(prefix = 'id')`: generate a collision resistant id with a semantic prefix, a per runtime tag, and a monotonic counter (for example, `'field a3k21'`).\n\n## form associated api\n\nimport from `@vielzeug/ore`.\n\n### `usefield(options)`\n\nwire 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.\n\n```ts\ntype formfieldvalidity = { flags: validitystateflags; message: string };\n\ntype formfieldoptions<t> = {\n disabled?: readable<boolean>;\n /**\n * when true, a null/undefined value is submitted as '' instead of null,\n * keeping the field's key present in formdata even when the value is absent.\n * only applies to the default toformvalue; ignored if toformvalue is provided.\n * @default false\n */\n emptystringfornull?: boolean;\n /** called when the ancestor <form> resets (see onformreset): restore local field state here. */\n onreset?: () => void;\n toformvalue?: (value: t) => file | formdata | string | null;\n /**\n * recomputed reactively and passed straight to internals.setvalidity().\n * null (or omitted) = always valid. flags and message travel together because\n * the platform's setvalidity() contract couples them (flags without a\n * non empty message throw); a single { flags, message } | null input makes a\n * desynced pair unrepresentable.\n */\n validity?: readable<formfieldvalidity | null>;\n value: signal<t> | readable<t>;\n};\n\ntype formfieldhandle = {\n checkvalidity(): boolean;\n readonly internals: elementinternals;\n reportvalidity(): boolean;\n /** set (non empty message) or clear (empty string) a custom validity error. */\n setcustomvalidity(message: string): void;\n};\n```\n\npass a single `validity` signal to make `required` style constraints participate in native constraint validation\nthrough `checkvalidity()` and `reportvalidity()`. the `{ flags, message }` pair is the one validity input: the old\nsplit `validity` + `validationmessage` options are gone, since they could drift apart into a state the platform\nrejects.\n\n```ts\nconst isblank = (v: string) => v.trim() === '';\n\nusefield({\n validity: computed(() =>\n required.value && isblank(value.value)\n ? { flags: { valuemissing: true }, message: 'this field is required.' }\n : null,\n ),\n value,\n});\n```\n\n## testing apis\n\nimport from `@vielzeug/ore/testing`.\n\n| api | purpose |\n| | |\n| `mount(setup, options?)` | mount a component and return a test fixture |\n| `cleanup()` | remove all mounted elements and reset test state |\n| `install(aftereach)` | register auto cleanup after each test |\n| `flush(options?)` | drain reactive updates and animation frames |\n| `renderhook(setup)` | run lifecycle hooks in isolation; overload accepts `propdefs` as first arg for typed props |\n| `oretimeouterror` | error thrown when `flush()` cannot settle tracked ore work |\n\n> **test isolation:** `cleanup()` removes mounted elements and resets ore's cross test caches and counters. call it in `aftereach`, or use `install(aftereach)`.\n\nimport `within`, named dispatchers such as `fireclick`, and waits such as `waituntil` or `waitforevent` from\n`@vielzeug/assay`.\n\n> **form associated component testing:** jsdom implements none of the `elementinternals` form association api.\n> `@vielzeug/assay` exports `installforminternalspolyfill()` for this: it polyfills\n> `setformvalue`/`setvalidity`/`checkvalidity`/`reportvalidity`/`validationmessage`/`validity`/`states`, mixes\n> `checkvalidity`/`reportvalidity`/`validity`/`validationmessage` onto the host element itself (real browsers do\n> this for any `formassociated: true` element), makes `formdata` collect a form associated element's set value, and\n> makes `<form>.reset()` invoke `formresetcallback()`. every patch is a guarded no op when its target already\n> exists, and the function returns an `uninstall()` that restores every patched global. call it directly from a\n> test setup (alongside `install(aftereach)`) only when the suite tests form associated components: the patches\n> are global, so suites without them shouldn't carry them. a downstream package (e.g. a component library built\n> on `ore`) should rely on it instead of hand rolling its own copy.\n\n#### `fixture` interface\n\n```ts\ninterface fixture<t extends htmlelement = htmlelement> {\n [symbol.dispose](): void; // delegates to dispose(): enables `using` declarations\n element: t;\n readonly disposed: boolean; // true after dispose() has been called\n readonly shadow: shadowroot | null;\n get<e extends element>(selector: string): e;\n query<e extends element>(selector: string): e | null;\n queryall<e extends element>(selector: string): e[];\n getbytext<e extends element>(text: string, selector?: string): e;\n querybytext<e extends element>(text: string, selector?: string): e | null;\n queryallbytext<e extends element>(text: string, selector?: string): e[];\n getbytestid<e extends element>(testid: string): e;\n querybytestid<e extends element>(testid: string): e | null;\n queryallbytestid<e extends element>(testid: string): e[];\n attr(name: string, value: string | number | boolean): promise<void>;\n attrs(record: record<string, string | number | boolean>): promise<void>;\n flush(options?: flushoptions): promise<void>;\n act(fn: () => unknown): promise<void>;\n dispose(): void; // removes the component from the dom: idempotent\n}\n```\n\n#### `renderhook`\n\nuseful for testing composable lifecycle hooks (`onmounted`, `watcheffect`, `inject`, etc.) without a template. `onmounted`/`oncleanup`/`watcheffect`/... work exactly as inside a real `setup()`, since they resolve the same implicit current component context:\n\n```ts\n// without props\nconst { result, flush, dispose } = await renderhook(() => {\n const count = signal(0);\n onmounted(() => {\n count.value = 1;\n });\n return count;\n});\nexpect(result.value).tobe(1);\n\n// with typed props (prop defs overload)\nconst { result } = await renderhook({ label: prop.string('hello'), count: prop.number(0) }, (props) => props.label);\nexpect(result.value).tobe('hello');\n```\n\n## ripple primitives\n\nore does **not** re export reactive primitives. import them directly from `@vielzeug/ripple`:\n\n```ts\nimport { batch, computed, signal, watch } from '@vielzeug/ripple';\n```\n\nsee the [ripple documentation](/ripple/) for the full api.\n\n## lifecycle events\n\n| event | when |\n| | |\n| `ore:error` | when a lifecycle callback fails: bubbles, composed; detail is `orelifecycleerror` |\n\n## types\n\n```ts\ntype propdef<t> = {\n readonly default: t;\n readonly parse: (value: string | null) => t;\n reflect?: boolean;\n};\n\ntype propsdef<t extends record<string, unknown>> = {\n [k in keyof required<t>]: propdef<t[k & keyof t]>;\n};\n\ntype propinputdefs = record<string, propdef<unknown>>;\n\n/**\n * infer reactive props type from a propinputdefs map.\n * each entry becomes readable<t> keyed by prop name.\n */\ntype inferprops<d extends propinputdefs> = {\n readonly [k in keyof d] ?: readable<inferpropvalue<d[k]>>;\n};\n\n// runtime hooks: all plain functions imported from '@vielzeug/ore', not fields on an object.\ntype onmountedcallback = () => cleanup | undefined;\ntype onformresetcallback = () => void;\n\ndeclare function onmounted(fn: onmountedcallback): void; // dom ready callback; runs after each connection's render\ndeclare function oncleanup(fn: cleanup): void; // register teardown; called on disconnect\ndeclare function onelement<t extends htmlelement>(\n ref: readable<t | null>,\n callback: (el: t) => cleanup | undefined,\n): () => void;\ndeclare function onevent<k extends keyof htmlelementeventmap>(\n target: eventtarget | null | undefined,\n event: k,\n listener: (e: htmlelementeventmap[k]) => void,\n options?: addeventlisteneroptions,\n): void;\ndeclare function onevent(\n target: eventtarget | null | undefined,\n event: string,\n listener: eventlistener,\n options?: addeventlisteneroptions,\n): void;\ndeclare function onformreset(fn: onformresetcallback): void; // runs on every ancestor <form> reset; formassociated only\ndeclare function watcheffect(fn: () => cleanup | undefined): () => void; // scoped reactive effect; auto cleaned on disconnect\ndeclare function bind(config: hostbindconfig, options?: bindoptions): () => void; // bindings for host or any target element\ndeclare function provide<t>(key: injectionkey<t>, value: t): void; // register a context value on the host element\ndeclare function inject<t>(key: injectionkey<t>): t | undefined;\ndeclare function inject<t>(key: injectionkey<t>, fallback: t): t;\ndeclare function gethost(): htmlelement; // the current component's host element\ndeclare function useemit<emits extends record<string, unknown> = record<string, never>>(): emitfn<emits>;\ndeclare function useslots<slotnames extends string = string>(): componentslots<slotnames>;\n\ntype componentdefinition<props extends record<string, unknown> = record<never, never>> = {\n formassociated?: boolean;\n props?: propsdef<props>;\n setup: (props: inferprops<propsdef<props>>) => htmlresult | null;\n shadow?: partial<shadowrootinit> | false; // false = light dom\n styles?: (string | cssstylesheet | cssresult)[];\n};\n\ntype maybereactive<t> = t | readable<t> | (() => t); // plain value, signal, or getter: accepted by when(), each(), classmap(), and bind()\n\ntype hostbindingvalue = maybereactive<string | number | boolean | null | undefined>;\n\ntype attributebindings = record<string, hostbindingvalue>;\n\ntype hostbindconfig = {\n attr?: attributebindings;\n class?: (() => record<string, boolean>) | record<string, readable<boolean> | (() => boolean) | boolean>;\n on?: record<string, ((event: event) => void) | undefined>;\n style?: record<string, hostbindingvalue>;\n};\n\ntype bindoptions = addeventlisteneroptions & {\n target?: element;\n};\n\ntype componentslots<s extends string = string> = {\n elements(name?: s): readable<element[]>;\n has(name?: s): readable<boolean>;\n};\n\ntype ref<t extends element> = signal<t | null>;\n\ntype refcallback<t extends element> = (el: t | null) => void;\n\ntype injectionkey<t> = symbol & { readonly __ore_injection_key?: t };\n\ninterface htmlresult {\n mount(\n parent: parentnode,\n anchor: node | null,\n registercleanup: (fn: () => void) => void,\n ): node[];\n}\n\ntype cssresult = {\n content: string;\n tostring(): string;\n};\n\ntype livebinding<t> = { readonly source: readable<t> };\n\ntype modeloptions<t = unknown> = {\n /** write back event (default: 'change' for checkbox/radio/select, 'input' otherwise). */\n event?: string;\n /** value reader (default: el.checked for checkbox/radio, el.value otherwise). */\n get?: (el: htmlelement) => t;\n};\n\ntype emitfn<t extends record<string, unknown>> = {\n <k extends keyswithoutdetail<t>>(event: k): boolean;\n <k extends exclude<keyof t, keyswithoutdetail<t>>>(event: k, detail: t[k]): boolean;\n};\n// keyswithoutdetail is an internal helper type, not exported.\n\ntype formfieldvalidity = { flags: validitystateflags; message: string };\n\ntype formfieldoptions<t = unknown> = {\n disabled?: readable<boolean>;\n emptystringfornull?: boolean;\n onreset?: () => void;\n toformvalue?: (value: t) => file | formdata | string | null;\n validity?: readable<formfieldvalidity | null>;\n value: signal<t> | readable<t>;\n};\n\ntype formfieldhandle = {\n checkvalidity: () => boolean;\n readonly internals: elementinternals;\n reportvalidity: () => boolean;\n setcustomvalidity: (message: string) => void;\n};\n\n/** phase in which a oreerror occurred. */\ntype oreerrorphase = 'each reconcile' | 'form reset' | 'mounted' | 'setup';\n```\n\n## errors\n\n`oreerror` is the base class for every ore error class: `err instanceof oreerror` catches all of them.\n\n **`oreapierror`**: thrown when the `ore` api itself is misused: calling `define()` with a duplicate tag, calling a lifecycle hook (`inject`, `onmounted`, `oncleanup`, `onevent`, …) outside of `setup()`, or passing an invalid prop definition to `define()`.\n **`oreinternalerror`**: thrown when an ore invariant fails, indicating a package bug rather than invalid application code.\n **`orelifecycleerror`**: reported in the `ore:error` event when component `setup()`, a mounted callback, a form reset callback, or `each()` reconciliation fails. extends `oreerror` with:\n `component: string`: the element's local name\n `phase: oreerrorphase`: `'setup'` | `'mounted'` | `'form reset'` | `'each reconcile'`\n `cause: error`: the original error thrown by `setup()`\n **`oretimeouterror`**: thrown by `flush()` (from `@vielzeug/ore/testing`) when pending ore work does not settle before its timeout.\n\nlifecycle failures dispatch a bubbling, composed `ore:error` event whose `detail` is the `orelifecycleerror`. setup\nfailures still rethrow their original error; mounted and form reset callback failures are reported through the same\nevent so their remaining callbacks can continue.\n",
933
+ "usage": " \ntitle: 'ore: usage guide'\ndescription: practical ore usage patterns for components, props, templates, slots, context, forms, sentinel integration, and tests.\n \n\n[[toc]]\n\n## basic usage\n\n`define(tag, definition)` creates and registers a custom element. call it from the module or browser bootstrap responsible for registration.\n\nyour `setup()` function receives typed prop signals and returns an `htmlresult` directly. its state belongs to the\ncurrent connection: disconnect disposes it, and reconnecting the same element runs setup again.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('status chip', {\n setup() {\n const online = signal(true);\n\n return html`\n <button @click=${() => (online.value = !online.value)}>${() => (online.value ? 'online' : 'offline')}</button>\n `;\n },\n});\n```\n\neverything besides `props`: lifecycle hooks, host bindings, context, slots, emit: is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls):\n\n```ts\nimport { define, gethost, html, bind, useemit, useslots } from '@vielzeug/ore';\n\ndefine('my widget', {\n setup(_props) {\n const el = gethost(); // the host htmlelement\n const emit = useemit<{ close: undefined }>(); // typed event emitter\n const slots = useslots<'header'>(); // reactive slot observation\n\n bind({ attr: { role: 'group' } }); // host binding helper (attr, class, style, on)\n\n return html`<slot></slot>`;\n },\n});\n```\n\n## signals and effects\n\nore does not re export ripple primitives: import them directly from `@vielzeug/ripple`.\n\n```ts\nimport { batch, computed, effect, signal, watch } from '@vielzeug/ripple';\n\nconst count = signal(0);\nconst doubled = computed(() => count.value * 2);\n\neffect(() => {\n console.log('doubled =', doubled.value);\n});\n\nwatch(count, (next, prev) => {\n console.log('count changed', prev, ' >', next);\n});\n\nbatch(() => {\n count.value = 1;\n count.value = 2;\n});\n```\n\n## onmounted and lifecycle\n\nuse `onmounted()` for dom dependent initialization that must run after the template is mounted. use `onelement(ref, cb)` for work tied to a specific dom node. `onevent()` attaches a listener that is automatically removed on disconnect.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html, onelement, onevent, onmounted, ref, useslots } from '@vielzeug/ore';\n\ndefine('deferred init', {\n setup(_props) {\n const tabindex = signal(0);\n const inputref = ref<htmlinputelement>();\n const slots = useslots<'items'>();\n\n onmounted(() => {\n const items = slots.elements('items').value;\n console.log('found', items.length, 'items');\n });\n\n onelement(inputref, (input) => {\n input.focus();\n });\n\n onevent(window, 'keydown', (e: keyboardevent) => {\n if (e.key === 'escape') tabindex.value = 0;\n });\n\n return html`<div><slot name=\"items\"></slot><input ref=${inputref} /></div>`;\n },\n});\n```\n\n## prop definitions\n\nuse `prop.*` helpers for common cases, or raw `propdef` objects for custom parsing or `reflect: false`.\n\n```ts\nimport { define, html, prop } from '@vielzeug/ore';\n\ndefine('x button', {\n props: {\n label: prop.string('button'),\n disabled: prop.bool(false),\n variant: prop.oneof(['primary', 'secondary'] as const, 'primary'),\n count: prop.number(0),\n },\n setup(props) {\n return html`\n <button ?disabled=${props.disabled} data variant=${props.variant}>${props.label} (${props.count})</button>\n `;\n },\n});\n```\n\n## template bindings\n\n`html` supports text, attributes, booleans, properties, events, refs, and nested templates. event bindings accept\n`.stop`, `.prevent`, `.once`, and `.capture`: the first two apply before the handler runs, while the latter two use\nnative listener options. unknown modifiers throw while ore compiles the template.\n\n```ts\nimport { computed, signal } from '@vielzeug/ripple';\nimport { define, html, model, ref } from '@vielzeug/ore';\n\ndefine('profile name', {\n setup() {\n const name = signal('alice');\n const inputref = ref<htmlinputelement>();\n\n return html`\n <label title=${computed(() => 'current: ' + name.value)}>name</label>\n <input\n ref=${inputref}\n value=${model(name)}\n aria label=${() => 'current name ' + name.value} />\n <p>hello ${name}</p>\n `;\n },\n});\n```\n\n## directives\n\nore exports `each`, `classmap`, `stylemap`, `when`, `live`, `model`, and `unsafehtml` from `@vielzeug/ore`.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { classmap, define, each, html, stylemap, when } from '@vielzeug/ore';\n\ndefine('task list', {\n setup() {\n const tasks = signal([{ id: 1, text: 'write tests' }]);\n const active = signal(true);\n\n return html`\n <ul\n class=\"${classmap({ ready: () => tasks.value.length > 0 })}\"\n style=${stylemap({ opacity: () => (active.value ? 1 : 0.5) })}>\n ${when(\n () => active.value,\n () => html`<li>active</li>`,\n () => html`<li>paused</li>`,\n )}\n ${each(\n tasks,\n (task) => task.id,\n (task) => html`<li>${() => task.value.text}</li>`,\n )}\n </ul>\n `;\n },\n});\n```\n\n### each() api\n\n`each(source, key, render, fallback?)` takes positional arguments:\n\n **source**: signal, getter, or plain array\n **key**: function returning a unique string or number per item; number and string keys remain distinct\n **render**: receives reactive `item` and `index` signals\n **fallback**: optional, rendered when the list is empty\n\n```ts\neach(\n items,\n (item) => item.id,\n (item, index) => html`<li>#${index}: ${() => item.value.label}</li>`,\n () => html`<li>no items</li>`,\n);\n```\n\neach reactive reconciliation is transactional. ore creates missing entries before committing updates or removing stale\nentries; if a render callback throws, entries created during that pass are rolled back and the last successful list\nremains on screen. ore reports the failure through `ore:error` with phase `each reconcile`.\n\n## model: two way form bindings\n\nuse `model(signal)` for a controlled input: it writes the signal to the control's `value`/`checked` property and\nwrites user input back on the control's change event (`'change'` for checkboxes, radios, and selects; `'input'`\notherwise). this replaces the `live()` + manual `@input` handler pair with one binding.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html, model } from '@vielzeug/ore';\n\ndefine('live search', {\n setup() {\n const query = signal('');\n const agree = signal(false);\n\n return html`\n <input value=${model(query)} />\n <input type=\"checkbox\" checked=${model(agree)} />\n `;\n },\n});\n```\n\na native text input's `value` is always a string, so pass a `get` reader to keep a numeric signal numeric, and an\n`event` name for custom controls that emit their own change event:\n\n```ts\nconst count = signal(0);\n\nhtml`\n <input type=\"number\" value=${model(count, { get: (el) => number(el.value) })} />\n <my picker value=${model(pick, { event: 'pick change', get: (el) => (el as mypicker).selection })} />\n`;\n```\n\n## live: one way bindings that preserve user edits\n\nuse `live(signal)` when a binding must skip a stale app state write while the user is mid edit, without writing\nuser input back. `model()` covers the common controlled input case; `live()` is for one way bindings that only\nneed the stale write guard.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html, live } from '@vielzeug/ore';\n\ndefine('live search', {\n setup() {\n const query = signal('');\n\n return html`\n <input value=${live(query)} @input=${(e: event) => (query.value = (e.target as htmlinputelement).value)} />\n `;\n },\n});\n```\n\n## host bindings\n\n`bind()` wires reactive attrs, classes, styles, and events to the host element.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { bind, define, html } from '@vielzeug/ore';\n\ndefine('x toggle', {\n setup(_props) {\n const open = signal(false);\n\n bind({\n attr: { 'aria expanded': () => string(open.value), role: 'button', tabindex: 0 },\n class: { 'is open': open },\n on: { click: () => (open.value = !open.value) },\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\nthe `bind` config supports `attr`, `class`, `style`, and `on` sections.\n\n## aria bindings\n\nuse explicit `aria *` keys in `bind({ attr: config }, { target })` to reactively sync aria attributes to any element.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { bind, define, html, onmounted } from '@vielzeug/ore';\n\ndefine('x disclosure', {\n setup(_props) {\n const open = signal(false);\n const panelid = 'disclosure panel';\n\n bind({\n attr: { role: 'button', tabindex: 0 },\n on: { click: () => (open.value = !open.value) },\n });\n\n onmounted(() => {\n const trigger = document.queryselector('#trigger') as htmlelement;\n if (trigger) {\n // bind() registers cleanup automatically when called inside setup\n bind(\n {\n attr: {\n 'aria controls': panelid,\n 'aria expanded': () => string(open.value),\n 'aria haspopup': 'region',\n },\n },\n { target: trigger },\n );\n }\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\nstatic values are applied once. getter functions create reactive effects. setting a value to `null`, `undefined`, or `false` removes the attribute.\n\n`bind()` always returns a cleanup function. use it to stop syncing early when a trigger element can be swapped out:\n\n```ts\nonmounted(() => {\n const trigger = document.queryselector('#trigger') as htmlelement;\n const stoparia = bind({ attr: { 'aria expanded': () => string(open.value) } }, { target: trigger });\n\n // stop syncing when the trigger is replaced\n oncleanup(stoparia);\n});\n```\n\n### binding a non host element with `bind()`\n\npass `{ target: el }` as a second argument to bind attributes, classes, styles, or events to any element:\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { bind, define, html, onmounted, ref } from '@vielzeug/ore';\n\ndefine('button wrapper', {\n setup(_props) {\n const visible = signal(false);\n const btnref = ref<htmlbuttonelement>();\n\n onmounted(() => {\n const btn = btnref.value;\n if (!btn) return;\n\n bind(\n {\n attr: { 'aria pressed': () => string(visible.value) },\n on: { click: () => (visible.value = !visible.value) },\n },\n { target: btn },\n );\n });\n\n return html`<button ref=${btnref}>toggle</button>`;\n },\n});\n```\n\n## slots and emits\n\n```ts\nimport { define, html, useemit, useslots, when } from '@vielzeug/ore';\n\ndefine('card with footer', {\n setup(_props) {\n const slots = useslots<'header' | 'footer'>();\n const emit = useemit<{ action: undefined }>();\n\n return html`\n <div class=\"card\">\n <slot name=\"header\"></slot>\n <slot></slot>\n ${when(slots.has('footer'), () => html`<footer><slot name=\"footer\"></slot></footer>`)}\n </div>\n <button @click=${() => emit('action')}>go</button>\n `;\n },\n});\n```\n\npass a `slotnames` type parameter to `useslots<slotnames>()` to get typed `slots.has()` and `slots.elements()` calls.\n\n## context provide/inject\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { createcontext, define, html, injectstrict, provide } from '@vielzeug/ore';\n\nconst count_ctx = createcontext<returntype<typeof signal<number>>>('count');\n\ndefine('count provider', {\n setup(_props) {\n const count = signal(0);\n provide(count_ctx, count);\n\n return html`<button @click=${() => count.value++}><slot></slot></button>`;\n },\n});\n\ndefine('count consumer', {\n setup() {\n const count = injectstrict(count_ctx);\n\n return html`<p>count: ${count}</p>`;\n },\n});\n```\n\n`provide()` registers cleanup automatically: context keys are removed from the registry when the providing component disconnects. on reconnect, `setup()` runs fresh and `provide()` re registers without spurious \"overwriting\" warnings. provide a `readable` (signal/computed) rather than a raw value if descendants need to observe later changes: `inject()` resolves and caches the value once per consumer connection.\n\n## form associated elements\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html, prop } from '@vielzeug/ore';\nimport { usefield } from '@vielzeug/ore';\n\ndefine('rating input', {\n formassociated: true,\n setup() {\n const value = signal(0);\n const field = usefield({ value });\n\n return html`\n <button @click=${() => (value.value = 1)}>1</button>\n <button @click=${() => (value.value = 2)}>2</button>\n <button @click=${() => (value.value = 3)}>3</button>\n <button @click=${() => field.reportvalidity()}>validate</button>\n <p>current: ${value}</p>\n `;\n },\n});\n```\n\n## sentinel observers\n\nuse `@vielzeug/sentinel` for subscribable browser and dom observations. create element dependent sentinels inside `onmounted()` and dispose them with the component.\n\n```ts\nimport { define, html, oncleanup, onmounted, ref, watcheffect } from '@vielzeug/ore';\nimport { fromsubscribable } from '@vielzeug/ripple';\nimport { createelementsize, sentinelunavailableerror } from '@vielzeug/sentinel';\n\ndefine('x observed', {\n setup(_props) {\n const boxref = ref<htmldivelement>();\n\n onmounted(() => {\n const element = boxref.value;\n if (!element) return;\n\n try {\n const size = createelementsize(element);\n const sizestate = fromsubscribable(size, { signal: size.disposalsignal });\n\n watcheffect(() => {\n console.log(sizestate.value?.width);\n });\n\n oncleanup(() => size.dispose());\n } catch (error) {\n if (!(error instanceof sentinelunavailableerror)) throw error;\n }\n });\n\n return html`<div ref=${boxref}>observe me</div>`;\n },\n});\n```\n\n## testing utilities\n\nimport from `@vielzeug/ore/testing`.\n\n```ts\nimport { aftereach, describe, expect, it } from 'vitest';\nimport { signal } from '@vielzeug/ripple';\nimport { fireclick } from '@vielzeug/assay';\nimport { html } from '@vielzeug/ore';\nimport { cleanup, mount } from '@vielzeug/ore/testing';\n\ndescribe('my counter', () => {\n aftereach(cleanup);\n\n it('increments on click', async () => {\n let count!: returntype<typeof signal<number>>;\n const { query, act } = await mount(() => {\n count = signal(0);\n return html`<button @click=${() => count.value++}>${count}</button>`;\n });\n\n expect(query('button')?.textcontent).tobe('0');\n\n await act(() => fireclick(query('button')!));\n\n expect(query('button')?.textcontent).tobe('1');\n });\n});\n```\n\n## framework integration\n\nore components are standard custom elements and work natively in any framework.\n\n::: code group\n\n```tsx [react]\n// react 19+ supports custom elements natively.\nimport './x toggle'; // wherever define('x toggle', { ... }) is called\n\nfunction app() {\n return <x toggle aria label=\"open menu\" />;\n}\n```\n\n```ts [vue 3]\n<script setup lang=\"ts\">\nimport './x toggle'; // wherever define('x toggle', { ... }) is called\nimport { ref } from 'vue';\n\nconst open = ref(false);\n</script>\n\n<template>\n <x toggle :aria label=\"'open menu'\" @click=\"open = !open\" />\n</template>\n```\n\n```svelte [svelte]\n<script>\n import './x toggle'; // wherever define('x toggle', { ... }) is called\n\n function handleclick() {\n console.log('toggled');\n }\n</script>\n\n<x toggle aria label=\"open menu\" on:click={handleclick} />\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### with ripple\n\nimport ripple primitives directly from `@vielzeug/ripple` for standalone reactive state outside components.\n\n```ts\nimport { signal, computed } from '@vielzeug/ripple';\nimport { define, html } from '@vielzeug/ore';\n\n// shared state created outside any component\nconst theme = signal<'light' | 'dark'>('light');\nconst isdark = computed(() => theme.value === 'dark');\n\ndefine('theme toggle', {\n setup() {\n return html`\n <button @click=${() => (theme.value = isdark.value ? 'light' : 'dark')}>\n ${() =>\n isdark.value ? '<ore icon name=\"sun\" size=\"16\"></ore icon>' : '<ore icon name=\"moon\" size=\"16\"></ore icon>'}\n </button>\n `;\n },\n});\n```\n\n### with forge\n\nuse `@vielzeug/forge` for typed form state. `usefield()` remains intentionally narrow: it connects a form associated\ncustom element to native `elementinternals` without imposing submission, validation, or dirty state policy.\n\n```ts\nimport { createform } from '@vielzeug/forge';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('signup form', {\n setup(_props) {\n const form = createform({ initialvalues: { email: '' } });\n\n return html`\n <form\n @submit=${(event: submitevent) => {\n event.preventdefault();\n void form.submit(async (values) => {\n console.log(values);\n });\n }}>\n <slot></slot>\n </form>\n `;\n },\n});\n```\n\n## best practices\n\n setup returns `html\\`...\\`` directly: not a function wrapping the template.\n use `watcheffect()` for reactive subscriptions tied to component lifetime: it auto registers cleanup on disconnect.\n use `onelement(ref, cb)` instead of `onmounted` when the work is tied to a single dom node.\n bind host attributes and classes via `bind()` rather than mutating the element directly.\n provide context at the nearest ancestor: avoid global context singletons.\n call `oncleanup()` for every resource allocated in `setup()` (websockets, intervals, external subscriptions).\n use `model(signal)` for two way form inputs; use `live(signal)` for a one way binding that must not clobber user in progress edits.\n 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.\n test component mounting and lifecycle with `@vielzeug/ore/testing`; import generic dom events, queries, and waits\n from `@vielzeug/assay`.\n",
922
934
  "examples": " \ntitle: 'ore: examples'\ndescription: practical examples and recipes for ore.\n \n\n## examples\n\n [counter component](./examples/counter component.md)\n [typed props and emits](./examples/typed props and emits.md)\n [observers in onmounted()](./examples/observers in onmount.md)\n [search list with directives](./examples/search list with directives.md)\n [context provider and consumer](./examples/context provider and consumer.md)\n [prop helpers and raw propdef](./examples/propsof builder api.md)\n [form associated rating input](./examples/form associated rating input.md)\n [test example with @vielzeug/ore/testing](./examples/test example at vielzeug ore testing.md)\n"
923
935
  },
924
936
  "examples": [],
925
- "exports": "define prop html css ref createcontext inject injectstrict provide onmounted oncleanup onevent onelement onformreset watcheffect useemit useslots gethost bind each when classmap stylemap live unsafehtml usefield createid oreerror oreapierror oreinternalerror orelifecycleerror attributebindings bindoptions maybereactive",
937
+ "exports": "define prop html css ref createcontext inject injectstrict provide onmounted oncleanup onevent onelement onformreset watcheffect useemit useslots gethost bind each when classmap stylemap live model unsafehtml usefield createid oreerror oreapierror oreinternalerror orelifecycleerror attributebindings bindoptions maybereactive",
926
938
  "keywords": "web components custom elements reactive templates signals lifecycle",
927
939
  "name": "@vielzeug/ore",
928
940
  "related": "ripple refine orbit",
929
941
  "slug": "ore",
930
- "source": "export type { componentdefinition } from './component types';\nexport { createcontext, type injectionkey, inject, injectstrict, provide } from './context';\nexport { define } from './define';\n// near universal template directives: used in most non trivial components (lists,\n// conditionals, and class/style maps. kept in the main entry alongside\n// `html`/`define` rather than a separate sub path: tree shaking already means an unused export\n// costs nothing in a bundled consumer, so splitting these off only adds an extra import line\n// for functionality most components need on day one. `unsafehtml()` and `live()` remain here\n// too: their explicit names make their specialized behavior clear without a second import path.\nexport { classmap } from './directives/classmap';\nexport { each } from './directives/each';\nexport { type livebinding, live } from './directives/live';\nexport { stylemap } from './directives/stylemap';\nexport { unsafehtml } from './directives/unsafe html';\nexport { when } from './directives/when';\nexport { oreapierror, oreerror, type oreerrorphase, oreinternalerror, orelifecycleerror } from './errors';\nexport { type formfieldhandle, type formfieldoptions, usefield } from './forms/field';\nexport {\n type attributebindings,\n type bindoptions,\n bind,\n type hostbindconfig,\n type hostbindingvalue,\n} from './host bind';\nexport type { inferprops, propdef, propinputdefs, propsdef } from './props';\nexport { prop } from './props';\n// lifecycle hooks: plain functions, called during setup() or a composable it invokes.\nexport {\n gethost,\n type onformresetcallback,\n type onmountedcallback,\n oncleanup,\n onelement,\n onevent,\n onformreset,\n onmounted,\n watcheffect,\n} from './runtime';\nexport { type componentslots, useslots } from './slots';\nexport { html } from './template/instantiator';\nexport { type htmlresult, type ref, type refcallback, ref } from './template/result';\nexport { type cssresult, css } from './utils/css';\nexport type { maybereactive } from './utils/dom';\nexport { type emitfn, useemit } from './utils/emit';\n\nexport { createid } from './utils/id';\n"
942
+ "source": "export type { componentdefinition } from './component types';\nexport { createcontext, type injectionkey, inject, injectstrict, provide } from './context';\nexport { define } from './define';\n// near universal template directives: used in most non trivial components (lists,\n// conditionals, and class/style maps. kept in the main entry alongside\n// `html`/`define` rather than a separate sub path: tree shaking already means an unused export\n// costs nothing in a bundled consumer, so splitting these off only adds an extra import line\n// for functionality most components need on day one. `unsafehtml()` and `live()` remain here\n// too: their explicit names make their specialized behavior clear without a second import path.\nexport { classmap } from './directives/classmap';\nexport { each } from './directives/each';\nexport { type livebinding, live } from './directives/live';\nexport { type modeloptions, model } from './directives/model';\nexport { stylemap } from './directives/stylemap';\nexport { unsafehtml } from './directives/unsafe html';\nexport { when } from './directives/when';\nexport { oreapierror, oreerror, type oreerrorphase, oreinternalerror, orelifecycleerror } from './errors';\nexport { type formfieldhandle, type formfieldoptions, type formfieldvalidity, usefield } from './forms/field';\nexport {\n type attributebindings,\n type bindoptions,\n bind,\n type hostbindconfig,\n type hostbindingvalue,\n} from './host bind';\nexport type { inferprops, propdef, propinputdefs, propsdef } from './props';\nexport { prop } from './props';\n// lifecycle hooks: plain functions, called during setup() or a composable it invokes.\nexport {\n gethost,\n type onformresetcallback,\n type onmountedcallback,\n oncleanup,\n onelement,\n onevent,\n onformreset,\n onmounted,\n watcheffect,\n} from './runtime';\nexport { type componentslots, useslots } from './slots';\nexport { html } from './template/instantiator';\nexport { type htmlresult, type ref, type refcallback, ref } from './template/result';\nexport { type cssresult, css } from './utils/css';\nexport type { maybereactive } from './utils/dom';\nexport { type emitfn, useemit } from './utils/emit';\n\nexport { createid } from './utils/id';\n"
931
943
  },
932
944
  {
933
945
  "category": "async",
@@ -1014,9 +1026,9 @@
1014
1026
  "category": "ui components",
1015
1027
  "description": "accessible, themeable web components built with ore for framework and vanilla dom apps.",
1016
1028
  "docs": {
1017
- "index": " \ntitle: 'refine: web component library'\ndescription: accessible, themeable web components built with ore for framework and vanilla dom apps.\npackage: refine\ncategory: ui components\nkeywords: [web components, accessible, themeable, ui, components, design system]\nrelated: [ore, orbit, forge, keymap]\nexports:\n [\n ore accordion,\n ore accordion item,\n ore alert,\n ore async,\n ore avatar,\n ore avatar group,\n ore badge,\n ore box,\n ore breadcrumb,\n ore breadcrumb item,\n ore button,\n ore button group,\n ore calendar,\n ore card,\n ore carousel,\n ore carousel slide,\n ore chat message,\n ore checkbox,\n ore checkbox group,\n ore chip,\n ore code window,\n ore column,\n ore combobox,\n ore combobox option,\n ore command palette,\n ore command palette item,\n ore cookie banner,\n ore copy command,\n ore counter,\n ore datagrid,\n ore date picker,\n ore dialog,\n ore drawer,\n ore file input,\n ore grid,\n ore grid item,\n ore icon,\n ore input,\n ore keyboard key,\n ore keyboard shortcut,\n ore list,\n ore list item,\n ore marquee,\n ore menu,\n ore menu item,\n ore menu separator,\n ore message composer,\n ore navbar,\n ore navbar item,\n ore navigation menu,\n ore navigation menu item,\n ore navigation menu panel,\n ore number input,\n ore otp input,\n ore pagination,\n ore password strength,\n ore popover,\n ore progress,\n ore qr code,\n ore qr scanner,\n ore query bar,\n ore radio,\n ore radio group,\n ore rating,\n ore select,\n ore separator,\n ore sidebar,\n ore sidebar group,\n ore sidebar item,\n ore skeleton,\n ore slider,\n ore speech player,\n ore stats,\n ore step,\n ore stepper,\n ore switch,\n ore tab item,\n ore tab panel,\n ore table,\n ore tabs,\n ore td,\n ore text,\n ore th,\n ore tr,\n ore textarea,\n ore time picker,\n ore toast,\n ore tooltip,\n ore typing indicator,\n ]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"refine\" />\n\n## why refine?\n\nevery project needs ui primitives. refine provides accessible web components that work natively anywhere html is rendered, no framework required.\n\n```html\n<! before: roll your own button with aria >\n<button class=\"btn btn primary\" role=\"button\" aria pressed=\"false\" tabindex=\"0\">\n <span class=\"btn spinner\" aria hidden=\"true\"></span>\n save\n</button>\n\n<! after: refine >\n<ore button variant=\"solid\" color=\"primary\" loading>save</ore button>\n```\n\n| feature | refine | shoelace | material web |\n| | | | |\n| bundle size | <packageinfo package=\"refine\" type=\"size\" /> | ~145 kb | ~200 kb |\n| built with | ore | lit | lit |\n| runtime dependencies | vielzeug ui primitives + lucide | lit + utilities | lit + material foundations |\n| accessible | wcag aa | wcag aa | wcag aa |\n| framework agnostic | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use refine when** you want accessible web components that match the vielzeug design system without a heavy framework dependency.\n\n**consider shoelace or material web** if your team is already standardized on those ecosystems and you need their established component catalogs.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/refine\n```\n\n```sh [npm]\nnpm install @vielzeug/refine\n```\n\n```sh [yarn]\nyarn add @vielzeug/refine\n```\n\n:::\n\n## quick start\n\n```ts\n// 1. import global styles once\nimport '@vielzeug/refine/fouc.css'; // hide unupgraded custom elements until first paint\nimport '@vielzeug/refine/tokens.css'; // tokens, animations, cascade layers\n\n// 2. register only the elements you need\nimport '@vielzeug/refine/button';\nimport '@vielzeug/refine/input';\nimport '@vielzeug/refine/card';\n```\n\n```html\n<ore button variant=\"solid\" color=\"primary\">save</ore button>\n<ore input label=\"email\" type=\"email\" required></ore input>\n<ore card padding=\"lg\">\n <span slot=\"header\">account</span>\n <p>card content goes here.</p>\n</ore card>\n```\n\n```ts\n```\n\n### cdn / vanilla html\n\nuse the self contained iife bundle to load refine directly from a cdn in any html page: no build step required:\n\n```html\n<! 1. styles >\n<link rel=\"stylesheet\" href=\"https://unpkg.com/@vielzeug/refine/dist/styles/fouc.css\" />\n<link rel=\"stylesheet\" href=\"https://unpkg.com/@vielzeug/refine/dist/styles/tokens.css\" />\n\n<! 2. all components (iife: registers global refine namespace) >\n<script src=\"https://unpkg.com/@vielzeug/refine/dist/refine.iife.js\"></script>\n```\n\nfor bundler based projects that still want a cdn url, use the esm bundle via an import map:\n\n```html\n<script type=\"importmap\">\n {\n \"imports\": {\n \"@vielzeug/refine\": \"https://esm.sh/@vielzeug/refine\",\n \"@vielzeug/refine/button\": \"https://esm.sh/@vielzeug/refine/button\",\n \"@vielzeug/refine/input\": \"https://esm.sh/@vielzeug/refine/input\"\n }\n }\n</script>\n\n<script type=\"module\">\n import '@vielzeug/refine/button';\n import '@vielzeug/refine/input';\n</script>\n```\n\n### package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/refine/fouc.css` | fouc suppression for unupgraded custom elements |\n| `@vielzeug/refine/tokens.css` | global design tokens and cascade layers |\n| `@vielzeug/refine/styles/preflight.css` | optional browser default reset (includes fouc suppression) |\n\ncomponent registration happens through side effect imports such as `@vielzeug/refine/button` and `@vielzeug/refine/dialog`.\n\n### components\n\n**content:** `ore avatar`, `ore avatar group`, `ore breadcrumb`, `ore card`, `ore carousel`, `ore carousel slide`, `ore chat message`, `ore icon`, `ore keyboard key`, `ore keyboard shortcut`, `ore list`, `ore list item`, `ore marquee`, `ore pagination`, `ore qr code`, `ore separator`, `ore stats`, `ore step`, `ore stepper`, `ore table`, `ore text`\n\n**disclosure:** `ore accordion`, `ore accordion item`, `ore tabs`, `ore tab item`, `ore tab panel`\n\n**feedback:** `ore alert`, `ore async`, `ore badge`, `ore chip`, `ore cookie banner`, `ore password strength`, `ore progress`, `ore skeleton`, `ore toast`, `ore typing indicator`\n\n**inputs:** `ore button`, `ore button group`, `ore calendar`, `ore checkbox`, `ore checkbox group`, `ore column`, `ore combobox`, `ore counter`, `ore datagrid`, `ore date picker`, `ore file input`, `ore input`, `ore message composer`, `ore number input`, `ore otp input`, `ore qr scanner`, `ore query bar`, `ore radio`, `ore radio group`, `ore rating`, `ore select`, `ore slider`, `ore switch`, `ore textarea`, `ore time picker`\n\n**layout:** `ore box`, `ore grid`, `ore grid item`, `ore navbar`, `ore sidebar`\n\n**overlay:** `ore command palette`, `ore command palette item`, `ore dialog`, `ore drawer`, `ore menu`, `ore popover`, `ore tooltip`\n\n## component portfolio\n\nrefine keeps one public package and classifies components by composition level rather than stability. all listed components remain supported and tested.\n\n **foundations**: buttons, inputs, selection controls, disclosure, dialogs, menus, tooltips, layout primitives, and feedback indicators. start here when building an application specific interface.\n **composed patterns**: data grids, command palettes, navigation menus, steppers, calendars, date/time pickers, file inputs, and message composers. these package recurring accessibility and interaction behavior that is costly to rebuild correctly.\n **presentation patterns**: cards, chat messages, code windows, stats, marquees, avatars, badges, and skeletons. use these when their product vocabulary matches the application; prefer foundations when it does not.\n\nclassification guides discovery and bundle selection. it is not a reason to remove working components or move them into unstable packages; per component subpath imports remain the tree shaking boundary.\n\n## features\n\n<div class=\"features grid\">\n\n **accessible**: keyboard navigation, aria wiring, and focus management across interactive components\n **themeable**: global tokens plus component level css custom properties\n **framework agnostic**: works anywhere html can be rendered\n **tree shakeable**: import only the component entry points you register\n **comprehensive surface**: inputs, content, disclosure, feedback, layout, and overlay primitives\n **typed framework declarations**: dom, react, and vue tags derive from the component property contracts\n\n</div>\n\n### prerequisites\n\n browser runtime with custom elements support.\n import `@vielzeug/refine/fouc.css` and `@vielzeug/refine/tokens.css` before rendering components.\n for ssr, render placeholders server side and hydrate components only on the client.\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [ore](/ore/): web component runtime that powers refine\n [orbit](/orbit/): floating ui positioning used in refine's overlays\n [forge](/forge/): form state management for use with refine inputs\n [keymap](/keymap/): keyboard shortcut manager that powers the command palette's global trigger\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
1018
- "api": " \ntitle: 'refine: api reference'\ndescription: published component, stylesheet, framework type, and error entry points for @vielzeug/refine.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `@vielzeug/refine/<component>` | register one custom element and export its public types | sync | importing a type alone does not register the element |\n| `@vielzeug/refine/tokens.css` | load required theme tokens, animations, and cascade layers | css | import once before components render |\n| `@vielzeug/refine/fouc.css` | hide unregistered `ore *` elements until upgrade | css | load before first paint |\n| `@vielzeug/refine/styles/preflight.css` | apply the optional browser reset | css | the reset affects global elements |\n| `@vielzeug/refine/frameworks/elements` | register typed dom tag mappings | types only | import for side effects in typescript |\n| `@vielzeug/refine/frameworks/react` | register typed react jsx elements | types only | does not provide runtime wrappers |\n| `@vielzeug/refine/frameworks/vue` | register typed vue global components | types only | does not install a vue plugin |\n| `@vielzeug/refine/theme` | `createthemecontroller()`: light/dark/system document theme | sync | does not persist the preference or set an accent color |\n| `eventfieldvalue()` / `eventfieldchecked()` | read a form control's value/checked from `event.currenttarget` | sync | exported from the package root; guards non field targets |\n| `refineerror` | base class for package defined public errors | sync | component configuration warnings do not throw this error |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/refine` | export `refineerror` and the `eventfieldvalue`/`eventfieldchecked` field readers without registering components |\n| `@vielzeug/refine/<component>` | register one component and export its tag constant, props, events, and related types |\n| `@vielzeug/refine/theme` | `createthemecontroller()` and its theme types |\n| `@vielzeug/refine/tokens.css` | required global design contract |\n| `@vielzeug/refine/fouc.css` | optional pre upgrade visibility rule |\n| `@vielzeug/refine/styles/*` | focused theme, animation, layer, and preflight stylesheets |\n| `@vielzeug/refine/frameworks/*` | typescript augmentations for dom, react, and vue |\n\n## styles\n\n```ts\nimport '@vielzeug/refine/fouc.css';\nimport '@vielzeug/refine/tokens.css';\nimport '@vielzeug/refine/styles/preflight.css';\n```\n\n`tokens.css` is required. it defines tokens, animations, and cascade layer order without resetting global elements. `preflight.css` is optional and includes fouc suppression.\n\n| import path | purpose |\n| | |\n| `@vielzeug/refine/fouc.css` | fouc suppression for unupgraded custom elements |\n| `@vielzeug/refine/tokens.css` | tokens, animation helpers, and cascade layers |\n| `@vielzeug/refine/styles/theme.css` | theme token declarations |\n| `@vielzeug/refine/styles/animation.css` | animation helpers |\n| `@vielzeug/refine/styles/layers.css` | cascade layer declarations |\n| `@vielzeug/refine/styles/preflight.css` | optional browser reset and fouc suppression |\n\n## components\n\neach component uses one registration and type entry point:\n\n```ts\nimport '@vielzeug/refine/button';\nimport type { orebuttonprops } from '@vielzeug/refine/button';\n```\n\n| area | components |\n| | |\n| content | `avatar`, `avatar group`, `breadcrumb`, `card`, `carousel`, `chat message`, `code window`, `copy command`, `icon`, `keyboard key`, `list`, `list item`, `marquee`, `pagination`, `separator`, `speech player`, `stats`, `step`, `stepper`, `table`, `text` |\n| disclosure | `accordion`, `accordion item`, `tabs`, `tab item`, `tab panel` |\n| feedback | `alert`, `async`, `badge`, `chip`, `cookie banner`, `password strength`, `progress`, `skeleton`, `toast`, `typing indicator` |\n| inputs | `button`, `button group`, `calendar`, `checkbox`, `checkbox group`, `combobox`, `counter`, `datagrid`, `date picker`, `file input`, `input`, `message composer`, `number input`, `otp input`, `query bar`, `radio`, `radio group`, `rating`, `select`, `slider`, `switch`, `textarea`, `time picker` |\n| layout | `box`, `grid`, `grid item`, `navbar`, `sidebar` |\n| overlays | `command palette`, `dialog`, `drawer`, `menu`, `navigation menu`, `popover`, `tooltip` |\n\neach component page lists its attributes, javascript properties, events, slots, parts, and css custom properties.\n\n## framework types\n\n```ts\nimport type {} from '@vielzeug/refine/frameworks/elements';\nimport type {} from '@vielzeug/refine/frameworks/react';\nimport type {} from '@vielzeug/refine/frameworks/vue';\n```\n\nthe declarations cover every supported tag and derive component properties from refine's authoritative `htmlelementtagnamemap`. they provide types only; component registration still uses component subpaths.\n\n## events and form controls\n\nform controls expose `.value` or `.checked` and dispatch standard `input` and `change` events. read the property from `event.currenttarget`.\n\nstateful 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.\n\n## utilities\n\n### `createthemecontroller(options?)`\n\n```ts\nimport { createthemecontroller } from '@vielzeug/refine/theme';\n\nconst theme = createthemecontroller(); // starts in 'system' mode\ntheme.setpreference('dark');\ntheme.dispose();\n```\n\nowns the light/dark/system theme for the document: a reactive `preference` signal, the effective `resolved` mode (`'dark' | 'light'` after resolving `system` against the os), and the dom application `styles/theme.css` expects: the `.dark` class plus the `color scheme` property on the root element, so every `light dark()` token resolves to the right branch. while in `system` mode the controller tracks the os `prefers color scheme` via sentinel's `createmediaquery`.\n\nthe controller does **not** persist the preference or expose an accent color: those are consumer concerns (wire `watch(controller.preference, save)` for persistence; set ` color primary hue` yourself). options: `initial` (starting preference, default `'system'`), `root` (element carrying the classes, default `document.documentelement`), `target` (window whose `matchmedia` backs `system`). the handle is disposable: `dispose()`, `disposed`, `disposalsignal`, `[symbol.dispose]`.\n\n### `eventfieldvalue(event)` / `eventfieldchecked(event)`\n\n```ts\nimport { eventfieldchecked, eventfieldvalue } from '@vielzeug/refine';\n\ninput.addeventlistener('input', (event) => {\n const value = eventfieldvalue(event); // string | undefined: never a blind cast\n});\n```\n\nguarded readers for the standard `input`/`change` event pattern above: they read `value`/`checked` from `event.currenttarget` only when the target actually carries it, so a handler wired to the wrong element yields `undefined`/`false` instead of a crash or a lie. exported from the package root, which registers no components.\n\n## types\n\ncomponent props follow the `ore<component>props` naming pattern. event maps follow `ore<component>events`. import both from the component subpath that owns them.\n\n`refineelementmap`, `refinereactintrinsicelements`, and `refinevueglobalcomponents` are exported from their respective `frameworks/*` type entry points.\n\n## errors\n\n### `refineerror`\n\n```ts\nclass refineerror extends error {\n constructor(message: string, opts?: erroroptions);\n}\n```\n\nbase class for public refine errors. it preserves `cause` through `erroroptions`.\n",
1019
- "usage": " \ntitle: 'refine: usage guide'\ndescription: register refine components, configure properties, handle events, compose slots, and integrate frameworks.\n \n\n[[toc]]\n\n## basic usage\n\nload the global design contract once, then register only the components used by the page.\n\n```ts\nimport '@vielzeug/refine/tokens.css';\nimport '@vielzeug/refine/button';\nimport '@vielzeug/refine/input';\nimport '@vielzeug/refine/dialog';\n\nconst input = document.queryselector('ore input');\nconst save = document.queryselector('ore button');\n\nif (!(input instanceof htmlelement) || !(save instanceof htmlelement)) {\n throw new error('expected refine controls');\n}\n\nsave.addeventlistener('click', () => {\n console.log((input as htmlelement & { value: string }).value);\n});\n```\n\nadd the browser reset only when the application owns global element styling:\n\n```ts\nimport '@vielzeug/refine/styles/preflight.css';\n```\n\n## set attributes and properties\n\nuse attributes for serializable html state.\n\n```html\n<ore button variant=\"outline\" color=\"secondary\" size=\"lg\" disabled>\n save changes\n</ore button>\n```\n\nuse javascript properties for arrays, objects, callbacks, and controlled state.\n\n```ts\nimport '@vielzeug/refine/datagrid';\nimport type { oredatagridprops } from '@vielzeug/refine/datagrid';\n\nconst grid = document.queryselector('ore datagrid') as htmlelement & oredatagridprops;\ngrid.columns = [\n { key: 'name', label: 'name', sortable: true },\n { key: 'role', label: 'role' },\n];\ngrid.rows = [\n { id: '1', name: 'ada', role: 'admin' },\n { id: '2', name: 'linus', role: 'editor' },\n];\n```\n\n## handle events\n\nform controls dispatch standard `input` and `change` events. read their current property from `event.currenttarget`.\n\n```ts\nconst input = document.queryselector('ore input');\ninput?.addeventlistener('input', (event) => {\n const target = event.currenttarget as htmlelement & { value: string };\n console.log(target.value);\n});\n```\n\ncustom events use kebab case names and typed detail objects.\n\n```ts\nimport type { oredialogevents } from '@vielzeug/refine/dialog';\n\nconst dialog = document.queryselector('ore dialog');\ndialog?.addeventlistener('open change', (event: customevent<oredialogevents['open change']>) => {\n console.log(event.detail.open, event.detail.reason);\n});\n```\n\n## compose slots\n\ndefault slots hold primary content. named slots identify component owned regions.\n\n```html\n<ore card>\n <span slot=\"header\">account</span>\n <p>profile settings</p>\n <div slot=\"footer\">\n <ore button variant=\"ghost\">cancel</ore button>\n <ore button>save</ore button>\n </div>\n</ore card>\n```\n\ninput components commonly expose `prefix`, `suffix`, `label`, `helper`, and `error` slots. each component reference lists its supported slots.\n\n## load typescript framework declarations\n\nimport the declaration matching the framework once in an application type entry point.\n\n::: code group\n\n```ts [dom]\nimport type {} from '@vielzeug/refine/frameworks/elements';\n```\n\n```ts [react]\nimport type {} from '@vielzeug/refine/frameworks/react';\n```\n\n```ts [vue]\nimport type {} from '@vielzeug/refine/frameworks/vue';\n```\n\n:::\n\nthese entry points provide typed tags and component properties. they do not register components or install runtime adapters.\n\n## framework integration\n\nrefine elements use native dom properties, events, and slots. frameworks differ only in how they forward custom element properties and events.\n\n::: code group\n\n```tsx [react 19]\nimport '@vielzeug/refine/button';\n\nexport function savebutton() {\n return <ore button variant=\"solid\">save</ore button>;\n}\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nimport '@vielzeug/refine/button';\n</script>\n\n<template>\n <ore button variant=\"solid\">save</ore button>\n</template>\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import '@vielzeug/refine/button';\n</script>\n\n<ore button variant=\"solid\">save</ore button>\n```\n\n:::\n\nsee [framework integration](./frameworks.md) for custom events, controlled properties, ssr guards, and framework specific configuration.\n\n## working with other vielzeug libraries\n\n### ore\n\nuse ore when authoring a custom element that composes refine components.\n\n```ts\nimport '@vielzeug/refine/button';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('save panel', {\n setup() {\n return html`<ore button @click=${() => console.log('save')}>save</ore button>`;\n },\n});\n```\n\n### ripple\n\nuse ripple to drive component properties from application state.\n\n```ts\nimport '@vielzeug/refine/button';\nimport { effect, signal } from '@vielzeug/ripple';\n\nconst loading = signal(false);\nconst button = document.queryselector('ore button') as htmlelement & { loading?: boolean };\nconst loadingeffect = effect(() => {\n button.loading = loading.value;\n});\n\nloadingeffect.dispose();\n```\n\n## best practices\n\n **import** `tokens.css` once before rendering components.\n **register** components through explicit subpaths.\n **set** structured data and callbacks as javascript properties.\n **read** form state from the event target instead of custom event payloads.\n **provide** accessible labels for icon only controls.\n **use** component reference pages for supported slots, parts, and css properties.\n **dispose** application owned ripple effects and service handles.\n **guard** dom dependent component registration during ssr.\n **working with ai agents?** install the `vielzeug` skill (`npx y @vielzeug/codex skills install`) so agents reach for refine components and tokens instead of custom widgets: see the [codex usage guide](../codex/usage.md#agent skill).\n"
1029
+ "index": " \ntitle: 'refine: web component library'\ndescription: accessible, themeable web components built with ore for framework and vanilla dom apps.\npackage: refine\ncategory: ui components\nkeywords: [web components, accessible, themeable, ui, components, design system]\nrelated: [ore, orbit, forge, keymap]\nexports:\n [\n ore accordion,\n ore accordion item,\n ore alert,\n ore async,\n ore avatar,\n ore avatar group,\n ore badge,\n ore box,\n ore breadcrumb,\n ore breadcrumb item,\n ore button,\n ore button group,\n ore calendar,\n ore card,\n ore carousel,\n ore carousel slide,\n ore chat message,\n ore checkbox,\n ore checkbox group,\n ore chip,\n ore code window,\n ore column,\n ore combobox,\n ore combobox option,\n ore command palette,\n ore command palette item,\n ore cookie banner,\n ore copy command,\n ore counter,\n ore datagrid,\n ore date picker,\n ore dialog,\n ore drawer,\n ore file input,\n ore grid,\n ore grid item,\n ore icon,\n ore input,\n ore keyboard key,\n ore keyboard shortcut,\n ore list,\n ore list item,\n ore marquee,\n ore menu,\n ore menu item,\n ore menu separator,\n ore message composer,\n ore navbar,\n ore navbar item,\n ore navigation menu,\n ore navigation menu item,\n ore navigation menu panel,\n ore number input,\n ore otp input,\n ore pagination,\n ore password strength,\n ore popover,\n ore progress,\n ore qr code,\n ore qr scanner,\n ore query bar,\n ore radio,\n ore radio group,\n ore rating,\n ore select,\n ore separator,\n ore sidebar,\n ore sidebar group,\n ore sidebar item,\n ore skeleton,\n ore slider,\n ore speech player,\n ore stats,\n ore step,\n ore stepper,\n ore switch,\n ore tab item,\n ore tab panel,\n ore table,\n ore tabs,\n ore td,\n ore text,\n ore th,\n ore tr,\n ore textarea,\n ore time picker,\n ore toast,\n ore tooltip,\n ore typing indicator,\n ]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"refine\" />\n\n## why refine?\n\nevery project needs ui primitives. refine provides accessible web components that work natively anywhere html is rendered, no framework required.\n\n```html\n<! before: roll your own button with aria >\n<button class=\"btn btn primary\" role=\"button\" aria pressed=\"false\" tabindex=\"0\">\n <span class=\"btn spinner\" aria hidden=\"true\"></span>\n save\n</button>\n\n<! after: refine >\n<ore button variant=\"solid\" color=\"primary\" loading>save</ore button>\n```\n\n| feature | refine | shoelace | material web |\n| | | | |\n| bundle size | <packageinfo package=\"refine\" type=\"size\" /> | ~145 kb | ~200 kb |\n| built with | ore | lit | lit |\n| runtime dependencies | vielzeug ui primitives + lucide | lit + utilities | lit + material foundations |\n| accessible | wcag aa | wcag aa | wcag aa |\n| framework agnostic | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use refine when** you want accessible web components that match the vielzeug design system without a heavy framework dependency.\n\n**consider shoelace or material web** if your team is already standardized on those ecosystems and you need their established component catalogs.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/refine\n```\n\n```sh [npm]\nnpm install @vielzeug/refine\n```\n\n```sh [yarn]\nyarn add @vielzeug/refine\n```\n\n:::\n\n## quick start\n\n```ts\n// 1. import global styles once\nimport '@vielzeug/refine/fouc.css'; // hide unupgraded custom elements until first paint\nimport '@vielzeug/refine/tokens.css'; // tokens, animations, cascade layers\n\n// 2. register only the elements you need\nimport '@vielzeug/refine/button';\nimport '@vielzeug/refine/input';\nimport '@vielzeug/refine/card';\n```\n\n```html\n<ore button variant=\"solid\" color=\"primary\">save</ore button>\n<ore input label=\"email\" type=\"email\" required></ore input>\n<ore card padding=\"lg\">\n <span slot=\"header\">account</span>\n <p>card content goes here.</p>\n</ore card>\n```\n\n```ts\n```\n\n### cdn / vanilla html\n\nuse the self contained iife bundle to load refine directly from a cdn in any html page: no build step required:\n\n```html\n<! 1. styles >\n<link rel=\"stylesheet\" href=\"https://unpkg.com/@vielzeug/refine/dist/styles/fouc.css\" />\n<link rel=\"stylesheet\" href=\"https://unpkg.com/@vielzeug/refine/dist/styles/tokens.css\" />\n\n<! 2. all components (iife: registers global refine namespace) >\n<script src=\"https://unpkg.com/@vielzeug/refine/dist/refine.iife.js\"></script>\n```\n\nfor bundler based projects that still want a cdn url, use the esm bundle via an import map:\n\n```html\n<script type=\"importmap\">\n {\n \"imports\": {\n \"@vielzeug/refine\": \"https://esm.sh/@vielzeug/refine\",\n \"@vielzeug/refine/button\": \"https://esm.sh/@vielzeug/refine/button\",\n \"@vielzeug/refine/input\": \"https://esm.sh/@vielzeug/refine/input\"\n }\n }\n</script>\n\n<script type=\"module\">\n import '@vielzeug/refine/button';\n import '@vielzeug/refine/input';\n</script>\n```\n\n### package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/refine/fouc.css` | fouc suppression for unupgraded custom elements |\n| `@vielzeug/refine/tokens.css` | global design tokens and cascade layers |\n| `@vielzeug/refine/styles/preflight.css` | optional browser default reset (includes fouc suppression) |\n\ncomponent registration happens through side effect imports such as `@vielzeug/refine/button` and `@vielzeug/refine/dialog`.\n\n### components\n\n**content:** `ore avatar`, `ore avatar group`, `ore breadcrumb`, `ore card`, `ore carousel`, `ore carousel slide`, `ore chat message`, `ore icon`, `ore keyboard key`, `ore keyboard shortcut`, `ore list`, `ore list item`, `ore marquee`, `ore pagination`, `ore qr code`, `ore separator`, `ore stats`, `ore step`, `ore stepper`, `ore table`, `ore text`\n\n**disclosure:** `ore accordion`, `ore accordion item`, `ore tabs`, `ore tab item`, `ore tab panel`\n\n**feedback:** `ore alert`, `ore async`, `ore badge`, `ore chip`, `ore cookie banner`, `ore password strength`, `ore progress`, `ore skeleton`, `ore toast`, `ore typing indicator`\n\n**inputs:** `ore button`, `ore button group`, `ore calendar`, `ore checkbox`, `ore checkbox group`, `ore column`, `ore combobox`, `ore counter`, `ore datagrid`, `ore date picker`, `ore file input`, `ore input`, `ore message composer`, `ore number input`, `ore otp input`, `ore qr scanner`, `ore query bar`, `ore radio`, `ore radio group`, `ore rating`, `ore select`, `ore slider`, `ore switch`, `ore textarea`, `ore time picker`\n\n**layout:** `ore box`, `ore grid`, `ore grid item`, `ore navbar`, `ore sidebar`\n\n**overlay:** `ore command palette`, `ore command palette item`, `ore dialog`, `ore drawer`, `ore menu`, `ore popover`, `ore tooltip`\n\n## component portfolio\n\nrefine keeps one public package and classifies components by composition level rather than stability. all listed components remain supported and tested.\n\n **foundations**: buttons, inputs, selection controls, disclosure, dialogs, menus, tooltips, layout primitives, and feedback indicators. start here when building an application specific interface.\n **composed patterns**: data grids, command palettes, navigation menus, steppers, calendars, date/time pickers, file inputs, and message composers. these package recurring accessibility and interaction behavior that is costly to rebuild correctly.\n **presentation patterns**: cards, chat messages, code windows, stats, marquees, avatars, badges, and skeletons. use these when their product vocabulary matches the application; prefer foundations when it does not.\n\nclassification guides discovery and bundle selection. it is not a reason to remove working components or move them into unstable packages; per component subpath imports remain the tree shaking boundary.\n\n## features\n\n<div class=\"features grid\">\n\n **accessible**: keyboard navigation, aria wiring, and focus management across interactive components\n **themeable**: global tokens plus component level css custom properties\n **framework agnostic**: works anywhere html can be rendered\n **tree shakeable**: import only the component entry points you register\n **comprehensive surface**: inputs, content, disclosure, feedback, layout, and overlay primitives\n **native form integration**: form associated controls submit values, validate supported required constraints, and reset with their form\n **typed framework declarations**: dom, react, and vue tags derive from the component property contracts\n\n</div>\n\n### prerequisites\n\n browser runtime with custom elements support.\n import `@vielzeug/refine/fouc.css` and `@vielzeug/refine/tokens.css` before rendering components.\n for ssr, render placeholders server side and hydrate components only on the client.\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [ore](/ore/): web component runtime that powers refine\n [orbit](/orbit/): floating ui positioning used in refine's overlays\n [forge](/forge/): form state management for use with refine inputs\n [keymap](/keymap/): keyboard shortcut manager that powers the command palette's global trigger\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
1030
+ "api": " \ntitle: 'refine: api reference'\ndescription: published component, stylesheet, framework type, and error entry points for @vielzeug/refine.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `@vielzeug/refine/<component>` | register one custom element and export its public types | sync | importing a type alone does not register the element |\n| `@vielzeug/refine/tokens.css` | load required theme tokens, animations, and cascade layers | css | import once before components render |\n| `@vielzeug/refine/fouc.css` | hide unregistered `ore *` elements until upgrade | css | load before first paint |\n| `@vielzeug/refine/styles/preflight.css` | apply the optional browser reset | css | the reset affects global elements |\n| `@vielzeug/refine/frameworks/elements` | register typed dom tag mappings | types only | import for side effects in typescript |\n| `@vielzeug/refine/frameworks/react` | register typed react jsx elements | types only | does not provide runtime wrappers |\n| `@vielzeug/refine/frameworks/vue` | register typed vue global components | types only | does not install a vue plugin |\n| `@vielzeug/refine/theme` | `createthemecontroller()`: light/dark/system document theme | sync | does not persist the preference or set an accent color |\n| `eventfieldvalue()` / `eventfieldchecked()` | read a form control's value/checked from `event.currenttarget` | sync | exported from the package root; guards non field targets |\n| `refineerror` | base class for package defined public errors | sync | component configuration warnings do not throw this error |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/refine` | export `refineerror` and the `eventfieldvalue`/`eventfieldchecked` field readers without registering components |\n| `@vielzeug/refine/<component>` | register one component and export its tag constant, props, events, and related types |\n| `@vielzeug/refine/theme` | `createthemecontroller()` and its theme types |\n| `@vielzeug/refine/tokens.css` | required global design contract |\n| `@vielzeug/refine/fouc.css` | optional pre upgrade visibility rule |\n| `@vielzeug/refine/styles/*` | focused theme, animation, layer, and preflight stylesheets |\n| `@vielzeug/refine/frameworks/*` | typescript augmentations for dom, react, and vue |\n\n## styles\n\n```ts\nimport '@vielzeug/refine/fouc.css';\nimport '@vielzeug/refine/tokens.css';\nimport '@vielzeug/refine/styles/preflight.css';\n```\n\n`tokens.css` is required. it defines tokens, animations, and cascade layer order without resetting global elements. `preflight.css` is optional and includes fouc suppression.\n\n| import path | purpose |\n| | |\n| `@vielzeug/refine/fouc.css` | fouc suppression for unupgraded custom elements |\n| `@vielzeug/refine/tokens.css` | tokens, animation helpers, and cascade layers |\n| `@vielzeug/refine/styles/theme.css` | theme token declarations |\n| `@vielzeug/refine/styles/animation.css` | animation helpers |\n| `@vielzeug/refine/styles/layers.css` | cascade layer declarations |\n| `@vielzeug/refine/styles/preflight.css` | optional browser reset and fouc suppression |\n\n## components\n\neach component uses one registration and type entry point:\n\n```ts\nimport '@vielzeug/refine/button';\nimport type { orebuttonprops } from '@vielzeug/refine/button';\n```\n\n| area | components |\n| | |\n| content | `avatar`, `avatar group`, `breadcrumb`, `card`, `carousel`, `chat message`, `code window`, `copy command`, `icon`, `keyboard key`, `list`, `list item`, `marquee`, `pagination`, `separator`, `speech player`, `stats`, `step`, `stepper`, `table`, `text` |\n| disclosure | `accordion`, `accordion item`, `tabs`, `tab item`, `tab panel` |\n| feedback | `alert`, `async`, `badge`, `chip`, `cookie banner`, `password strength`, `progress`, `skeleton`, `toast`, `typing indicator` |\n| inputs | `button`, `button group`, `calendar`, `checkbox`, `checkbox group`, `combobox`, `counter`, `datagrid`, `date picker`, `file input`, `input`, `message composer`, `number input`, `otp input`, `query bar`, `radio`, `radio group`, `rating`, `select`, `slider`, `switch`, `textarea`, `time picker` |\n| layout | `box`, `grid`, `grid item`, `navbar`, `sidebar` |\n| overlays | `command palette`, `dialog`, `drawer`, `menu`, `navigation menu`, `popover`, `tooltip` |\n\neach component page lists its attributes, javascript properties, events, slots, parts, and css custom properties.\n\n## framework types\n\n```ts\nimport type {} from '@vielzeug/refine/frameworks/elements';\nimport type {} from '@vielzeug/refine/frameworks/react';\nimport type {} from '@vielzeug/refine/frameworks/vue';\n```\n\nthe declarations cover every supported tag and derive component properties from refine's authoritative `htmlelementtagnamemap`. they provide types only; component registration still uses component subpaths.\n\n## events and form controls\n\nform controls expose `.value` or `.checked` and dispatch standard `input` and `change` events. read the property from `event.currenttarget`.\n\nfinite choice properties use ore's `prop.oneof()` parsing: values outside a component's documented options resolve to\nthe declared default, or `undefined` when no default is declared.\n\nform associated controls participate in native form submission, validity checks, and reset. a named control's value\nis available through `new formdata(form)`, and supported `required` constraints affect `form.checkvalidity()`,\n`form.reportvalidity()`, and submit blocking. the `error` property renders and announces feedback; it does not by\nitself set native constraint validity.\n\nstateful 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.\n\n## utilities\n\n### `createthemecontroller(options?)`\n\n```ts\nimport { createthemecontroller } from '@vielzeug/refine/theme';\n\nconst theme = createthemecontroller(); // starts in 'system' mode\ntheme.setpreference('dark');\ntheme.dispose();\n```\n\nowns the light/dark/system theme for the document: a reactive `preference` signal, the effective `resolved` mode (`'dark' | 'light'` after resolving `system` against the os), and the dom application `styles/theme.css` expects: the `.dark` class plus the `color scheme` property on the root element, so every `light dark()` token resolves to the right branch. while in `system` mode the controller tracks the os `prefers color scheme` via sentinel's `createmediaquery`.\n\nthe controller does **not** persist the preference or expose an accent color: those are consumer concerns (wire `watch(controller.preference, save)` for persistence; set ` color primary hue` yourself). options: `initial` (starting preference, default `'system'`), `root` (element carrying the classes, default `document.documentelement`), `target` (window whose `matchmedia` backs `system`). the handle is disposable: `dispose()`, `disposed`, `disposalsignal`, `[symbol.dispose]`.\n\n### `eventfieldvalue(event)` / `eventfieldchecked(event)`\n\n```ts\nimport { eventfieldchecked, eventfieldvalue } from '@vielzeug/refine';\n\ninput.addeventlistener('input', (event) => {\n const value = eventfieldvalue(event); // string | undefined: never a blind cast\n});\n```\n\nguarded readers for the standard `input`/`change` event pattern above: they read `value`/`checked` from `event.currenttarget` only when the target actually carries it, so a handler wired to the wrong element yields `undefined`/`false` instead of a crash or a lie. exported from the package root, which registers no components.\n\n## types\n\ncomponent props follow the `ore<component>props` naming pattern. event maps follow `ore<component>events`. import both from the component subpath that owns them.\n\n`refineelementmap`, `refinereactintrinsicelements`, and `refinevueglobalcomponents` are exported from their respective `frameworks/*` type entry points.\n\n## errors\n\n### `refineerror`\n\n```ts\nclass refineerror extends error {\n constructor(message: string, opts?: erroroptions);\n}\n```\n\nbase class for public refine errors. it preserves `cause` through `erroroptions`.\n",
1031
+ "usage": " \ntitle: 'refine: usage guide'\ndescription: register refine components, configure properties, handle events, compose slots, and integrate frameworks.\n \n\n[[toc]]\n\n## basic usage\n\nload the global design contract once, then register only the components used by the page.\n\n```ts\nimport '@vielzeug/refine/tokens.css';\nimport '@vielzeug/refine/button';\nimport '@vielzeug/refine/input';\nimport '@vielzeug/refine/dialog';\n\nconst input = document.queryselector('ore input');\nconst save = document.queryselector('ore button');\n\nif (!(input instanceof htmlelement) || !(save instanceof htmlelement)) {\n throw new error('expected refine controls');\n}\n\nsave.addeventlistener('click', () => {\n console.log((input as htmlelement & { value: string }).value);\n});\n```\n\nadd the browser reset only when the application owns global element styling:\n\n```ts\nimport '@vielzeug/refine/styles/preflight.css';\n```\n\n## set attributes and properties\n\nuse attributes for serializable html state.\n\n```html\n<ore button variant=\"outline\" color=\"secondary\" size=\"lg\" disabled>\n save changes\n</ore button>\n```\n\nuse javascript properties for arrays, objects, callbacks, and controlled state.\n\n```ts\nimport '@vielzeug/refine/datagrid';\nimport type { oredatagridprops } from '@vielzeug/refine/datagrid';\n\nconst grid = document.queryselector('ore datagrid') as htmlelement & oredatagridprops;\ngrid.columns = [\n { key: 'name', label: 'name', sortable: true },\n { key: 'role', label: 'role' },\n];\ngrid.rows = [\n { id: '1', name: 'ada', role: 'admin' },\n { id: '2', name: 'linus', role: 'editor' },\n];\n```\n\nenumerated properties such as `variant`, `size`, and `color` accept the values listed on each component's reference\npage. an unsupported value resolves to the property's declared default (or `undefined` when it has no default).\n\nin an ore template, `model()` can bind a refine field's `value` to a ripple signal and write user input back:\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { html, model } from '@vielzeug/ore';\nimport '@vielzeug/refine/input';\n\nconst email = signal('');\nconst field = html`<ore input label=\"email\" type=\"email\" value=${model(email)} />`;\n```\n\n## handle events\n\nform controls dispatch standard `input` and `change` events. read their current property from `event.currenttarget`.\n\n```ts\nconst input = document.queryselector('ore input');\ninput?.addeventlistener('input', (event) => {\n const target = event.currenttarget as htmlelement & { value: string };\n console.log(target.value);\n});\n```\n\ncustom events use kebab case names and typed detail objects.\n\n```ts\nimport type { oredialogevents } from '@vielzeug/refine/dialog';\n\nconst dialog = document.queryselector('ore dialog');\ndialog?.addeventlistener('open change', (event: customevent<oredialogevents['open change']>) => {\n console.log(event.detail.open, event.detail.reason);\n});\n```\n\n## native form validation\n\nrefine's form associated controls integrate with native form submission, `formdata`, validity checks, and reset.\nuse `required` for a required value constraint; setting `error` displays and announces feedback but does not, by\nitself, invalidate the form.\n\n```html\n<form id=\"profile form\">\n <ore input name=\"email\" type=\"email\" label=\"email\" required></ore input>\n <ore select name=\"role\" label=\"role\" required>\n <option value=\"\">choose a role…</option>\n <option value=\"editor\">editor</option>\n <option value=\"admin\">admin</option>\n </ore select>\n <ore button type=\"submit\">save</ore button>\n</form>\n\n<script type=\"module\">\n import '@vielzeug/refine/input';\n import '@vielzeug/refine/select';\n import '@vielzeug/refine/button';\n\n const form = document.queryselector('#profile form');\n form.addeventlistener('submit', (event) => {\n event.preventdefault();\n console.log(new formdata(form).get('email'));\n });\n</script>\n```\n\nthe browser runs native validation before dispatching `submit`. call `form.reportvalidity()` to show validation\nfeedback directly, or `form.checkvalidity()` for a boolean check without the browser's validation ui.\n\n## compose slots\n\ndefault slots hold primary content. named slots identify component owned regions.\n\n```html\n<ore card>\n <span slot=\"header\">account</span>\n <p>profile settings</p>\n <div slot=\"footer\">\n <ore button variant=\"ghost\">cancel</ore button>\n <ore button>save</ore button>\n </div>\n</ore card>\n```\n\ninput components commonly expose `prefix`, `suffix`, `label`, `helper`, and `error` slots. each component reference lists its supported slots.\n\n## load typescript framework declarations\n\nimport the declaration matching the framework once in an application type entry point.\n\n::: code group\n\n```ts [dom]\nimport type {} from '@vielzeug/refine/frameworks/elements';\n```\n\n```ts [react]\nimport type {} from '@vielzeug/refine/frameworks/react';\n```\n\n```ts [vue]\nimport type {} from '@vielzeug/refine/frameworks/vue';\n```\n\n:::\n\nthese entry points provide typed tags and component properties. they do not register components or install runtime adapters.\n\n## framework integration\n\nrefine elements use native dom properties, events, and slots. frameworks differ only in how they forward custom element properties and events.\n\n::: code group\n\n```tsx [react 19]\nimport '@vielzeug/refine/button';\n\nexport function savebutton() {\n return <ore button variant=\"solid\">save</ore button>;\n}\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nimport '@vielzeug/refine/button';\n</script>\n\n<template>\n <ore button variant=\"solid\">save</ore button>\n</template>\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import '@vielzeug/refine/button';\n</script>\n\n<ore button variant=\"solid\">save</ore button>\n```\n\n:::\n\nsee [framework integration](./frameworks.md) for custom events, controlled properties, ssr guards, and framework specific configuration.\n\n## working with other vielzeug libraries\n\n### ore\n\nuse ore when authoring a custom element that composes refine components.\n\n```ts\nimport '@vielzeug/refine/button';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('save panel', {\n setup() {\n return html`<ore button @click=${() => console.log('save')}>save</ore button>`;\n },\n});\n```\n\n### ripple\n\nuse ripple to drive component properties from application state.\n\n```ts\nimport '@vielzeug/refine/button';\nimport { effect, signal } from '@vielzeug/ripple';\n\nconst loading = signal(false);\nconst button = document.queryselector('ore button') as htmlelement & { loading?: boolean };\nconst loadingeffect = effect(() => {\n button.loading = loading.value;\n});\n\nloadingeffect.dispose();\n```\n\n## best practices\n\n **import** `tokens.css` once before rendering components.\n **register** components through explicit subpaths.\n **set** structured data and callbacks as javascript properties.\n **read** form state from the event target instead of custom event payloads.\n **provide** accessible labels for icon only controls.\n **use** component reference pages for supported slots, parts, and css properties.\n **dispose** application owned ripple effects and service handles.\n **guard** dom dependent component registration during ssr.\n **working with ai agents?** install the `vielzeug` skill (`npx y @vielzeug/codex skills install`) so agents reach for refine components and tokens instead of custom widgets: see the [codex usage guide](../codex/usage.md#agent skill).\n"
1020
1032
  },
1021
1033
  "examples": [],
1022
1034
  "exports": "ore accordion ore accordion item ore alert ore async ore avatar ore avatar group ore badge ore box ore breadcrumb ore breadcrumb item ore button ore button group ore calendar ore card ore carousel ore carousel slide ore chat message ore checkbox ore checkbox group ore chip ore code window ore column ore combobox ore combobox option ore command palette ore command palette item ore cookie banner ore copy command ore counter ore datagrid ore date picker ore dialog ore drawer ore file input ore grid ore grid item ore icon ore input ore keyboard key ore keyboard shortcut ore list ore list item ore marquee ore menu ore menu item ore menu separator ore message composer ore navbar ore navbar item ore navigation menu ore navigation menu item ore navigation menu panel ore number input ore otp input ore pagination ore password strength ore popover ore progress ore qr code ore qr scanner ore query bar ore radio ore radio group ore rating ore select ore separator ore sidebar ore sidebar group ore sidebar item ore skeleton ore slider ore speech player ore stats ore step ore stepper ore switch ore tab item ore tab panel ore table ore tabs ore td ore text ore th ore tr ore textarea ore time picker ore toast ore tooltip ore typing indicator",
@@ -1028,17 +1040,17 @@
1028
1040
  },
1029
1041
  {
1030
1042
  "category": "state",
1031
- "description": "framework agnostic signals, effects, async resources, scopes, structural bridges, and observability.",
1043
+ "description": "framework agnostic signals, effects, async resources, scopes, and structural bridges.",
1032
1044
  "docs": {
1033
- "index": " \ntitle: 'ripple: reactive graphs'\ndescription: framework agnostic signals, effects, async resources, scopes, structural bridges, and observability.\npackage: ripple\ncategory: state\nkeywords: [reactive, signals, computed, effects, graph, scope, batch, watch, tap, observability]\nrelated: [ore, clockwork, ledger]\nexports: [createripple, signal, computed, effect, batch, createscope, untrack, watch, resource, fromsubscribable, isreactive]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"ripple\" />\n\n## why ripple?\n\nhand rolled reactive state spreads subscription, cleanup, and derived value rules across application code. ripple gives you one graph boundary with explicit disposal and fine grained dependencies while keeping rendering and routing outside the runtime.\n\n```ts\n// before\nlet count = 0;\nconst listeners = new set<() => void>();\n\nfunction setcount(next: number) {\n count = next;\n for (const listener of listeners) listener();\n}\n\n// after\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\nconst doubled = ripple.computed(() => count.value * 2);\nconst stop = ripple.effect(() => console.log(doubled.value));\n\ncount.value = 1;\nstop.dispose();\nripple.dispose();\n```\n\n| feature | ripple | zustand | jotai |\n| | | | |\n| bundle size | <packageinfo package=\"ripple\" type=\"size\" /> | ~3.5 kb | ~7 kb |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| framework agnostic | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | react first |\n| explicit graph lifetime | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| fine grained derived values | <ore icon name=\"check\" size=\"16\"></ore icon> | selectors | atoms |\n\n<div class=\"decision callout\">\n\n**use ripple when** you need framework independent state with explicit graph lifetime and small composable primitives.\n\n**consider a framework store when** component bindings, server cache, or framework specific tooling matter more than portable reactive state.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/ripple\n```\n\n:::\n\n## quick start\n\ncreate one graph, derive a value, observe it, then dispose when the graph lifetime ends.\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\nconst doubled = ripple.computed(() => count.value * 2);\nconst stop = ripple.effect(() => console.log(doubled.value));\n\nripple.batch(() => {\n count.value = 1;\n count.value = 2;\n});\n\nstop.dispose();\nripple.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createripple()` creates an isolated graph and lifetime boundary.\n `signal()` stores writable values with configurable equality.\n `computed()` derives lazy read only values.\n `effect()` reacts to dependency changes with cleanup support.\n `batch()` coalesces synchronous writes and notifications.\n `createscope()` groups owned reactive work.\n `watch()` observes one selected source transition.\n `tap()` provides runtime observability for all graph events.\n `resource()` owns focused latest request async state.\n `fromsubscribable()` creates a disposable bridge for external `{ getsnapshot, subscribe }` sources.\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [ore](/ore/): uses ripple signals and effects for web component reactivity.\n [clockwork](/clockwork/): exposes machine state through reactive ripple values.\n [ledger](/ledger/): adds command based undo and redo beside ripple state.\n\n</div>\n\n<! markdownlint enable >\n",
1034
- "api": " \ntitle: 'ripple: api reference'\ndescription: complete reference for reactive graphs, signals, effects, scopes, watchers, and observability.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createripple()` | create isolated graph | sync | disposal is terminal; create a new graph instead of reusing it |\n| `signal()` | create writable value | sync | default graph is process wide |\n| `computed()` | create lazy derived value | sync | keep derivation pure |\n| `effect()` | react to dependency reads | sync | dispose handle or return cleanup |\n| `batch()` | coalesce synchronous writes | sync | does not roll back writes |\n| `createscope()` | group owned reactive work | sync | call `run()` to activate it |\n| `untrack()` | read without tracking | sync | read still happens immediately |\n| `watch()` | observe selected output | sync | use `effect()` for broad reads |\n| `tap()` | observe runtime events | sync | handler errors are swallowed |\n| `fromsubscribable()` | bridge external source | sync | returned bridge must be disposed |\n| `isreactive()` | test ripple's marked readable contract | sync | not a security or provenance check |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/ripple` | all primitives, types, and errors: signals, computed, effects, scopes, watch, tap, and the isolated graph factory |\n\n## graph creation\n\n### `createripple(options?)`\n\n```ts\nfunction createripple(options?: rippleoptions): ripple;\n```\n\ncreates 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.\n\n| parameter | type | description |\n| | | |\n| `options.errorpolicy` | `'throw' \\| 'swallow'` | controls whether computed refresh, effect, cleanup, and listener failures rethrow. default `'throw'`. error events are always emitted through `tap()`. |\n\n**returns:** `ripple`.\n\n**example:**\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\nconst stop = ripple.effect(() => console.log(count.value));\n\nstop.dispose();\nripple.dispose();\n```\n\n \n\n### `isreactive(value)`\n\n```ts\nfunction isreactive<t>(value: t | readable<t>): value is readable<t>;\n```\n\ntests ripple's shared symbol marker and complete readable surface (`value`, `peek()`, and `subscribe()`). recognition works across duplicated ripple module graphs. the marker is intentionally structural and forgeable; do not use this guard for trust or provenance decisions.\n\n**returns:** `true` for marked signals, computeds, resources, and external source bridges with the complete readable contract.\n\n**example:**\n\n```ts\nimport { isreactive, signal } from '@vielzeug/ripple';\n\nconsole.log(isreactive(signal(0)));\n```\n\n## default graph functions\n\nroot 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()`.\n\n### `signal(initial, options?)`\n\n```ts\nfunction signal<t>(initial: t, options?: signaloptions<t>): signal<t>;\n```\n\ncreates writable state on the default graph. use `update()` for immutable replacement patterns.\n\n**returns:** `signal<t>`.\n\n**example:**\n\n```ts\nimport { signal } from '@vielzeug/ripple';\n\nconst count = signal(0);\ncount.value += 1;\n\nconst cart = signal({ items: 0 });\ncart.update((state) => ({ ...state, items: state.items + 1 }));\n```\n\n \n\n### `computed(derive, options?)`\n\n```ts\nfunction computed<t>(derive: () => t, options?: computedoptions<t>): readable<t>;\n```\n\ncreates 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.\n\n**returns:** `readable<t>`.\n\n**example:**\n\n```ts\nimport { computed, signal } from '@vielzeug/ripple';\n\nconst count = signal(2);\nconst doubled = computed(() => count.value * 2);\nconsole.log(doubled.value);\n```\n\n \n\n### `effect(callback, options?)`\n\n```ts\nfunction effect(callback: () => cleanup | undefined, options?: effectoptions): effecthandle;\n```\n\nruns immediately and reruns when its tracked reads change. a returned cleanup runs before the next callback or disposal.\n\n**returns:** `effecthandle`.\n\n**example:**\n\n```ts\nimport { effect, signal } from '@vielzeug/ripple';\n\nconst connected = signal(false);\nconst stop = effect(() => {\n if (!connected.value) return;\n\n return () => console.log('disconnect');\n});\n\nstop.dispose();\n```\n\n \n\n### `batch(fn)` and `untrack(fn)`\n\n```ts\nfunction batch<t>(fn: () => t): t;\nfunction untrack<t>(fn: () => t): t;\n```\n\n`batch()` defers effects and listeners until its callback returns. `untrack()` reads current state without adding dependencies to an enclosing effect.\n\n**returns:** the callback result.\n\n**example:**\n\n```ts\nimport { batch, signal, untrack } from '@vielzeug/ripple';\n\nconst first = signal('ada');\nconst last = signal('lovelace');\nconst locale = signal('en us');\n\nbatch(() => {\n first.value = 'grace';\n last.value = 'hopper';\n});\n\nconsole.log(untrack(() => locale.value));\n```\n\n \n\n### `createscope(name?)`\n\n```ts\nfunction createscope(name?: string): scope;\n```\n\ncreates a disposable ownership boundary. work created inside `scope.run()` belongs to that scope. explicit scopes created during an effect run attach to the enclosing scope (not the transient effect run owner) and therefore survive reruns until explicitly or parent disposed. disposed children detach from parent ownership immediately.\n\n**returns:** `scope`.\n\n**example:**\n\n```ts\nimport { createscope, effect, signal } from '@vielzeug/ripple';\n\nconst scope = createscope('panel');\nconst count = signal(0);\n\nscope.run(() => effect(() => console.log(count.value)));\nscope.dispose();\n```\n\n### `resource(source, loader, options?)`\n\n```ts\nfunction resource<source, value>(\n source: () => source,\n loader: (source: source, context: { readonly signal: abortsignal }) => promise<value>,\n options?: resourceoptions,\n): resource<value>;\n```\n\ncreates 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`.\n\n```ts\nconst user = resource(\n () => userid.value,\n (id, { signal }) => fetch(`/users/${id}`, { signal }).then((response) => response.json()),\n);\n```\n\nthe state is `{ status: 'pending', previous? }`, `{ status: 'success', value }`, or `{ status: 'error', error, previous? }`. reads remain available after disposal; `reload()` and new subscriptions throw `rippledisposedresourceerror`.\n\n## watch and observability\n\n### `watch(source, callback, options?)`\n\n```ts\nfunction watch<t>(\n source: readable<t> | (() => t),\n callback: (value: t, previous: t | undefined) => void,\n options?: watchoptions<t>,\n): effecthandle;\n```\n\nobserves selected output changes using the default graph or a `ripple.watch()` method. callback only reactive reads are untracked; without `immediate`, initial evaluation does not count as an invocation. for one shot observation, dispose the returned handle from inside the callback: with `immediate: true`, do the first invocation at the call site instead, because the callback runs before the handle exists.\n\n**returns:** `effecthandle`.\n\n**example:**\n\n```ts\nimport { signal, watch } from '@vielzeug/ripple';\n\nconst count = signal(0);\nconst stop = watch(count, (value, previous) => console.log(previous, value), { immediate: true });\nstop.dispose();\n```\n\n \n\n### `ripple.tap(handler, options?)`\n\n```ts\nfunction tap(\n handler: (event: rippleevent) => void,\n options?: { signal?: abortsignal },\n): unsubscribe;\n```\n\nruntime 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.\n\nthe `ripple.tap()` method is available on instances created with `createripple()`. after runtime disposal it returns a no op unsubscribe.\n\n**returns:** `unsubscribe`.\n\n**example:**\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple({ errorpolicy: 'swallow' });\nconst stop = ripple.tap((event) => {\n if (event.type === 'error') console.error(event.context.kind, event.error);\n if (event.type === 'write') console.log(`${event.name}: ${event.previous} → ${event.next}`);\n});\n\nconst count = ripple.signal(0);\ncount.value = 1;\n\nstop();\nripple.dispose();\n```\n\n \n\n### `fromsubscribable(source, options?)`\n\n```ts\nfunction fromsubscribable<t>(source: subscribable<t>, options?: fromsubscribableoptions): bridgedreadable<t>;\n```\n\nbridges an external `{ getsnapshot, subscribe }` source into a disposable reactive readable. the bridge subscribes and then rereads the snapshot to avoid missing a change during registration. the root helper uses the default graph; `ripple.fromsubscribable()` binds ownership to an isolated graph. an optional signal disposes the bridge.\n\n**returns:** `bridgedreadable<t>`. disposal invokes the source unsubscribe function. reads remain available afterward, while new subscriptions throw.\n\n**example:**\n\n```ts\nimport { fromsubscribable } from '@vielzeug/ripple';\n\nconst routerstate = fromsubscribable({\n getsnapshot: () => router.getsnapshot(),\n subscribe: (cb) => router.subscribe(cb),\n});\n\nrouterstate.dispose();\n```\n\n## types\n\n```ts\ntype cleanup = () => void;\ntype equality<t> = (previous: t, next: t) => boolean;\ntype unsubscribe = () => void;\n\ntype signaloptions<t> = { equals?: equality<t>; name?: string };\ntype computedoptions<t> = { equals?: equality<t>; name?: string };\ntype effectoptions = { name?: string };\ntype watchoptions<t> = { equals?: equality<t>; immediate?: boolean; name?: string };\n\ntype rippleerrorcontext = { readonly kind: 'cleanup' | 'computed' | 'effect' | 'listener'; readonly name?: string };\ntype rippleerrorpolicy = 'throw' | 'swallow';\ntype rippleoptions = { errorpolicy?: rippleerrorpolicy };\n\ntype rippleevent =\n | { readonly type: 'compute'; readonly name?: string }\n | { readonly type: 'effect'; readonly name?: string }\n | { readonly type: 'write'; readonly name?: string; readonly next: unknown; readonly previous: unknown }\n | { readonly type: 'dispose'; readonly name?: string; readonly node: 'effect' | 'graph' | 'scope' }\n | { readonly type: 'error'; readonly error: unknown; readonly context: rippleerrorcontext };\n\ninterface readable<t> {\n readonly name?: string;\n peek(): t;\n subscribe(listener: () => void): unsubscribe;\n readonly value: t;\n}\n\ninterface signal<t> extends readable<t> { update(updater: (prev: t) => t): void; value: t }\ninterface disposable { dispose(): void; readonly disposed: boolean; readonly disposalsignal: abortsignal; [symbol.dispose](): void }\ntype effecthandle = disposable;\ninterface scope extends disposable { run<t>(fn: () => t): t }\n\ninterface subscribable<t> {\n getsnapshot(): t;\n subscribe(listener: () => void): unsubscribe;\n}\n\ntype fromsubscribableoptions = { name?: string; signal?: abortsignal };\ntype bridgedreadable<t> = readable<t> & disposable;\n\ntype asyncstate<t> =\n | { readonly previous?: t; readonly status: 'pending' }\n | { readonly status: 'success'; readonly value: t }\n | { readonly error: unknown; readonly previous?: t; readonly status: 'error' };\n\ninterface resource<t> extends readable<asyncstate<t>>, disposable {\n reload(): void;\n}\n\ntype resourceoptions = { name?: string };\n\ninterface ripple extends disposable {\n batch<t>(fn: () => t): t;\n computed<t>(derive: () => t, options?: computedoptions<t>): readable<t>;\n createscope(name?: string): scope;\n effect(callback: () => cleanup | undefined, options?: effectoptions): effecthandle;\n fromsubscribable<t>(source: subscribable<t>, options?: fromsubscribableoptions): bridgedreadable<t>;\n resource<source, value>(\n source: () => source,\n loader: (source: source, context: { readonly signal: abortsignal }) => promise<value>,\n options?: resourceoptions,\n ): resource<value>;\n signal<t>(initial: t, options?: signaloptions<t>): signal<t>;\n tap(handler: (event: rippleevent) => void, options?: { signal?: abortsignal }): unsubscribe;\n untrack<t>(fn: () => t): t;\n watch<t>(source: readable<t> | (() => t), callback: (value: t, previous: t | undefined) => void, options?: watchoptions<t>): effecthandle;\n}\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `rippleerror` | base ripple error | use `instanceof rippleerror` to narrow unknown values. |\n| `ripplecomputedcycleerror` | computed dependency reads itself through a cycle | extends `rippleerror`. |\n| `rippledisposedresourceerror` | `reload()` or `subscribe()` used after resource disposal | extends `rippleerror`. |\n| `rippledisposedruntimeerror` | factory or execution api used after `ripple.dispose()` | extends `rippleerror`. |\n| `rippledisposedscopeerror` | `scope.run()` after scope disposal | extends `rippleerror`. |\n| `rippleinfinitelooperror` | effect flush exceeds graph iteration limit | extends `rippleerror`. |\n",
1035
- "usage": " \ntitle: 'ripple: usage guide'\ndescription: build reactive state with one explicit graph boundary.\n \n\n[[toc]]\n\n## basic usage\n\nuse top level functions when one application lifetime graph is sufficient. read a signal inside an effect to make that read reactive.\n\n```ts\nimport { computed, effect, signal } from '@vielzeug/ripple';\n\nconst count = signal(0);\nconst label = computed(() => `count: ${count.value}`);\nconst stop = effect(() => console.log(label.value));\n\ncount.value = 1;\nstop.dispose();\n```\n\n## isolated graphs\n\nuse `createripple()` for tests, ssr requests, embedded applications, or independently disposable features. never mix reactive values from separate graphs.\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple({ errorpolicy: 'swallow' });\n\nripple.tap((event) => {\n if (event.type === 'error') console.log(event.context.kind, event.error);\n});\n\nconst count = ripple.signal(0);\nconst stop = ripple.effect(() => console.log(count.value));\n\nstop.dispose();\nripple.dispose();\n```\n\n## derived values and batches\n\nuse `computed()` for pure derivation. use `untrack()` when a current read must not become an effect dependency. use `batch()` for related synchronous writes.\n\n```ts\nconst first = ripple.signal('ada');\nconst last = ripple.signal('lovelace');\nconst locale = ripple.signal('en us');\nconst name = ripple.computed(() => `${first.value} ${last.value}`);\n\nripple.effect(() => {\n console.log({ locale: ripple.untrack(() => locale.value), name: name.value });\n});\n\nripple.batch(() => {\n first.value = 'grace';\n last.value = 'hopper';\n});\n```\n\n## scheduling and subscriptions\n\nripple propagates every synchronous write before flushing effects. each flush pass runs effects queued at its\nstart before direct `subscribe()` listeners queued at its start. work queued by either runs in a later pass.\neffects coalesce writes made inside the same synchronous flush: a `batch()` (or any writes during a flush) produces a single effect run.\neach `subscribe()` registration fires once per flush, even when several subscriptions share one callback function.\n\n```ts\nconst count = ripple.signal(0);\nconst log: string[] = [];\n\nripple.effect(() => log.push(`effect: ${count.value}`));\ncount.subscribe(() => log.push(`listener: ${count.value}`));\n\nlog.length = 0; // ignore synchronous creation runs.\nripple.batch(() => {\n count.value = 1;\n count.value = 2;\n});\nconsole.log(log); // ['effect: 2', 'listener: 2']\n```\n\n## ownership with scopes\n\ncreate a scope when a group of effects or derived values shares one lifetime. dispose the scope when its feature ends.\n\n```ts\nconst scope = ripple.createscope('panel');\nconst count = ripple.signal(0);\n\nscope.run(() => {\n ripple.effect(() => console.log(`panel count: ${count.value}`));\n});\n\ncount.value = 1;\nscope.dispose();\n```\n\neffects and computeds created directly during an effect run belong to that run and are disposed before rerun. explicit scopes intentionally attach to the enclosing scope and survive effect reruns; create them outside rerunning effects or retain and dispose each scope yourself.\n\ndisposed owned nodes detach from their parent immediately, so long lived graphs do not retain released effects or scopes.\n\n## watch selected values\n\nuse `watch()` for one selected output. reactive reads made only inside its callback are untracked. for one shot observation, dispose the returned handle from inside the callback; with `immediate: true` the callback runs before the handle exists, so do the first invocation at the call site. use `effect()` when every callback read should be a dependency.\n\n```ts\nconst stopwatch = ripple.watch(\n () => `${first.value} ${last.value}`,\n (value, previous) => console.log({ previous, value }),\n { immediate: true },\n);\n\nstopwatch.dispose();\n```\n\n## observability\n\n`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.\n\n```ts\nconst ripple = createripple({ errorpolicy: 'swallow' });\n\nripple.tap((event) => {\n switch (event.type) {\n case 'write':\n console.log(`${event.name}: ${event.previous} → ${event.next}`);\n break;\n case 'error':\n console.error(`${event.context.kind}:`, event.error);\n break;\n }\n});\n```\n\n`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.\n\n## bridging external sources\n\n`fromsubscribable()` bridges external `{ getsnapshot, subscribe }` sources into a disposable reactive readable. use `ripple.fromsubscribable()` inside isolated graphs; the root helper uses the process lifetime default graph. the bridge rereads after subscription to close registration races, and disposal invokes the external unsubscribe function.\n\n```ts\nimport { fromsubscribable, effect } from '@vielzeug/ripple';\n\nconst routerstate = fromsubscribable({\n getsnapshot: () => router.getsnapshot(),\n subscribe: (cb) => router.subscribe(cb),\n});\n\nconst stop = effect(() => {\n console.log('current route:', routerstate.value);\n});\n\nstop.dispose();\nrouterstate.dispose();\n```\n\n## load async resources\n\n`resource()` starts immediately, tracks its source reads, and aborts the previous loader whenever the source changes or `reload()` is called. it preserves the last successful value (including `undefined`) as `previous` while newer work is pending or fails.\n\n```ts\nconst userid = ripple.signal('42');\nconst user = ripple.resource(\n () => userid.value,\n (id, { signal }) => fetch(`/users/${id}`, { signal }).then((response) => response.json()),\n { name: 'user' },\n);\n\nconst stop = ripple.effect(() => {\n const state = user.value;\n if (state.status === 'success') renderuser(state.value);\n if (state.status === 'error') rendererror(state.error);\n});\n\nstop.dispose();\nuser.dispose();\n```\n\ndisposed 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.\n\n## object state\n\n`signal()` with `update()` holds one value and supports immutable replacement patterns. return replacement objects from `update()` when object consumers depend on immutable updates.\n\n```ts\nconst cart = ripple.signal({ items: 0, label: 'empty' });\nconst items = ripple.computed(() => cart.value.items);\n\ncart.update((state) => ({ ...state, items: state.items + 1 }));\ncart.value = { items: 3, label: 'ready' };\n\nconsole.log(items.value);\n```\n\n## testing\n\ncreate an isolated graph per test. disposal prevents effects from leaking into later tests.\n\n```ts\nimport { expect, test } from 'vitest';\nimport { createripple } from '@vielzeug/ripple';\n\ntest('derives a doubled count', () => {\n const ripple = createripple();\n const count = ripple.signal(2);\n const doubled = ripple.computed(() => count.value * 2);\n\n expect(doubled.value).tobe(4);\n ripple.dispose();\n});\n```\n\n## framework integration\n\nuse signals and effects with any renderer. dispose component owned effects when the component unmounts.\n\n::: code group\n\n```ts [react]\nimport { useeffect, usestate } from 'react';\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\n\nexport function counter() {\n const [, rerender] = usestate(0);\n\n useeffect(() => {\n const stop = ripple.effect(() => {\n void count.value;\n rerender((revision) => revision + 1);\n });\n\n return () => stop.dispose();\n }, []);\n\n return <button onclick={() => (count.value += 1)}>{count.value}</button>;\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, ref } from 'vue';\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\nconst revision = ref(0);\nconst stop = ripple.effect(() => {\n void count.value;\n revision.value++;\n});\n\nonunmounted(() => stop.dispose());\n```\n\n```ts [svelte]\n<script lang=\"ts\">\n import { ondestroy } from 'svelte';\n import { createripple } from '@vielzeug/ripple';\n\n const ripple = createripple();\n const count = ripple.signal(0);\n let revision = 0;\n const stop = ripple.effect(() => {\n void count.value;\n revision++;\n });\n\n ondestroy(() => stop.dispose());\n</script>\n\n<button on:click={() => (count.value += 1)}>{count.value}</button>\n```\n\n:::\n\n## working with other vielzeug libraries\n\nore uses ripple for component reactivity. clockwork actors expose framework neutral snapshots; bridge actor subscriptions into a ripple signal. ledger adds undo/redo commands around state changes without replacing graph.\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\nimport { definemachine } from '@vielzeug/clockwork';\n\nconst ripple = createripple();\nconst actor = definemachine<record<string, never>, { type: 'start' }>()({\n initial: 'idle',\n states: { active: {}, idle: { on: { start: { target: 'active' } } } },\n}).createactor();\n\nconst snapshot = ripple.signal(actor.snapshot);\nconst stop = actor.subscribe((next) => (snapshot.value = next));\nconst status = ripple.computed(() => snapshot.value.state);\nconsole.log(status.value);\n\nstop();\nactor.dispose();\nripple.dispose();\n```\n\n## gotchas\n\n### `subscribe()` forces computed evaluation\n\n`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.\n\n### computed first run failure is recoverable\n\nif 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.\n\n## best practices\n\n create one graph per ownership boundary.\n keep computed callbacks pure.\n return cleanup from effects.\n dispose test and feature graphs.\n batch related synchronous writes.\n use `watch()` only for selected source transitions; callback only reads are untracked.\n dispose external source bridges when their owner ends.\n use `tap()` for runtime observability; set `errorpolicy: 'swallow'` to silence rethrow.\n use `resource()` for focused async state and sourcerer for paginated collection workflows.\n",
1045
+ "index": " \ntitle: 'ripple: reactive graphs'\ndescription: framework agnostic signals, effects, async resources, scopes, and structural bridges.\npackage: ripple\ncategory: state\nkeywords: [reactive, signals, computed, effects, graph, scope, batch, watch]\nrelated: [ore, clockwork, ledger]\nexports: [createripple, signal, computed, effect, resource]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"ripple\" />\n\n## why ripple?\n\nhand rolled reactive state spreads subscription, cleanup, and derived value rules across application code. ripple gives you one graph boundary with explicit disposal and fine grained dependencies while keeping rendering and routing outside the runtime.\n\n```ts\n// before\nlet count = 0;\nconst listeners = new set<() => void>();\n\nfunction setcount(next: number) {\n count = next;\n for (const listener of listeners) listener();\n}\n\n// after\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\nconst doubled = ripple.computed(() => count.value * 2);\nconst stop = ripple.effect(() => console.log(doubled.value));\n\ncount.value = 1;\nstop.dispose();\nripple.dispose();\n```\n\n| feature | ripple | zustand | jotai |\n| | | | |\n| bundle size | <packageinfo package=\"ripple\" type=\"size\" /> | ~3.5 kb | ~7 kb |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| framework agnostic | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | react first |\n| explicit graph lifetime | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| fine grained derived values | <ore icon name=\"check\" size=\"16\"></ore icon> | selectors | atoms |\n\n<div class=\"decision callout\">\n\n**use ripple when** you need framework independent state with explicit graph lifetime and small composable primitives.\n\n**consider a framework store when** component bindings, server cache, or framework specific tooling matter more than portable reactive state.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/ripple\n```\n\n:::\n\n## quick start\n\ncreate one graph, derive a value, observe it, then dispose when the graph lifetime ends.\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\nconst doubled = ripple.computed(() => count.value * 2);\nconst stop = ripple.effect(() => console.log(doubled.value));\n\nripple.batch(() => {\n count.value = 1;\n count.value = 2;\n});\n\nstop.dispose();\nripple.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createripple({ onerror })` creates an isolated graph with one runtime failure callback.\n `signal()` stores writable values with configurable equality.\n `computed()` derives lazy read only values.\n `effect()` reacts to dependency changes with cleanup support.\n `batch()` coalesces synchronous writes and notifications.\n `createscope()` groups owned reactive work.\n `watch()` observes one selected source transition.\n `resource()` owns focused latest request async state.\n `fromsubscribable()` creates a disposable bridge for external `{ getsnapshot, subscribe }` sources.\n `rippleerror`, `ripplecycleerror`, and `rippledisposederror` distinguish package originated failures.\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [ore](/ore/): uses ripple signals and effects for web component reactivity.\n [clockwork](/clockwork/): exposes machine state through reactive ripple values.\n [ledger](/ledger/): adds command based undo and redo beside ripple state.\n\n</div>\n\n<! markdownlint enable >\n",
1046
+ "api": " \ntitle: 'ripple: api reference'\ndescription: complete reference for reactive graphs, signals, effects, scopes, and watchers.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createripple()` | create isolated graph | sync | disposal is terminal; create a new graph instead of reusing it |\n| `signal()` | create writable value | sync | default graph is process wide |\n| `computed()` | create lazy derived value | sync | keep derivation pure |\n| `effect()` | react to dependency reads | sync | dispose handle or return cleanup |\n| `batch()` | coalesce synchronous writes | sync | does not roll back writes |\n| `createscope()` | group owned reactive work | sync | call `run()` to activate it |\n| `untrack()` | read without tracking | sync | read still happens immediately |\n| `watch()` | observe selected output | sync | use `effect()` for broad reads |\n| `resource()` | load from tracked reactive input | sync factory / async loader | `reload()` returns `void`; completion is observable state |\n| `fromsubscribable()` | bridge external source | sync | returned bridge must be disposed |\n| `isreactive()` | test ripple's marked readable contract | sync | not a security or provenance check |\n| `rippleerror` | base package error | sync | runtime callback failures retain their original value |\n| `ripplecycleerror` | computed cycle or runaway flush | sync | flush failures reach `onerror`, not the write site |\n| `rippledisposederror` | work after disposal | sync | cached reads remain available |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/ripple` | all primitives, types, and errors: signals, computed, effects, scopes, watch, and the isolated graph factory |\n\n## graph creation\n\n### `createripple(options?)`\n\n```ts\nfunction createripple(options?: rippleoptions): ripple;\n```\n\ncreates 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.\n\n| parameter | type | description |\n| | | |\n| `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. |\n\n`onerror` is the only runtime option. `rippleerrorcontext.kind` is `cleanup`, `computed`,\n`effect`, `flush`, or `listener`; `name` is optional. there is no event stream or error policy option.\n\n**returns:** `ripple`.\n\n**example:**\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\nconst stop = ripple.effect(() => console.log(count.value));\n\nstop.dispose();\nripple.dispose();\n```\n\n \n\n### `isreactive(value)`\n\n```ts\nfunction isreactive<t>(value: t | readable<t>): value is readable<t>;\n```\n\ntests ripple's shared symbol marker and complete readable surface (`value`, `peek()`, and `subscribe()`). recognition works across duplicated ripple module graphs. the marker is intentionally structural and forgeable; do not use this guard for trust or provenance decisions.\n\n**returns:** `true` for marked signals, computeds, resources, and external source bridges with the complete readable contract.\n\n**example:**\n\n```ts\nimport { isreactive, signal } from '@vielzeug/ripple';\n\nconsole.log(isreactive(signal(0)));\n```\n\n## default graph functions\n\nroot 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()`.\n\n### `signal(initial, options?)`\n\n```ts\nfunction signal<t>(initial: t, options?: signaloptions<t>): signal<t>;\n```\n\ncreates writable state on the default graph. use `update()` for immutable replacement patterns.\n\n**returns:** `signal<t>`.\n\n**example:**\n\n```ts\nimport { signal } from '@vielzeug/ripple';\n\nconst count = signal(0);\ncount.value += 1;\n\nconst cart = signal({ items: 0 });\ncart.update((state) => ({ ...state, items: state.items + 1 }));\n```\n\n \n\n### `computed(derive, options?)`\n\n```ts\nfunction computed<t>(derive: () => t, options?: computedoptions<t>): readable<t>;\n```\n\ncreates 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.\n\n**returns:** `readable<t>`.\n\n**example:**\n\n```ts\nimport { computed, signal } from '@vielzeug/ripple';\n\nconst count = signal(2);\nconst doubled = computed(() => count.value * 2);\nconsole.log(doubled.value);\n```\n\n \n\n### `effect(callback, options?)`\n\n```ts\nfunction effect(callback: () => cleanup | undefined, options?: effectoptions): effecthandle;\n```\n\nruns immediately and reruns when its tracked reads change. a returned cleanup runs before the next callback or disposal.\n\n| parameter | type | description |\n| | | |\n| `options.name` | `string` | optional label in error context |\n\neffects flush synchronously; `effectoptions` has no scheduler option. use `batch()` to coalesce writes.\n\n**returns:** `effecthandle`.\n\n**example:**\n\n```ts\nimport { effect, signal } from '@vielzeug/ripple';\n\nconst connected = signal(false);\nconst stop = effect(() => {\n if (!connected.value) return;\n\n return () => console.log('disconnect');\n});\n\nstop.dispose();\n```\n\n \n\n### `batch(fn)` and `untrack(fn)`\n\n```ts\nfunction batch<t>(fn: () => t): t;\nfunction untrack<t>(fn: () => t): t;\n```\n\n`batch()` defers effects and listeners until its callback returns. `untrack()` reads current state without adding dependencies to an enclosing effect.\n\n**returns:** the callback result.\n\n**example:**\n\n```ts\nimport { batch, signal, untrack } from '@vielzeug/ripple';\n\nconst first = signal('ada');\nconst last = signal('lovelace');\nconst locale = signal('en us');\n\nbatch(() => {\n first.value = 'grace';\n last.value = 'hopper';\n});\n\nconsole.log(untrack(() => locale.value));\n```\n\n \n\n### `createscope(name?)`\n\n```ts\nfunction createscope(name?: string): scope;\n```\n\ncreates a disposable ownership boundary. work created inside `scope.run()` belongs to that scope. explicit scopes created during an effect run attach to the enclosing scope (not the transient effect run owner) and therefore survive reruns until explicitly or parent disposed. disposed children detach from parent ownership immediately.\n\n**returns:** `scope`.\n\n**example:**\n\n```ts\nimport { createscope, effect, signal } from '@vielzeug/ripple';\n\nconst scope = createscope('panel');\nconst count = signal(0);\n\nscope.run(() => effect(() => console.log(count.value)));\nscope.dispose();\n```\n\n### `resource(source, loader, options?)`\n\n```ts\nfunction resource<source, value>(\n source: () => source,\n loader: (source: source, context: { readonly signal: abortsignal }) => promise<value>,\n options?: resourceoptions,\n): resource<value>;\n```\n\ncreates 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`.\n\n| parameter | type | description |\n| | | |\n| `source` | `() => source` | tracks reactive input reads |\n| `loader` | `(source, context) => promise<value>` | receives input and a request owned cancellation signal |\n| `options.name` | `string` | optional resource label |\n\n**returns:** `resource<value>`. source and loader failures become error state, not `onerror` reports.\n\n**example:**\n\n```ts\nimport { resource, signal } from '@vielzeug/ripple';\n\nconst userid = signal('42');\nconst user = resource(\n () => userid.value,\n (id, { signal }) => fetch(`/users/${id}`, { signal }).then((response) => response.json()),\n);\nconsole.log(user.value);\nuser.dispose();\n```\n\nthe state is `{ status: 'pending', previous? }`, `{ status: 'success', value }`, or `{ status: 'error', error, previous? }`. reads remain available after disposal; `reload()` and new subscriptions throw `rippledisposederror`.\n\n## watch\n\n### `watch(source, callback, options?)`\n\n```ts\nfunction watch<t>(\n source: readable<t> | (() => t),\n callback: (value: t, previous: t | undefined) => void,\n options?: watchoptions<t>,\n): effecthandle;\n```\n\nobserves selected output changes using the default graph or a `ripple.watch()` method. callback only reactive reads are untracked; without `immediate`, initial evaluation does not count as an invocation. for one shot observation, dispose the returned handle from inside the callback: with `immediate: true`, do the first invocation at the call site instead, because the callback runs before the handle exists.\n\n**returns:** `effecthandle`.\n\n**example:**\n\n```ts\nimport { signal, watch } from '@vielzeug/ripple';\n\nconst count = signal(0);\nconst stop = watch(count, (value, previous) => console.log(previous, value), { immediate: true });\nstop.dispose();\n```\n\n \n\n### `fromsubscribable(source, options?)`\n\n```ts\nfunction fromsubscribable<t>(source: subscribable<t>, options?: fromsubscribableoptions): bridgedreadable<t>;\n```\n\nbridges an external `{ getsnapshot, subscribe }` source into a disposable reactive readable. the bridge subscribes and then rereads the snapshot to avoid missing a change during registration. the root helper uses the default graph; `ripple.fromsubscribable()` binds ownership to an isolated graph. an optional signal disposes the bridge.\n\n**returns:** `bridgedreadable<t>`. disposal invokes the source unsubscribe function. reads remain available afterward, while new subscriptions throw.\n\n**example:**\n\n```ts\nimport { fromsubscribable } from '@vielzeug/ripple';\n\nlet current = 'idle';\nconst listeners = new set<() => void>();\nconst state = fromsubscribable({\n getsnapshot: () => current,\n subscribe: (listener) => {\n listeners.add(listener);\n return () => { listeners.delete(listener); };\n },\n});\n\ncurrent = 'ready';\nfor (const listener of listeners) listener();\nconsole.log(state.value);\nstate.dispose();\n```\n\n## types\n\n```ts\ntype cleanup = () => void;\ntype equality<t> = (previous: t, next: t) => boolean;\ntype unsubscribe = () => void;\n\ntype signaloptions<t> = { equals?: equality<t>; name?: string };\ntype computedoptions<t> = { equals?: equality<t>; name?: string };\ntype effectoptions = { name?: string };\ntype watchoptions<t> = { equals?: equality<t>; immediate?: boolean; name?: string };\n\ntype rippleerrorcontext = { readonly kind: 'cleanup' | 'computed' | 'effect' | 'flush' | 'listener'; readonly name?: string };\ntype rippleoptions = { onerror?: (error: unknown, context: rippleerrorcontext) => void };\n\ninterface readable<t> {\n readonly name?: string;\n peek(): t;\n /** change only: the listener is not called on subscribe, only after a tracked source changes. */\n subscribe(listener: () => void): unsubscribe;\n readonly value: t;\n}\n\ninterface signal<t> extends readable<t> { update(updater: (prev: t) => t): void; value: t }\ninterface disposable { dispose(): void; readonly disposed: boolean; readonly disposalsignal: abortsignal; [symbol.dispose](): void }\ntype effecthandle = disposable;\ninterface scope extends disposable { run<t>(fn: () => t): t }\n\ninterface subscribable<t> {\n getsnapshot(): t;\n subscribe(listener: () => void): unsubscribe;\n}\n\ntype fromsubscribableoptions = { name?: string; signal?: abortsignal };\ntype bridgedreadable<t> = readable<t> & disposable;\n\ntype asyncstate<t> =\n | { readonly previous?: t; readonly status: 'pending' }\n | { readonly status: 'success'; readonly value: t }\n | { readonly error: unknown; readonly previous?: t; readonly status: 'error' };\n\ninterface resource<t> extends readable<asyncstate<t>>, disposable {\n reload(): void;\n}\n\ntype resourceoptions = { name?: string };\n\ninterface ripple extends disposable {\n batch<t>(fn: () => t): t;\n computed<t>(derive: () => t, options?: computedoptions<t>): readable<t>;\n createscope(name?: string): scope;\n effect(callback: () => cleanup | undefined, options?: effectoptions): effecthandle;\n fromsubscribable<t>(source: subscribable<t>, options?: fromsubscribableoptions): bridgedreadable<t>;\n resource<source, value>(\n source: () => source,\n loader: (source: source, context: { readonly signal: abortsignal }) => promise<value>,\n options?: resourceoptions,\n ): resource<value>;\n signal<t>(initial: t, options?: signaloptions<t>): signal<t>;\n untrack<t>(fn: () => t): t;\n watch<t>(source: readable<t> | (() => t), callback: (value: t, previous: t | undefined) => void, options?: watchoptions<t>): effecthandle;\n}\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `rippleerror` | base ripple error; a new subscription to a disposed external source bridge | use `instanceof rippleerror` to narrow unknown values. |\n| `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. |\n| `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. |\n\nthese are the three exported error classes. user callback failures are reported as their original\nvalues through `onerror`; they are not wrapped in `rippleerror`. a direct computed read can throw\nits derivation error to the caller.\n",
1047
+ "usage": " \ntitle: 'ripple: usage guide'\ndescription: build reactive state with one explicit graph boundary.\n \n\n[[toc]]\n\n## basic usage\n\nuse top level functions when one application lifetime graph is sufficient. read a signal inside an effect to make that read reactive.\n\n```ts\nimport { computed, effect, signal } from '@vielzeug/ripple';\n\nconst count = signal(0);\nconst label = computed(() => `count: ${count.value}`);\nconst stop = effect(() => console.log(label.value));\n\ncount.value = 1;\nstop.dispose();\n```\n\n## isolated graphs\n\nuse `createripple()` for tests, ssr requests, embedded applications, or independently disposable features. never mix reactive values from separate graphs.\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple({\n onerror: (error, context) => console.log(context.kind, error),\n});\n\nconst count = ripple.signal(0);\nconst stop = ripple.effect(() => console.log(count.value));\n\nstop.dispose();\nripple.dispose();\n```\n\n## derived values and batches\n\nuse `computed()` for pure derivation. use `untrack()` when a current read must not become an effect dependency. use `batch()` for related synchronous writes.\n\n```ts\nconst first = ripple.signal('ada');\nconst last = ripple.signal('lovelace');\nconst locale = ripple.signal('en us');\nconst name = ripple.computed(() => `${first.value} ${last.value}`);\n\nripple.effect(() => {\n console.log({ locale: ripple.untrack(() => locale.value), name: name.value });\n});\n\nripple.batch(() => {\n first.value = 'grace';\n last.value = 'hopper';\n});\n```\n\n## scheduling and subscriptions\n\nripple propagates every synchronous write before flushing effects. each flush pass runs effects queued at its\nstart before direct `subscribe()` listeners queued at its start. work queued by either runs in a later pass.\neffects coalesce writes made inside the same synchronous flush: a `batch()` (or any writes during a flush) produces a single effect run.\neach `subscribe()` registration fires once per flush, even when several subscriptions share one callback function.\n\n```ts\nconst count = ripple.signal(0);\nconst log: string[] = [];\n\nripple.effect(() => log.push(`effect: ${count.value}`));\ncount.subscribe(() => log.push(`listener: ${count.value}`));\n\nlog.length = 0; // ignore synchronous creation runs.\nripple.batch(() => {\n count.value = 1;\n count.value = 2;\n});\nconsole.log(log); // ['effect: 2', 'listener: 2']\n```\n\n## ownership with scopes\n\ncreate a scope when a group of effects or derived values shares one lifetime. dispose the scope when its feature ends.\n\n```ts\nconst scope = ripple.createscope('panel');\nconst count = ripple.signal(0);\n\nscope.run(() => {\n ripple.effect(() => console.log(`panel count: ${count.value}`));\n});\n\ncount.value = 1;\nscope.dispose();\n```\n\neffects and computeds created directly during an effect run belong to that run and are disposed before rerun. explicit scopes intentionally attach to the enclosing scope and survive effect reruns; create them outside rerunning effects or retain and dispose each scope yourself.\n\ndisposed owned nodes detach from their parent immediately, so long lived graphs do not retain released effects or scopes.\n\n## watch selected values\n\nuse `watch()` for one selected output. reactive reads made only inside its callback are untracked. for one shot observation, dispose the returned handle from inside the callback; with `immediate: true` the callback runs before the handle exists, so do the first invocation at the call site. use `effect()` when every callback read should be a dependency.\n\n```ts\nconst stopwatch = ripple.watch(\n () => `${first.value} ${last.value}`,\n (value, previous) => console.log({ previous, value }),\n { immediate: true },\n);\n\nstopwatch.dispose();\n```\n\n## error handling\n\nfailures 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:\n\n```ts\nconst ripple = createripple({\n onerror: (error, context) => {\n // context.kind: 'cleanup' | 'computed' | 'effect' | 'flush' | 'listener'\n console.error(`${context.kind}:`, error);\n },\n});\n```\n\n`onerror` is the only runtime option. effect options accept only a `name`; effects do not have a\nmicrotask scheduler. use `batch()` to coalesce related writes.\n\na direct computed read can throw its derivation error to the caller. `onerror` handles computed\nrefresh failures during propagation, not every possible synchronous exception. the exported\nhierarchy is `rippleerror`, `ripplecycleerror`, and `rippledisposederror`; callback failures retain\ntheir original values.\n\na 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.\n\n## bridging external sources\n\n`fromsubscribable()` bridges external `{ getsnapshot, subscribe }` sources into a disposable reactive readable. use `ripple.fromsubscribable()` inside isolated graphs; the root helper uses the process lifetime default graph. the bridge rereads after subscription to close registration races, and disposal invokes the external unsubscribe function.\n\n```ts\nimport { fromsubscribable, effect } from '@vielzeug/ripple';\n\nconst routerstate = fromsubscribable({\n getsnapshot: () => router.getsnapshot(),\n subscribe: (cb) => router.subscribe(cb),\n});\n\nconst stop = effect(() => {\n console.log('current route:', routerstate.value);\n});\n\nstop.dispose();\nrouterstate.dispose();\n```\n\n## load async resources\n\n`resource()` starts immediately, tracks its source reads, and aborts the previous loader whenever the source changes or `reload()` is called. it preserves the last successful value (including `undefined`) as `previous` while newer work is pending or fails.\n\n```ts\nconst userid = ripple.signal('42');\nconst user = ripple.resource(\n () => userid.value,\n (id, { signal }) => fetch(`/users/${id}`, { signal }).then((response) => response.json()),\n { name: 'user' },\n);\n\nconst stop = ripple.effect(() => {\n const state = user.value;\n if (state.status === 'success') console.log(state.value);\n if (state.status === 'error') console.error(state.error);\n});\n\nstop.dispose();\nuser.dispose();\n```\n\nsource and loader failures become resource error state rather than `onerror` reports.\n`reload()` returns `void`; observe state to know when loading finishes. disposed resources retain\nreadable state but throw `rippledisposederror` from `reload()` and new subscriptions. use sourcerer\nfor paginated collection state and a dedicated server state cache for shared keyed reads, retries,\nand invalidation.\n\n## object state\n\n`signal()` with `update()` holds one value and supports immutable replacement patterns. return replacement objects from `update()` when object consumers depend on immutable updates.\n\n```ts\nconst cart = ripple.signal({ items: 0, label: 'empty' });\nconst items = ripple.computed(() => cart.value.items);\n\ncart.update((state) => ({ ...state, items: state.items + 1 }));\ncart.value = { items: 3, label: 'ready' };\n\nconsole.log(items.value);\n```\n\n## testing\n\ncreate an isolated graph per test. disposal prevents effects from leaking into later tests.\n\n```ts\nimport { expect, test } from 'vitest';\nimport { createripple } from '@vielzeug/ripple';\n\ntest('derives a doubled count', () => {\n const ripple = createripple();\n const count = ripple.signal(2);\n const doubled = ripple.computed(() => count.value * 2);\n\n expect(doubled.value).tobe(4);\n ripple.dispose();\n});\n```\n\n## framework integration\n\nuse signals and effects with any renderer. dispose component owned effects when the component unmounts.\n\n::: code group\n\n```ts [react]\nimport { useeffect, usestate } from 'react';\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\n\nexport function counter() {\n const [, rerender] = usestate(0);\n\n useeffect(() => {\n const stop = ripple.effect(() => {\n void count.value;\n rerender((revision) => revision + 1);\n });\n\n return () => stop.dispose();\n }, []);\n\n return <button onclick={() => (count.value += 1)}>{count.value}</button>;\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, ref } from 'vue';\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\nconst revision = ref(0);\nconst stop = ripple.effect(() => {\n void count.value;\n revision.value++;\n});\n\nonunmounted(() => stop.dispose());\n```\n\n```ts [svelte]\n<script lang=\"ts\">\n import { ondestroy } from 'svelte';\n import { createripple } from '@vielzeug/ripple';\n\n const ripple = createripple();\n const count = ripple.signal(0);\n let revision = 0;\n const stop = ripple.effect(() => {\n void count.value;\n revision++;\n });\n\n ondestroy(() => stop.dispose());\n</script>\n\n<button on:click={() => (count.value += 1)}>{count.value}</button>\n```\n\n:::\n\n## working with other vielzeug libraries\n\nore uses ripple for component reactivity. clockwork actors expose framework neutral snapshots; bridge actor subscriptions into a ripple signal. ledger adds undo/redo commands around state changes without replacing graph.\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\nimport { definemachine } from '@vielzeug/clockwork';\n\nconst ripple = createripple();\nconst actor = definemachine<record<string, never>, { type: 'start' }>()({\n initial: 'idle',\n states: { active: {}, idle: { on: { start: { target: 'active' } } } },\n}).createactor();\n\nconst snapshot = ripple.signal(actor.snapshot);\nconst stop = actor.subscribe((next) => (snapshot.value = next));\nconst status = ripple.computed(() => snapshot.value.state);\nconsole.log(status.value);\n\nstop();\nactor.dispose();\nripple.dispose();\n```\n\n## gotchas\n\n### `subscribe()` is change only\n\n`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.\n\n### `subscribe()` forces computed evaluation\n\n`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.\n\n### diamonds recompute each path once per write\n\na 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.\n\n### computed first run failure is recoverable\n\nif 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.\n\n## best practices\n\n create one graph per ownership boundary.\n keep computed callbacks pure.\n return cleanup from effects.\n dispose test and feature graphs.\n batch related synchronous writes.\n use `watch()` only for selected source transitions; callback only reads are untracked.\n dispose external source bridges when their owner ends.\n route runtime failures with `onerror` and observe async failures through resource state.\n",
1036
1048
  "examples": " \ntitle: 'ripple: examples'\ndescription: practical ripple recipes.\n \n\n## examples\n\n [reactive counter](./examples/reactive counter.md)\n [batch and untrack](./examples/batch and untrack.md)\n [scope ownership](./examples/scope ownership.md)\n [watch selected value](./examples/watch selected value.md)\n [async resource](./examples/async resource.md)\n [immutable state](./examples/immutable store.md)\n [isolated graph](./examples/isolated runtime.md)\n"
1037
1049
  },
1038
1050
  "examples": [
1039
1051
  {
1040
1052
  "id": "async-resource",
1041
- "text": "async resource import { createripple } from '@vielzeug/ripple'\n\n// resource reloads from tracked input and ignores stale loader work.\nconst ripple = createripple()\nconst userid = ripple.signal('u1')\nconst user = ripple.resource(\n () => userid.value,\n async (id, { signal }) => {\n await new promise((resolve) => settimeout(resolve, 30))\n if (signal.aborted) throw new error('request aborted')\n return { id, name: 'user ' + id }\n },\n)\n\nripple.effect(() => console.log(user.value))\n\nuserid.value = 'u2'\nsettimeout(() => user.reload(), 50)\nsettimeout(() => {\n user.dispose()\n ripple.dispose()\n}, 100)"
1053
+ "text": "async resource import { createripple } from '@vielzeug/ripple'\n\n// resource reloads from tracked input and ignores stale loader work.\nconst ripple = createripple()\nconst userid = ripple.signal('u1')\nconst user = ripple.resource(\n () => userid.value,\n async (id, { signal }) => {\n await new promise((resolve) => settimeout(resolve, 30))\n if (signal.aborted) throw new error('request aborted')\n return { id, name: 'user ' + id }\n },\n)\n\nripple.effect(() => console.log(user.value))\n\nuserid.value = 'u2'\nawait new promise((resolve) => settimeout(resolve, 50))\nuser.reload()\nawait new promise((resolve) => settimeout(resolve, 50))\nuser.dispose()\nripple.dispose()"
1042
1054
  },
1043
1055
  {
1044
1056
  "id": "basic-signal",
@@ -1050,7 +1062,7 @@
1050
1062
  },
1051
1063
  {
1052
1064
  "id": "effect-options",
1053
- "text": "microtask effect import { createripple } from '@vielzeug/ripple'\n\n// microtask effects coalesce writes until current task ends.\nconst ripple = createripple()\nconst count = ripple.signal(0)\nconst stop = ripple.effect(\n () => console.log('count:', count.value),\n { name: 'count logger', scheduler: 'microtask' },\n)\n\ncount.value = 1\ncount.value = 2\ncount.value = 3\nconsole.log('writes complete')\n\nqueuemicrotask(() => {\n stop.dispose()\n ripple.dispose()\n})"
1065
+ "text": "named effect and onerror import { createripple } from '@vielzeug/ripple'\n\n// runtime errors use one callback; named effects flush synchronously.\nconst ripple = createripple({\n onerror: (error, context) => console.log(context.kind, context.name, error.message),\n})\nconst count = ripple.signal(0)\nconst stop = ripple.effect(\n () => {\n if (count.value < 0) throw new error('count must be non negative')\n console.log('count:', count.value)\n },\n { name: 'count logger' },\n)\n\nripple.batch(() => {\n count.value = 1\n count.value = 2\n})\ncount.value = 1\ncount.value = 3\n\nstop.dispose()\nripple.dispose()"
1054
1066
  },
1055
1067
  {
1056
1068
  "id": "scope-ownership",
@@ -1065,12 +1077,12 @@
1065
1077
  "text": "watch selected value import { createripple } from '@vielzeug/ripple'\n\n// watch receives selected value transitions, not every graph update.\nconst ripple = createripple()\nconst first = ripple.signal('ada')\nconst last = ripple.signal('lovelace')\nconst fullname = ripple.computed(() => first.value + ' ' + last.value)\n\nconst stop = ripple.watch(fullname, (value, previous) => {\n console.log({ previous, value })\n}, { immediate: true })\n\nfirst.value = 'grace'\nlast.value = 'hopper'\n\nstop.dispose()\nripple.dispose()"
1066
1078
  }
1067
1079
  ],
1068
- "exports": "createripple signal computed effect batch createscope untrack watch resource fromsubscribable isreactive",
1069
- "keywords": "reactive signals computed effects graph scope batch watch tap observability",
1080
+ "exports": "createripple signal computed effect resource",
1081
+ "keywords": "reactive signals computed effects graph scope batch watch",
1070
1082
  "name": "@vielzeug/ripple",
1071
1083
  "related": "ore clockwork ledger",
1072
1084
  "slug": "ripple",
1073
- "source": "export type { asyncstate, resource, resourceoptions } from './_async';\nexport { createripple, type ripple } from './_default';\nexport type { bridgedreadable, fromsubscribableoptions } from './_subscribable';\nexport type { watchoptions } from './_watch';\nexport {\n ripplecomputedcycleerror,\n rippledisposedresourceerror,\n rippledisposedruntimeerror,\n rippledisposedscopeerror,\n rippleerror,\n rippleinfinitelooperror,\n} from './errors';\nexport { isreactive } from './runtime';\nexport type {\n cleanup,\n computedoptions,\n disposable,\n effecthandle,\n effectoptions,\n equality,\n readable,\n rippleerrorcontext,\n rippleerrorpolicy,\n rippleevent,\n rippleoptions,\n scope,\n signal,\n signaloptions,\n subscribable,\n unsubscribe,\n} from './types';\n\nimport { defaultripple } from './_default';\n\nexport const signal = defaultripple.signal;\nexport const computed = defaultripple.computed;\nexport const effect = defaultripple.effect;\nexport const batch = defaultripple.batch;\nexport const createscope = defaultripple.createscope;\nexport const untrack = defaultripple.untrack;\nexport const watch = defaultripple.watch;\nexport const resource = defaultripple.resource;\nexport const fromsubscribable = defaultripple.fromsubscribable;\n"
1085
+ "source": "export type { asyncstate, resource, resourceoptions } from './_async';\nexport { createripple, type ripple } from './_default';\nexport type { bridgedreadable, fromsubscribableoptions } from './_subscribable';\nexport type { watchoptions } from './_watch';\nexport { ripplecycleerror, rippledisposederror, rippleerror } from './errors';\nexport { isreactive } from './runtime';\nexport type {\n cleanup,\n computedoptions,\n disposable,\n effecthandle,\n effectoptions,\n equality,\n readable,\n rippleerrorcontext,\n rippleoptions,\n scope,\n signal,\n signaloptions,\n subscribable,\n unsubscribe,\n} from './types';\n\nimport { defaultripple } from './_default';\n\nexport const signal = defaultripple.signal;\nexport const computed = defaultripple.computed;\nexport const effect = defaultripple.effect;\nexport const batch = defaultripple.batch;\nexport const createscope = defaultripple.createscope;\nexport const untrack = defaultripple.untrack;\nexport const watch = defaultripple.watch;\nexport const resource = defaultripple.resource;\nexport const fromsubscribable = defaultripple.fromsubscribable;\n"
1074
1086
  },
1075
1087
  {
1076
1088
  "category": "logging",
@@ -1286,21 +1298,25 @@
1286
1298
  },
1287
1299
  {
1288
1300
  "category": "data",
1289
- "description": "framework agnostic local and remote collection state with page, cursor, and infinite pagination.",
1301
+ "description": "framework agnostic collection pagination and observable single value resources.",
1290
1302
  "docs": {
1291
- "index": " \ntitle: 'sourcerer: reactive collection sources'\ndescription: framework agnostic local and remote collection state with page, cursor, and infinite pagination.\npackage: sourcerer\ncategory: data\nkeywords: [pagination, data source, cursor, infinite scroll, collection]\nrelated: [courier, ripple, scout, wayfinder]\nexports:\n [\n createcursorsource,\n createinfinitesource,\n createlocalsource,\n createpagesource,\n cursorloadcontext,\n cursorpagination,\n cursorresult,\n cursorsource,\n cursorsourceconfig,\n cursorsourcestate,\n infiniteloadcontext,\n infinitepagination,\n infinitesource,\n infinitesourceconfig,\n infinitesourcestate,\n localsource,\n localsourceconfig,\n localsourcestate,\n pageloadcontext,\n pagepagination,\n pageresult,\n pagesource,\n pagesourceconfig,\n pagesourcestate,\n sourcerererror,\n sourcererconfigurationerror,\n sourcererdisposederror,\n ]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"sourcerer\" />\n\n## why sourcerer?\n\npaginated views need collection data, loading state, errors, parameters, and navigation to change coherently. sourcerer owns that state and request succession without coupling it to a framework or transport client.\n\n```ts\n// before\nlet items = [];\nlet loading = false;\nlet page = 1;\n\n// after\nconst source = createpagesource({\n load: async ({ page, pagesize }) => ({ items: await loadusers(page, pagesize), totalitems: 100 }),\n});\nsource.subscribe((state) => render(state));\n```\n\n| feature | sourcerer | manual store | general query cache |\n| | | | |\n| bundle size | <packageinfo package=\"sourcerer\" type=\"size\" /> | application defined | package defined |\n| zero runtime dependencies | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> |\n| page, cursor, and infinite navigation | <ore icon name=\"check\" size=\"16\"></ore icon> | application defined | application defined |\n| framework neutral subscriptions | <ore icon name=\"check\" size=\"16\"></ore icon> | application defined | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> |\n| http transport policy | loader defined | application defined | loader defined |\n\n<div class=\"decision callout\">\n\n**use sourcerer when** a collection view needs explicit pagination, observable state, cancellation, and lifecycle ownership.\n\n**consider a general query cache when** your primary requirement is shared keyed caching, invalidation, mutations, or server state normalization rather than collection navigation.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/sourcerer\n```\n\n```sh [npm]\nnpm install @vielzeug/sourcerer\n```\n\n```sh [yarn]\nyarn add @vielzeug/sourcerer\n```\n\n:::\n\n## quick start\n\ncreate a page source, load it, read its state, and dispose it with its owner.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\ntype user = { id: number; name: string };\nconst users: user[] = [\n { id: 1, name: 'ada' },\n { id: 2, name: 'grace' },\n];\nconst source = createpagesource({\n load: async ({ page, pagesize }) => {\n const start = (page 1) * pagesize;\n return { items: users.slice(start, start + pagesize), totalitems: users.length };\n },\n pagesize: 1,\n});\n\ntry {\n await source.reload();\n console.log(source.state.items);\n} catch (error) {\n console.error(error);\n} finally {\n source.dispose();\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createlocalsource()`: filters and paginates an in memory collection synchronously\n `createpagesource()`: loads numbered pages and exposes direct navigation commands\n `createcursorsource()`: follows opaque cursors returned by the loader\n `createinfinitesource()`: appends pages while preserving loaded items\n `setparams()`: replaces consumer owned loader parameters and resets pagination\n `subscribe()`: publishes complete immutable state replacements\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [courier](/courier/): provide http transport, middleware, and structured transport errors inside loaders\n [scout](/scout/): index larger in memory collections before passing matches to a local source\n [ripple](/ripple/): project source state into reactive computations\n [wayfinder](/wayfinder/): validate and synchronize source parameters with route state\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
1292
- "api": " \ntitle: 'sourcerer: api reference'\ndescription: public api for @vielzeug/sourcerer.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createlocalsource()` | filter and paginate in memory items | sync | `params` replacement uses `object.is` |\n| `createpagesource()` | load numbered pages | async | construction is inert |\n| `createcursorsource()` | load server issued cursor pages | async | `after` and `before` cannot coexist initially |\n| `createinfinitesource()` | append numbered pages | async | `loadmore()` is a no op while loading or exhausted |\n| `sourcerererror` | base package error | sync | loader errors retain their original type |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/sourcerer` | all factories, states, pagination types, loader contexts, and errors |\n\n## source factories\n\n### `createlocalsource()`\n\n```ts\nfunction createlocalsource<t>(items: readonly t[]): localsource<t>\nfunction createlocalsource<t, tparams = undefined>(\n items: readonly t[],\n config: localsourceconfig<t, tparams>,\n): localsource<t, tparams>\n```\n\nreturns a synchronous in memory source.\n\n| parameter | type | description |\n| | | |\n| `items` | `readonly t[]` | initial collection |\n| `config.filter` | `(item: t, params: tparams) => boolean` | optional inclusion predicate |\n| `config.pagesize` | `number` | positive page size; defaults to `20` |\n| `config.params` | `tparams` | initial params; required when `tparams` excludes `undefined` |\n\n**returns:** `localsource<t, tparams>`.\n\n```ts\nimport { createlocalsource } from '@vielzeug/sourcerer';\n\nconst source = createlocalsource(['ada', 'grace'], {\n filter: (name, search: string) => name.includes(search),\n params: '',\n});\nsource.setparams('ada');\n```\n\n| method | returns | description |\n| | | |\n| `first()` | `void` | move to page one |\n| `goto(page)` | `void` | move to a positive page, clamped to the known range |\n| `last()` | `void` | move to the final page |\n| `next()` | `void` | move forward when available |\n| `previous()` | `void` | move backward when available |\n| `setitems(items)` | `void` | replace the collection and recompute state |\n| `setpagesize(pagesize)` | `void` | replace page size and reset to page one |\n| `setparams(params)` | `void` | replace params and reset to page one |\n| `subscribe(listener)` | `() => void` | register a state listener and return its unsubscribe function |\n| `dispose()` | `void` | stop notifications and reject later mutations |\n\n \n\n### `createpagesource()`\n\n```ts\nfunction createpagesource<t, tparams = undefined>(\n config: pagesourceconfig<t, tparams>,\n): pagesource<t, tparams>\n```\n\nreturns an inert numbered page source.\n\n| parameter | type | description |\n| | | |\n| `config.load` | `(context: pageloadcontext<tparams>) => promise<pageresult<t>>` | page loader |\n| `config.pagesize` | `number` | positive page size; defaults to `20` |\n| `config.params` | `tparams` | initial params; required when `tparams` excludes `undefined` |\n\n**returns:** `pagesource<t, tparams>`.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst source = createpagesource({\n load: async ({ page }) => ({ items: [page], totalitems: 10 }),\n});\nawait source.reload();\n```\n\n| method | returns | description |\n| | | |\n| `first()` | `promise<void>` | load page one |\n| `goto(page)` | `promise<void>` | load a positive page |\n| `last()` | `promise<void>` | load the last known page |\n| `next()` | `promise<void>` | load the next known page |\n| `previous()` | `promise<void>` | load the previous page |\n| `reload()` | `promise<void>` | reload the requested page and params |\n| `setpagesize(pagesize)` | `promise<void>` | reset to page one and load with a new size |\n| `setparams(params)` | `promise<void>` | reset to page one and load replacement params |\n| `subscribe(listener)` | `() => void` | register a state listener and return its unsubscribe function |\n| `dispose()` | `void` | abort work and stop notifications |\n\n \n\n### `createcursorsource()`\n\n```ts\nfunction createcursorsource<t, tparams = undefined, tcursor = string>(\n config: cursorsourceconfig<t, tparams, tcursor>,\n): cursorsource<t, tparams, tcursor>\n```\n\nreturns an inert cursor source.\n\n| parameter | type | description |\n| | | |\n| `config.after` | `tcursor` | optional initial forward cursor |\n| `config.before` | `tcursor` | optional initial backward cursor |\n| `config.load` | `(context: cursorloadcontext<tparams, tcursor>) => promise<cursorresult<t, tcursor>>` | cursor loader |\n| `config.pagesize` | `number` | positive page size; defaults to `20` |\n| `config.params` | `tparams` | initial params; required when `tparams` excludes `undefined` |\n\n**returns:** `cursorsource<t, tparams, tcursor>`.\n\n```ts\nimport { createcursorsource } from '@vielzeug/sourcerer';\n\nconst source = createcursorsource({ load: async () => ({ items: ['first'], nextcursor: 'next' }) });\nawait source.reload();\nawait source.next();\n```\n\n| method | returns | description |\n| | | |\n| `next()` | `promise<void>` | load `state.pagination.nextcursor` when available |\n| `previous()` | `promise<void>` | load `state.pagination.previouscursor` when available |\n| `reload()` | `promise<void>` | reload the requested cursor and params |\n| `setpagesize(pagesize)` | `promise<void>` | clear cursors and reload with a new size |\n| `setparams(params)` | `promise<void>` | clear cursors and load replacement params |\n| `subscribe(listener)` | `() => void` | register a state listener and return its unsubscribe function |\n| `dispose()` | `void` | abort work and stop notifications |\n\n \n\n### `createinfinitesource()`\n\n```ts\nfunction createinfinitesource<t, tparams = undefined>(\n config: infinitesourceconfig<t, tparams>,\n): infinitesource<t, tparams>\n```\n\nreturns an inert source that appends numbered pages.\n\n| parameter | type | description |\n| | | |\n| `config.load` | `(context: infiniteloadcontext<tparams>) => promise<pageresult<t>>` | page loader |\n| `config.pagesize` | `number` | positive page size; defaults to `20` |\n| `config.params` | `tparams` | initial params; required when `tparams` excludes `undefined` |\n\n**returns:** `infinitesource<t, tparams>`.\n\n```ts\nimport { createinfinitesource } from '@vielzeug/sourcerer';\n\nconst source = createinfinitesource({ load: async () => ({ items: ['post'], totalitems: 1 }) });\nawait source.reload();\nawait source.loadmore();\n```\n\n| method | returns | description |\n| | | |\n| `loadmore()` | `promise<void>` | append the next page when available |\n| `reload()` | `promise<void>` | replace items from page one |\n| `setpagesize(pagesize)` | `promise<void>` | replace items from page one with a new size |\n| `setparams(params)` | `promise<void>` | replace items from page one with new params |\n| `subscribe(listener)` | `() => void` | register a state listener and return its unsubscribe function |\n| `dispose()` | `void` | abort work and stop notifications |\n\n## types\n\n### page source types\n\n```ts\ntype pagepagination = readonly<{\n hasnext: boolean;\n hasprevious: boolean;\n page: number;\n pagecount: number;\n pagesize: number;\n totalitems: number;\n}>;\n\ntype pagesourcestate<t, tparams = undefined> = readonly<{\n error: error | null;\n items: readonly t[];\n loading: boolean;\n pagination: pagepagination;\n params: tparams;\n pendingparams?: tparams;\n}>;\n\ntype pageloadcontext<tparams = undefined> = readonly<{\n page: number;\n pagesize: number;\n params: tparams;\n signal: abortsignal;\n}>;\n\ntype pageresult<t> = readonly<{\n items: readonly t[];\n totalitems: number;\n}>;\n\ntype pagesourceconfig<t, tparams = undefined> = readonly<{\n load(context: pageloadcontext<tparams>): promise<pageresult<t>>;\n pagesize?: number;\n } & (undefined extends tparams ? { params?: tparams } : { params: tparams })\n>;\n\ntype pagesource<t, tparams = undefined> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n first(): promise<void>;\n goto(page: number): promise<void>;\n last(): promise<void>;\n next(): promise<void>;\n previous(): promise<void>;\n reload(): promise<void>;\n setpagesize(pagesize: number): promise<void>;\n setparams(params: tparams): promise<void>;\n readonly state: pagesourcestate<t, tparams>;\n subscribe(listener: (state: pagesourcestate<t, tparams>) => void): () => void;\n};\n```\n\nthe conditional config field makes `params` optional only when `tparams` includes `undefined`.\n\n### local source types\n\n```ts\ntype localsourcestate<t, tparams = undefined> = readonly<{\n error: null;\n items: readonly t[];\n loading: false;\n pagination: pagepagination;\n params: tparams;\n}>;\n\ntype localsourceconfig<t, tparams = undefined> = readonly<{\n filter?: (item: t, params: tparams) => boolean;\n pagesize?: number;\n } & (undefined extends tparams ? { params?: tparams } : { params: tparams })\n>;\n\ntype localsource<t, tparams = undefined> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n first(): void;\n goto(page: number): void;\n last(): void;\n next(): void;\n previous(): void;\n setitems(items: readonly t[]): void;\n setpagesize(pagesize: number): void;\n setparams(params: tparams): void;\n readonly state: localsourcestate<t, tparams>;\n subscribe(listener: (state: localsourcestate<t, tparams>) => void): () => void;\n};\n```\n\n### cursor source types\n\n```ts\ntype cursorpagination<tcursor = string> = readonly<{\n nextcursor?: tcursor;\n pagesize: number;\n previouscursor?: tcursor;\n totalitems?: number;\n}>;\n\ntype cursorloadcontext<tparams = undefined, tcursor = string> = readonly<{\n after?: tcursor;\n before?: tcursor;\n pagesize: number;\n params: tparams;\n signal: abortsignal;\n}>;\n\ntype cursorresult<t, tcursor = string> = readonly<{\n items: readonly t[];\n nextcursor?: tcursor;\n previouscursor?: tcursor;\n totalitems?: number;\n}>;\n\ntype cursorsourcestate<t, tparams = undefined, tcursor = string> = readonly<{\n error: error | null;\n items: readonly t[];\n loading: boolean;\n pagination: cursorpagination<tcursor>;\n params: tparams;\n pendingparams?: tparams;\n}>;\n\ntype cursorsourceconfig<t, tparams = undefined, tcursor = string> = readonly<{\n after?: tcursor;\n before?: tcursor;\n load(context: cursorloadcontext<tparams, tcursor>): promise<cursorresult<t, tcursor>>;\n pagesize?: number;\n } & (undefined extends tparams ? { params?: tparams } : { params: tparams })\n>;\n\ntype cursorsource<t, tparams = undefined, tcursor = string> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n next(): promise<void>;\n previous(): promise<void>;\n reload(): promise<void>;\n setpagesize(pagesize: number): promise<void>;\n setparams(params: tparams): promise<void>;\n readonly state: cursorsourcestate<t, tparams, tcursor>;\n subscribe(listener: (state: cursorsourcestate<t, tparams, tcursor>) => void): () => void;\n};\n```\n\n### infinite source types\n\n```ts\ntype infinitepagination = readonly<{\n hasmore: boolean;\n loadeditems: number;\n pagesize: number;\n totalitems: number;\n}>;\n\ntype infiniteloadcontext<tparams = undefined> = readonly<{\n page: number;\n pagesize: number;\n params: tparams;\n signal: abortsignal;\n}>;\n\ntype infinitesourcestate<t, tparams = undefined> = readonly<{\n error: error | null;\n items: readonly t[];\n loading: boolean;\n pagination: infinitepagination;\n params: tparams;\n pendingparams?: tparams;\n}>;\n\ntype infinitesourceconfig<t, tparams = undefined> = readonly<{\n load(context: infiniteloadcontext<tparams>): promise<pageresult<t>>;\n pagesize?: number;\n } & (undefined extends tparams ? { params?: tparams } : { params: tparams })\n>;\n\ntype infinitesource<t, tparams = undefined> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n loadmore(): promise<void>;\n reload(): promise<void>;\n setpagesize(pagesize: number): promise<void>;\n setparams(params: tparams): promise<void>;\n readonly state: infinitesourcestate<t, tparams>;\n subscribe(listener: (state: infinitesourcestate<t, tparams>) => void): () => void;\n};\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `sourcerererror` | base class for package originated configuration and lifecycle errors | `instanceof sourcerererror` narrows the hierarchy |\n| `sourcererconfigurationerror` | invalid page, page size, total, or initial cursor direction | extends `sourcerererror` |\n| `sourcererdisposederror` | a source command runs after disposal | extends `sourcerererror` |\n\nloader failures are preserved when they are `error` instances. non error rejections become `error` objects whose `cause` contains the original value.\n",
1293
- "usage": " \ntitle: 'sourcerer: usage guide'\ndescription: build local, page, cursor, and infinite collection sources.\n \n\n[[toc]]\n\n## basic usage\n\ncreate a page source and call `reload()` to start the first request. construction performs no i/o.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst names = ['ada', 'grace', 'linus'];\nconst source = createpagesource({\n load: async ({ page, pagesize }) => {\n const start = (page 1) * pagesize;\n return { items: names.slice(start, start + pagesize), totalitems: names.length };\n },\n pagesize: 2,\n});\n\nawait source.reload();\nconsole.log(source.state.items);\nsource.dispose();\n```\n\nread one `state` object per render. it contains `items`, `loading`, `error`, `params`, and pagination metadata from one committed result. commands return `promise<void>`; read `source.state` after awaiting them.\n\n## pass loader parameters\n\nuse `params` for application owned filters, search terms, sort state, or request options. `setparams()` replaces the entire value and resets pagination.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\ntype userparams = { role: 'admin' | 'user'; search: string };\nconst users = ['ada', 'grace', 'linus'];\nconst source = createpagesource<string, userparams>({\n load: async ({ page, pagesize, params }) => {\n const matching = users.filter((name) => name.tolowercase().includes(params.search.tolowercase()));\n const start = (page 1) * pagesize;\n return { items: matching.slice(start, start + pagesize), totalitems: matching.length };\n },\n params: { role: 'admin', search: '' },\n});\n\nawait source.setparams({ role: 'admin', search: 'ada' });\nconsole.log(source.state.params);\nsource.dispose();\n```\n\na 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.\n\nwhile 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`.\n\n## navigate numbered pages\n\nuse direct commands for numbered page navigation. `setpagesize()` resets to page one.\n\n```ts\nawait source.reload();\nawait source.next();\nawait source.goto(4);\nawait source.previous();\nawait source.first();\nawait source.last();\nawait source.setpagesize(50);\n```\n\nafter the first successful load, navigation is clamped to the known `pagecount`. before that load, `goto()` accepts any positive page because the total is unknown.\n\n## filter local collections\n\nuse a local source when data is already in memory. its commands are synchronous.\n\n```ts\nimport { createlocalsource } from '@vielzeug/sourcerer';\n\nconst source = createlocalsource(['ada', 'grace', 'linus'], {\n filter: (name, search: string) => name.tolowercase().includes(search.tolowercase()),\n pagesize: 2,\n params: '',\n});\n\nsource.setparams('a');\nsource.next();\nsource.setitems(['ada', 'grace']);\nconsole.log(source.state.items);\nsource.dispose();\n```\n\nuse ordinary arrays for one off transformations. a local source is useful when pagination, subscriptions, and lifecycle ownership are also required.\n\n## use cursor pagination\n\nreturn opaque cursors from the loader. `next()` and `previous()` use only cursors from the committed state.\n\n```ts\nimport { createcursorsource } from '@vielzeug/sourcerer';\n\nconst rows = ['a', 'b', 'c', 'd'];\nconst source = createcursorsource({\n load: async ({ after, pagesize }) => {\n const start = after ? number(after) : 0;\n const items = rows.slice(start, start + pagesize);\n const next = start + items.length;\n return {\n items,\n nextcursor: next < rows.length ? string(next) : undefined,\n previouscursor: start > 0 ? string(math.max(0, start pagesize)) : undefined,\n };\n },\n pagesize: 2,\n});\n\nawait source.reload();\nawait source.next();\nconsole.log(source.state.items);\nsource.dispose();\n```\n\nchanging params or page size clears current cursor direction. a source cannot start with both `after` and `before`.\n\n## build an infinite feed\n\nuse an infinite source when later pages should append to committed items.\n\n```ts\nimport { createinfinitesource } from '@vielzeug/sourcerer';\n\nconst values = [1, 2, 3, 4, 5];\nconst source = createinfinitesource({\n load: async ({ page, pagesize }) => {\n const start = (page 1) * pagesize;\n return { items: values.slice(start, start + pagesize), totalitems: values.length };\n },\n pagesize: 2,\n});\n\nawait source.reload();\nawait source.loadmore();\nconsole.log(source.state.items);\nsource.dispose();\n```\n\n`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.\n\n## handle requests and errors\n\na 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.\n\n```ts\ntry {\n await source.reload();\n} catch (error) {\n console.error(error);\n console.log(source.state.error);\n}\n```\n\nloader result arrays are copied before publication. commands after disposal throw or reject `sourcererdisposederror`.\n\n## testing\n\ninject deterministic loaders and await commands before asserting final state.\n\n```ts\nimport { expect, it } from 'vitest';\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nit('loads a page', async () => {\n const source = createpagesource({ load: async () => ({ items: ['ada'], totalitems: 1 }) });\n\n await source.reload();\n\n expect(source.state.items).toequal(['ada']);\n source.dispose();\n});\n```\n\n## framework integration\n\ncreate one source per component lifetime and bridge `subscribe()` to the framework lifecycle.\n\n::: code group\n\n```tsx [react]\nimport { createpagesource } from '@vielzeug/sourcerer';\nimport { useeffect, usememo, usesyncexternalstore } from 'react';\n\nexport function users() {\n const source = usememo(\n () => createpagesource({ load: async () => ({ items: [{ id: 1, name: 'ada' }], totalitems: 1 }) }),\n [],\n );\n const state = usesyncexternalstore(source.subscribe, () => source.state);\n\n useeffect(() => {\n void source.reload().catch(() => undefined);\n return () => source.dispose();\n }, [source]);\n\n return <p>{state.loading ? 'loading' : state.items.length}</p>;\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, shallowref } from 'vue';\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst source = createpagesource({ load: async () => ({ items: [{ id: 1, name: 'ada' }], totalitems: 1 }) });\nconst state = shallowref(source.state);\nconst stop = source.subscribe((next) => (state.value = next));\nvoid source.reload().catch(() => undefined);\n\nonunmounted(() => {\n stop();\n source.dispose();\n});\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { ondestroy } from 'svelte';\n import { createpagesource } from '@vielzeug/sourcerer';\n\n const source = createpagesource({ load: async () => ({ items: [{ id: 1, name: 'ada' }], totalitems: 1 }) });\n let state = source.state;\n const stop = source.subscribe((next) => (state = next));\n void source.reload().catch(() => undefined);\n\n ondestroy(() => {\n stop();\n source.dispose();\n });\n</script>\n\n{#if state.loading}loading{/if}\n{#each state.items as user}{user.name}{/each}\n```\n\n:::\n\n## working with other vielzeug libraries\n\nuse courier for transport policy and pass the source owned cancellation signal through.\n\n```ts\nimport { createcourier } from '@vielzeug/courier';\nimport { createpagesource } from '@vielzeug/sourcerer';\n\ntype user = { id: number; name: string };\ntype response = { data: user[]; total: number };\nconst courier = createcourier({ baseurl: '/api' });\nconst source = createpagesource({\n load: async ({ page, pagesize, params: search, signal }) => {\n const result = await courier.get<response>('/users', { query: { page, pagesize, search }, signal });\n return { items: result.data, totalitems: result.total };\n },\n params: '',\n});\n```\n\nuse scout to compute indexed local matches, ripple to project state into reactive computations, and wayfinder to validate and synchronize params with the url.\n\n## best practices\n\n dispose each source with its owning view, request, or service scope.\n read one `state` object per render.\n await direct commands or handle rejected current request failures.\n keep transport, caching, and persistence policy outside sourcerer.\n treat `params` as an immutable value and replace it atomically.\n debounce text input before calling `setparams()` on a remote source.\n validate route and form values before constructing params or page numbers.\n keep rendering committed items while `loading` is true.\n",
1294
- "examples": " \ntitle: 'sourcerer: examples'\ndescription: recipes for local, page, cursor, infinite, and framework source usage.\n \n\n## examples\n\n [local pagination and filtering](./examples/local pagination and filtering.md)\n [page params with url state](./examples/remote search with url state.md)\n [cursor based pagination](./examples/cursor based pagination.md)\n [infinite scroll](./examples/infinite scroll.md)\n [framework integration](./examples/framework integration.md)\n [remote data with courier](./examples/sourcerer with courier.md)\n [reactive controls with ripple](./examples/sourcerer with ripple.md)\n [url synced list with wayfinder](./examples/sourcerer with wayfinder.md)\n"
1303
+ "index": " \ntitle: 'sourcerer: reactive collection sources'\ndescription: framework agnostic collection pagination and observable single value resources.\npackage: sourcerer\ncategory: data\nkeywords: [pagination, data source, cursor, infinite scroll, collection, resource]\nrelated: [courier, ripple, scout, wayfinder]\nexports: [createlocalsource, createpagesource, createcursorsource, createinfinitesource, createresource]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"sourcerer\" />\n\n## why sourcerer?\n\npaginated views need collection data, loading state, errors, parameters, and navigation to change coherently. sourcerer owns that state and request succession without coupling it to a framework or transport client.\n\n```ts\n// before\nlet items = [];\nlet loading = false;\nlet page = 1;\n\n// after\nconst source = createpagesource({\n load: async ({ page, pagesize }) => ({ items: await loadusers(page, pagesize), totalitems: 100 }),\n});\nsource.subscribe((state) => render(state));\n```\n\n| feature | sourcerer | manual store | general query cache |\n| | | | |\n| bundle size | <packageinfo package=\"sourcerer\" type=\"size\" /> | application defined | package defined |\n| zero runtime dependencies | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> |\n| page, cursor, and infinite navigation | <ore icon name=\"check\" size=\"16\"></ore icon> | application defined | application defined |\n| framework neutral subscriptions | <ore icon name=\"check\" size=\"16\"></ore icon> | application defined | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> |\n| http transport policy | loader defined | application defined | loader defined |\n\n<div class=\"decision callout\">\n\n**use sourcerer when** a collection view needs explicit pagination, observable state, cancellation, and lifecycle ownership.\n\n**consider a general query cache when** your primary requirement is shared keyed caching, invalidation, mutations, or server state normalization rather than collection navigation.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/sourcerer\n```\n\n```sh [npm]\nnpm install @vielzeug/sourcerer\n```\n\n```sh [yarn]\nyarn add @vielzeug/sourcerer\n```\n\n:::\n\n## quick start\n\ncreate a page source, load it, read its state, and dispose it with its owner.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\ntype user = { id: number; name: string };\nconst users: user[] = [\n { id: 1, name: 'ada' },\n { id: 2, name: 'grace' },\n];\nconst source = createpagesource({\n load: async ({ page, pagesize }) => {\n const start = (page 1) * pagesize;\n return { items: users.slice(start, start + pagesize), totalitems: users.length };\n },\n pagesize: 1,\n});\n\ntry {\n await source.reload();\n console.log(source.state.items);\n} catch (error) {\n console.error(error);\n} finally {\n source.dispose();\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createlocalsource()`: filters and paginates an in memory collection synchronously\n `createpagesource()`: loads numbered pages and exposes direct navigation commands\n `createcursorsource()`: follows opaque cursors returned by the loader\n `createinfinitesource()`: appends pages while preserving loaded items\n `createresource()`: starts loading one value immediately, or awaits `reload()` with `start: false`\n `setparams()`: replaces consumer owned loader parameters and resets pagination\n `pagesource.apply()`: reconciles page and structurally compared params in at most one request\n `subscribe()`: publishes complete immutable state replacements\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [courier](/courier/): provide http transport, middleware, and structured transport errors inside loaders\n [scout](/scout/): index larger in memory collections before passing matches to a local source\n [ripple](/ripple/): project source state into reactive computations\n [wayfinder](/wayfinder/): validate and synchronize source parameters with route state\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
1304
+ "api": " \ntitle: 'sourcerer: api reference'\ndescription: public api for @vielzeug/sourcerer.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createlocalsource()` | filter and paginate in memory items | sync | `params` replacement uses `object.is` |\n| `createpagesource()` | load numbered pages | async | construction is inert |\n| `createcursorsource()` | load server issued cursor pages | async | `after` and `before` cannot coexist initially |\n| `createinfinitesource()` | append numbered pages | async | `loadmore()` is a no op while loading or exhausted |\n| `createresource()` | load one reactive value | async | starts loading during construction; `start: false` opts out |\n| `sourcerererror` | base package error | sync | loader errors retain their original type |\n| `sourcererconfigurationerror` | invalid pagination configuration | sync | pages and page sizes must be positive integers |\n| `sourcererdisposederror` | command after source disposal | sync | in flight work instead settles without committing |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/sourcerer` | all factories, states, pagination types, loader contexts, and errors |\n\n## source factories\n\n### `createlocalsource()`\n\n```ts\nfunction createlocalsource<t>(items: readonly t[]): localsource<t>\nfunction createlocalsource<t, tparams = undefined>(\n items: readonly t[],\n config: localsourceconfig<t, tparams>,\n): localsource<t, tparams>\n```\n\nreturns a synchronous in memory source.\n\n| parameter | type | description |\n| | | |\n| `items` | `readonly t[]` | initial collection |\n| `config.filter` | `(item: t, params: tparams) => boolean` | optional inclusion predicate |\n| `config.pagesize` | `number` | positive page size; defaults to `20` |\n| `config.params` | `tparams` | initial params; required when `tparams` excludes `undefined` |\n\n**returns:** `localsource<t, tparams>`.\n\n```ts\nimport { createlocalsource } from '@vielzeug/sourcerer';\n\nconst source = createlocalsource(['ada', 'grace'], {\n filter: (name, search: string) => name.includes(search),\n params: '',\n});\nsource.setparams('ada');\n```\n\n| method | returns | description |\n| | | |\n| `first()` | `void` | move to page one |\n| `goto(page)` | `void` | move to a positive page, clamped to the known range |\n| `last()` | `void` | move to the final page |\n| `next()` | `void` | move forward when available |\n| `previous()` | `void` | move backward when available |\n| `setitems(items)` | `void` | replace the collection and recompute state |\n| `setpagesize(pagesize)` | `void` | replace page size and reset to page one |\n| `setparams(params)` | `void` | replace params and reset to page one |\n| `subscribe(listener)` | `() => void` | register a state listener and return its unsubscribe function |\n| `dispose()` | `void` | stop notifications and reject later mutations |\n\n \n\n### `createpagesource()`\n\n```ts\nfunction createpagesource<t, tparams = undefined>(\n config: pagesourceconfig<t, tparams>,\n): pagesource<t, tparams>\n```\n\nreturns an inert numbered page source.\n\n| parameter | type | description |\n| | | |\n| `config.load` | `(context: pageloadcontext<tparams>) => promise<pageresult<t>>` | page loader |\n| `config.pagesize` | `number` | positive page size; defaults to `20` |\n| `config.params` | `tparams` | initial params; required when `tparams` excludes `undefined` |\n\n**returns:** `pagesource<t, tparams>`.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst source = createpagesource({\n load: async ({ page }) => ({ items: [page], totalitems: 10 }),\n});\nawait source.reload();\n```\n\n| method | returns | description |\n| | | |\n| `apply(update)` | `promise<void>` | reconcile `{ params, page }` in at most one load; params compare structurally |\n| `first()` | `promise<void>` | load page one |\n| `goto(page)` | `promise<void>` | load a positive page |\n| `last()` | `promise<void>` | load the last known page |\n| `next()` | `promise<void>` | load the next known page |\n| `previous()` | `promise<void>` | load the previous page |\n| `reload()` | `promise<void>` | reload the requested page and params |\n| `setpagesize(pagesize)` | `promise<void>` | reset to page one and load with a new size |\n| `setparams(params)` | `promise<void>` | reset to page one and load replacement params |\n| `subscribe(listener)` | `() => void` | register a state listener and return its unsubscribe function |\n| `dispose()` | `void` | abort work and stop notifications |\n\n`apply(update: pagesourceupdate<tparams>): promise<void>` compares params structurally with\n`isequal`. changed params use the supplied positive page without clamping, or page one when omitted.\nunchanged params with a page use `goto()` and clamp after the first successful load.\nan unchanged request does not refetch a committed result or duplicate an active request; it loads\nan inert source or retries an uncommitted request after failure. omitted or `undefined` params\npreserve the requested value; use `setparams(undefined)` when `undefined` is a valid replacement.\nthe update does not include page size; use `setpagesize()` separately.\n\n \n\n### `createcursorsource()`\n\n```ts\nfunction createcursorsource<t, tparams = undefined, tcursor = string>(\n config: cursorsourceconfig<t, tparams, tcursor>,\n): cursorsource<t, tparams, tcursor>\n```\n\nreturns an inert cursor source.\n\n| parameter | type | description |\n| | | |\n| `config.after` | `tcursor` | optional initial forward cursor |\n| `config.before` | `tcursor` | optional initial backward cursor |\n| `config.load` | `(context: cursorloadcontext<tparams, tcursor>) => promise<cursorresult<t, tcursor>>` | cursor loader |\n| `config.pagesize` | `number` | positive page size; defaults to `20` |\n| `config.params` | `tparams` | initial params; required when `tparams` excludes `undefined` |\n\n**returns:** `cursorsource<t, tparams, tcursor>`.\n\n```ts\nimport { createcursorsource } from '@vielzeug/sourcerer';\n\nconst source = createcursorsource({ load: async () => ({ items: ['first'], nextcursor: 'next' }) });\nawait source.reload();\nawait source.next();\n```\n\n| method | returns | description |\n| | | |\n| `next()` | `promise<void>` | load `state.pagination.nextcursor` when available |\n| `previous()` | `promise<void>` | load `state.pagination.previouscursor` when available |\n| `reload()` | `promise<void>` | reload the requested cursor and params |\n| `setpagesize(pagesize)` | `promise<void>` | clear cursors and reload with a new size |\n| `setparams(params)` | `promise<void>` | clear cursors and load replacement params |\n| `subscribe(listener)` | `() => void` | register a state listener and return its unsubscribe function |\n| `dispose()` | `void` | abort work and stop notifications |\n\n \n\n### `createinfinitesource()`\n\n```ts\nfunction createinfinitesource<t, tparams = undefined>(\n config: infinitesourceconfig<t, tparams>,\n): infinitesource<t, tparams>\n```\n\nreturns an inert source that appends numbered pages.\n\n| parameter | type | description |\n| | | |\n| `config.load` | `(context: infiniteloadcontext<tparams>) => promise<pageresult<t>>` | page loader |\n| `config.pagesize` | `number` | positive page size; defaults to `20` |\n| `config.params` | `tparams` | initial params; required when `tparams` excludes `undefined` |\n\n**returns:** `infinitesource<t, tparams>`.\n\n```ts\nimport { createinfinitesource } from '@vielzeug/sourcerer';\n\nconst source = createinfinitesource({ load: async () => ({ items: ['post'], totalitems: 1 }) });\nawait source.reload();\nawait source.loadmore();\n```\n\n| method | returns | description |\n| | | |\n| `loadmore()` | `promise<void>` | append the next page when available |\n| `reload()` | `promise<void>` | replace items from page one |\n| `setpagesize(pagesize)` | `promise<void>` | replace items from page one with a new size |\n| `setparams(params)` | `promise<void>` | replace items from page one with new params |\n| `subscribe(listener)` | `() => void` | register a state listener and return its unsubscribe function |\n| `dispose()` | `void` | abort work and stop notifications |\n\n \n\n### `createresource()`\n\n```ts\nfunction createresource<t>(config: resourceconfig<t>): resource<t>\n```\n\nreturns observable state for one asynchronously loaded value. it does not suspend rendering,\ncache by key, or track external reactive inputs.\n\n| parameter | type | description |\n| | | |\n| `config.load` | `(context: resourceloadcontext) => promise<t>` | loader; receives a signal aborted on disposal and on the next `reload()` |\n| `config.start` | `boolean` | start the initial load during construction; defaults to `true` |\n\n**returns:** `resource<t>`.\n\n```ts\nimport { createresource } from '@vielzeug/sourcerer';\n\nconst profile = createresource({\n load: ({ signal }) => fetch('/me', { signal }).then((response) => response.json()),\n start: false,\n});\n\nconst stop = profile.subscribe((state) => console.log(state.data, state.loading, state.error));\nconsole.log(profile.state);\ntry {\n await profile.reload();\n} finally {\n stop();\n profile.dispose();\n}\n```\n\n| method | returns | description |\n| | | |\n| `reload()` | `promise<void>` | refetch with a fresh signal; rejects so callers can await it |\n| `subscribe(listener)` | `() => void` | register a state listener and return its unsubscribe function |\n| `dispose()` | `void` | abort the in flight load and stop notifications |\n\nconstruction starts the initial load unless `start: false`. its failure surfaces through\n`state.error`; a later `reload()` rejects and retains the previous `data` on failure.\na superseded or disposed request settles without touching state. subscriptions are change only:\nread `state` for the current snapshot, including the initial loading state. `dispose()` is idempotent,\naborts `disposalsignal`, and leaves the last snapshot readable; subscriptions after disposal are inert.\n\n## types\n\n### page source types\n\n```ts\ntype pagepagination = readonly<{\n hasnext: boolean;\n hasprevious: boolean;\n page: number;\n pagecount: number;\n pagesize: number;\n totalitems: number;\n}>;\n\ntype pagesourcestate<t, tparams = undefined> = readonly<{\n error: error | null;\n items: readonly t[];\n loading: boolean;\n pagination: pagepagination;\n params: tparams;\n pendingparams?: tparams;\n}>;\n\ntype pageloadcontext<tparams = undefined> = readonly<{\n page: number;\n pagesize: number;\n params: tparams;\n signal: abortsignal;\n}>;\n\ntype pageresult<t> = readonly<{\n items: readonly t[];\n totalitems: number;\n}>;\n\ntype pagesourceconfig<t, tparams = undefined> = readonly<{\n load(context: pageloadcontext<tparams>): promise<pageresult<t>>;\n pagesize?: number;\n } & (undefined extends tparams ? { params?: tparams } : { params: tparams })\n>;\n\ntype pagesourceupdate<tparams = undefined> = readonly<{\n page?: number;\n params?: tparams;\n}>;\n\ntype pagesource<t, tparams = undefined> = {\n [symbol.dispose](): void;\n apply(update: pagesourceupdate<tparams>): promise<void>;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n first(): promise<void>;\n goto(page: number): promise<void>;\n last(): promise<void>;\n next(): promise<void>;\n previous(): promise<void>;\n reload(): promise<void>;\n setpagesize(pagesize: number): promise<void>;\n setparams(params: tparams): promise<void>;\n readonly state: pagesourcestate<t, tparams>;\n subscribe(listener: (state: pagesourcestate<t, tparams>) => void): () => void;\n};\n```\n\nthe conditional config field makes `params` optional only when `tparams` includes `undefined`.\n\n### resource types\n\n```ts\ntype resourceloadcontext = readonly<{\n signal: abortsignal;\n}>;\n\ntype resourcestate<t> = readonly<{\n data: t | undefined;\n error: error | null;\n loading: boolean;\n}>;\n\ntype resourceconfig<t> = readonly<{\n load(context: resourceloadcontext): promise<t>;\n start?: boolean;\n}>;\n\ntype resource<t> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n reload(): promise<void>;\n readonly state: resourcestate<t>;\n subscribe(listener: (state: resourcestate<t>) => void): () => void;\n};\n```\n\n### local source types\n\n```ts\ntype localsourcestate<t, tparams = undefined> = readonly<{\n error: null;\n items: readonly t[];\n loading: false;\n pagination: pagepagination;\n params: tparams;\n}>;\n\ntype localsourceconfig<t, tparams = undefined> = readonly<{\n filter?: (item: t, params: tparams) => boolean;\n pagesize?: number;\n } & (undefined extends tparams ? { params?: tparams } : { params: tparams })\n>;\n\ntype localsource<t, tparams = undefined> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n first(): void;\n goto(page: number): void;\n last(): void;\n next(): void;\n previous(): void;\n setitems(items: readonly t[]): void;\n setpagesize(pagesize: number): void;\n setparams(params: tparams): void;\n readonly state: localsourcestate<t, tparams>;\n subscribe(listener: (state: localsourcestate<t, tparams>) => void): () => void;\n};\n```\n\n### cursor source types\n\n```ts\ntype cursorpagination<tcursor = string> = readonly<{\n nextcursor?: tcursor;\n pagesize: number;\n previouscursor?: tcursor;\n totalitems?: number;\n}>;\n\ntype cursorloadcontext<tparams = undefined, tcursor = string> = readonly<{\n after?: tcursor;\n before?: tcursor;\n pagesize: number;\n params: tparams;\n signal: abortsignal;\n}>;\n\ntype cursorresult<t, tcursor = string> = readonly<{\n items: readonly t[];\n nextcursor?: tcursor;\n previouscursor?: tcursor;\n totalitems?: number;\n}>;\n\ntype cursorsourcestate<t, tparams = undefined, tcursor = string> = readonly<{\n error: error | null;\n items: readonly t[];\n loading: boolean;\n pagination: cursorpagination<tcursor>;\n params: tparams;\n pendingparams?: tparams;\n}>;\n\ntype cursorsourceconfig<t, tparams = undefined, tcursor = string> = readonly<{\n after?: tcursor;\n before?: tcursor;\n load(context: cursorloadcontext<tparams, tcursor>): promise<cursorresult<t, tcursor>>;\n pagesize?: number;\n } & (undefined extends tparams ? { params?: tparams } : { params: tparams })\n>;\n\ntype cursorsource<t, tparams = undefined, tcursor = string> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n next(): promise<void>;\n previous(): promise<void>;\n reload(): promise<void>;\n setpagesize(pagesize: number): promise<void>;\n setparams(params: tparams): promise<void>;\n readonly state: cursorsourcestate<t, tparams, tcursor>;\n subscribe(listener: (state: cursorsourcestate<t, tparams, tcursor>) => void): () => void;\n};\n```\n\n### infinite source types\n\n```ts\ntype infinitepagination = readonly<{\n hasmore: boolean;\n loadeditems: number;\n pagesize: number;\n totalitems: number;\n}>;\n\ntype infiniteloadcontext<tparams = undefined> = readonly<{\n page: number;\n pagesize: number;\n params: tparams;\n signal: abortsignal;\n}>;\n\ntype infinitesourcestate<t, tparams = undefined> = readonly<{\n error: error | null;\n items: readonly t[];\n loading: boolean;\n pagination: infinitepagination;\n params: tparams;\n pendingparams?: tparams;\n}>;\n\ntype infinitesourceconfig<t, tparams = undefined> = readonly<{\n load(context: infiniteloadcontext<tparams>): promise<pageresult<t>>;\n pagesize?: number;\n } & (undefined extends tparams ? { params?: tparams } : { params: tparams })\n>;\n\ntype infinitesource<t, tparams = undefined> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n loadmore(): promise<void>;\n reload(): promise<void>;\n setpagesize(pagesize: number): promise<void>;\n setparams(params: tparams): promise<void>;\n readonly state: infinitesourcestate<t, tparams>;\n subscribe(listener: (state: infinitesourcestate<t, tparams>) => void): () => void;\n};\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `sourcerererror` | base class for package originated configuration and lifecycle errors | `instanceof sourcerererror` narrows the hierarchy |\n| `sourcererconfigurationerror` | invalid page, page size, total, or initial cursor direction | extends `sourcerererror` |\n| `sourcererdisposederror` | a source command runs after disposal | extends `sourcerererror` |\n\nloader failures are preserved when they are `error` instances. non error rejections become `error` objects whose `cause` contains the original value.\n",
1305
+ "usage": " \ntitle: 'sourcerer: usage guide'\ndescription: build collection sources, reconcile page and params, and load single value resources.\n \n\n[[toc]]\n\n## basic usage\n\ncreate a page source and call `reload()` to start the first request. construction performs no i/o.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst names = ['ada', 'grace', 'linus'];\nconst source = createpagesource({\n load: async ({ page, pagesize }) => {\n const start = (page 1) * pagesize;\n return { items: names.slice(start, start + pagesize), totalitems: names.length };\n },\n pagesize: 2,\n});\n\nawait source.reload();\nconsole.log(source.state.items);\nsource.dispose();\n```\n\nread one `state` object per render. it contains `items`, `loading`, `error`, `params`, and pagination metadata from one committed result. commands return `promise<void>`; read `source.state` after awaiting them.\n\n## pass loader parameters\n\nuse `params` for application owned filters, search terms, sort state, or request options. `setparams()` replaces the entire value and resets pagination.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\ntype userparams = { role: 'admin' | 'user'; search: string };\nconst users = ['ada', 'grace', 'linus'];\nconst source = createpagesource<string, userparams>({\n load: async ({ page, pagesize, params }) => {\n const matching = users.filter((name) => name.tolowercase().includes(params.search.tolowercase()));\n const start = (page 1) * pagesize;\n return { items: matching.slice(start, start + pagesize), totalitems: matching.length };\n },\n params: { role: 'admin', search: '' },\n});\n\nawait source.setparams({ role: 'admin', search: 'ada' });\nconsole.log(source.state.params);\nsource.dispose();\n```\n\na 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).\n\nwhile 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`.\n\n## navigate numbered pages\n\nuse direct commands for numbered page navigation. `setpagesize()` resets to page one.\n\n```ts\nawait source.reload();\nawait source.next();\nawait source.goto(4);\nawait source.previous();\nawait source.first();\nawait source.last();\nawait source.setpagesize(50);\n```\n\nafter the first successful load, navigation is clamped to the known `pagecount`. before that load, `goto()` accepts any positive page because the total is unknown.\n\n## apply page and filter together\n\na 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.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst names = ['ada', 'grace', 'linus', 'ada byron'];\nconst source = createpagesource({\n load: async ({ page, pagesize, params }) => {\n const matching = names.filter((name) => name.includes(params.search));\n const start = (page 1) * pagesize;\n return { items: matching.slice(start, start + pagesize), totalitems: matching.length };\n },\n pagesize: 1,\n params: { search: '' },\n});\n\ntry {\n await source.apply({ page: 2, params: { search: 'ada' } });\n console.log(source.state.items); // ['ada byron']\n await source.apply({ params: { search: 'ada' } });\n console.log(source.state.pagination.page); // 2; no extra request\n await source.apply({ params: { search: 'grace' } });\n console.log(source.state.pagination.page); // 1\n} finally {\n source.dispose();\n}\n```\n\nparameters are compared structurally, so a freshly built object with the same contents does not\nrefetch a committed result or duplicate active work. an inert source still loads, and an unchanged\nrequest can retry after failure. when parameters change,\n`page` is validated but never clamped: the page count still describes the previous filter set, so\nclamping against it would silently move the reader elsewhere. omit `page` in that case and the\nsource returns to page one. when parameters are unchanged, `apply()` routes through `goto()` and\nclamps to the known page count.\n\nomitted or `undefined` params keep the requested value. use `setparams(undefined)` when\nyour parameter type allows `undefined` and you want to replace the value with it.\n\n## filter local collections\n\nuse a local source when data is already in memory. its commands are synchronous.\n\n```ts\nimport { createlocalsource } from '@vielzeug/sourcerer';\n\nconst source = createlocalsource(['ada', 'grace', 'linus'], {\n filter: (name, search: string) => name.tolowercase().includes(search.tolowercase()),\n pagesize: 2,\n params: '',\n});\n\nsource.setparams('a');\nsource.next();\nsource.setitems(['ada', 'grace']);\nconsole.log(source.state.items);\nsource.dispose();\n```\n\nuse ordinary arrays for one off transformations. a local source is useful when pagination, subscriptions, and lifecycle ownership are also required.\n\n## use cursor pagination\n\nreturn opaque cursors from the loader. `next()` and `previous()` use only cursors from the committed state.\n\n```ts\nimport { createcursorsource } from '@vielzeug/sourcerer';\n\nconst rows = ['a', 'b', 'c', 'd'];\nconst source = createcursorsource({\n load: async ({ after, pagesize }) => {\n const start = after ? number(after) : 0;\n const items = rows.slice(start, start + pagesize);\n const next = start + items.length;\n return {\n items,\n nextcursor: next < rows.length ? string(next) : undefined,\n previouscursor: start > 0 ? string(math.max(0, start pagesize)) : undefined,\n };\n },\n pagesize: 2,\n});\n\nawait source.reload();\nawait source.next();\nconsole.log(source.state.items);\nsource.dispose();\n```\n\nchanging params or page size clears current cursor direction. a source cannot start with both `after` and `before`.\n\n## build an infinite feed\n\nuse an infinite source when later pages should append to committed items.\n\n```ts\nimport { createinfinitesource } from '@vielzeug/sourcerer';\n\nconst values = [1, 2, 3, 4, 5];\nconst source = createinfinitesource({\n load: async ({ page, pagesize }) => {\n const start = (page 1) * pagesize;\n return { items: values.slice(start, start + pagesize), totalitems: values.length };\n },\n pagesize: 2,\n});\n\nawait source.reload();\nawait source.loadmore();\nconsole.log(source.state.items);\nsource.dispose();\n```\n\n`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.\n\n## load a single value\n\nuse a resource for one reactive value: a profile, a settings document, a pricing summary. it loads once and refetches on demand.\n\n```ts\nimport { createresource } from '@vielzeug/sourcerer';\n\ntype profile = { name: string };\nconst profile = createresource<profile>({\n load: async ({ signal }) => {\n const response = await fetch('/me', { signal });\n if (!response.ok) throw new error(`profile request failed: ${response.status}`);\n return response.json();\n },\n start: false,\n});\n\nconst stop = profile.subscribe((state) => console.log(state.data, state.loading, state.error));\nconsole.log(profile.state);\ntry {\n await profile.reload();\n} catch (error) {\n console.error(error);\n} finally {\n stop();\n profile.dispose();\n}\n```\n\na resource starts loading during construction by default; the example uses `start: false` to await\nthe first load explicitly. automatic initial load failures land in `state.error` without an\nunhandled rejection. explicit `reload()` calls reject current failures and preserve previous `data`.\nreloading aborts older work, whose promise settles without committing.\n\nread `state` before subscribing when you need the initial snapshot: subscriptions notify only\nchanges. a resource does not track ripple signals or cache values by key; call `reload()` when\nexternal inputs change. disposal aborts work and retains the last readable snapshot.\n\n## handle requests and errors\n\na 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.\n\n```ts\ntry {\n await source.reload();\n} catch (error) {\n console.error(error);\n console.log(source.state.error);\n}\n```\n\nloader result arrays are copied before publication. commands after disposal throw or reject `sourcererdisposederror`.\n\n## testing\n\ninject deterministic loaders and await commands before asserting final state.\n\n```ts\nimport { expect, it } from 'vitest';\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nit('loads a page', async () => {\n const source = createpagesource({ load: async () => ({ items: ['ada'], totalitems: 1 }) });\n\n await source.reload();\n\n expect(source.state.items).toequal(['ada']);\n source.dispose();\n});\n```\n\n## framework integration\n\ncreate one source per component lifetime and bridge `subscribe()` to the framework lifecycle.\n\n::: code group\n\n```tsx [react]\nimport { createpagesource } from '@vielzeug/sourcerer';\nimport { useeffect, usememo, usesyncexternalstore } from 'react';\n\nexport function users() {\n const source = usememo(\n () => createpagesource({ load: async () => ({ items: [{ id: 1, name: 'ada' }], totalitems: 1 }) }),\n [],\n );\n const state = usesyncexternalstore(source.subscribe, () => source.state);\n\n useeffect(() => {\n void source.reload().catch(() => undefined);\n return () => source.dispose();\n }, [source]);\n\n return <p>{state.loading ? 'loading' : state.items.length}</p>;\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, shallowref } from 'vue';\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst source = createpagesource({ load: async () => ({ items: [{ id: 1, name: 'ada' }], totalitems: 1 }) });\nconst state = shallowref(source.state);\nconst stop = source.subscribe((next) => (state.value = next));\nvoid source.reload().catch(() => undefined);\n\nonunmounted(() => {\n stop();\n source.dispose();\n});\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { ondestroy } from 'svelte';\n import { createpagesource } from '@vielzeug/sourcerer';\n\n const source = createpagesource({ load: async () => ({ items: [{ id: 1, name: 'ada' }], totalitems: 1 }) });\n let state = source.state;\n const stop = source.subscribe((next) => (state = next));\n void source.reload().catch(() => undefined);\n\n ondestroy(() => {\n stop();\n source.dispose();\n });\n</script>\n\n{#if state.loading}loading{/if}\n{#each state.items as user}{user.name}{/each}\n```\n\n:::\n\n## working with other vielzeug libraries\n\nuse courier for transport policy and pass the source owned cancellation signal through.\n\n```ts\nimport { createcourier } from '@vielzeug/courier';\nimport { createpagesource } from '@vielzeug/sourcerer';\n\ntype user = { id: number; name: string };\ntype response = { data: user[]; total: number };\nconst courier = createcourier({ baseurl: '/api' });\nconst source = createpagesource({\n load: async ({ page, pagesize, params: search, signal }) => {\n const result = await courier.get<response>('/users', { query: { page, pagesize, search }, signal });\n return { items: result.data, totalitems: result.total };\n },\n params: '',\n});\n```\n\nuse scout to compute indexed local matches, ripple to project state into reactive computations, and wayfinder to validate and synchronize params with the url.\n\n## best practices\n\n dispose each source with its owning view, request, or service scope.\n read one `state` object per render.\n await direct commands or handle rejected current request failures.\n keep transport, caching, and persistence policy outside sourcerer.\n treat `params` as an immutable value and replace it atomically.\n debounce text input before calling `setparams()` on a remote source.\n validate route and form values before constructing params or page numbers.\n keep rendering committed items while `loading` is true.\n",
1306
+ "examples": " \ntitle: 'sourcerer: examples'\ndescription: recipes for local, page, cursor, infinite, resource, and framework source use.\n \n\n## examples\n\n [local pagination and filtering](./examples/local pagination and filtering.md)\n [page params with url state](./examples/remote search with url state.md)\n [cursor based pagination](./examples/cursor based pagination.md)\n [infinite scroll](./examples/infinite scroll.md)\n [load a single value](./examples/single value resource.md)\n [framework integration](./examples/framework integration.md)\n [remote data with courier](./examples/sourcerer with courier.md)\n [reactive controls with ripple](./examples/sourcerer with ripple.md)\n [url synced list with wayfinder](./examples/sourcerer with wayfinder.md)\n"
1295
1307
  },
1296
1308
  "examples": [
1309
+ {
1310
+ "id": "apply",
1311
+ "text": "apply page and params in one call import { createpagesource } from '@vielzeug/sourcerer'\n\nconst allusers = ['ada lovelace', 'grace hopper', 'linus torvalds', 'ada byron']\n\nconst source = createpagesource({\n load: async ({ page, pagesize, params: { search } }) => {\n const matching = search ? allusers.filter((name) => name.includes(search)) : allusers\n const start = (page 1) * pagesize\n return { items: matching.slice(start, start + pagesize), totalitems: matching.length }\n },\n pagesize: 2,\n params: { search: '' },\n})\n\nawait source.reload()\n\n// one call, one request: page and filters arrive together\nawait source.apply({ page: 2, params: { search: '' } })\nconsole.log('page 2:', source.state.items)\n\n// a structurally identical params object is a no op, so a re render cannot refetch\nawait source.apply({ params: { search: '' } })\nconsole.log('still page 2:', source.state.pagination.page)\n\n// new params reset to page 1 unless a page is given\nawait source.apply({ params: { search: 'ada' } })\nconsole.log('filtered:', source.state.items, 'page', source.state.pagination.page)\n\nsource.dispose()"
1312
+ },
1297
1313
  {
1298
1314
  "id": "cursor-source",
1299
1315
  "text": "cursor source import { createcursorsource } from '@vielzeug/sourcerer'\n\nconst items = array.from({ length: 30 }, (_, index) => ({ id: index + 1, label: `item ${index + 1}` }))\n\nconst source = createcursorsource({\n load: async ({ after, pagesize }) => {\n const start = after ? number(after) : 0\n const pageitems = items.slice(start, start + pagesize)\n const next = start + pageitems.length\n return { items: pageitems, nextcursor: next < items.length ? string(next) : undefined, previouscursor: start ? string(math.max(0, start pagesize)) : undefined }\n },\n pagesize: 10,\n})\n\nawait source.reload()\nawait source.next()\nconsole.log(source.state.items.map((item) => item.label))\nconsole.log(source.state.pagination)\n\nsource.dispose()"
1300
1316
  },
1301
1317
  {
1302
1318
  "id": "error-handling",
1303
- "text": "error handling import { createpagesource } from '@vielzeug/sourcerer'\n\nconst source = createpagesource({\n load: async () => { throw new error('network down') },\n})\n\ntry {\n await source.reload()\n} catch (error) {\n console.log((error as error).message)\n}\n\nconsole.log(source.state.error?.message)\nsource.dispose()"
1319
+ "text": "error handling import { createpagesource } from '@vielzeug/sourcerer'\n\nconst source = createpagesource({\n load: async () => { throw new error('network down') },\n})\n\ntry {\n await source.reload()\n} catch (error) {\n console.log(error.message)\n}\n\nconsole.log(source.state.error?.message)\nsource.dispose()"
1304
1320
  },
1305
1321
  {
1306
1322
  "id": "infinite-source",
@@ -1317,14 +1333,18 @@
1317
1333
  {
1318
1334
  "id": "page-source",
1319
1335
  "text": "page source import { createpagesource } from '@vielzeug/sourcerer'\n\nconst allitems = array.from({ length: 47 }, (_, index) => ({ id: index + 1, name: `item ${index + 1}` }))\n\nconst source = createpagesource({\n load: async ({ page, pagesize, params: search }) => {\n const filtered = search ? allitems.filter((item) => item.name.includes(search)) : allitems\n const start = (page 1) * pagesize\n return { items: filtered.slice(start, start + pagesize), totalitems: filtered.length }\n },\n pagesize: 10,\n params: '',\n})\n\nawait source.reload()\nawait source.setparams('item 4')\nconsole.log(source.state.items.map((item) => item.name))\nconsole.log(source.state.pagination)\n\nsource.dispose()"
1336
+ },
1337
+ {
1338
+ "id": "resource",
1339
+ "text": "createresource one reactive value import { createresource } from '@vielzeug/sourcerer'\n\nlet revision = 0\n\nconst profile = createresource({\n load: async ({ signal }) => {\n if (signal.aborted) throw new error('request aborted')\n revision += 1\n return { name: 'ada lovelace', revision }\n },\n start: false,\n})\n\nconsole.log('initial:', profile.state)\nprofile.subscribe((state) => console.log('notify:', state.data, 'loading:', state.loading))\n\nawait profile.reload()\nconsole.log('data:', profile.state.data, 'error:', profile.state.error)\n\nprofile.dispose()\nconsole.log('✓ disposed')"
1320
1340
  }
1321
1341
  ],
1322
- "exports": "createcursorsource createinfinitesource createlocalsource createpagesource cursorloadcontext cursorpagination cursorresult cursorsource cursorsourceconfig cursorsourcestate infiniteloadcontext infinitepagination infinitesource infinitesourceconfig infinitesourcestate localsource localsourceconfig localsourcestate pageloadcontext pagepagination pageresult pagesource pagesourceconfig pagesourcestate sourcerererror sourcererconfigurationerror sourcererdisposederror",
1323
- "keywords": "pagination data source cursor infinite scroll collection",
1342
+ "exports": "createlocalsource createpagesource createcursorsource createinfinitesource createresource",
1343
+ "keywords": "pagination data source cursor infinite scroll collection resource",
1324
1344
  "name": "@vielzeug/sourcerer",
1325
1345
  "related": "courier ripple scout wayfinder",
1326
1346
  "slug": "sourcerer",
1327
- "source": "export { createcursorsource } from './cursorsource';\nexport { sourcererconfigurationerror, sourcererdisposederror, sourcerererror } from './errors';\nexport { createinfinitesource } from './infinitesource';\nexport { createlocalsource } from './localsource';\nexport { createpagesource } from './pagesource';\nexport type {\n cursorloadcontext,\n cursorpagination,\n cursorresult,\n cursorsource,\n cursorsourceconfig,\n cursorsourcestate,\n infiniteloadcontext,\n infinitepagination,\n infinitesource,\n infinitesourceconfig,\n infinitesourcestate,\n localsource,\n localsourceconfig,\n localsourcestate,\n pageloadcontext,\n pagepagination,\n pageresult,\n pagesource,\n pagesourceconfig,\n pagesourcestate,\n} from './types';\n"
1347
+ "source": "export { createcursorsource } from './cursorsource';\nexport { sourcererconfigurationerror, sourcererdisposederror, sourcerererror } from './errors';\nexport { createinfinitesource } from './infinitesource';\nexport { createlocalsource } from './localsource';\nexport { createpagesource } from './pagesource';\nexport { createresource } from './resourcesource';\nexport type {\n cursorloadcontext,\n cursorpagination,\n cursorresult,\n cursorsource,\n cursorsourceconfig,\n cursorsourcestate,\n infiniteloadcontext,\n infinitepagination,\n infinitesource,\n infinitesourceconfig,\n infinitesourcestate,\n localsource,\n localsourceconfig,\n localsourcestate,\n pageloadcontext,\n pagepagination,\n pageresult,\n pagesource,\n pagesourceconfig,\n pagesourcestate,\n pagesourceupdate,\n resource,\n resourceconfig,\n resourceloadcontext,\n resourcestate,\n} from './types';\n"
1328
1348
  },
1329
1349
  {
1330
1350
  "category": "validation",