@vielzeug/codex 26.10.5 → 26.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,1303 @@
1
1
  # Vielzeug: Full Documentation
2
2
 
3
- > Complete documentation for 41 packages. Version: 26.10.4
3
+ > Complete documentation for 42 packages. Version: 26.10.6
4
+
5
+ ---
6
+
7
+ ## @vielzeug/archive
8
+
9
+ **Category:** utilities
10
+
11
+ ### Overview
12
+
13
+ ## Why Archive?
14
+
15
+ You can share content entries with ranked browsing, filters, likes and account
16
+ erasure on Cloudflare Workers + D1. `defineArchiveCollection` supplies your JSON
17
+ payload schema and indexing rules; the Worker supplies sessions, guards,
18
+ idempotent publishing, cursors and rate limits.
19
+
20
+ ```ts
21
+ // Before: each deployment repeats validation, indexing and route plumbing.
22
+
23
+ // After: the deployment is one collection definition plus one factory call.
24
+ import { defineArchiveCollection } from '@vielzeug/archive';
25
+ import { createArchiveWorker } from '@vielzeug/archive/worker';
26
+ import { s } from '@vielzeug/spell';
27
+
28
+ const notes = defineArchiveCollection({
29
+ name: 'notes', contentVersion: 1,
30
+ payloadSchema: s.object({ title: s.string().min(1).max(128) }),
31
+ index: ({ title }) => ({ title, filter: null, tags: [] }),
32
+ });
33
+ export default createArchiveWorker([notes]);
34
+ ```
35
+
36
+ | Feature | Archive | Hand-rolled Worker | BaaS (Firebase/Supabase) |
37
+ | ------------------------------------ | -------------------------------------------------------- | ------------------------------------ | ------------------------------------- |
38
+ | Bundle size (core) | | Depends on deployment code | Depends on selected SDK |
39
+ | Zero dependencies | No — Spell and jose | Depends on implementation | No — client SDK |
40
+ | Content knowledge in the core | none — one collection seam | baked in | rules everywhere |
41
+ | Canonical fresh-database D1 schema | in the package | Deployment-owned | Not a D1 backend |
42
+ | Idempotent publish w/ tombstones | built-in | rebuild it | rebuild it |
43
+ | GDPR erasure / export endpoints | built-in | rebuild it | partial |
44
+ | Strict DTO re-validation on read | every entry | optional | optional |
45
+
46
+ **Use Archive when** an app needs a shareable, ranked, liked community space for
47
+ content entries on Cloudflare Workers + D1, and can express its content as one strict
48
+ payload schema.
49
+ **Consider alternatives when** you need rich queries beyond filter/tag/search, live
50
+ collaboration, or a backend you do not control — Archive is deliberately one narrow,
51
+ hardened surface.
52
+
53
+ ## Installation
54
+
55
+ ```sh [pnpm]
56
+ pnpm add @vielzeug/archive @vielzeug/spell
57
+ ```
58
+
59
+ ```sh [npm]
60
+ npm install @vielzeug/archive @vielzeug/spell
61
+ ```
62
+
63
+ ```sh [yarn]
64
+ yarn add @vielzeug/archive @vielzeug/spell
65
+ ```
66
+
67
+ ## Quick Start
68
+
69
+ A deployment is one collection definition plus one factory call. The definition
70
+ brings the payload schema and what to index; everything else — the emitted-entry
71
+ envelope, the content hash, the wire contract, the routes — is composed for you.
72
+
73
+ ```ts
74
+ import { defineArchiveCollection } from '@vielzeug/archive';
75
+ import { createArchiveWorker } from '@vielzeug/archive/worker';
76
+ import { s } from '@vielzeug/spell';
77
+
78
+ // The collection's payload: one strict schema is the whole content contract.
79
+ const payloadSchema = s.object({
80
+ cuisine: s.enum(['italian', 'japanese']),
81
+ ingredients: s.array(s.string().trim().min(1).max(64)).min(1).max(20),
82
+ name: s.string().trim().min(1).max(32),
83
+ });
84
+
85
+ const recipesCollection = defineArchiveCollection({
86
+ canonicalize: (payload) => ({ ...payload, ingredients: [...payload.ingredients].sort() }),
87
+ contentVersion: 1,
88
+ filters: ['italian', 'japanese'],
89
+ index: (payload) => ({
90
+ filter: payload.cuisine,
91
+ tags: [payload.ingredients.length ({ title, filter: null, tags: [] }),
92
+ });
93
+ const response = await fetch('/api/v1/collections/notes/entries?sort=liked');
94
+ if (!response.ok) throw new Error('Archive unavailable');
95
+ const parsed = notes.contract.page.parse(await response.json());
96
+ console.log(parsed.entries);
97
+ ```
98
+
99
+ ## Features
100
+
101
+ - `defineArchiveCollection` — the one content seam: payload schema, canonicalization, filter/tag/title derivation, union-typed vocabularies
102
+ - `createArchiveWorker` — the whole routes/auth/limits/SQL stack from registered collections
103
+ - `collection.contract` — entry, page, publish/update input schemas shared by client and Worker
104
+ - `ARCHIVE_LIMITS` + `archiveHealthSchema` — the application bounds, advertised by health and validated on both sides
105
+ - `archiveAuthHealthSchema` + `AUTH_SCHEMA_VERSION` — typed issuer readiness checked against the issuer DDL stamp
106
+ - `archiveErrorResponseSchema` — the one error envelope, code-typed across the wire
107
+ - `createAuthWorker` — scanner-safe confirmation, live session-version enforcement, durable budgets, metadata export and coordinated erasure
108
+ - `AUTH_ISSUER_SERVICE` — required uncached issuer check after local catalog JWT verification
109
+ - `ArchiveDerivationError` — distinguishes faulty collection code from invalid submitted content
110
+ - Moderation — reader reports, the operator soft-hide and the report queue, keyed on a verified `roles` claim
111
+ - `worker.tap()` / `issuer.tap()` — typed sanitized runtime events; neither core logs by default
112
+ - `migrations/` — canonical fresh-database DDL with the storage stamp health reads back; no upgrade framework
113
+
114
+ ## Documentation
115
+
116
+ - [Usage Guide](./usage.md) — stand up a collection, deploy the Worker, wire the client.
117
+ - [API Reference](./api.md) — every export, all three entry points, the full route table.
118
+ - [Examples](./examples.md) — recipes from first deployment to GDPR erasure.
119
+ - [Migration](./migration.md) — breaking changes after the first release and
120
+ fresh-database scope.
121
+
122
+ ## See Also
123
+
124
+ - [Spell](/spell/) — every payload schema here is a spell schema; the core composes envelopes from it.
125
+ - [Courier](/courier/) — the HTTP client shape an app's frontend mirrors to consume these routes.
126
+ - [Rune](/rune/) — the structured logger a deployment points `worker.tap()` events at.
127
+
128
+ ### API Reference
129
+
130
+ ## API Overview
131
+
132
+ | Symbol | Purpose | Execution mode | Common gotcha |
133
+ | --- | --- | --- | --- |
134
+ | `defineArchiveCollection` | Compose validation, indexing, identity and wire schemas | Sync | Invalid content derives to `null`; callback faults throw |
135
+ | `createArchiveWorker` | Build the catalog Worker | Sync | Collection names must be unique |
136
+ | `createAuthWorker` | Build the passwordless issuer | Sync | `AUTH_ARCHIVE` is required |
137
+ | `worker.tap`, `issuer.tap` | Observe sanitized runtime outcomes | Sync | Neither core logs by default; observer exceptions are swallowed |
138
+ | `ArchiveDerivationError` | Identify a collection implementation/runtime fault | Sync | Faults become sanitized 500s, not content-validation 422s |
139
+ | `ARCHIVE_LIMITS` | Shared application bounds | Sync | Page bounds do not cap total exports |
140
+ | `archiveErrorResponseSchema`, `archiveAuthHealthSchema` | Validate error and issuer-health responses | Sync | Unknown error codes and health states reject |
141
+ | `ArchiveError`, `ArchiveDefinitionError` | Public base and invalid-definition errors | Sync | Thrown locally, not transmitted as error objects |
142
+ | `sessionCookieValue` | Compose shared session-cookie attributes | Sync | Deployments must choose `secure` from their environment |
143
+ | `isWellFormedText` | Check control characters and lone surrogates | Sync | Does not trim or impose a length bound |
144
+
145
+ ## Package Entry Point
146
+
147
+ | Import | Purpose |
148
+ | --- | --- |
149
+ | `@vielzeug/archive` | Browser-safe collections, constants, schemas and errors |
150
+ | `@vielzeug/archive/worker` | Catalog factory, issuer-session service binding and event types |
151
+ | `@vielzeug/archive/auth` | Issuer factory and binding types |
152
+
153
+ ## Collections
154
+
155
+ ### `defineArchiveCollection`
156
+
157
+ Creates a frozen collection and its contract; malformed names, versions or vocabularies throw `ArchiveDefinitionError`.
158
+
159
+ ```ts
160
+ function defineArchiveCollection(definition: ArchiveCollectionDefinition): ArchiveCollection;
161
+ ```
162
+
163
+ | Parameter | Contract |
164
+ | --- | --- |
165
+ | `name` | Lowercase `[a-z0-9-]{1,32}` |
166
+ | `contentVersion` | Positive safe integer |
167
+ | `payloadSchema` | Spell schema whose output is a stable JSON object |
168
+ | `filters`, `tags` | Optional readonly arrays; omitted means empty vocabulary |
169
+ | `canonicalize` | Optional pure, idempotent, schema-valid normalization |
170
+ | `index` | Derive `filter`, `tags` and searchable `title` without mutating the payload |
171
+
172
+ **Returns:** `ArchiveCollection`.
173
+
174
+ **Example:** define and derive one collection without Worker bindings.
175
+
176
+ ```ts
177
+ import { defineArchiveCollection } from '@vielzeug/archive';
178
+ import { s } from '@vielzeug/spell';
179
+
180
+ const recipes = defineArchiveCollection({
181
+ name: 'recipes',
182
+ contentVersion: 1,
183
+ payloadSchema: s.object({ name: s.string().min(1).max(32) }),
184
+ index: (payload) => ({ filter: null, tags: [], title: payload.name }),
185
+ });
186
+ const derived = await recipes.derive({ name: 'Rice' });
187
+ if (derived === null) throw new Error('Invalid recipe');
188
+ console.log(derived.contentHash);
189
+ ```
190
+
191
+ | Method / property | Result |
192
+ | --- | --- |
193
+ | `derive(raw)` | `Promise` |
194
+ | `contract.entry` | Public entry schema |
195
+ | `contract.ownedEntry`, `ownedEntries` | Owner view with `clientKey` and `hidden` |
196
+ | `contract.page` | Entries and the forward cursor |
197
+ | `contract.publishInput`, `updateInput` | Publish and revision-guarded update request schemas |
198
+ | `filters`, `tags` | Frozen vocabulary snapshots |
199
+
200
+ `derive` returns `null` for invalid JSON objects, schema-invalid input, invalid titles, too many tags and oversized canonical payloads. Unexpected schema, callback or hashing failures throw `ArchiveDerivationError`. Non-idempotent or schema-invalid canonicalization, unstable schema transformations, index mutation and out-of-vocabulary projections are also derivation faults. Routes map `null` to 422 `invalid-payload` and faults to a sanitized 500 `internal`.
201
+ Schema output that is lossy or no longer JSON is a derivation fault, not invalid
202
+ submitted content. Invalid index-result shapes also throw.
203
+
204
+ Const generics preserve the content-version literal and infer filter/tag unions from vocabulary arrays without `as const`. `NoInfer` prevents `index` from widening those unions; omitted arrays infer `never`, permitting only `filter: null` and empty tags.
205
+
206
+ Objects serialize with recursively sorted keys; arrays preserve order unless your canonicalizer changes it. SHA-256 hashes the core-owned `{contentVersion, payload}` envelope. Stored payloads are canonical by construction, so the emitted entry schemas embed the plain payload schema.
207
+
208
+ `filter`, tags and searchable title are persisted projections, not computed during reads. Changing `index` or its vocabulary does not update stored rows. The active definition and persisted dataset must remain consistent; Archive has no projection-update or historical-schema API.
209
+
210
+ ## Worker and Maintenance
211
+
212
+ ### `createArchiveWorker`
213
+
214
+ Returns the catalog request/scheduled handler and its observation side channel.
215
+
216
+ ```ts
217
+ function createArchiveWorker(
218
+ collections: readonly ArchiveCollection[],
219
+ ): ArchiveWorkerHandler;
220
+ ```
221
+
222
+ **Returns:** `ArchiveWorkerHandler`; duplicate names throw `ArchiveDefinitionError` at registration.
223
+
224
+ **Example:** export a deployable Worker using the package's structural bindings.
225
+
226
+ ```ts
227
+ import { defineArchiveCollection } from '@vielzeug/archive';
228
+ import { createArchiveWorker, type ArchiveWorkerEnv } from '@vielzeug/archive/worker';
229
+ import { s } from '@vielzeug/spell';
230
+
231
+ const notes = defineArchiveCollection({
232
+ name: 'notes', contentVersion: 1,
233
+ payloadSchema: s.object({ title: s.string().min(1).max(128) }),
234
+ index: ({ title }) => ({ title, filter: null, tags: [] }),
235
+ });
236
+ export default createArchiveWorker([notes]);
237
+ ```
238
+
239
+ | Method | Contract |
240
+ | --- | --- |
241
+ | `fetch(request, env, ctx)` | Returns `Promise` |
242
+ | `scheduled(controller, env, ctx)` | Sweeps expired retry keys and revocation tombstones; emits failures and rethrows |
243
+ | `tap(handler, { signal? }?)` | Returns unsubscribe; a pre-aborted signal registers nothing |
244
+
245
+ `AUTH_ISSUER_SERVICE` is a required `fetch`-shaped service binding to the issuer.
246
+ The catalog verifies the signed JWT locally, then asks the issuer to validate its
247
+ current account session version. It does not cache successful issuer verdicts.
248
+ If the service exchange fails or exceeds its fixed 5000 ms timeout (body decoding
249
+ included), the catalog returns `503 auth-unavailable`; the timeout does not cancel
250
+ work already started by the issuer.
251
+
252
+ Only 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.
253
+
254
+ ---
255
+
256
+ ### `worker.tap`
257
+
258
+ Observes typed request, scheduled and cache-failure events without affecting request behavior.
259
+
260
+ ```ts
261
+ tap(handler: (event: ArchiveEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;
262
+ ```
263
+
264
+ **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.
265
+
266
+ **Example:** observe a Worker and detach explicitly.
267
+
268
+ ```ts
269
+ import { createArchiveWorker, type ArchiveWorkerEnv } from '@vielzeug/archive/worker';
270
+
271
+ const worker = createArchiveWorker([]);
272
+ const stop = worker.tap((event) => console.log(JSON.stringify(event)));
273
+ stop();
274
+ ```
275
+
276
+ ---
277
+
278
+ ## Identity Issuer
279
+
280
+ ### `createAuthWorker`
281
+
282
+ Returns the passwordless email issuer with account export and coordinated erasure.
283
+
284
+ ```ts
285
+ function createAuthWorker(): AuthWorkerHandler;
286
+ ```
287
+
288
+ **Returns:** `AuthWorkerHandler`.
289
+
290
+ **Example:** export an issuer configured through deployment bindings.
291
+
292
+ ```ts
293
+ import { createAuthWorker, type AuthWorkerEnv } from '@vielzeug/archive/auth';
294
+
295
+ export default createAuthWorker();
296
+ ```
297
+
298
+ | Method | Contract |
299
+ | --- | --- |
300
+ | `fetch(request, env, ctx?)` | Returns `Promise` |
301
+ | `scheduled(controller, env, ctx?)` | Removes expired links, closed send windows and unverified unreferenced accounts |
302
+ | `tap(handler, { signal? }?)` | Observes typed `AuthEvent`; returns unsubscribe; pre-aborted signals are inert |
303
+
304
+ Magic links expire after 15 minutes and work once. The durable per-email budget allows three sends per hour; consumption, expiration and send failure do not refund it. Ordinary sessions last at most 30 days. Operator sessions use `OPERATOR_SESSION_MAX_AGE_SECONDS` (900 seconds). `OPERATOR_EMAILS` controls newly minted role claims.
305
+
306
+ The issuer-global admission budget is durable D1 state in `auth_sign_in_budget`:
307
+ 20 requests per 60-second window before account creation or email delivery.
308
+ This supplements, rather than replaces, the durable per-email budget. No external
309
+ rate-limiter binding is required.
310
+ Admission spends are not refunded after later database or email failures.
311
+ Request emails are trimmed and lowercased before validation. `SENDER_ADDRESS`
312
+ must already be canonical; `OPERATOR_EMAILS` entries are trimmed and lowercased
313
+ before validation.
314
+
315
+ Sign-in requests create pending account rows with `lastSignInAt: null`. Successful
316
+ redemption atomically claims the one-use link and stamps the account's sign-in
317
+ time. The sweep deletes expired links and closed per-user send windows, then
318
+ unverified accounts with neither remaining links nor send-budget references.
319
+ Verified accounts are retained until explicit account deletion.
320
+
321
+ `OPERATOR_SESSION_MAX_AGE_SECONDS = 900` is part of the browser-safe root
322
+ contract; `/auth` exports the factory and issuer binding types.
323
+
324
+ `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.
325
+
326
+ Opening a magic link performs a safe GET that displays a click-confirmation page,
327
+ not redemption. Only a marked POST from an exact allowed origin or
328
+ `PUBLIC_ORIGIN` consumes `{ token }` and returns 204 with the session cookie. The
329
+ confirmation page navigates to `/signed-in` after success; the POST is not a
330
+ redirect. Confirmation responses are `no-store` with no-referrer and restrictive
331
+ CSP; the page removes the token from the address bar and loads no external assets.
332
+ GET displays confirmation for any syntactically valid token, including a used
333
+ token; only POST establishes whether the link is still redeemable. POST accepts
334
+ exactly `{ token }` and rejects query parameters.
335
+
336
+ The issuer never logs directly. `tap` emits sanitized request and scheduled
337
+ outcomes; observer failures are swallowed, abort detaches the observer and failed
338
+ scheduled work emits then rethrows. `AuthDiagnostic` reports only a category.
339
+
340
+ ## HTTP Routes
341
+
342
+ Every route below is mounted under `/api/v1`. Mutation guards require an exact allowed `Origin` and `x-archive-request: 1`. Registered publishers pin `x-archive-publisher` to the identity captured when preparing private requests. The header is a freshness precondition, not authority.
343
+
344
+ | Route | Contract |
345
+ | --- | --- |
346
+ | `GET /health` | Public catalog readiness; 200 `ok` or 503 `degraded` |
347
+ | `POST /sign-out` | Catalog-local sign-out; `{ ok: true }` and expired session cookie, without issuer access |
348
+ | `GET /collections/:collection` | Collection metadata |
349
+ | `GET /collections/:collection/entries` | `author`, `filter`, `tag`, `search`, `sort`, `cursor`, `limit`; forward entry page |
350
+ | `POST /collections/:collection/entries` | `{ clientKey, payload }`; 201 create or 200 replay |
351
+ | `GET /collections/:collection/entries/:id` | Public entry; always `no-store` |
352
+ | `PUT /collections/:collection/entries/:id` | Owner-only `{ expectedRevision, payload }` |
353
+ | `DELETE /collections/:collection/entries/:id` | Owner or operator removal; 204 |
354
+ | `PUT` / `DELETE /collections/:collection/entries/:id/like` | Like / unlike visible entries only; guarded write and response; like-state response |
355
+ | `POST /collections/:collection/entries/:id/report` | `{ reason }`; idempotent per entry/reporter; 204 |
356
+ | `PUT` / `DELETE /collections/:collection/entries/:id/hidden` | Operator hide / unhide; 204 |
357
+ | `GET /collections/:collection/reports` | Operator page; `status=unresolved` (default) or `resolved`, `cursor`, `limit` |
358
+ | `PUT /collections/:collection/reports/:id/resolved` | Operator resolves report by its unique report ID; idempotent 204 |
359
+ | `DELETE /publishers/:id` | Operator erases Archive profile; not issuer account |
360
+ | `GET /moderation-log` | Operator forward page; `cursor`, `limit` |
361
+ | `GET` / `POST` / `PUT /me` | Identity, register with optional `{ name }`, rename with required `{ name }` |
362
+ | `DELETE /me` | Profile-only cascade; 204; does not clear cookie |
363
+ | `DELETE /me/account` | Revoke issuer/subject and atomically erase profile; 204; coordinated issuer-erasure seam |
364
+ | `GET /me/publications?collection=…` | Owner entries including hidden state and retry keys |
365
+ | `GET /me/likes` | Forward page `{ ids, cursor }`; `cursor`, `limit` |
366
+ | `GET /me/likes?ids=…` | Subset lookup `{ ids }`; at most `likeStateIds` input IDs |
367
+ | `GET /me/reports` | Own forward report page across collections; `cursor`, `limit` |
368
+ | `GET /auth/health` | Public issuer readiness; typed `AuthHealth`, 200 `ok` or 503 `degraded`; no email or peer probe |
369
+ | `GET /auth/session` | Internal issuer-session check for the catalog service binding; exact `{ ok: true }`, `no-store`, no PII; 401 for an invalid, stale or absent-account session |
370
+ | `POST /auth/sign-out-all` | Requires marker, exact origin, session and captured `ACCOUNT_HEADER`; atomically increments `session_version`, deletes outstanding links, preserves account and catalog data; 200 `{ ok: true }` with an expired cookie only on durable success |
371
+ | `GET /auth/jwks.json` | Active and configured retired public verification keys |
372
+ | `POST /auth/sign-in` | `{ email }` (trimmed and lowercased); 202 `{ ok: true }` |
373
+ | `GET /auth/verify?token=…` | Safe no-store click-confirmation page; no token consumption or session change |
374
+ | `POST /auth/verify` | JSON `{ token }`, marker and exact allowed origin or `PUBLIC_ORIGIN`; consume once; 204 with session cookie |
375
+ | `GET /auth/account` | `{ id, email, createdAt, lastSignInAt }`; no tokens or budget data |
376
+ | `DELETE /auth/account` | Requires `ACCOUNT_HEADER` with the captured issuer account UUID; catalog `/me/account` via `AUTH_ARCHIVE`, require exact 204, then issuer user and cookie clear; 204 |
377
+
378
+ Forward pages are newest first with opaque context-bound keyset cursors. Keep the same query context when following a cursor; end at `cursor: null`. `maxPageSize` bounds each page, not the total number of liked IDs, reports or audit actions you can export. The subset-like query is not a page and cannot combine `ids` with `cursor` or `limit`.
379
+
380
+ Full erasure creates an issuer/subject revocation tombstone for 30 days plus ten
381
+ seconds and deletes the profile in the same transaction. Catalog authentication
382
+ rejects that subject with 401 on ordinary private routes; registration also checks
383
+ revocation in SQL to prevent resurrection races. Only `DELETE /me/account` may
384
+ bypass the issuer session lookup, and only to retry erasure with the original
385
+ signed session of the same principal while its existing revocation tombstone is
386
+ valid; JWT signature and claims are still verified. It is not a general
387
+ stale-session exception: initial erasure validates the issuer session. The
388
+ issuer-facing `DELETE /auth/account` always checks the current session version
389
+ before calling the catalog, so retrying it after completed erasure returns 401
390
+ because the account is absent.
391
+ Direct profile-only `DELETE /me` allows later registration using the same session.
392
+ The sweep removes expired tombstones. Re-enrollment through the reference issuer
393
+ creates a fresh account UUID subject; an external issuer must issue a new subject
394
+ to re-enroll during the revocation window.
395
+
396
+ The issuer stores `auth_users.session_version` as a safe non-negative integer,
397
+ defaulting to `0`. Every session JWT carries the current value as the required
398
+ `sessionVersion` claim. Each ordinary issuer-authenticated route compares that
399
+ claim to the current account row; a mismatch is unauthenticated. `POST
400
+ /api/v1/auth/sign-out-all` checks the captured issuer account UUID against the
401
+ verified subject before atomically incrementing the version and deleting pending
402
+ links. A changed account answers 409 `account-changed`; a failed request does not
403
+ clear the cookie. This revokes all sessions, including the caller's, after a
404
+ successful response. Requests authorized before revocation may finish; there is
405
+ no cross-database transactional or in-flight cancellation guarantee.
406
+
407
+ The catalog requires `AUTH_ISSUER_SERVICE`, a service binding to the issuer. After
408
+ local JWT signature/claims verification, it requests
409
+ `GET /api/v1/auth/session` through that binding on every private request. Only a
410
+ strictly validated 401 `unauthenticated` remains a 401. Malformed, unexpected,
411
+ provider and 5xx responses become 503 `auth-unavailable`; there is no authority
412
+ cache. Both service directions use a fixed 5000 ms timeout that includes body
413
+ decoding. The issuer's `/api/v1/auth/session` is an internal
414
+ service contract, not a frontend session API.
415
+
416
+ Issuer readiness is independent of catalog readiness. `GET
417
+ /api/v1/auth/health` returns `archiveAuthHealthSchema` as `AuthHealth`; its
418
+ read-only probe checks configuration, a signing and verification key proof,
419
+ binding shapes, the singleton `auth_schema_meta` stamp against
420
+ `AUTH_SCHEMA_VERSION`, and readability of expected tables, columns and triggers.
421
+ It does not write, send email or probe the catalog service. Responses are
422
+ sanitized.
423
+ Configuration failure, database failure and schema mismatch return HTTP 503
424
+ with `status: 'degraded'` and the corresponding reason.
425
+
426
+ Effective 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.
427
+
428
+ ## Constants and Schemas
429
+
430
+ | Export | Value or parsed output |
431
+ | --- | --- |
432
+ | `ARCHIVE_CONTRACT_VERSION`, `ARCHIVE_SCHEMA_VERSION` | Both debut at `1`; wire and storage versions are independent |
433
+ | `AUTH_SCHEMA_VERSION` | `1`; issuer DDL stamp checked by `/auth/health` |
434
+ | `ARCHIVE_COLLECTION_NAME_PATTERN` | `/^[a-z0-9-]{1,32}$/` |
435
+ | `ARCHIVE_OPERATOR_ROLE` | `'archive-operator'` |
436
+ | `SESSION_COOKIE_NAME`, `SESSION_COOKIE_PATH` | `'archive_session'`, `'/api/v1'` |
437
+ | `SESSION_MAX_AGE_SECONDS` | `2592000` |
438
+ | `OPERATOR_SESSION_MAX_AGE_SECONDS` | `900`; shared root contract |
439
+ | `ACCOUNT_HEADER` | `'X-Archive-Account'`; required issuer-account UUID on coordinated erasure |
440
+ | `PUBLISHER_HEADER`, `REQUEST_MARKER_HEADER` | `'x-archive-publisher'`, `'x-archive-request'` |
441
+ | `ILL_FORMED_TEXT`, `isWellFormedText(value: string): boolean` | Reject control characters and lone UTF-16 surrogates |
442
+ | `uuidSchema`, `isoTimestampSchema`, `hex64Schema` | UUID, ISO timestamp, lowercase SHA-256 hex strings |
443
+ | `archiveAliasSchema` | Trimmed, well-formed alias of at most 24 characters |
444
+ | `archiveAuthorSchema` | `{ id: string; name: string }` |
445
+ | `archiveIdentitySchema` | `ArchiveIdentity` |
446
+ | `archiveAccountSchema` | `ArchiveAccount`; issuer metadata, excluding private fields |
447
+ | `archiveLimitsSchema` | All numeric `ARCHIVE_LIMITS` fields |
448
+ | `archiveHealthSchema` | `ArchiveHealth` |
449
+ | `archiveAuthHealthSchema` | `AuthHealth` |
450
+ | `archiveErrorResponseSchema` | `ArchiveErrorResponse` |
451
+ | `archiveOkSchema` | `{ ok: true }` |
452
+ | `archiveCollectionMetaSchema` | `ArchiveCollectionMeta` |
453
+ | `archiveLikedIdsSchema` | `{ ids: string[] }` subset response |
454
+ | `archiveLikesPageSchema` | `ArchiveLikesPage` |
455
+ | `archiveLikeStateSchema` | `{ id: string; likeCount: number; liked: boolean }` |
456
+ | `archiveReportsSchema`, `archiveOwnReportsSchema` | `ArchiveReports`, `ArchiveOwnReports` |
457
+ | `archiveModerationLogSchema` | `ArchiveModerationLog` |
458
+ | `archiveSortSchema` | `ArchiveSort` |
459
+ | `ARCHIVE_ERROR_CODES` | Values of `ArchiveErrorCode` below |
460
+ | `ARCHIVE_MODERATION_ACTIONS` | `['hide', 'unhide', 'remove', 'erase-publisher']` |
461
+
462
+ ### `sessionCookieValue`
463
+
464
+ Composes a session cookie using the shared security attributes.
465
+
466
+ ```ts
467
+ function sessionCookieValue(token: string, options: { maxAgeSeconds: number; secure: boolean }): string;
468
+ ```
469
+
470
+ | Parameter | Contract |
471
+ | --- | --- |
472
+ | `token` | Session token, or empty string when clearing |
473
+ | `options.maxAgeSeconds` | Cookie lifetime; `0` clears it |
474
+ | `options.secure` | Include `Secure` for production HTTPS |
475
+
476
+ **Returns:** the `Set-Cookie` value with `HttpOnly`, `SameSite=Strict`, the shared path and optional `Secure`.
477
+
478
+ **Example:** clear the production cookie.
479
+
480
+ ```ts
481
+ import { sessionCookieValue } from '@vielzeug/archive';
482
+
483
+ const expiredCookie = sessionCookieValue('', { maxAgeSeconds: 0, secure: true });
484
+ console.log(expiredCookie);
485
+ ```
486
+
487
+ ---
488
+
489
+ ### `isWellFormedText`
490
+
491
+ Checks text for control characters and lone UTF-16 surrogates.
492
+
493
+ ```ts
494
+ function isWellFormedText(value: string): boolean;
495
+ ```
496
+
497
+ **Returns:** `true` when `ILL_FORMED_TEXT` does not match; it does not trim or impose bounds.
498
+
499
+ **Example:** validate text independently of the alias schema.
500
+
501
+ ```ts
502
+ import { isWellFormedText } from '@vielzeug/archive';
503
+
504
+ console.log(isWellFormedText('Rice'), isWellFormedText('\u0000'));
505
+ ```
506
+
507
+ ### `ARCHIVE_LIMITS`
508
+
509
+ `ARCHIVE_LIMITS` is frozen with these values:
510
+
511
+ ```ts
512
+ const limits = {
513
+ aliasChars: 24, cursorChars: 1024, idempotencyRetentionMs: 172800000,
514
+ likeStateIds: 50, maxPageSize: 50, maxPublications: 50, pageSize: 10,
515
+ payloadBytes: 8192, publicCacheSeconds: 30, reportReasonChars: 256,
516
+ requestBytes: 12288, searchChars: 64, searchTerms: 6, tagsPerEntry: 16,
517
+ titleChars: 128, vocabChars: 64,
518
+ };
519
+ ```
520
+
521
+ ## Types
522
+
523
+ The following definitions use `Schema` from `@vielzeug/spell`. Schema-derived aliases show their complete output shape.
524
+
525
+ ```ts
526
+ interface ArchiveIndexing {
527
+ filter: F | null;
528
+ tags: readonly T[];
529
+ title: string;
530
+ }
531
+ interface ArchiveCollectionDefinition {
532
+ canonicalize?(payload: P): P;
533
+ contentVersion: C;
534
+ filters?: readonly F[];
535
+ index(payload: P): ArchiveIndexing, NoInfer>;
536
+ name: string;
537
+ payloadSchema: Schema;
538
+ tags?: readonly T[];
539
+ }
540
+ interface ArchiveDerivation {
541
+ canonicalJson: string;
542
+ contentHash: string;
543
+ contentVersion: number;
544
+ filter: string | null;
545
+ tags: readonly string[];
546
+ title: string;
547
+ }
548
+ interface ArchiveEntry {
549
+ author: { id: string; name: string };
550
+ contentHash: string;
551
+ contentVersion: C;
552
+ id: string;
553
+ likeCount: number;
554
+ payload: P;
555
+ publishedAt: string;
556
+ revision: number;
557
+ schemaVersion: typeof ARCHIVE_CONTRACT_VERSION;
558
+ updatedAt: string;
559
+ }
560
+ interface ArchiveOwnedEntry extends ArchiveEntry {
561
+ clientKey: string;
562
+ hidden: boolean;
563
+ }
564
+ interface ArchiveContract {
565
+ entry: Schema>;
566
+ ownedEntries: Schema[] }>;
567
+ ownedEntry: Schema>;
568
+ page: Schema>>;
569
+ publishInput: Schema;
570
+ updateInput: Schema;
571
+ }
572
+ interface ArchiveCollection {
573
+ readonly contentVersion: C;
574
+ readonly contract: ArchiveContract;
575
+ readonly derive: (raw: unknown) => Promise;
576
+ readonly filters: readonly string[];
577
+ readonly name: string;
578
+ readonly tags: readonly string[];
579
+ }
580
+ interface ArchivePage {
581
+ cursor: string | null;
582
+ entries: readonly T[];
583
+ }
584
+ type ArchiveSort = 'liked' | 'recent';
585
+ type ArchiveDerivationStage = 'canonicalize' | 'hash' | 'index' | 'schema';
586
+ type ArchiveIdentity = { id: string; name: string; operator: boolean } | null;
587
+ type ArchiveAccount = {
588
+ id: string; email: string; createdAt: string; lastSignInAt: string | null;
589
+ };
590
+ type ArchiveCollectionMeta = { filters: string[]; name: string; tags: string[] };
591
+ type ArchiveLikesPage = { cursor: string | null; ids: string[] };
592
+ type ArchiveErrorCode =
593
+ | 'account-changed' | 'auth-unavailable' | 'bad-request' | 'conflict' | 'forbidden' | 'gone'
594
+ | 'internal' | 'invalid-payload' | 'likes-disabled' | 'method-not-allowed'
595
+ | 'not-found' | 'publisher-changed' | 'publishing-disabled' | 'quota'
596
+ | 'rate-limited' | 'record-unavailable' | 'timeout' | 'too-large'
597
+ | 'unauthenticated' | 'unavailable' | 'unsupported-media';
598
+ type ArchiveErrorResponse = {
599
+ error: { code: ArchiveErrorCode; message: string; requestId: string };
600
+ };
601
+ type ArchiveHealth = {
602
+ dbSchemaVersion: number | null;
603
+ features: { likes: boolean; publishing: boolean };
604
+ limits: {
605
+ aliasChars: number; cursorChars: number; idempotencyRetentionMs: number;
606
+ likeStateIds: number; maxPageSize: number; maxPublications: number;
607
+ pageSize: number; payloadBytes: number; publicCacheSeconds: number;
608
+ reportReasonChars: number; requestBytes: number; searchChars: number;
609
+ searchTerms: number; tagsPerEntry: number; titleChars: number; vocabChars: number;
610
+ };
611
+ schemaVersion: 1;
612
+ status: 'ok' | 'degraded';
613
+ reason: 'database-unavailable' | 'schema-mismatch' | null;
614
+ };
615
+ type AuthHealth = {
616
+ dbSchemaVersion: number | null;
617
+ schemaVersion: 1;
618
+ status: 'ok' | 'degraded';
619
+ reason: 'configuration-unavailable' | 'database-unavailable' | 'schema-mismatch' | null;
620
+ };
621
+ type ArchiveReports = {
622
+ cursor: string | null;
623
+ reports: {
624
+ id: string; createdAt: string; entryId: string; entryTitle: string;
625
+ reason: string; reporter: { id: string; name: string }; resolvedAt: string | null;
626
+ }[];
627
+ };
628
+ type ArchiveOwnReports = {
629
+ cursor: string | null;
630
+ reports: {
631
+ collection: string; createdAt: string; entryId: string; id: string;
632
+ reason: string; resolvedAt: string | null;
633
+ }[];
634
+ };
635
+ type ArchiveModerationAction = 'hide' | 'unhide' | 'remove' | 'erase-publisher';
636
+ type ArchiveModerationLog = {
637
+ cursor: string | null;
638
+ actions: {
639
+ id: string; action: ArchiveModerationAction; collection: string | null;
640
+ createdAt: string; operatorId: string; targetId: string;
641
+ }[];
642
+ };
643
+ ```
644
+
645
+ Worker subpath types model only the binding methods the catalog calls.
646
+
647
+ ```ts
648
+ interface D1Statement {
649
+ all(): Promise;
650
+ bind(...params: (string | number | null)[]): D1Statement;
651
+ first(): Promise;
652
+ run(): Promise;
653
+ }
654
+ interface D1Like {
655
+ batch(statements: D1Statement[]): Promise;
656
+ prepare(sql: string): D1Statement;
657
+ }
658
+ interface RateLimiterBinding {
659
+ limit(input: { key: string }): Promise;
660
+ }
661
+ interface ArchiveWorkerEnv {
662
+ AUTH_ISSUER_SERVICE: AuthIssuerServiceBinding;
663
+ ALLOWED_ORIGINS: string;
664
+ APP_ENV: string;
665
+ DB: D1Like;
666
+ JWT_AUDIENCE: string;
667
+ JWT_ISSUER: string;
668
+ JWT_JWKS_URL: string;
669
+ LIKES_ENABLED: string;
670
+ PUBLISHING_ENABLED: string;
671
+ RATE_LIMIT_KEY_SECRET: string;
672
+ READ_LIMIT: RateLimiterBinding;
673
+ WRITE_LIMIT: RateLimiterBinding;
674
+ }
675
+ interface AuthIssuerServiceBinding {
676
+ fetch(request: Request): Promise;
677
+ }
678
+ interface ArchiveExecutionContext {
679
+ waitUntil(promise: Promise): void;
680
+ }
681
+ type ArchiveDiagnostic =
682
+ | { readonly kind: 'configuration'; readonly field: string }
683
+ | { readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection' };
684
+ interface ArchiveRequestEvent {
685
+ code: ArchiveErrorCode | 'ok';
686
+ diagnostic?: ArchiveDiagnostic;
687
+ ms: number;
688
+ requestId: string;
689
+ route: string;
690
+ status: number;
691
+ type: 'request';
692
+ }
693
+ type ArchiveScheduledEvent =
694
+ | { removed: number; type: 'scheduled'; status: 'ok' }
695
+ | { type: 'scheduled'; status: 'failed'; diagnostic: ArchiveDiagnostic };
696
+ type ArchiveEvent =
697
+ | ArchiveRequestEvent
698
+ | ArchiveScheduledEvent
699
+ | { readonly type: 'cache-error'; readonly diagnostic: ArchiveDiagnostic };
700
+ interface ArchiveWorkerHandler {
701
+ fetch(request: Request, env: E, ctx: ArchiveExecutionContext): Promise;
702
+ scheduled?(controller: unknown, env: E, ctx: ArchiveExecutionContext): void | Promise;
703
+ tap(handler: (event: ArchiveEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;
704
+ }
705
+ ```
706
+
707
+ Scheduled `removed` counts the combined expired retry-key and revocation rows.
708
+
709
+ Auth subpath types describe the sibling issuer and its required service binding.
710
+
711
+ ```ts
712
+ interface SendEmail {
713
+ send(message: { from: string; html?: string; subject: string; text: string; to: string }): Promise;
714
+ }
715
+ interface AuthArchiveBinding {
716
+ fetch(request: Request): Promise;
717
+ }
718
+ interface AuthWorkerEnv {
719
+ ALLOWED_ORIGINS: string;
720
+ APP_ENV: string;
721
+ APP_NAME: string;
722
+ AUDIENCE: string;
723
+ AUTH_ARCHIVE: AuthArchiveBinding;
724
+ AUTH_DB: D1Like;
725
+ AUTH_EMAIL: SendEmail;
726
+ AUTH_ISSUER: string;
727
+ AUTH_JWT_PREVIOUS_PUBLIC_JWKS?: string;
728
+ AUTH_JWT_PRIVATE_JWK: string;
729
+ OPERATOR_EMAILS: string;
730
+ PUBLIC_ORIGIN: string;
731
+ SENDER_ADDRESS: string;
732
+ }
733
+ interface AuthWorkerHandler {
734
+ fetch(request: Request, env: E, ctx?: ArchiveExecutionContext): Promise;
735
+ scheduled(controller: unknown, env: E, ctx?: ArchiveExecutionContext): Promise;
736
+ tap(handler: (event: AuthEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;
737
+ }
738
+ type AuthDiagnostic = {
739
+ readonly kind: 'configuration' | 'database' | 'email' | 'catalog' | 'internal';
740
+ };
741
+ type AuthEvent =
742
+ | {
743
+ readonly type: 'request';
744
+ readonly requestId: string;
745
+ readonly route:
746
+ | '/auth/account'
747
+ | '/auth/health'
748
+ | '/auth/jwks.json'
749
+ | '/auth/session'
750
+ | '/auth/sign-in'
751
+ | '/auth/sign-out-all'
752
+ | '/auth/verify'
753
+ | 'unmatched';
754
+ readonly status: number;
755
+ readonly code: ArchiveErrorCode | 'ok';
756
+ readonly ms: number;
757
+ readonly diagnostic?: AuthDiagnostic;
758
+ }
759
+ | { readonly type: 'scheduled'; readonly status: 'ok'; readonly removed: number }
760
+ | { readonly type: 'scheduled'; readonly status: 'failed'; readonly diagnostic: AuthDiagnostic };
761
+ ```
762
+
763
+ Issuer scheduled `removed` counts expired token rows, closed per-user send windows
764
+ and deleted unverified accounts. The singleton admission row is retained and its
765
+ window resets atomically on a later sign-in request.
766
+
767
+ ## Errors
768
+
769
+ | Exported class | Trigger | Properties |
770
+ | --- | --- | --- |
771
+ | `ArchiveError` | Base for public package errors | `name`, `message`, optional `cause` |
772
+ | `ArchiveDefinitionError` | Invalid collection definition or duplicate registered collection names | Extends `ArchiveError` |
773
+ | `ArchiveDerivationError` | Unexpected schema/canonicalization/index/hash fault or violated collection invariant | Extends `ArchiveError`; readonly `stage: ArchiveDerivationStage`; optional `cause` |
774
+
775
+ `ArchiveError` and `ArchiveDefinitionError` accept `(message: string, opts?: ErrorOptions)`.
776
+ `ArchiveDerivationError` accepts `(stage: ArchiveDerivationStage, message: string, opts?: ErrorOptions)`.
777
+ Its message and cause are local diagnostics, not response content. Worker request
778
+ failures use the wire envelope, not these thrown classes. Internal `ApiError` and
779
+ `AuthOperationError` are not exported.
780
+
781
+ Stored entries that fail the current wire schema return 503 `record-unavailable`.
782
+ Stored payloads are canonical by construction, so the emitted entry schemas embed
783
+ the plain payload schema: parsing a response re-runs that schema, and an
784
+ exception from one of its callbacks becomes a sanitized 500.
785
+
786
+ The complete public error signatures are:
787
+
788
+ ```ts
789
+ class ArchiveError extends Error {
790
+ constructor(message: string, opts?: ErrorOptions);
791
+ }
792
+ class ArchiveDefinitionError extends ArchiveError {}
793
+ class ArchiveDerivationError extends ArchiveError {
794
+ readonly stage: ArchiveDerivationStage;
795
+ constructor(stage: ArchiveDerivationStage, message: string, opts?: ErrorOptions);
796
+ }
797
+ ```
798
+
799
+ Catalog 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`.
800
+
801
+ ### Usage Guide
802
+
803
+ ## Basic Usage
804
+
805
+ A deployment is a collection definition plus one factory call. Put the definition in a
806
+ module both sides import — the browser needs the composed schemas, the Worker needs
807
+ the collection itself.
808
+
809
+ ```ts
810
+ // recipes-collection.ts — the one content contract, shared by both sides.
811
+ import { defineArchiveCollection } from '@vielzeug/archive';
812
+ import { s } from '@vielzeug/spell';
813
+
814
+ const payloadSchema = s.object({
815
+ cuisine: s.enum(['italian', 'japanese']),
816
+ ingredients: s.array(s.string().trim().min(1).max(64)).min(1).max(20),
817
+ name: s.string().trim().min(1).max(32),
818
+ });
819
+
820
+ export const recipesCollection = defineArchiveCollection({
821
+ // Ingredient order is not identity: the canonical form sorts, so reordered
822
+ // content hashes identically and updates as a no-op.
823
+ canonicalize: (payload) => ({ ...payload, ingredients: [...payload.ingredients].sort() }),
824
+ contentVersion: 1,
825
+ filters: ['italian', 'japanese'],
826
+ index: (payload) => ({
827
+ filter: payload.cuisine,
828
+ tags: [payload.ingredients.length console.log(JSON.stringify(event)));
829
+
830
+ export default worker;
831
+ ```
832
+
833
+ The browser imports the same collection for its schemas — never the worker subpath:
834
+
835
+ ```ts
836
+ import { recipesCollection } from './recipes-collection';
837
+ import { archiveIdentitySchema, PUBLISHER_HEADER } from '@vielzeug/archive';
838
+
839
+ const response = await fetch('/api/v1/collections/recipes/entries');
840
+ if (!response.ok) throw new Error('Archive unavailable');
841
+ const page = recipesCollection.contract.page.parse(await response.json());
842
+ const meResponse = await fetch('/api/v1/me', { credentials: 'same-origin' });
843
+ if (!meResponse.ok) throw new Error('Identity unavailable');
844
+ const me = archiveIdentitySchema.parse(await meResponse.json());
845
+ if (me === null) throw new Error('Register a publisher first');
846
+ const mineResponse = await fetch('/api/v1/me/publications?collection=recipes', {
847
+ credentials: 'same-origin', headers: { [PUBLISHER_HEADER]: me.id },
848
+ });
849
+ if (!mineResponse.ok) throw new Error('Own publications unavailable');
850
+ const owned = recipesCollection.contract.ownedEntries.parse(await mineResponse.json());
851
+ console.log(page.entries, owned.entries);
852
+ ```
853
+
854
+ ## The collection contract
855
+
856
+ `defineArchiveCollection` is the only place content knowledge lives. The definition is
857
+ validated at registration — a bad `name`, `contentVersion`, or any vocabulary value
858
+ (well-formed text, 1..`vocabChars` chars) throws there, never mid-request — and every
859
+ ordinary invalid content returns `null` → `422 invalid-payload`. Collection
860
+ implementation faults instead throw `ArchiveDerivationError` → sanitized
861
+ `500 internal`:
862
+
863
+ - **Parse** — accept stable JSON objects only; schema outputs must remain JSON.
864
+ - **Canonicalize** — optional; a result that leaves the payload schema throws, so a
865
+ broken normalizer can never store invalid content.
866
+ - **Index** — the returned `filter` must be declared in `filters`, every `tag` in
867
+ `tags` (duplicates collapse), and the `title` is trimmed and bounded
868
+ (`titleChars`, well-formed text). A derivation outside the declared vocabulary
869
+ would store a row no list query can reach — it throws instead.
870
+ - **Size** — the canonical JSON must fit `payloadBytes`.
871
+ - **Identity** — the content hash is SHA-256 over `{contentVersion, payload}`:
872
+ identical content hashes identically, a real edit always re-identifies, and the
873
+ core owns the envelope so collections cannot drift it.
874
+
875
+ Declare vocabularies with readonly arrays (`['fire', 'ice']`) for automatic
876
+ literal-union inference. Const generics retain the version literal and vocabulary
877
+ unions; `NoInfer` prevents the index callback from widening them. Omitted arrays
878
+ mean empty vocabularies: return `null` for
879
+ the filter and no tags. Object keys are recursively sorted by the core; schema
880
+ transformations must stabilize and `canonicalize` must be pure and idempotent.
881
+ Non-JSON input returns `null`. Unexpected callback, schema or hashing failures,
882
+ unstable transformations, non-idempotent canonicalization and index mutation throw
883
+ `ArchiveDerivationError`; its `stage` identifies the failing phase.
884
+ Lossy or non-JSON schema output and invalid index-result shapes also throw.
885
+
886
+ `contentVersion` participates in content identity. Request handling validates
887
+ only the current collection definition; it does not interpret historical schemas.
888
+ Archive provides no content-upgrade APIs: a new definition never reinterprets
889
+ stored rows.
890
+
891
+ Filters, tags and searchable titles are persisted data derived at publish/update
892
+ time. Editing `index` or its vocabularies does not recompute existing projections.
893
+ Keep the active definition and dataset consistent. For a development
894
+ deployment, deliberately reset and reseed a fresh database when you change payload
895
+ or projection semantics; the package does not supply a migration framework.
896
+ Resetting destroys existing data. Perform it only as an explicit, deliberate
897
+ development operation, not automatically during startup or deployment.
898
+
899
+ **Payloads carry decisions, not content.** The payload schema should accept
900
+ user-authored choices expressed as IDs plus user-written text — never the content's
901
+ own copyrighted expression (card text, artwork, rules), never more than the game's
902
+ own identifiers. For games with paid or DLC content this is the whole answer:
903
+ ownership resolves on each viewer's device against their own library, the service
904
+ never learns what anyone owns, and the strict schema is the enforcement seam —
905
+ use a strict schema that rejects unknown fields, so a collection that only models IDs cannot
906
+ accidentally start shipping paid content. For example, published game builds can
907
+ reference cards, equipment and potions by ID plus a user-written name. The client
908
+ renders pieces the viewer does not own as unavailable. Keep the browser-safe
909
+ publication contract in a shared domain module consumed by both the frontend and
910
+ its backend, without importing frontend application code into the Worker.
911
+
912
+ ## The Worker and its environment
913
+
914
+ `createArchiveWorker(collections)` returns the full handler: anonymous public reads,
915
+ cookie-authenticated mutations behind exact-origin and request-marker guards, local
916
+ RS256/JWKS verification followed by an uncached issuer session-version check,
917
+ per-IP read limits and per-subject write limits keyed by peppered HMACs, the
918
+ 30-second anonymous cache on public entry lists only, operator moderation, and a
919
+ scheduled expiry sweep for retired publish retry keys and revocation tombstones.
920
+ Health sits outside the rate-limit and session pipelines: probes never burn the
921
+ anonymous read budget or call the issuer.
922
+
923
+ The Worker reads its environment fail-closed — every binding must be present and
924
+ well-formed, or the route answers 503 without naming what is missing:
925
+
926
+ | Variable | Meaning |
927
+ | ------------------------ | ---------------------------------------------------------------- |
928
+ | `APP_ENV` | `local` or `production` (transport rules and the pepper policy) |
929
+ | `ALLOWED_ORIGINS` | Exact canonical origins; never wildcards |
930
+ | `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | Your identity provider |
931
+ | `AUTH_ISSUER_SERVICE` | Required issuer service binding for live session-version checks |
932
+ | `LIKES_ENABLED` / `PUBLISHING_ENABLED` | Feature flags |
933
+ | `RATE_LIMIT_KEY_SECRET` | HMAC pepper. Required ≥ 32 chars in production; local may fall back |
934
+ | `DB` / `READ_LIMIT` / `WRITE_LIMIT` | D1 + rate-limit bindings |
935
+
936
+ Both limiter bindings must run 60-second windows (`simple` limiters:
937
+ `period: 60`): the platform binding does not expose its period at runtime, so the
938
+ Worker's 429 `Retry-After` header is fixed at 60 seconds — a longer window would
939
+ make the header lie.
940
+
941
+ Generated Env types (wrangler's `worker-configuration.d.ts`) satisfy the package's
942
+ structural `ArchiveWorkerEnv` — no `@cloudflare/workers-types` dependency, no casts.
943
+
944
+ ## The auth issuer
945
+
946
+ `createAuthWorker()` from `@vielzeug/archive/auth` is the reference passwordless
947
+ identity issuer a deployment runs beside the catalog Worker: one POST with an email
948
+ sends a single-use magic link, and explicitly confirming it sets the `archive_session`
949
+ cookie. The catalog verifies the JWT locally, then calls the issuer through
950
+ `AUTH_ISSUER_SERVICE` to check the account's current session version. Requests create
951
+ pending account rows; explicit link redemption atomically stamps the account's
952
+ sign-in time and claims the token. The issuer's D1 schema ships at
953
+ `node_modules/@vielzeug/archive/migrations/auth/`; point the issuer's
954
+ `migrations_dir` there. The catalog's `JWT_ISSUER` and `JWT_JWKS_URL` must identify
955
+ the issuer, and its audience must match the issuer's `AUDIENCE`.
956
+
957
+ | Variable | Meaning |
958
+ | --- | --- |
959
+ | `APP_NAME` | The app's display name: the magic-link email's subject and copy name it |
960
+ | `ALLOWED_ORIGINS` | Exact origins allowed to trigger unsafe routes (the app origin(s)) |
961
+ | `APP_ENV` | `local` or `production` — production requires HTTPS and sets Secure cookies |
962
+ | `AUDIENCE` / `AUTH_ISSUER` | The claims every minted session carries; the catalog verifies the same pair |
963
+ | `AUTH_DB` / `AUTH_EMAIL` | The issuer's D1 and send-email bindings |
964
+ | `AUTH_ARCHIVE` | Required service binding to the catalog Worker; deletion calls its profile-erasure route |
965
+ | `AUTH_JWT_PRIVATE_JWK` | The RS256 private key as a JWK JSON string: a wrangler secret in production |
966
+ | `AUTH_JWT_PREVIOUS_PUBLIC_JWKS` | Optional retired public RS256 JWKS; publish during rotation until old sessions expire |
967
+ | `OPERATOR_EMAILS` | Comma-separated emails whose newly minted sessions carry the operator role |
968
+ | `PUBLIC_ORIGIN` | App origin for links, confirmation POST origin checks and client navigation after sign-in |
969
+ | `SENDER_ADDRESS` | The address magic links come from: a domain onboarded to Cloudflare Email Service |
970
+
971
+ Under `wrangler dev` the send-email binding is simulated — the link prints to the
972
+ issuer's console, so that console is the inbox. Demo-only Alice/Bob routes are
973
+ deployment wrappers, not package APIs.
974
+
975
+ The issuer first admits sign-in requests through its singleton durable
976
+ `auth_sign_in_budget` table: 20 requests per 60 seconds before creating accounts
977
+ or delivering email. No rate-limiter binding is required. It then
978
+ permits three sign-in emails per email in an hourly durable window.
979
+ Later database or delivery failures do not refund global admission.
980
+ Using or expiring a link never refunds sends; failed delivery also spends the
981
+ allowance. Links expire after 15 minutes. Operator sessions expire after 900
982
+ seconds, ordinary sessions after at most 30 days. Retain retired public keys during
983
+ rotation; the active private key signs every new session. Sign-in input trims and
984
+ lowercases email before validation. `SENDER_ADDRESS` must already be canonical;
985
+ `OPERATOR_EMAILS` entries are trimmed and lowercased before validation.
986
+
987
+ The scheduled sweep deletes expired links and closed per-user send windows, then
988
+ pending accounts only when neither a link nor send-budget row still references
989
+ them. Failed delivery deletes the unusable token but retains the spent allowance;
990
+ it does not immediately remove the pending account. Verified accounts stay until
991
+ explicit erasure. The singleton admission row remains and resets its window on a
992
+ later admitted request.
993
+
994
+ Opening `GET /auth/verify?token=…` renders a no-store confirmation page and does
995
+ not consume the link or change the session, so email scanners cannot sign in by
996
+ fetching it. A deliberate click sends `POST /auth/verify` with JSON `{ token }`,
997
+ the request marker and an exact allowed origin or `PUBLIC_ORIGIN`.
998
+ Success is 204 with Set-Cookie; the page then navigates to `/signed-in`. The
999
+ redemption update and account sign-in stamp are trigger-backed and atomic.
1000
+ The page removes the token from the address bar, loads no external assets, and
1001
+ sets a restrictive CSP and `Referrer-Policy: no-referrer`.
1002
+
1003
+ `GET /auth/account` exports account metadata only. For full erasure, call
1004
+ `DELETE /auth/account` with `ACCOUNT_HEADER` (`X-Archive-Account`) set to the
1005
+ issuer account UUID captured from `GET /auth/account` before confirmation.
1006
+ The issuer rejects missing or invalid UUIDs with 400 and a different signed-in
1007
+ account with 409 `account-changed`, before any erasure. This guard applies even
1008
+ before publisher registration and must stay pinned across retries.
1009
+ The issuer forwards the caller's session and publisher
1010
+ precondition through `AUTH_ARCHIVE` to `DELETE /me/account`, requires exactly 204,
1011
+ then deletes its
1012
+ own user and clears the cookie. Failure preserves the session for retry. Direct
1013
+ `DELETE /me` removes only the Archive profile and leaves the cookie intact.
1014
+
1015
+ Several game frontends can share one issuer and catalog. Keep the cookie
1016
+ host-only on the central API host, use `credentials: 'include'` from same-site
1017
+ HTTPS game subdomains, and allow each exact frontend origin in both Workers.
1018
+ Do not share cookies through a parent-domain `Domain` attribute. The
1019
+ [multi-collection recipe](./examples/multi-collection-deployment.md) describes
1020
+ the topology, session revalidation and platform-wide account boundary.
1021
+
1022
+ `DELETE /me/account` atomically revokes the issuer/subject and cascades profile
1023
+ deletion. Its tombstone lasts 30 days plus ten seconds; old sessions cannot read
1024
+ private data or register again, including a racing registration. The dedicated
1025
+ account-erasure route alone may bypass the issuer lookup, and only to retry with
1026
+ the same principal's original signed session while its existing tombstone is
1027
+ valid; signature and claims are still verified, and initial erasure validates the
1028
+ issuer. The issuer's `DELETE /auth/account` checks the current version before
1029
+ starting erasure; after completed erasure a retry there returns 401. A fresh
1030
+ issuer account gets a new UUID subject. External issuers must use a new subject to
1031
+ re-enroll during the revocation window. Profile-only deletion does not revoke the
1032
+ subject and permits re-registration with existing credentials.
1033
+
1034
+ Each JWT also carries the issuer account's required safe-integer `sessionVersion`.
1035
+ The issuer compares it with `auth_users.session_version` on every ordinary
1036
+ authenticated route. A JWT with an old version is unauthenticated even before its
1037
+ expiry; the catalog asks the issuer on every private request and does not cache a
1038
+ successful verdict. A valid local JWT with an unavailable, malformed, 5xx or
1039
+ timed-out (fixed 5000 ms, including body decoding) issuer response becomes
1040
+ `503 auth-unavailable`, not anonymous identity.
1041
+
1042
+ ## The database
1043
+
1044
+ The package ships the canonical catalog migration:
1045
+ `node_modules/@vielzeug/archive/migrations/0001_community_archive.sql` — the whole schema,
1046
+ moderation included, as one designed file. The runtime SQL in the package is
1047
+ written against exactly this schema, and the package's own integration suite runs
1048
+ against the same file. Point wrangler at the directory:
1049
+
1050
+ ```jsonc
1051
+ // wrangler.jsonc — the deployment owns its D1 database, not its DDL.
1052
+ {
1053
+ "d1_databases": [
1054
+ {
1055
+ "binding": "DB",
1056
+ "database_name": "my-app",
1057
+ "migrations_dir": "./node_modules/@vielzeug/archive/migrations"
1058
+ }
1059
+ ]
1060
+ }
1061
+ ```
1062
+
1063
+ The issuer points to
1064
+ `node_modules/@vielzeug/archive/migrations/auth/0001_auth.sql`. Both files are
1065
+ fresh-database initialization definitions, not upgrades. The issuer DDL initializes
1066
+ `auth_users.session_version` to `0` and stamps `auth_schema_meta.applied` as
1067
+ `AUTH_SCHEMA_VERSION` (`1`). Neither file automatically deletes or resets data.
1068
+ Archive's canonical initial schemas are edited directly. Reset and reseed a
1069
+ development database deliberately when its schema or collection semantics no
1070
+ longer match; reapplying the initial file does not update an existing dataset.
1071
+ The initialization file also stamps the storage schema
1072
+ (`archive_schema_meta`) at `ARCHIVE_SCHEMA_VERSION`, and health reads it back: a
1073
+ database with a mismatched stamp answers `status: 'degraded'` with the
1074
+ observed `dbSchemaVersion` and HTTP 503 (`reason: 'schema-mismatch'`).
1075
+ An unavailable database produces HTTP 503 with `dbSchemaVersion: null` and
1076
+ `reason: 'database-unavailable'`. The debut storage stamp is `1`. A unit
1077
+ test pins the shipped DDL's `CHECK` bounds to `ARCHIVE_LIMITS`, so the two can never
1078
+ drift silently. The stamp does not detect changes in your collection's payload
1079
+ schema, vocabulary or indexing function; you own that dataset consistency.
1080
+
1081
+ ## Routes and wire names
1082
+
1083
+ ```
1084
+ GET /api/v1/health
1085
+ POST /api/v1/sign-out
1086
+ GET /api/v1/auth/health
1087
+ GET /api/v1/auth/session (internal issuer check)
1088
+ POST /api/v1/auth/sign-out-all (revoke every session)
1089
+ GET /api/v1/collections/:collection (self-description)
1090
+ GET /api/v1/collections/:collection/entries (author, filter, tag, search, sort, cursor, limit)
1091
+ POST /api/v1/collections/:collection/entries
1092
+ GET /api/v1/collections/:collection/entries/:id
1093
+ PUT /api/v1/collections/:collection/entries/:id (expectedRevision)
1094
+ DELETE /api/v1/collections/:collection/entries/:id
1095
+ PUT /api/v1/collections/:collection/entries/:id/like
1096
+ DELETE /api/v1/collections/:collection/entries/:id/like
1097
+ POST /api/v1/collections/:collection/entries/:id/report (reason)
1098
+ PUT /api/v1/collections/:collection/entries/:id/hidden (operator)
1099
+ DELETE /api/v1/collections/:collection/entries/:id/hidden (operator)
1100
+ GET /api/v1/collections/:collection/reports (operator: status, cursor, limit)
1101
+ PUT /api/v1/collections/:collection/reports/:id/resolved (operator: report ID)
1102
+ DELETE /api/v1/publishers/:id (operator: erase any publisher)
1103
+ GET /api/v1/moderation-log (operator: cursor, limit)
1104
+ GET /api/v1/me POST /me PUT /me DELETE /me
1105
+ GET /api/v1/me/publications?collection=:collection GET /api/v1/me/likes[?ids=…]
1106
+ GET /api/v1/me/reports (cursor, limit)
1107
+ GET /api/v1/auth/account
1108
+ GET /api/v1/auth/jwks.json
1109
+ POST /api/v1/auth/sign-in (email)
1110
+ DELETE /api/v1/auth/account (coordinated full erasure)
1111
+ DELETE /api/v1/me/account (issuer erasure seam; revokes subject)
1112
+ GET /api/v1/auth/verify?token=… (confirmation only)
1113
+ POST /api/v1/auth/verify (guarded {token} redemption)
1114
+ ```
1115
+
1116
+ Publishing is bounded by `maxPublications` live entries per `(collection, owner)`.
1117
+ The quota check runs inside the guarded insert.
1118
+
1119
+ Every list — entry pages, `/me/likes` without `ids`, `/me/reports`, operator
1120
+ reports and `/moderation-log` — pages forward-only: follow `cursor` until `null`,
1121
+ preserving query context; a cursor minted for another query is a 400. `limit`
1122
+ defaults to `pageSize` and is at most `maxPageSize`; there is no total export
1123
+ bound. `/me/likes?ids=…` remains a subset lookup returning `{ ids }`, not a
1124
+ page, and cannot combine `ids` with `cursor` or `limit`.
1125
+
1126
+ Shared wire names live in the package — `archive_session` cookie,
1127
+ `x-archive-publisher` and `x-archive-request` headers, `ARCHIVE_ERROR_CODES`, and
1128
+ `ARCHIVE_LIMITS` — so a client and the Worker cannot drift. Parse failures with the
1129
+ collection's composed schemas; parse errors with `archiveErrorResponseSchema`, the
1130
+ sign-out body with `archiveOkSchema`, a collection's self-description with
1131
+ `archiveCollectionMetaSchema` and the operator queue with `archiveReportsSchema`.
1132
+ Every entry's `schemaVersion` is the archive contract's version, not the
1133
+ collection's; the collection's own format version lives in its hash envelope and in
1134
+ each entry's `contentVersion`.
1135
+
1136
+ Only anonymous entry lists carry a 30-second edge-cache contract
1137
+ (`publicCacheSeconds`). Detail is always `no-store`. Any Cookie or Authorization
1138
+ header disables caching, regardless of validity; metadata, private and error
1139
+ responses also use `no-store`.
1140
+
1141
+ Implement UI ownership checks in your application by comparing the current
1142
+ publisher ID to the entry's `author.id`; there is no package action-policy helper.
1143
+ Server mutations remain authoritative.
1144
+
1145
+ `POST /api/v1/sign-out` is deliberately local: it clears only this browser's
1146
+ cookie and does not contact the issuer or revoke copied sessions. To revoke every
1147
+ session, post to `/api/v1/auth/sign-out-all` with the mutation marker, an exact
1148
+ allowed `Origin`, and the issuer account UUID captured from
1149
+ `GET /api/v1/auth/account` in `X-Archive-Account`. The issuer returns 409
1150
+ `account-changed` if the active subject differs. On success it atomically increments
1151
+ `session_version`, deletes outstanding links, preserves account and catalog data,
1152
+ and returns `{ ok: true }` with an expired cookie. Requests authorized before
1153
+ revocation may finish; there is no cross-database cancellation guarantee.
1154
+
1155
+ `GET /api/v1/auth/health` is public and read-only. It validates issuer
1156
+ configuration, key signing/verification, binding shapes, the `auth_schema_meta`
1157
+ stamp and expected tables, columns and triggers, returns `AuthHealth` through
1158
+ `archiveAuthHealthSchema`, and answers 503 `degraded` for configuration,
1159
+ database or schema failures. It never writes, sends email or probes the catalog.
1160
+
1161
+ ## Moderation
1162
+
1163
+ Operator authority is the session token's optional `roles` claim carrying
1164
+ `ARCHIVE_OPERATOR_ROLE` (`'archive-operator'`) — minted by your issuer, verified with
1165
+ the same signature, audience and issuer checks as any session, and never a separate
1166
+ credential. Moderation routes additionally require a registered publisher row: one
1167
+ accountable identity per action.
1168
+
1169
+ - **Report** — any registered publisher reports a visible entry with a bounded
1170
+ free-text reason (`reportReasonChars`). Idempotent per (entry, reporter): the same
1171
+ report twice is the same 204. Missing or hidden entries answer 404 — there is
1172
+ nothing left to report.
1173
+ - **Hide** — an operator removes an entry from every public surface (list, detail,
1174
+ likes) with a soft-hide: the row, its likes and its retry key stay intact. The
1175
+ owner still sees the entry in `/me/publications` with `hidden: true`, edits and
1176
+ key replay are frozen (403), removal stays allowed, and unhiding restores
1177
+ everything. Already hidden or already visible answer the same 204.
1178
+ - **Queue** — the operator pages newest reports with entry title and reporter
1179
+ identity; `status` is `unresolved` (default) or `resolved`. Each report has its
1180
+ own `id` and nullable `resolvedAt`. Resolve it with
1181
+ `PUT /collections/:collection/reports/:id/resolved`; repeating is a 204 no-op.
1182
+ - **Log** — every effective moderation action (hide, unhide, removal, publisher
1183
+ erasure) appends one accountability row in the same transaction that applies it,
1184
+ and `GET /api/v1/moderation-log` pages the newest actions to
1185
+ operators. Rows carry only ids, the action kind and the time — no names (they
1186
+ change), no content — and no foreign keys: the audit must outlive the erasures it
1187
+ records. Idempotent repeats (hiding an already-hidden entry, erasing an
1188
+ already-erased publisher) answer the same 204 with no new row, including
1189
+ concurrent repeats: audit insertion tests the effective state inside the batch.
1190
+
1191
+ Like and unlike both require a visible entry. Their SQL guards visibility during
1192
+ the mutation, and the response checks it again; hiding or deleting an entry
1193
+ cannot turn a racing unlike into a successful response for unavailable content.
1194
+
1195
+ ## Observability
1196
+
1197
+ The core never logs. `worker.tap(handler, { signal? })` emits one `ArchiveEvent` per
1198
+ request — `{ type: 'request', code, ms, requestId, route, status, diagnostic? }` —
1199
+ and successful sweep — `{ type: 'scheduled', status: 'ok', removed }`. Failed
1200
+ sweeps emit `{ type: 'scheduled', status: 'failed', diagnostic }` and rethrow so
1201
+ the runtime sees failure. Cache failures emit `{ type: 'cache-error', diagnostic }`.
1202
+ The Worker swallows observer errors so
1203
+ observability cannot affect the Worker. With no handlers registered there is zero
1204
+ work. Route is a finite class like `/collections/:collection/entries`, never a
1205
+ raw path; no event carries a URL, cookie, subject, alias or token. Diagnostics are
1206
+ typed sanitized categories, not raw exceptions. A pre-aborted tap signal is inert.
1207
+ The sweep's `removed` counts expired retry keys and revocation tombstones together.
1208
+
1209
+ The issuer also exposes `tap()` with `AuthEvent` from `@vielzeug/archive/auth`.
1210
+ It never logs directly. Events contain finite route labels, codes, counts,
1211
+ duration and sanitized `AuthDiagnostic` categories, never emails, tokens or raw
1212
+ exceptions. Scheduled failures emit then rethrow; pre-aborted taps are inert.
1213
+ Issuer sweep counts include expired tokens, closed per-user send windows and
1214
+ removed pending accounts; the singleton admission row is not removed.
1215
+
1216
+ ```ts
1217
+ import { createLogger } from '@vielzeug/rune';
1218
+ import { createArchiveWorker } from '@vielzeug/archive/worker';
1219
+ import { recipesCollection } from './recipes-collection';
1220
+
1221
+ const log = createLogger({ namespace: 'recipes' });
1222
+ const worker = createArchiveWorker([recipesCollection]);
1223
+ worker.tap((event) => log.debug(event, `archive:${event.type}`));
1224
+ export default worker;
1225
+ ```
1226
+
1227
+ ## Testing
1228
+
1229
+ The package proves itself against real workerd + local D1:
1230
+
1231
+ ```sh
1232
+ pnpm vitest run packages/archive/src/__tests__/ # fast unit suite
1233
+ pnpm --filter @vielzeug/archive test:integration # real workerd + local D1 + issuer coverage
1234
+ pnpm --filter @vielzeug/archive test:types # strict types over src and the integration suite
1235
+ ```
1236
+
1237
+ Prove your own collection the way the reference deployment does: a slim deployment
1238
+ suite that publishes over your real vocabulary and asserts the derivation, plus the
1239
+ package's generic suite for the core behavior. A collection with an `index` that can
1240
+ drift out of vocabulary should unit-test `derive` directly: ordinary invalid input
1241
+ returns `null`, while implementation faults throw `ArchiveDerivationError`.
1242
+ Direct DDL tests exercise the shipped schema rather than a duplicate schema.
1243
+
1244
+ ## Framework Integration
1245
+
1246
+ The root entry is plain TypeScript with no framework coupling; Vue, React and Svelte
1247
+ apps all consume the composed schemas the same way. A Vue 3 composable that pages a
1248
+ collection:
1249
+
1250
+ ```ts
1251
+ // useRecipes.ts — one collection's typed client in any component tree.
1252
+ import { recipesCollection } from './recipes-collection';
1253
+ import { ref, shallowRef } from 'vue';
1254
+
1255
+ export function useRecipes() {
1256
+ const entries = shallowRef[]>([]);
1257
+ const cursor = ref(null);
1258
+ async function load(sort: 'liked' | 'recent') {
1259
+ const params = new URLSearchParams({ sort });
1260
+ if (cursor.value !== null) params.set('cursor', cursor.value);
1261
+ const response = await fetch(`/api/v1/collections/recipes/entries?${params}`);
1262
+ if (!response.ok) throw new Error('The archive is unavailable.');
1263
+ const page = recipesCollection.contract.page.parse(await response.json());
1264
+ entries.value = [...entries.value, ...page.entries];
1265
+ cursor.value = page.cursor;
1266
+ }
1267
+ return { cursor, entries, load };
1268
+ }
1269
+ ```
1270
+
1271
+ ## Working with Other Vielzeug Libraries
1272
+
1273
+ - [Spell](/spell/) — the payload schema a collection declares; the core composes the
1274
+ envelope schemas from it, so the wire and the types cannot drift.
1275
+ - [Courier](/courier/) — the HTTP client shape an app's frontend mirrors to consume
1276
+ these routes: strict response schemas over one typed port.
1277
+ - [Rune](/rune/) — the structured logger a deployment points `tap()` events at.
1278
+
1279
+ ## Best Practices
1280
+
1281
+ - Compose client schemas from `collection.contract`; never hand-write the envelope.
1282
+ - Declare exactly the vocabulary your `index` derives, with union types where you
1283
+ have them; a value outside it rejects loudly.
1284
+ - Keep `canonicalize` pure and schema-true: reordered lists in, canonical form out.
1285
+ - Initialize fresh databases with the shipped DDL and deliberately reset/reseed
1286
+ development data when collection or storage semantics change.
1287
+ - Treat `ARCHIVE_ERROR_CODES` and `archiveErrorResponseSchema` as one tested contract.
1288
+ - Pin the expected-publisher header from the identity you captured at sign-in, never from storage alone.
1289
+ - Grant the operator role claim only through your issuer, and only to accounts that
1290
+ are also registered publishers.
1291
+ - Wire a sanitized `tap()` sink and register the rate-limit pepper as a production secret.
1292
+
1293
+ ### Examples
1294
+
1295
+ - [Stand Up a Content Archive](./examples/stand-up-a-content-archive.md)
1296
+ - [Content Identity and Versions](./examples/content-identity-and-versions.md)
1297
+ - [Multi-Collection Deployment](./examples/multi-collection-deployment.md)
1298
+ - [Moderate Content](./examples/moderate-content.md)
1299
+ - [Client Error Mapping](./examples/client-error-mapping.md)
1300
+ - [GDPR Erasure and Export](./examples/gdpr-erasure-and-export.md)
4
1301
 
5
1302
  ---
6
1303
 
@@ -119,6 +1416,8 @@ console.log(byRole, health, profile);
119
1416
  | `allocate` | Lossless proportional distribution | Sync | Available from `/math` |
120
1417
  | `isEqual` | Structural equality | Sync | Root export |
121
1418
  | `hash` | Deterministic serialization for cache keys | Sync | Available from `/object` |
1419
+ | `compressBytes` / `decompressBytes` | Native byte compression with bounded decompression | Async | Requires native compression streams; decoding requires an output limit |
1420
+ | `pack` / `unpack` | Versioned, compressed, URL-safe payload envelope | Async | Both sides bound the payload with `maxBytes`; compression falls back to raw |
122
1421
 
123
1422
  ## Package Entry Points
124
1423
 
@@ -128,6 +1427,7 @@ console.log(byRole, health, profile);
128
1427
  | `@vielzeug/arsenal/array` | Immutable transforms, set operations, sorting, fuzzy search |
129
1428
  | `@vielzeug/arsenal/async` | Retry, cancellation, task pool, timing |
130
1429
  | `@vielzeug/arsenal/cache` | In-memory cache and memoization |
1430
+ | `@vielzeug/arsenal/encoding` | Base64url and native byte compression |
131
1431
  | `@vielzeug/arsenal/function` | Assertions, composition, timing, teardown |
132
1432
  | `@vielzeug/arsenal/guards` | Type guards, predicate combinators, equality guards |
133
1433
  | `@vielzeug/arsenal/math` | Ranges, aggregation, statistics, interpolation, exact allocation |
@@ -245,6 +1545,101 @@ base64UrlToBytes(code); // throws on characters outside the alphabet
245
1545
  utf8Bytes('Hunter äöü'); // 11: byte length of a UTF-8 string
246
1546
  ```
247
1547
 
1548
+ ### compressBytes
1549
+
1550
+ ```ts
1551
+ compressBytes(bytes: Uint8Array, options?: CompressionOptions): Promise
1552
+ ```
1553
+
1554
+ Compresses bytes using the native `CompressionStream` API without modifying the input.
1555
+
1556
+ | Option | Type | Default | Meaning |
1557
+ | --- | --- | --- | --- |
1558
+ | `format` | `CompressionFormat` | `'deflate-raw'` | Compression format; the receiver must use the same format |
1559
+
1560
+ **Returns:** Compressed bytes. Rejects with `ArsenalSerializationError` when the format or capability is unavailable, or stream processing fails. Does not fall back to another format.
1561
+
1562
+ **Example:** Encode compressed bytes for a URL.
1563
+
1564
+ ```ts
1565
+ import { bytesToBase64Url, compressBytes } from '@vielzeug/arsenal';
1566
+
1567
+ const bytes = new TextEncoder().encode(JSON.stringify({ name: 'Hunters' }));
1568
+ const code = bytesToBase64Url(await compressBytes(bytes));
1569
+ ```
1570
+
1571
+ ---
1572
+
1573
+ ### decompressBytes
1574
+
1575
+ ```ts
1576
+ decompressBytes(bytes: Uint8Array, options: DecompressionOptions): Promise
1577
+ ```
1578
+
1579
+ Decompresses bytes using the native `DecompressionStream` API, enforcing the output limit while reading chunks.
1580
+
1581
+ | Option | Type | Default | Meaning |
1582
+ | --- | --- | --- | --- |
1583
+ | `format` | `CompressionFormat` | `'deflate-raw'` | Format used by the sender |
1584
+ | `maxOutputBytes` | `number` | Required | Non-negative safe integer; maximum decoded byte length |
1585
+
1586
+ **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.
1587
+
1588
+ **Example:** Decode text within an application-defined byte limit.
1589
+
1590
+ ```ts
1591
+ import { compressBytes, decompressBytes } from '@vielzeug/arsenal';
1592
+
1593
+ const compressed = await compressBytes(new TextEncoder().encode('Hunters'));
1594
+ const bytes = await decompressBytes(compressed, { maxOutputBytes: 8192 });
1595
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
1596
+ ```
1597
+
1598
+ ---
1599
+
1600
+ ### pack
1601
+
1602
+ ```ts
1603
+ pack(payload: unknown, options: ShareCodeOptions): Promise
1604
+ ```
1605
+
1606
+ Serializes `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.
1607
+
1608
+ | Option | Type | Meaning |
1609
+ | --- | --- | --- |
1610
+ | `maxBytes` | `number` | Positive safe integer; maximum serialized payload size |
1611
+ | `prefix` | `string` | 1–8 alphanumeric characters identifying the code family and version |
1612
+
1613
+ **Returns:** The shorter of the raw (`prefix` + `r`) and compressed (`prefix` + `d`) representations. Throws `ShareCodeError` (`too-large`, `invalid`) for oversized payloads and misconfigured options.
1614
+
1615
+ **Example:** Encode a shareable build as a code small enough for a URL.
1616
+
1617
+ ```ts
1618
+ import { pack } from '@vielzeug/arsenal';
1619
+
1620
+ const code = await pack({ cards: ['slash', 'guard'] }, { maxBytes: 8192, prefix: 'ab1' });
1621
+ ```
1622
+
1623
+ ---
1624
+
1625
+ ### unpack
1626
+
1627
+ ```ts
1628
+ unpack(code: string, options: ShareCodeOptions): Promise
1629
+ ```
1630
+
1631
+ Decodes 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.
1632
+
1633
+ **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`.
1634
+
1635
+ **Example:** Decode untrusted input within the byte budget.
1636
+
1637
+ ```ts
1638
+ import { unpack } from '@vielzeug/arsenal';
1639
+
1640
+ const payload = await unpack(code, { maxBytes: 8192, prefix: 'ab1' });
1641
+ ```
1642
+
248
1643
  ## Object
249
1644
 
250
1645
  ### tryParseJson
@@ -327,13 +1722,22 @@ type CacheOptions = {
327
1722
  now?: () => number;
328
1723
  ttlMs?: number;
329
1724
  };
1725
+
1726
+ interface CompressionOptions {
1727
+ format?: CompressionFormat;
1728
+ }
1729
+
1730
+ interface DecompressionOptions extends CompressionOptions {
1731
+ maxOutputBytes: number;
1732
+ }
330
1733
  ```
331
1734
 
332
1735
  ## Errors
333
1736
 
334
1737
  - `RangeError`: invalid numeric bounds, capacity, concurrency, or retry count.
335
1738
  - `TypeError`: invalid value types, unsupported comparison, or required path missing.
336
- - `ArsenalSerializationError`: memo or hash cannot serialize supplied input.
1739
+ - `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`.
1740
+ - `ShareCodeError`: a share code cannot be encoded or decoded; the `reason` field is `invalid`, `too-large`, or `compression-unavailable`.
337
1741
 
338
1742
  ### Usage Guide
339
1743
 
@@ -500,6 +1904,27 @@ const payload = JSON.parse(base64UrlToText(code));
500
1904
 
501
1905
  Decoding throws on characters outside the base64url alphabet, so corrupted input fails loudly instead of decoding to garbage.
502
1906
 
1907
+ ## Compress Share Payloads
1908
+
1909
+ Compress UTF-8 bytes before Base64url encoding. Set a decoded-byte limit before accepting compressed input from another device.
1910
+
1911
+ ```ts
1912
+ import { base64UrlToBytes, bytesToBase64Url, compressBytes, decompressBytes } from '@vielzeug/arsenal';
1913
+
1914
+ const payload = JSON.stringify({ name: 'Hunters', notes: 'An expedition through Alborea. '.repeat(20) });
1915
+ const compressed = await compressBytes(new TextEncoder().encode(payload));
1916
+ const code = bytesToBase64Url(compressed);
1917
+ const bytes = await decompressBytes(base64UrlToBytes(code), { maxOutputBytes: 8192 });
1918
+ const restored = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
1919
+ console.log(restored === payload);
1920
+ ```
1921
+
1922
+ Both 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.
1923
+
1924
+ For a complete versioned envelope over these primitives — prefix, compression mode marker, byte budgets on both sides — see [`pack` / `unpack`](./api.md#pack).
1925
+
1926
+ Compression 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.
1927
+
503
1928
  ## Working with Other Vielzeug Libraries
504
1929
 
505
1930
  Use Spell after `tryParseJson` for typed external data. Use Vault instead of `cache` when data must survive reloads or process restart.
@@ -533,6 +1958,7 @@ const settings = parsed.ok ? Settings.parse(parsed.value) : { theme: 'system' };
533
1958
  - [Array utilities](./examples/array.md)
534
1959
  - [Async utilities](./examples/async.md)
535
1960
  - [Cache utilities](./examples/cache.md)
1961
+ - [Encoding utilities](./examples/encoding.md)
536
1962
  - [Function utilities](./examples/function.md)
537
1963
  - [Guards / Typed predicates](./examples/typed.md)
538
1964
  - [Math utilities](./examples/math.md)
@@ -555,6 +1981,7 @@ const settings = parsed.ok ? Settings.parse(parsed.value) : { theme: 'system' };
555
1981
  - taskPool - Bounded concurrent work (id: `async-queue`)
556
1982
  - retry - Retry failed operations (id: `async-retry`)
557
1983
  - waitFor - Poll until condition is true or timeout/abort fires (id: `async-waitFor`)
1984
+ - compressBytes - Bounded share payload compression (id: `encoding-compression`)
558
1985
  - debounce - Trailing (default) and leading-edge options (id: `function-debounce`)
559
1986
  - memo - LRU cache with size tracking and invalidation (id: `function-memo`)
560
1987
  - pipe - Left-to-right function composition (id: `function-pipe`)
@@ -3207,7 +4634,13 @@ Sends one HTTP request. `config.method` defaults to `GET`; pass any method (incl
3207
4634
  | `timeout` | `number` | Integer milliseconds from 1 to 2,147,483,647, or `Infinity`; overrides default |
3208
4635
  | `fetchInit` | `Omit` | Raw fetch options for advanced use |
3209
4636
 
3210
- **Returns:** Parsed response body (or `undefined` for empty bodies).
4637
+ **Returns:** Parsed response body (or `undefined` for empty bodies). With
4638
+ `responseType: 'raw'` the resolved value is the `Response` itself; a `204`/`205`
4639
+ answer whose transport carried an unexpected empty body stream is normalized to
4640
+ `body: null` — the stray stream is cancelled and headers, status, statusText,
4641
+ URL, redirect and type metadata are preserved (on the response and its clones).
4642
+ A null-body raw response owns no pending resource, so no consumption is required
4643
+ to release the request.
3211
4644
 
3212
4645
  ```ts
3213
4646
  const created = await courier.request('/users', {
@@ -3487,7 +4920,7 @@ Concurrent callers share physical work but own their own signal and timeout. One
3487
4920
 
3488
4921
  ## Response Parsing
3489
4922
 
3490
- Courier 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.
4923
+ Courier 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.
3491
4924
 
3492
4925
  ```ts
3493
4926
  const buffer = await courier.request('/files/{id}', {
@@ -3670,6 +5103,7 @@ There is no `use()` method. To change middleware, create a new client. This keep
3670
5103
  ## Examples
3671
5104
 
3672
5105
  - [Authentication](./examples/authentication.md)
5106
+ - [Service Discovery and Custom Fetch](./examples/service-discovery-and-custom-fetch.md)
3673
5107
  - [CRUD Operations](./examples/crud-operations.md)
3674
5108
  - [Disposal](./examples/disposal.md)
3675
5109
  - [Error Handling Patterns](./examples/error-handling-patterns.md)
@@ -16668,7 +18102,7 @@ const chart = createScatterChart(host, {
16668
18102
  chart.dispose();
16669
18103
  ```
16670
18104
 
16671
- Scatter interaction reports one datum and series rather than a `values` array. Keyboard navigation visits points across series in x order.
18105
+ Scatter interaction reports the datum and series of the point nearest the pointer in two dimensions, without a `values` array. Keyboard navigation visits points across series in x order. Tooltip `titleFormat` receives the x-value and `valueFormat` the y-value. On mount, points grow from radius zero at their final positions; updates slide existing points and grow new ones.
16672
18106
 
16673
18107
  ### `createBarChart()`
16674
18108
 
@@ -16992,7 +18426,7 @@ export interface TransitionConfig {
16992
18426
  easing?: EasingName | ((t: number) => number);
16993
18427
  /** `system` skips animation when reduced motion is preferred. */
16994
18428
  preference?: 'always' | 'never' | 'system';
16995
- /** Per-bar delay on bar charts or per-series delay on radar charts. */
18429
+ /** Per-bar delay on bar charts, per-entering-point delay (x order) on scatter charts, or per-series delay on radar charts. Bar and scatter charts cap the total added delay at about 400 ms. */
16996
18430
  stagger?: number;
16997
18431
  }
16998
18432
 
@@ -17171,6 +18605,7 @@ export interface AxisConfig {
17171
18605
  position?: TPosition;
17172
18606
  tickCount?: number;
17173
18607
  tickFormat?: (value: Date | number | string) => string;
18608
+ tickValues?: ReadonlyArray;
17174
18609
  }
17175
18610
 
17176
18611
  export type XAxisConfig = AxisConfig;
@@ -17179,6 +18614,8 @@ export type YAxisConfig = AxisConfig;
17179
18614
  export interface TooltipConfig {
17180
18615
  offset?: number;
17181
18616
  render?: (datum: Datum, series: Series) => Node | string;
18617
+ titleFormat?: (key: Date | number | string) => string;
18618
+ valueFormat?: (value: number, datum: Datum, series: Series) => string;
17182
18619
  }
17183
18620
 
17184
18621
  export interface CrosshairConfig {
@@ -17196,7 +18633,11 @@ export interface LegendConfig {
17196
18633
 
17197
18634
  The crosshair defaults to a vertical line that snaps to the nearest datum; the horizontal line is hidden by default. With `snap: false`, only the guide follows the pointer—tooltip and callback data still refer to the nearest datum.
17198
18635
 
17199
- Tooltip strings are assigned as text, not parsed as HTML. For a radar chart, `tooltip.render` receives a datum whose `meta` contains the active axis, all series values, and the default announcement text.
18636
+ `tickValues` renders exactly the given ticks instead of scale-generated ones, and gridlines follow the same list. Values must match the axis's scale kind (numbers on a linear axis, `Date`s on a time axis, category strings on a band axis); an off-scale value warns in development. Without it, ticks come from the scale as before.
18637
+
18638
+ Numeric tick labels at a magnitude of 1e5 or above default to compact notation (`123K`, `1.23M`) so long numbers fit the default value-axis margin; pass `tickFormat: String` for raw labels. Supplying a partial `yAxis` (or the value axis of a horizontal bar chart) merges over the default, so `{ tickFormat }` alone keeps the default grid lines.
18639
+
18640
+ `titleFormat` and `valueFormat` shape the default comparison tooltip on line, area, and bar charts—the title from the focused key and each row's value—and the same formatting is applied to the tooltip's spoken summary. `render` still replaces the whole tooltip body and takes precedence. Tooltip strings are assigned as text, not parsed as HTML. For a radar chart, `tooltip.render` receives a datum whose `meta` contains the active axis, all series values, and the default announcement text.
17200
18641
 
17201
18642
  ### Chart Configuration Types
17202
18643
 
@@ -17519,7 +18960,17 @@ const chart = createLineChart(container, {
17519
18960
  chart.dispose();
17520
18961
  ```
17521
18962
 
17522
- `tickFormat` formats axis labels only. Tooltip content and accessibility announcements use the underlying keys and values.
18963
+ `tickFormat` formats axis labels only. Tooltip content and accessibility announcements use the underlying keys and values. To render an exact set of ticks instead of scale-generated ones, pass `tickValues`; gridlines follow the same list. Values must match the axis's scale kind, and an off-scale value warns in development:
18964
+
18965
+ ```ts
18966
+ // Force a tick on the last month, which nice-stepped ticks can skip.
18967
+ const chart = createLineChart(container, {
18968
+ series: [{ data: [{ key: 0, value: 90 }, { key: 11, value: 128 }], name: 'Costs' }],
18969
+ xAxis: { tickValues: [0, 6, 11] },
18970
+ });
18971
+ ```
18972
+
18973
+ Numeric tick labels at a magnitude of `1e5` or above default to compact notation (`123K`, `1.23M`) so long numbers fit the default value-axis margin; pass `tickFormat: String` for raw labels. Supplying a partial value-axis config merges over its default, so `yAxis: { tickFormat }` alone keeps the default grid lines.
17523
18974
 
17524
18975
  ## Bar Charts
17525
18976
 
@@ -17577,7 +19028,7 @@ chart.dispose();
17577
19028
 
17578
19029
  ## Scatter Charts
17579
19030
 
17580
- Scatter charts treat every datum as an independent point: `key` is the numeric x-value and `value` is the y-value. The scales fit the observed values rather than forcing the y-domain to include zero. Set `pointRadius` on a series to change its marker size.
19031
+ Scatter charts treat every datum as an independent point: `key` is the numeric or `Date` x-value and `value` is the y-value. The scales fit the observed values rather than forcing the y-domain to include zero. Set `pointRadius` on a series to change its marker size (default `3`).
17581
19032
 
17582
19033
  ```ts
17583
19034
  import { createScatterChart } from '@vielzeug/prism';
@@ -17599,7 +19050,7 @@ const chart = createScatterChart(container, {
17599
19050
  chart.dispose();
17600
19051
  ```
17601
19052
 
17602
- Scatter callbacks identify one nearest point and do not provide cross-series `values`, because points do not share a category key.
19053
+ Scatter callbacks identify the nearest point in two dimensions and do not provide cross-series `values`, because points do not share a category key. Keyboard navigation visits every point across series in x order. With a tooltip, `titleFormat` formats the x-value and `valueFormat` the y-value, in both the visible row and the spoken summary.
17603
19054
 
17604
19055
  ## Forecast and Emphasis Styling
17605
19056
 
@@ -17716,7 +19167,7 @@ For a stacked sparkline, each segment can set `value`, `color`, and `label`. Spa
17716
19167
 
17717
19168
  ## Interaction
17718
19169
 
17719
- Set `tooltip: true` to show Prism's default tooltip, or pass a `TooltipConfig` to format its content. A `render` callback receives the active `Datum` and `Series`. Returned strings are inserted as text; return a DOM `Node` for structured content.
19170
+ Set `tooltip: true` to show Prism's default tooltip, or pass a `TooltipConfig` to format its content. `titleFormat` and `valueFormat` shape the default comparison tooltip on line, area, and bar charts—the title from the focused key and each row's value—without rebuilding its markup, and the same formatting is applied to the tooltip's spoken summary. A `render` callback receives the active `Datum` and `Series` and replaces the whole body, taking precedence over the formatters. Returned strings are inserted as text; return a DOM `Node` for structured content.
17720
19171
 
17721
19172
  ```ts
17722
19173
  import { createLineChart } from '@vielzeug/prism';
@@ -17725,17 +19176,21 @@ const container = document.createElement('div');
17725
19176
  container.style.cssText = 'width:640px;height:320px';
17726
19177
  document.body.append(container);
17727
19178
 
19179
+ const compact = new Intl.NumberFormat(undefined, { notation: 'compact' });
19180
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
19181
+
17728
19182
  const chart = createLineChart(container, {
17729
- series: [{ data: [{ key: 1, value: 4200 }, { key: 2, value: 5100 }], name: 'Revenue' }],
19183
+ series: [{ data: [{ key: 11, value: 128000 }], name: 'EC2' }],
17730
19184
  tooltip: {
17731
- render: (datum, series) => `${series.name}: ${new Intl.NumberFormat().format(datum.value)}`,
19185
+ titleFormat: (key) => months[Number(key)] ?? String(key),
19186
+ valueFormat: (value) => compact.format(value),
17732
19187
  },
17733
19188
  });
17734
19189
 
17735
19190
  chart.dispose();
17736
19191
  ```
17737
19192
 
17738
- `onHover` receives an event while a point, category, slice, or radar axis is active, then `null` when the interaction clears. `onClick` receives the selected event. For line, area, and bar charts, `ChartEvent.values` contains each series' datum at that shared key; a missing datum is `undefined`. Scatter, pie, radar, and sparkline use their chart-specific event types.
19193
+ `onHover` receives an event while a point, category, slice, or radar axis is active, then `null` when the interaction clears. `onClick` receives the selected event. For line, area, and bar charts, `ChartEvent.values` contains each series' datum at that shared key; a missing datum is `undefined`. Scatter events are `ChartEvent` objects without `values`; pie, radar, and sparkline use their chart-specific event types.
17739
19194
 
17740
19195
  `crosshair` is available on line, area, and scatter charts. By default it shows a vertical guide and snaps the line to the nearest datum. Set `horizontal: true` to add a horizontal guide or `vertical: false` to hide the vertical one. Set `snap: false` to make the guide follow the pointer; tooltip and callback data still come from the nearest datum.
17741
19196
 
@@ -17766,11 +19221,11 @@ chart.dispose();
17766
19221
 
17767
19222
  Columns grow together from their baseline by default over 300 ms. Lines trace their final curve over 420 ms, uncovering point markers along the way without fading or shifting the series. Line data updates retain the default 300 ms geometry transition; an explicit `duration` overrides both timings.
17768
19223
 
17769
- `preference: 'system'` (the default) disables JavaScript motion when the user prefers reduced motion. Use `'always'` to ignore that preference or `'never'` to disable motion. `stagger` sets an optional delay between bars or radar series; bar charts cap the effective delay so long category axes do not add unbounded time. Named easing values are `ease-in`, `ease-in-out`, `ease-out`, `back-out`, `expo-out`, and `linear`; you can also provide an easing function.
19224
+ `preference: 'system'` (the default) disables JavaScript motion when the user prefers reduced motion. Use `'always'` to ignore that preference or `'never'` to disable motion. `stagger` sets an optional delay between bars, entering scatter points (in x order), or radar series; bar and scatter charts cap the effective delay so many marks do not add unbounded time. Named easing values are `ease-in`, `ease-in-out`, `ease-out`, `back-out`, `expo-out`, and `linear`; you can also provide an easing function.
17770
19225
 
17771
19226
  ## Responsive Layout and Lifecycle
17772
19227
 
17773
- Size the host with CSS. Prism observes its content box and updates the SVG dimensions and chart layout when it changes. Bar and line resize redraws apply immediately rather than animating between layout sizes; entrance animations and explicit data-update transitions remain enabled. The legend is rendered as container chrome outside the SVG and included in resize calculations.
19228
+ Size the host with CSS. Prism observes its content box and updates the SVG dimensions and chart layout when it changes. Bar, line, and scatter resize redraws apply immediately rather than animating between layout sizes; entrance animations and explicit data-update transitions remain enabled. The legend is rendered as container chrome outside the SVG and included in resize calculations.
17774
19229
 
17775
19230
  Call `dispose()` when the view no longer owns the chart. Disposal is idempotent: it removes the SVG, tooltip, legend, and resize observation, stops in-flight chart motion, and aborts `disposalSignal`. `chart[Symbol.dispose]()` calls the same cleanup method.
17776
19231
 
@@ -18939,7 +20394,7 @@ Component registration happens through side-effect imports such as `@vielzeug/re
18939
20394
 
18940
20395
  **Feedback:** `ore-alert`, `ore-async`, `ore-badge`, `ore-chip`, `ore-cookie-banner`, `ore-password-strength`, `ore-progress`, `ore-skeleton`, `ore-toast`, `ore-typing-indicator`
18941
20396
 
18942
- **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-radio`, `ore-radio-group`, `ore-rating`, `ore-select`, `ore-slider`, `ore-switch`, `ore-textarea`, `ore-time-picker`
20397
+ **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`
18943
20398
 
18944
20399
  **Layout:** `ore-box`, `ore-grid`, `ore-grid-item`, `ore-navbar`, `ore-sidebar`
18945
20400
 
@@ -19045,7 +20500,7 @@ import type { OreButtonProps } from '@vielzeug/refine/button';
19045
20500
  | 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` |
19046
20501
  | Disclosure | `accordion`, `accordion-item`, `tabs`, `tab-item`, `tab-panel` |
19047
20502
  | Feedback | `alert`, `async`, `badge`, `chip`, `cookie-banner`, `password-strength`, `progress`, `skeleton`, `toast`, `typing-indicator` |
19048
- | Inputs | `button`, `button-group`, `calendar`, `checkbox`, `checkbox-group`, `combobox`, `counter`, `datagrid`, `date-picker`, `file-input`, `input`, `message-composer`, `number-input`, `otp-input`, `radio`, `radio-group`, `rating`, `select`, `slider`, `switch`, `textarea`, `time-picker` |
20503
+ | 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` |
19049
20504
  | Layout | `box`, `grid`, `grid-item`, `navbar`, `sidebar` |
19050
20505
  | Overlays | `command-palette`, `dialog`, `drawer`, `menu`, `navigation-menu`, `popover`, `tooltip` |
19051
20506
 
@@ -28979,6 +30434,7 @@ useEffect(() => {
28979
30434
  ## Examples
28980
30435
 
28981
30436
  - [Sync a Vault Store](./examples/sync-a-vault-store.md)
30437
+ - [Make Store Commands Undoable](./examples/make-store-commands-undoable.md)
28982
30438
  - [Keep Deletions from Resurrecting](./examples/keep-deletions-from-resurrecting.md)
28983
30439
  - [Switch Accounts Safely](./examples/switch-accounts-safely.md)
28984
30440