@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.
package/data/llms.txt CHANGED
@@ -1,11 +1,12 @@
1
1
  # Vielzeug
2
2
 
3
- > 41 focused TypeScript packages. Version: 26.10.4
3
+ > 42 focused TypeScript packages. Version: 26.10.6
4
4
 
5
5
  Install any package independently: `pnpm add @vielzeug/<name>`
6
6
 
7
7
  ## Packages
8
8
 
9
+ - [@vielzeug/archive](/archive/): Community content publishing for Cloudflare Workers + D1: a hardened Worker core over per-collection payload schemas
9
10
  - [@vielzeug/arsenal](/arsenal/): Tree-shakeable TypeScript utilities with focused category entry points for arrays, async work, caching, objects, strings, math, and guards.
10
11
  - [@vielzeug/assay](/assay/): Scoped DOM queries, exact event dispatch, and cancellable async waiting for browser tests.
11
12
  - [@vielzeug/clockwork](/clockwork/): Framework-neutral typed state machines with pure transitions, actor-owned runtime work, timers, invokes, and explicit effects.
@@ -4,5 +4,5 @@
4
4
  "refine": "refine.json",
5
5
  "schemaVersion": 1,
6
6
  "search": "search.json",
7
- "version": "26.10.4"
7
+ "version": "26.10.6"
8
8
  }
@@ -0,0 +1,76 @@
1
+ {
2
+ "apiSource": "// Public root surface of @vielzeug/archive: the browser-safe contract — the wire\n// schemas and the collection seam. The Cloudflare Worker itself\n// lives behind the `@vielzeug/archive/worker` subpath so client bundles never\n// resolve worker machinery.\nexport * from './collection';\nexport * from './contract';\nexport * from './errors';\n",
3
+ "docs": {
4
+ "index": "---\ntitle: Archive\ndescription: \"Community content publishing for Cloudflare Workers + D1: a hardened Worker core over per-collection payload schemas\"\npackage: archive\ncategory: utilities\nkeywords: [cloudflare, worker, d1, community, publishing, content, catalog, gdpr]\nrelated: [spell, courier, rune]\nexports: [defineArchiveCollection, createArchiveWorker, createAuthWorker, archiveAuthHealthSchema, ArchiveDerivationError]\nenvironments: [browser, node]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"archive\" />\n\n## Why Archive?\n\nYou can share content entries with ranked browsing, filters, likes and account\nerasure on Cloudflare Workers + D1. `defineArchiveCollection` supplies your JSON\npayload schema and indexing rules; the Worker supplies sessions, guards,\nidempotent publishing, cursors and rate limits.\n\n```ts\n// Before: each deployment repeats validation, indexing and route plumbing.\n\n// After: the deployment is one collection definition plus one factory call.\nimport { defineArchiveCollection } from '@vielzeug/archive';\nimport { createArchiveWorker } from '@vielzeug/archive/worker';\nimport { s } from '@vielzeug/spell';\n\nconst notes = defineArchiveCollection({\n name: 'notes', contentVersion: 1,\n payloadSchema: s.object({ title: s.string().min(1).max(128) }),\n index: ({ title }) => ({ title, filter: null, tags: [] }),\n});\nexport default createArchiveWorker([notes]);\n```\n\n| Feature | Archive | Hand-rolled Worker | BaaS (Firebase/Supabase) |\n| ------------------------------------ | -------------------------------------------------------- | ------------------------------------ | ------------------------------------- |\n| Bundle size (core) | <PackageInfo package=\"archive\" type=\"size\" /> | Depends on deployment code | Depends on selected SDK |\n| Zero dependencies | No — Spell and jose | Depends on implementation | No — client SDK |\n| Content knowledge in the core | <ore-icon name=\"check\" size=\"16\"></ore-icon> none — one collection seam | <ore-icon name=\"x\" size=\"16\"></ore-icon> baked in | <ore-icon name=\"x\" size=\"16\"></ore-icon> rules everywhere |\n| Canonical fresh-database D1 schema | <ore-icon name=\"check\" size=\"16\"></ore-icon> in the package | Deployment-owned | Not a D1 backend |\n| Idempotent publish w/ tombstones | <ore-icon name=\"check\" size=\"16\"></ore-icon> built-in | <ore-icon name=\"x\" size=\"16\"></ore-icon> rebuild it | <ore-icon name=\"x\" size=\"16\"></ore-icon> rebuild it |\n| GDPR erasure / export endpoints | <ore-icon name=\"check\" size=\"16\"></ore-icon> built-in | <ore-icon name=\"x\" size=\"16\"></ore-icon> rebuild it | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> partial |\n| Strict DTO re-validation on read | <ore-icon name=\"check\" size=\"16\"></ore-icon> every entry | <ore-icon name=\"x\" size=\"16\"></ore-icon> optional | <ore-icon name=\"x\" size=\"16\"></ore-icon> optional |\n\n<div class=\"decision-callout\">\n\n**Use Archive when** an app needs a shareable, ranked, liked community space for\ncontent entries on Cloudflare Workers + D1, and can express its content as one strict\npayload schema.\n**Consider alternatives when** you need rich queries beyond filter/tag/search, live\ncollaboration, or a backend you do not control — Archive is deliberately one narrow,\nhardened surface.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/archive @vielzeug/spell\n```\n\n```sh [npm]\nnpm install @vielzeug/archive @vielzeug/spell\n```\n\n```sh [yarn]\nyarn add @vielzeug/archive @vielzeug/spell\n```\n\n:::\n\n## Quick Start\n\nA deployment is one collection definition plus one factory call. The definition\nbrings the payload schema and what to index; everything else — the emitted-entry\nenvelope, the content hash, the wire contract, the routes — is composed for you.\n\n```ts\nimport { defineArchiveCollection } from '@vielzeug/archive';\nimport { createArchiveWorker } from '@vielzeug/archive/worker';\nimport { s } from '@vielzeug/spell';\n\n// The collection's payload: one strict schema is the whole content contract.\nconst payloadSchema = s.object({\n cuisine: s.enum(['italian', 'japanese']),\n ingredients: s.array(s.string().trim().min(1).max(64)).min(1).max(20),\n name: s.string().trim().min(1).max(32),\n});\n\nconst recipesCollection = defineArchiveCollection({\n canonicalize: (payload) => ({ ...payload, ingredients: [...payload.ingredients].sort() }),\n contentVersion: 1,\n filters: ['italian', 'japanese'],\n index: (payload) => ({\n filter: payload.cuisine,\n tags: [payload.ingredients.length <= 5 ? 'quick' : 'involved'],\n title: payload.name,\n }),\n name: 'recipes',\n payloadSchema,\n tags: ['quick', 'involved'],\n});\n\n// The whole Worker: routing, auth, guards, limits, SQL and DTO disciplines included.\nexport default createArchiveWorker([recipesCollection]);\n```\n\nThe browser side never imports worker machinery. The same collection's composed\nschemas parse what the routes emit:\n\n```ts\nimport { defineArchiveCollection } from '@vielzeug/archive';\nimport { s } from '@vielzeug/spell';\n\nconst notes = defineArchiveCollection({\n name: 'notes', contentVersion: 1,\n payloadSchema: s.object({ title: s.string().min(1).max(128) }),\n index: ({ title }) => ({ title, filter: null, tags: [] }),\n});\nconst response = await fetch('/api/v1/collections/notes/entries?sort=liked');\nif (!response.ok) throw new Error('Archive unavailable');\nconst parsed = notes.contract.page.parse(await response.json());\nconsole.log(parsed.entries);\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `defineArchiveCollection` — the one content seam: payload schema, canonicalization, filter/tag/title derivation, union-typed vocabularies\n- `createArchiveWorker` — the whole routes/auth/limits/SQL stack from registered collections\n- `collection.contract` — entry, page, publish/update input schemas shared by client and Worker\n- `ARCHIVE_LIMITS` + `archiveHealthSchema` — the application bounds, advertised by health and validated on both sides\n- `archiveAuthHealthSchema` + `AUTH_SCHEMA_VERSION` — typed issuer readiness checked against the issuer DDL stamp\n- `archiveErrorResponseSchema` — the one error envelope, code-typed across the wire\n- `createAuthWorker` — scanner-safe confirmation, live session-version enforcement, durable budgets, metadata export and coordinated erasure\n- `AUTH_ISSUER_SERVICE` — required uncached issuer check after local catalog JWT verification\n- `ArchiveDerivationError` — distinguishes faulty collection code from invalid submitted content\n- Moderation — reader reports, the operator soft-hide and the report queue, keyed on a verified `roles` claim\n- `worker.tap()` / `issuer.tap()` — typed sanitized runtime events; neither core logs by default\n- `migrations/` — canonical fresh-database DDL with the storage stamp health reads back; no upgrade framework\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md) — stand up a collection, deploy the Worker, wire the client.\n- [API Reference](./api.md) — every export, all three entry points, the full route table.\n- [Examples](./examples.md) — recipes from first deployment to GDPR erasure.\n- [Migration](./migration.md) — breaking changes after the first release and\n fresh-database scope.\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Spell](/spell/) — every payload schema here is a spell schema; the core composes envelopes from it.\n- [Courier](/courier/) — the HTTP client shape an app's frontend mirrors to consume these routes.\n- [Rune](/rune/) — the structured logger a deployment points `worker.tap()` events at.\n\n</div>\n\n<!-- markdownlint-enable MD060 MD033 MD025 -->\n",
5
+ "api": "---\ntitle: \"Archive: API Reference\"\ndescription: \"Collection definitions, browser-safe wire schemas, Worker deployment, and identity issuance.\"\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `defineArchiveCollection` | Compose validation, indexing, identity and wire schemas | Sync | Invalid content derives to `null`; callback faults throw |\n| `createArchiveWorker` | Build the catalog Worker | Sync | Collection names must be unique |\n| `createAuthWorker` | Build the passwordless issuer | Sync | `AUTH_ARCHIVE` is required |\n| `worker.tap`, `issuer.tap` | Observe sanitized runtime outcomes | Sync | Neither core logs by default; observer exceptions are swallowed |\n| `ArchiveDerivationError` | Identify a collection implementation/runtime fault | Sync | Faults become sanitized 500s, not content-validation 422s |\n| `ARCHIVE_LIMITS` | Shared application bounds | Sync | Page bounds do not cap total exports |\n| `archiveErrorResponseSchema`, `archiveAuthHealthSchema` | Validate error and issuer-health responses | Sync | Unknown error codes and health states reject |\n| `ArchiveError`, `ArchiveDefinitionError` | Public base and invalid-definition errors | Sync | Thrown locally, not transmitted as error objects |\n| `sessionCookieValue` | Compose shared session-cookie attributes | Sync | Deployments must choose `secure` from their environment |\n| `isWellFormedText` | Check control characters and lone surrogates | Sync | Does not trim or impose a length bound |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/archive` | Browser-safe collections, constants, schemas and errors |\n| `@vielzeug/archive/worker` | Catalog factory, issuer-session service binding and event types |\n| `@vielzeug/archive/auth` | Issuer factory and binding types |\n\n## Collections\n\n### `defineArchiveCollection`\n\nCreates a frozen collection and its contract; malformed names, versions or vocabularies throw `ArchiveDefinitionError`.\n\n```ts\nfunction defineArchiveCollection<\n P extends object, const C extends number, const F extends string = never, const T extends string = never,\n>(definition: ArchiveCollectionDefinition<P, C, F, T>): ArchiveCollection<P, C>;\n```\n\n| Parameter | Contract |\n| --- | --- |\n| `name` | Lowercase `[a-z0-9-]{1,32}` |\n| `contentVersion` | Positive safe integer |\n| `payloadSchema` | Spell schema whose output is a stable JSON object |\n| `filters`, `tags` | Optional readonly arrays; omitted means empty vocabulary |\n| `canonicalize` | Optional pure, idempotent, schema-valid normalization |\n| `index` | Derive `filter`, `tags` and searchable `title` without mutating the payload |\n\n**Returns:** `ArchiveCollection<P, C>`.\n\n**Example:** define and derive one collection without Worker bindings.\n\n```ts\nimport { defineArchiveCollection } from '@vielzeug/archive';\nimport { s } from '@vielzeug/spell';\n\nconst recipes = defineArchiveCollection({\n name: 'recipes',\n contentVersion: 1,\n payloadSchema: s.object({ name: s.string().min(1).max(32) }),\n index: (payload) => ({ filter: null, tags: [], title: payload.name }),\n});\nconst derived = await recipes.derive({ name: 'Rice' });\nif (derived === null) throw new Error('Invalid recipe');\nconsole.log(derived.contentHash);\n```\n\n| Method / property | Result |\n| --- | --- |\n| `derive(raw)` | `Promise<ArchiveDerivation \\| null>` |\n| `contract.entry` | Public entry schema |\n| `contract.ownedEntry`, `ownedEntries` | Owner view with `clientKey` and `hidden` |\n| `contract.page` | Entries and the forward cursor |\n| `contract.publishInput`, `updateInput` | Publish and revision-guarded update request schemas |\n| `filters`, `tags` | Frozen vocabulary snapshots |\n\n`derive` returns `null` for invalid JSON objects, schema-invalid input, invalid titles, too many tags and oversized canonical payloads. Unexpected schema, callback or hashing failures throw `ArchiveDerivationError`. Non-idempotent or schema-invalid canonicalization, unstable schema transformations, index mutation and out-of-vocabulary projections are also derivation faults. Routes map `null` to 422 `invalid-payload` and faults to a sanitized 500 `internal`.\nSchema output that is lossy or no longer JSON is a derivation fault, not invalid\nsubmitted content. Invalid index-result shapes also throw.\n\nConst generics preserve the content-version literal and infer filter/tag unions from vocabulary arrays without `as const`. `NoInfer` prevents `index` from widening those unions; omitted arrays infer `never`, permitting only `filter: null` and empty tags.\n\nObjects serialize with recursively sorted keys; arrays preserve order unless your canonicalizer changes it. SHA-256 hashes the core-owned `{contentVersion, payload}` envelope. Stored payloads are canonical by construction, so the emitted entry schemas embed the plain payload schema.\n\n`filter`, tags and searchable title are persisted projections, not computed during reads. Changing `index` or its vocabulary does not update stored rows. The active definition and persisted dataset must remain consistent; Archive has no projection-update or historical-schema API.\n\n## Worker and Maintenance\n\n### `createArchiveWorker`\n\nReturns the catalog request/scheduled handler and its observation side channel.\n\n```ts\nfunction createArchiveWorker<E extends ArchiveWorkerEnv>(\n collections: readonly ArchiveCollection[],\n): ArchiveWorkerHandler<E>;\n```\n\n**Returns:** `ArchiveWorkerHandler<E>`; duplicate names throw `ArchiveDefinitionError` at registration.\n\n**Example:** export a deployable Worker using the package's structural bindings.\n\n```ts\nimport { defineArchiveCollection } from '@vielzeug/archive';\nimport { createArchiveWorker, type ArchiveWorkerEnv } from '@vielzeug/archive/worker';\nimport { s } from '@vielzeug/spell';\n\nconst notes = defineArchiveCollection({\n name: 'notes', contentVersion: 1,\n payloadSchema: s.object({ title: s.string().min(1).max(128) }),\n index: ({ title }) => ({ title, filter: null, tags: [] }),\n});\nexport default createArchiveWorker<ArchiveWorkerEnv>([notes]);\n```\n\n| Method | Contract |\n| --- | --- |\n| `fetch(request, env, ctx)` | Returns `Promise<Response>` |\n| `scheduled(controller, env, ctx)` | Sweeps expired retry keys and revocation tombstones; emits failures and rethrows |\n| `tap(handler, { signal? }?)` | Returns unsubscribe; a pre-aborted signal registers nothing |\n\n`AUTH_ISSUER_SERVICE` is a required `fetch`-shaped service binding to the issuer.\nThe catalog verifies the signed JWT locally, then asks the issuer to validate its\ncurrent account session version. It does not cache successful issuer verdicts.\nIf the service exchange fails or exceeds its fixed 5000 ms timeout (body decoding\nincluded), the catalog returns `503 auth-unavailable`; the timeout does not cancel\nwork already started by the issuer.\n\nOnly anonymous entry lists can use the 30-second edge cache. Any `Cookie` or `Authorization` header disables caching, even if invalid or empty. Detail, metadata, private and error responses are `no-store`. Public reads do not verify sessions.\n\n---\n\n### `worker.tap`\n\nObserves typed request, scheduled and cache-failure events without affecting request behavior.\n\n```ts\ntap(handler: (event: ArchiveEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;\n```\n\n**Returns:** an unsubscribe function; abort detaches the observer. Diagnostics contain categories and trusted configuration field names, never raw errors, SQL, subjects, aliases, tokens or URLs.\n\n**Example:** observe a Worker and detach explicitly.\n\n```ts\nimport { createArchiveWorker, type ArchiveWorkerEnv } from '@vielzeug/archive/worker';\n\nconst worker = createArchiveWorker<ArchiveWorkerEnv>([]);\nconst stop = worker.tap((event) => console.log(JSON.stringify(event)));\nstop();\n```\n\n---\n\n## Identity Issuer\n\n### `createAuthWorker`\n\nReturns the passwordless email issuer with account export and coordinated erasure.\n\n```ts\nfunction createAuthWorker<E extends AuthWorkerEnv>(): AuthWorkerHandler<E>;\n```\n\n**Returns:** `AuthWorkerHandler<E>`.\n\n**Example:** export an issuer configured through deployment bindings.\n\n```ts\nimport { createAuthWorker, type AuthWorkerEnv } from '@vielzeug/archive/auth';\n\nexport default createAuthWorker<AuthWorkerEnv>();\n```\n\n| Method | Contract |\n| --- | --- |\n| `fetch(request, env, ctx?)` | Returns `Promise<Response>` |\n| `scheduled(controller, env, ctx?)` | Removes expired links, closed send windows and unverified unreferenced accounts |\n| `tap(handler, { signal? }?)` | Observes typed `AuthEvent`; returns unsubscribe; pre-aborted signals are inert |\n\nMagic links expire after 15 minutes and work once. The durable per-email budget allows three sends per hour; consumption, expiration and send failure do not refund it. Ordinary sessions last at most 30 days. Operator sessions use `OPERATOR_SESSION_MAX_AGE_SECONDS` (900 seconds). `OPERATOR_EMAILS` controls newly minted role claims.\n\nThe issuer-global admission budget is durable D1 state in `auth_sign_in_budget`:\n20 requests per 60-second window before account creation or email delivery.\nThis supplements, rather than replaces, the durable per-email budget. No external\nrate-limiter binding is required.\nAdmission spends are not refunded after later database or email failures.\nRequest emails are trimmed and lowercased before validation. `SENDER_ADDRESS`\nmust already be canonical; `OPERATOR_EMAILS` entries are trimmed and lowercased\nbefore validation.\n\nSign-in requests create pending account rows with `lastSignInAt: null`. Successful\nredemption atomically claims the one-use link and stamps the account's sign-in\ntime. The sweep deletes expired links and closed per-user send windows, then\nunverified accounts with neither remaining links nor send-budget references.\nVerified accounts are retained until explicit account deletion.\n\n`OPERATOR_SESSION_MAX_AGE_SECONDS = 900` is part of the browser-safe root\ncontract; `/auth` exports the factory and issuer binding types.\n\n`AUTH_JWT_PREVIOUS_PUBLIC_JWKS` optionally publishes retired public RS256 keys and verifies sessions minted before rotation; private members reject configuration. The package has no demo sign-in routes.\n\nOpening a magic link performs a safe GET that displays a click-confirmation page,\nnot redemption. Only a marked POST from an exact allowed origin or\n`PUBLIC_ORIGIN` consumes `{ token }` and returns 204 with the session cookie. The\nconfirmation page navigates to `/signed-in` after success; the POST is not a\nredirect. Confirmation responses are `no-store` with no-referrer and restrictive\nCSP; the page removes the token from the address bar and loads no external assets.\nGET displays confirmation for any syntactically valid token, including a used\ntoken; only POST establishes whether the link is still redeemable. POST accepts\nexactly `{ token }` and rejects query parameters.\n\nThe issuer never logs directly. `tap` emits sanitized request and scheduled\noutcomes; observer failures are swallowed, abort detaches the observer and failed\nscheduled work emits then rethrows. `AuthDiagnostic` reports only a category.\n\n## HTTP Routes\n\nEvery route below is mounted under `/api/v1`. Mutation guards require an exact allowed `Origin` and `x-archive-request: 1`. Registered publishers pin `x-archive-publisher` to the identity captured when preparing private requests. The header is a freshness precondition, not authority.\n\n| Route | Contract |\n| --- | --- |\n| `GET /health` | Public catalog readiness; 200 `ok` or 503 `degraded` |\n| `POST /sign-out` | Catalog-local sign-out; `{ ok: true }` and expired session cookie, without issuer access |\n| `GET /collections/:collection` | Collection metadata |\n| `GET /collections/:collection/entries` | `author`, `filter`, `tag`, `search`, `sort`, `cursor`, `limit`; forward entry page |\n| `POST /collections/:collection/entries` | `{ clientKey, payload }`; 201 create or 200 replay |\n| `GET /collections/:collection/entries/:id` | Public entry; always `no-store` |\n| `PUT /collections/:collection/entries/:id` | Owner-only `{ expectedRevision, payload }` |\n| `DELETE /collections/:collection/entries/:id` | Owner or operator removal; 204 |\n| `PUT` / `DELETE /collections/:collection/entries/:id/like` | Like / unlike visible entries only; guarded write and response; like-state response |\n| `POST /collections/:collection/entries/:id/report` | `{ reason }`; idempotent per entry/reporter; 204 |\n| `PUT` / `DELETE /collections/:collection/entries/:id/hidden` | Operator hide / unhide; 204 |\n| `GET /collections/:collection/reports` | Operator page; `status=unresolved` (default) or `resolved`, `cursor`, `limit` |\n| `PUT /collections/:collection/reports/:id/resolved` | Operator resolves report by its unique report ID; idempotent 204 |\n| `DELETE /publishers/:id` | Operator erases Archive profile; not issuer account |\n| `GET /moderation-log` | Operator forward page; `cursor`, `limit` |\n| `GET` / `POST` / `PUT /me` | Identity, register with optional `{ name }`, rename with required `{ name }` |\n| `DELETE /me` | Profile-only cascade; 204; does not clear cookie |\n| `DELETE /me/account` | Revoke issuer/subject and atomically erase profile; 204; coordinated issuer-erasure seam |\n| `GET /me/publications?collection=…` | Owner entries including hidden state and retry keys |\n| `GET /me/likes` | Forward page `{ ids, cursor }`; `cursor`, `limit` |\n| `GET /me/likes?ids=…` | Subset lookup `{ ids }`; at most `likeStateIds` input IDs |\n| `GET /me/reports` | Own forward report page across collections; `cursor`, `limit` |\n| `GET /auth/health` | Public issuer readiness; typed `AuthHealth`, 200 `ok` or 503 `degraded`; no email or peer probe |\n| `GET /auth/session` | Internal issuer-session check for the catalog service binding; exact `{ ok: true }`, `no-store`, no PII; 401 for an invalid, stale or absent-account session |\n| `POST /auth/sign-out-all` | Requires marker, exact origin, session and captured `ACCOUNT_HEADER`; atomically increments `session_version`, deletes outstanding links, preserves account and catalog data; 200 `{ ok: true }` with an expired cookie only on durable success |\n| `GET /auth/jwks.json` | Active and configured retired public verification keys |\n| `POST /auth/sign-in` | `{ email }` (trimmed and lowercased); 202 `{ ok: true }` |\n| `GET /auth/verify?token=…` | Safe no-store click-confirmation page; no token consumption or session change |\n| `POST /auth/verify` | JSON `{ token }`, marker and exact allowed origin or `PUBLIC_ORIGIN`; consume once; 204 with session cookie |\n| `GET /auth/account` | `{ id, email, createdAt, lastSignInAt }`; no tokens or budget data |\n| `DELETE /auth/account` | Requires `ACCOUNT_HEADER` with the captured issuer account UUID; catalog `/me/account` via `AUTH_ARCHIVE`, require exact 204, then issuer user and cookie clear; 204 |\n\nForward pages are newest first with opaque context-bound keyset cursors. Keep the same query context when following a cursor; end at `cursor: null`. `maxPageSize` bounds each page, not the total number of liked IDs, reports or audit actions you can export. The subset-like query is not a page and cannot combine `ids` with `cursor` or `limit`.\n\nFull erasure creates an issuer/subject revocation tombstone for 30 days plus ten\nseconds and deletes the profile in the same transaction. Catalog authentication\nrejects that subject with 401 on ordinary private routes; registration also checks\nrevocation in SQL to prevent resurrection races. Only `DELETE /me/account` may\nbypass the issuer session lookup, and only to retry erasure with the original\nsigned session of the same principal while its existing revocation tombstone is\nvalid; JWT signature and claims are still verified. It is not a general\nstale-session exception: initial erasure validates the issuer session. The\nissuer-facing `DELETE /auth/account` always checks the current session version\nbefore calling the catalog, so retrying it after completed erasure returns 401\nbecause the account is absent.\nDirect profile-only `DELETE /me` allows later registration using the same session.\nThe sweep removes expired tombstones. Re-enrollment through the reference issuer\ncreates a fresh account UUID subject; an external issuer must issue a new subject\nto re-enroll during the revocation window.\n\nThe issuer stores `auth_users.session_version` as a safe non-negative integer,\ndefaulting to `0`. Every session JWT carries the current value as the required\n`sessionVersion` claim. Each ordinary issuer-authenticated route compares that\nclaim to the current account row; a mismatch is unauthenticated. `POST\n/api/v1/auth/sign-out-all` checks the captured issuer account UUID against the\nverified subject before atomically incrementing the version and deleting pending\nlinks. A changed account answers 409 `account-changed`; a failed request does not\nclear the cookie. This revokes all sessions, including the caller's, after a\nsuccessful response. Requests authorized before revocation may finish; there is\nno cross-database transactional or in-flight cancellation guarantee.\n\nThe catalog requires `AUTH_ISSUER_SERVICE`, a service binding to the issuer. After\nlocal JWT signature/claims verification, it requests\n`GET /api/v1/auth/session` through that binding on every private request. Only a\nstrictly validated 401 `unauthenticated` remains a 401. Malformed, unexpected,\nprovider and 5xx responses become 503 `auth-unavailable`; there is no authority\ncache. Both service directions use a fixed 5000 ms timeout that includes body\ndecoding. The issuer's `/api/v1/auth/session` is an internal\nservice contract, not a frontend session API.\n\nIssuer readiness is independent of catalog readiness. `GET\n/api/v1/auth/health` returns `archiveAuthHealthSchema` as `AuthHealth`; its\nread-only probe checks configuration, a signing and verification key proof,\nbinding shapes, the singleton `auth_schema_meta` stamp against\n`AUTH_SCHEMA_VERSION`, and readability of expected tables, columns and triggers.\nIt does not write, send email or probe the catalog service. Responses are\nsanitized.\nConfiguration failure, database failure and schema mismatch return HTTP 503\nwith `status: 'degraded'` and the corresponding reason.\n\nEffective hide, unhide, operator removal and publisher erasure append audit rows atomically with the state transition. Concurrent idempotent no-ops append nothing. Audit rows contain opaque IDs, not names or content, and have no foreign keys so they outlive erasure. Resolving a report sets `resolvedAt`; it is not one of the four audit action kinds.\n\n## Constants and Schemas\n\n| Export | Value or parsed output |\n| --- | --- |\n| `ARCHIVE_CONTRACT_VERSION`, `ARCHIVE_SCHEMA_VERSION` | Both debut at `1`; wire and storage versions are independent |\n| `AUTH_SCHEMA_VERSION` | `1`; issuer DDL stamp checked by `/auth/health` |\n| `ARCHIVE_COLLECTION_NAME_PATTERN` | `/^[a-z0-9-]{1,32}$/` |\n| `ARCHIVE_OPERATOR_ROLE` | `'archive-operator'` |\n| `SESSION_COOKIE_NAME`, `SESSION_COOKIE_PATH` | `'archive_session'`, `'/api/v1'` |\n| `SESSION_MAX_AGE_SECONDS` | `2592000` |\n| `OPERATOR_SESSION_MAX_AGE_SECONDS` | `900`; shared root contract |\n| `ACCOUNT_HEADER` | `'X-Archive-Account'`; required issuer-account UUID on coordinated erasure |\n| `PUBLISHER_HEADER`, `REQUEST_MARKER_HEADER` | `'x-archive-publisher'`, `'x-archive-request'` |\n| `ILL_FORMED_TEXT`, `isWellFormedText(value: string): boolean` | Reject control characters and lone UTF-16 surrogates |\n| `uuidSchema`, `isoTimestampSchema`, `hex64Schema` | UUID, ISO timestamp, lowercase SHA-256 hex strings |\n| `archiveAliasSchema` | Trimmed, well-formed alias of at most 24 characters |\n| `archiveAuthorSchema` | `{ id: string; name: string }` |\n| `archiveIdentitySchema` | `ArchiveIdentity` |\n| `archiveAccountSchema` | `ArchiveAccount`; issuer metadata, excluding private fields |\n| `archiveLimitsSchema` | All numeric `ARCHIVE_LIMITS` fields |\n| `archiveHealthSchema` | `ArchiveHealth` |\n| `archiveAuthHealthSchema` | `AuthHealth` |\n| `archiveErrorResponseSchema` | `ArchiveErrorResponse` |\n| `archiveOkSchema` | `{ ok: true }` |\n| `archiveCollectionMetaSchema` | `ArchiveCollectionMeta` |\n| `archiveLikedIdsSchema` | `{ ids: string[] }` subset response |\n| `archiveLikesPageSchema` | `ArchiveLikesPage` |\n| `archiveLikeStateSchema` | `{ id: string; likeCount: number; liked: boolean }` |\n| `archiveReportsSchema`, `archiveOwnReportsSchema` | `ArchiveReports`, `ArchiveOwnReports` |\n| `archiveModerationLogSchema` | `ArchiveModerationLog` |\n| `archiveSortSchema` | `ArchiveSort` |\n| `ARCHIVE_ERROR_CODES` | Values of `ArchiveErrorCode` below |\n| `ARCHIVE_MODERATION_ACTIONS` | `['hide', 'unhide', 'remove', 'erase-publisher']` |\n\n### `sessionCookieValue`\n\nComposes a session cookie using the shared security attributes.\n\n```ts\nfunction sessionCookieValue(token: string, options: { maxAgeSeconds: number; secure: boolean }): string;\n```\n\n| Parameter | Contract |\n| --- | --- |\n| `token` | Session token, or empty string when clearing |\n| `options.maxAgeSeconds` | Cookie lifetime; `0` clears it |\n| `options.secure` | Include `Secure` for production HTTPS |\n\n**Returns:** the `Set-Cookie` value with `HttpOnly`, `SameSite=Strict`, the shared path and optional `Secure`.\n\n**Example:** clear the production cookie.\n\n```ts\nimport { sessionCookieValue } from '@vielzeug/archive';\n\nconst expiredCookie = sessionCookieValue('', { maxAgeSeconds: 0, secure: true });\nconsole.log(expiredCookie);\n```\n\n---\n\n### `isWellFormedText`\n\nChecks text for control characters and lone UTF-16 surrogates.\n\n```ts\nfunction isWellFormedText(value: string): boolean;\n```\n\n**Returns:** `true` when `ILL_FORMED_TEXT` does not match; it does not trim or impose bounds.\n\n**Example:** validate text independently of the alias schema.\n\n```ts\nimport { isWellFormedText } from '@vielzeug/archive';\n\nconsole.log(isWellFormedText('Rice'), isWellFormedText('\\u0000'));\n```\n\n### `ARCHIVE_LIMITS`\n\n`ARCHIVE_LIMITS` is frozen with these values:\n\n```ts\nconst limits = {\n aliasChars: 24, cursorChars: 1024, idempotencyRetentionMs: 172800000,\n likeStateIds: 50, maxPageSize: 50, maxPublications: 50, pageSize: 10,\n payloadBytes: 8192, publicCacheSeconds: 30, reportReasonChars: 256,\n requestBytes: 12288, searchChars: 64, searchTerms: 6, tagsPerEntry: 16,\n titleChars: 128, vocabChars: 64,\n};\n```\n\n## Types\n\nThe following definitions use `Schema` from `@vielzeug/spell`. Schema-derived aliases show their complete output shape.\n\n```ts\ninterface ArchiveIndexing<F extends string = string, T extends string = string> {\n filter: F | null;\n tags: readonly T[];\n title: string;\n}\ninterface ArchiveCollectionDefinition<\n P extends object, C extends number, F extends string = never, T extends string = never,\n> {\n canonicalize?(payload: P): P;\n contentVersion: C;\n filters?: readonly F[];\n index(payload: P): ArchiveIndexing<NoInfer<F>, NoInfer<T>>;\n name: string;\n payloadSchema: Schema<P, unknown>;\n tags?: readonly T[];\n}\ninterface ArchiveDerivation {\n canonicalJson: string;\n contentHash: string;\n contentVersion: number;\n filter: string | null;\n tags: readonly string[];\n title: string;\n}\ninterface ArchiveEntry<P = unknown, C extends number = number> {\n author: { id: string; name: string };\n contentHash: string;\n contentVersion: C;\n id: string;\n likeCount: number;\n payload: P;\n publishedAt: string;\n revision: number;\n schemaVersion: typeof ARCHIVE_CONTRACT_VERSION;\n updatedAt: string;\n}\ninterface ArchiveOwnedEntry<P = unknown, C extends number = number> extends ArchiveEntry<P, C> {\n clientKey: string;\n hidden: boolean;\n}\ninterface ArchiveContract<P = unknown, C extends number = number> {\n entry: Schema<ArchiveEntry<P, C>>;\n ownedEntries: Schema<{ entries: ArchiveOwnedEntry<P, C>[] }>;\n ownedEntry: Schema<ArchiveOwnedEntry<P, C>>;\n page: Schema<ArchivePage<ArchiveEntry<P, C>>>;\n publishInput: Schema<{ clientKey: string; payload: P }, unknown>;\n updateInput: Schema<{ expectedRevision: number; payload: P }, unknown>;\n}\ninterface ArchiveCollection<P extends object = object, C extends number = number> {\n readonly contentVersion: C;\n readonly contract: ArchiveContract<P, C>;\n readonly derive: (raw: unknown) => Promise<ArchiveDerivation | null>;\n readonly filters: readonly string[];\n readonly name: string;\n readonly tags: readonly string[];\n}\ninterface ArchivePage<T> {\n cursor: string | null;\n entries: readonly T[];\n}\ntype ArchiveSort = 'liked' | 'recent';\ntype ArchiveDerivationStage = 'canonicalize' | 'hash' | 'index' | 'schema';\ntype ArchiveIdentity = { id: string; name: string; operator: boolean } | null;\ntype ArchiveAccount = {\n id: string; email: string; createdAt: string; lastSignInAt: string | null;\n};\ntype ArchiveCollectionMeta = { filters: string[]; name: string; tags: string[] };\ntype ArchiveLikesPage = { cursor: string | null; ids: string[] };\ntype ArchiveErrorCode =\n | 'account-changed' | 'auth-unavailable' | 'bad-request' | 'conflict' | 'forbidden' | 'gone'\n | 'internal' | 'invalid-payload' | 'likes-disabled' | 'method-not-allowed'\n | 'not-found' | 'publisher-changed' | 'publishing-disabled' | 'quota'\n | 'rate-limited' | 'record-unavailable' | 'timeout' | 'too-large'\n | 'unauthenticated' | 'unavailable' | 'unsupported-media';\ntype ArchiveErrorResponse = {\n error: { code: ArchiveErrorCode; message: string; requestId: string };\n};\ntype ArchiveHealth = {\n dbSchemaVersion: number | null;\n features: { likes: boolean; publishing: boolean };\n limits: {\n aliasChars: number; cursorChars: number; idempotencyRetentionMs: number;\n likeStateIds: number; maxPageSize: number; maxPublications: number;\n pageSize: number; payloadBytes: number; publicCacheSeconds: number;\n reportReasonChars: number; requestBytes: number; searchChars: number;\n searchTerms: number; tagsPerEntry: number; titleChars: number; vocabChars: number;\n };\n schemaVersion: 1;\n status: 'ok' | 'degraded';\n reason: 'database-unavailable' | 'schema-mismatch' | null;\n};\ntype AuthHealth = {\n dbSchemaVersion: number | null;\n schemaVersion: 1;\n status: 'ok' | 'degraded';\n reason: 'configuration-unavailable' | 'database-unavailable' | 'schema-mismatch' | null;\n};\ntype ArchiveReports = {\n cursor: string | null;\n reports: {\n id: string; createdAt: string; entryId: string; entryTitle: string;\n reason: string; reporter: { id: string; name: string }; resolvedAt: string | null;\n }[];\n};\ntype ArchiveOwnReports = {\n cursor: string | null;\n reports: {\n collection: string; createdAt: string; entryId: string; id: string;\n reason: string; resolvedAt: string | null;\n }[];\n};\ntype ArchiveModerationAction = 'hide' | 'unhide' | 'remove' | 'erase-publisher';\ntype ArchiveModerationLog = {\n cursor: string | null;\n actions: {\n id: string; action: ArchiveModerationAction; collection: string | null;\n createdAt: string; operatorId: string; targetId: string;\n }[];\n};\n```\n\nWorker subpath types model only the binding methods the catalog calls.\n\n```ts\ninterface D1Statement {\n all<T>(): Promise<{ results?: T[] }>;\n bind(...params: (string | number | null)[]): D1Statement;\n first<T>(): Promise<T | null>;\n run(): Promise<{ meta?: { changes?: number } }>;\n}\ninterface D1Like {\n batch(statements: D1Statement[]): Promise<{ meta?: { changes?: number } }[]>;\n prepare(sql: string): D1Statement;\n}\ninterface RateLimiterBinding {\n limit(input: { key: string }): Promise<{ success: boolean }>;\n}\ninterface ArchiveWorkerEnv {\n AUTH_ISSUER_SERVICE: AuthIssuerServiceBinding;\n ALLOWED_ORIGINS: string;\n APP_ENV: string;\n DB: D1Like;\n JWT_AUDIENCE: string;\n JWT_ISSUER: string;\n JWT_JWKS_URL: string;\n LIKES_ENABLED: string;\n PUBLISHING_ENABLED: string;\n RATE_LIMIT_KEY_SECRET: string;\n READ_LIMIT: RateLimiterBinding;\n WRITE_LIMIT: RateLimiterBinding;\n}\ninterface AuthIssuerServiceBinding {\n fetch(request: Request): Promise<Response>;\n}\ninterface ArchiveExecutionContext {\n waitUntil(promise: Promise<unknown>): void;\n}\ntype ArchiveDiagnostic =\n | { readonly kind: 'configuration'; readonly field: string }\n | { readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection' };\ninterface ArchiveRequestEvent {\n code: ArchiveErrorCode | 'ok';\n diagnostic?: ArchiveDiagnostic;\n ms: number;\n requestId: string;\n route: string;\n status: number;\n type: 'request';\n}\ntype ArchiveScheduledEvent =\n | { removed: number; type: 'scheduled'; status: 'ok' }\n | { type: 'scheduled'; status: 'failed'; diagnostic: ArchiveDiagnostic };\ntype ArchiveEvent =\n | ArchiveRequestEvent\n | ArchiveScheduledEvent\n | { readonly type: 'cache-error'; readonly diagnostic: ArchiveDiagnostic };\ninterface ArchiveWorkerHandler<E extends ArchiveWorkerEnv = ArchiveWorkerEnv> {\n fetch(request: Request, env: E, ctx: ArchiveExecutionContext): Promise<Response>;\n scheduled?(controller: unknown, env: E, ctx: ArchiveExecutionContext): void | Promise<void>;\n tap(handler: (event: ArchiveEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;\n}\n```\n\nScheduled `removed` counts the combined expired retry-key and revocation rows.\n\nAuth subpath types describe the sibling issuer and its required service binding.\n\n```ts\ninterface SendEmail {\n send(message: { from: string; html?: string; subject: string; text: string; to: string }): Promise<unknown>;\n}\ninterface AuthArchiveBinding {\n fetch(request: Request): Promise<Response>;\n}\ninterface AuthWorkerEnv {\n ALLOWED_ORIGINS: string;\n APP_ENV: string;\n APP_NAME: string;\n AUDIENCE: string;\n AUTH_ARCHIVE: AuthArchiveBinding;\n AUTH_DB: D1Like;\n AUTH_EMAIL: SendEmail;\n AUTH_ISSUER: string;\n AUTH_JWT_PREVIOUS_PUBLIC_JWKS?: string;\n AUTH_JWT_PRIVATE_JWK: string;\n OPERATOR_EMAILS: string;\n PUBLIC_ORIGIN: string;\n SENDER_ADDRESS: string;\n}\ninterface AuthWorkerHandler<E extends AuthWorkerEnv = AuthWorkerEnv> {\n fetch(request: Request, env: E, ctx?: ArchiveExecutionContext): Promise<Response>;\n scheduled(controller: unknown, env: E, ctx?: ArchiveExecutionContext): Promise<void>;\n tap(handler: (event: AuthEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;\n}\ntype AuthDiagnostic = {\n readonly kind: 'configuration' | 'database' | 'email' | 'catalog' | 'internal';\n};\ntype AuthEvent =\n | {\n readonly type: 'request';\n readonly requestId: string;\n readonly route:\n | '/auth/account'\n | '/auth/health'\n | '/auth/jwks.json'\n | '/auth/session'\n | '/auth/sign-in'\n | '/auth/sign-out-all'\n | '/auth/verify'\n | 'unmatched';\n readonly status: number;\n readonly code: ArchiveErrorCode | 'ok';\n readonly ms: number;\n readonly diagnostic?: AuthDiagnostic;\n }\n | { readonly type: 'scheduled'; readonly status: 'ok'; readonly removed: number }\n | { readonly type: 'scheduled'; readonly status: 'failed'; readonly diagnostic: AuthDiagnostic };\n```\n\nIssuer scheduled `removed` counts expired token rows, closed per-user send windows\nand deleted unverified accounts. The singleton admission row is retained and its\nwindow resets atomically on a later sign-in request.\n\n## Errors\n\n| Exported class | Trigger | Properties |\n| --- | --- | --- |\n| `ArchiveError` | Base for public package errors | `name`, `message`, optional `cause` |\n| `ArchiveDefinitionError` | Invalid collection definition or duplicate registered collection names | Extends `ArchiveError` |\n| `ArchiveDerivationError` | Unexpected schema/canonicalization/index/hash fault or violated collection invariant | Extends `ArchiveError`; readonly `stage: ArchiveDerivationStage`; optional `cause` |\n\n`ArchiveError` and `ArchiveDefinitionError` accept `(message: string, opts?: ErrorOptions)`.\n`ArchiveDerivationError` accepts `(stage: ArchiveDerivationStage, message: string, opts?: ErrorOptions)`.\nIts message and cause are local diagnostics, not response content. Worker request\nfailures use the wire envelope, not these thrown classes. Internal `ApiError` and\n`AuthOperationError` are not exported.\n\nStored entries that fail the current wire schema return 503 `record-unavailable`.\nStored payloads are canonical by construction, so the emitted entry schemas embed\nthe plain payload schema: parsing a response re-runs that schema, and an\nexception from one of its callbacks becomes a sanitized 500.\n\nThe complete public error signatures are:\n\n```ts\nclass ArchiveError extends Error {\n constructor(message: string, opts?: ErrorOptions);\n}\nclass ArchiveDefinitionError extends ArchiveError {}\nclass ArchiveDerivationError extends ArchiveError {\n readonly stage: ArchiveDerivationStage;\n constructor(stage: ArchiveDerivationStage, message: string, opts?: ErrorOptions);\n}\n```\n\nCatalog rate-limit responses use `Retry-After: 60`; configure catalog limiter periods to 60 seconds. Issuer admission/send-budget responses instead carry seconds until their durable window reopens. Health's degraded response is the health DTO, not an error envelope: schema mismatch reports the observed version; database unavailability reports `dbSchemaVersion: null`.\n",
6
+ "usage": "---\ntitle: \"Archive: Usage Guide\"\ndescription: \"Define a collection, initialize fresh D1 databases, deploy the Workers, and wire your client and observation policy.\"\n---\n\n[[toc]]\n\n## Basic Usage\n\nA deployment is a collection definition plus one factory call. Put the definition in a\nmodule both sides import — the browser needs the composed schemas, the Worker needs\nthe collection itself.\n\n```ts\n// recipes-collection.ts — the one content contract, shared by both sides.\nimport { defineArchiveCollection } from '@vielzeug/archive';\nimport { s } from '@vielzeug/spell';\n\nconst payloadSchema = s.object({\n cuisine: s.enum(['italian', 'japanese']),\n ingredients: s.array(s.string().trim().min(1).max(64)).min(1).max(20),\n name: s.string().trim().min(1).max(32),\n});\n\nexport const recipesCollection = defineArchiveCollection({\n // Ingredient order is not identity: the canonical form sorts, so reordered\n // content hashes identically and updates as a no-op.\n canonicalize: (payload) => ({ ...payload, ingredients: [...payload.ingredients].sort() }),\n contentVersion: 1,\n filters: ['italian', 'japanese'],\n index: (payload) => ({\n filter: payload.cuisine,\n tags: [payload.ingredients.length <= 5 ? 'quick' : 'involved'],\n title: payload.name,\n }),\n name: 'recipes',\n payloadSchema,\n tags: ['quick', 'involved'],\n});\n```\n\nThe Worker entry imports the collection and exports the built handler:\n\n```ts\n// backend/src/index.ts — the whole deployment.\nimport { createArchiveWorker } from '@vielzeug/archive/worker';\nimport { recipesCollection } from './recipes-collection';\n\nconst worker = createArchiveWorker([recipesCollection]);\n\n// One sanitized log line per request: ids, codes and route classes only.\nworker.tap((event) => console.log(JSON.stringify(event)));\n\nexport default worker;\n```\n\nThe browser imports the same collection for its schemas — never the worker subpath:\n\n```ts\nimport { recipesCollection } from './recipes-collection';\nimport { archiveIdentitySchema, PUBLISHER_HEADER } from '@vielzeug/archive';\n\nconst response = await fetch('/api/v1/collections/recipes/entries');\nif (!response.ok) throw new Error('Archive unavailable');\nconst page = recipesCollection.contract.page.parse(await response.json());\nconst meResponse = await fetch('/api/v1/me', { credentials: 'same-origin' });\nif (!meResponse.ok) throw new Error('Identity unavailable');\nconst me = archiveIdentitySchema.parse(await meResponse.json());\nif (me === null) throw new Error('Register a publisher first');\nconst mineResponse = await fetch('/api/v1/me/publications?collection=recipes', {\n credentials: 'same-origin', headers: { [PUBLISHER_HEADER]: me.id },\n});\nif (!mineResponse.ok) throw new Error('Own publications unavailable');\nconst owned = recipesCollection.contract.ownedEntries.parse(await mineResponse.json());\nconsole.log(page.entries, owned.entries);\n```\n\n## The collection contract\n\n`defineArchiveCollection` is the only place content knowledge lives. The definition is\nvalidated at registration — a bad `name`, `contentVersion`, or any vocabulary value\n(well-formed text, 1..`vocabChars` chars) throws there, never mid-request — and every\nordinary invalid content returns `null` → `422 invalid-payload`. Collection\nimplementation faults instead throw `ArchiveDerivationError` → sanitized\n`500 internal`:\n\n- **Parse** — accept stable JSON objects only; schema outputs must remain JSON.\n- **Canonicalize** — optional; a result that leaves the payload schema throws, so a\n broken normalizer can never store invalid content.\n- **Index** — the returned `filter` must be declared in `filters`, every `tag` in\n `tags` (duplicates collapse), and the `title` is trimmed and bounded\n (`titleChars`, well-formed text). A derivation outside the declared vocabulary\n would store a row no list query can reach — it throws instead.\n- **Size** — the canonical JSON must fit `payloadBytes`.\n- **Identity** — the content hash is SHA-256 over `{contentVersion, payload}`:\n identical content hashes identically, a real edit always re-identifies, and the\n core owns the envelope so collections cannot drift it.\n\nDeclare vocabularies with readonly arrays (`['fire', 'ice']`) for automatic\nliteral-union inference. Const generics retain the version literal and vocabulary\nunions; `NoInfer` prevents the index callback from widening them. Omitted arrays\nmean empty vocabularies: return `null` for\nthe filter and no tags. Object keys are recursively sorted by the core; schema\ntransformations must stabilize and `canonicalize` must be pure and idempotent.\nNon-JSON input returns `null`. Unexpected callback, schema or hashing failures,\nunstable transformations, non-idempotent canonicalization and index mutation throw\n`ArchiveDerivationError`; its `stage` identifies the failing phase.\nLossy or non-JSON schema output and invalid index-result shapes also throw.\n\n`contentVersion` participates in content identity. Request handling validates\nonly the current collection definition; it does not interpret historical schemas.\nArchive provides no content-upgrade APIs: a new definition never reinterprets\nstored rows.\n\nFilters, tags and searchable titles are persisted data derived at publish/update\ntime. Editing `index` or its vocabularies does not recompute existing projections.\nKeep the active definition and dataset consistent. For a development\ndeployment, deliberately reset and reseed a fresh database when you change payload\nor projection semantics; the package does not supply a migration framework.\nResetting destroys existing data. Perform it only as an explicit, deliberate\ndevelopment operation, not automatically during startup or deployment.\n\n**Payloads carry decisions, not content.** The payload schema should accept\nuser-authored choices expressed as IDs plus user-written text — never the content's\nown copyrighted expression (card text, artwork, rules), never more than the game's\nown identifiers. For games with paid or DLC content this is the whole answer:\nownership resolves on each viewer's device against their own library, the service\nnever learns what anyone owns, and the strict schema is the enforcement seam —\nuse a strict schema that rejects unknown fields, so a collection that only models IDs cannot\naccidentally start shipping paid content. For example, published game builds can\nreference cards, equipment and potions by ID plus a user-written name. The client\nrenders pieces the viewer does not own as unavailable. Keep the browser-safe\npublication contract in a shared domain module consumed by both the frontend and\nits backend, without importing frontend application code into the Worker.\n\n## The Worker and its environment\n\n`createArchiveWorker(collections)` returns the full handler: anonymous public reads,\ncookie-authenticated mutations behind exact-origin and request-marker guards, local\nRS256/JWKS verification followed by an uncached issuer session-version check,\nper-IP read limits and per-subject write limits keyed by peppered HMACs, the\n30-second anonymous cache on public entry lists only, operator moderation, and a\nscheduled expiry sweep for retired publish retry keys and revocation tombstones.\nHealth sits outside the rate-limit and session pipelines: probes never burn the\nanonymous read budget or call the issuer.\n\nThe Worker reads its environment fail-closed — every binding must be present and\nwell-formed, or the route answers 503 without naming what is missing:\n\n| Variable | Meaning |\n| ------------------------ | ---------------------------------------------------------------- |\n| `APP_ENV` | `local` or `production` (transport rules and the pepper policy) |\n| `ALLOWED_ORIGINS` | Exact canonical origins; never wildcards |\n| `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | Your identity provider |\n| `AUTH_ISSUER_SERVICE` | Required issuer service binding for live session-version checks |\n| `LIKES_ENABLED` / `PUBLISHING_ENABLED` | Feature flags |\n| `RATE_LIMIT_KEY_SECRET` | HMAC pepper. Required ≥ 32 chars in production; local may fall back |\n| `DB` / `READ_LIMIT` / `WRITE_LIMIT` | D1 + rate-limit bindings |\n\nBoth limiter bindings must run 60-second windows (`simple` limiters:\n`period: 60`): the platform binding does not expose its period at runtime, so the\nWorker's 429 `Retry-After` header is fixed at 60 seconds — a longer window would\nmake the header lie.\n\nGenerated Env types (wrangler's `worker-configuration.d.ts`) satisfy the package's\nstructural `ArchiveWorkerEnv` — no `@cloudflare/workers-types` dependency, no casts.\n\n## The auth issuer\n\n`createAuthWorker()` from `@vielzeug/archive/auth` is the reference passwordless\nidentity issuer a deployment runs beside the catalog Worker: one POST with an email\nsends a single-use magic link, and explicitly confirming it sets the `archive_session`\ncookie. The catalog verifies the JWT locally, then calls the issuer through\n`AUTH_ISSUER_SERVICE` to check the account's current session version. Requests create\npending account rows; explicit link redemption atomically stamps the account's\nsign-in time and claims the token. The issuer's D1 schema ships at\n`node_modules/@vielzeug/archive/migrations/auth/`; point the issuer's\n`migrations_dir` there. The catalog's `JWT_ISSUER` and `JWT_JWKS_URL` must identify\nthe issuer, and its audience must match the issuer's `AUDIENCE`.\n\n| Variable | Meaning |\n| --- | --- |\n| `APP_NAME` | The app's display name: the magic-link email's subject and copy name it |\n| `ALLOWED_ORIGINS` | Exact origins allowed to trigger unsafe routes (the app origin(s)) |\n| `APP_ENV` | `local` or `production` — production requires HTTPS and sets Secure cookies |\n| `AUDIENCE` / `AUTH_ISSUER` | The claims every minted session carries; the catalog verifies the same pair |\n| `AUTH_DB` / `AUTH_EMAIL` | The issuer's D1 and send-email bindings |\n| `AUTH_ARCHIVE` | Required service binding to the catalog Worker; deletion calls its profile-erasure route |\n| `AUTH_JWT_PRIVATE_JWK` | The RS256 private key as a JWK JSON string: a wrangler secret in production |\n| `AUTH_JWT_PREVIOUS_PUBLIC_JWKS` | Optional retired public RS256 JWKS; publish during rotation until old sessions expire |\n| `OPERATOR_EMAILS` | Comma-separated emails whose newly minted sessions carry the operator role |\n| `PUBLIC_ORIGIN` | App origin for links, confirmation POST origin checks and client navigation after sign-in |\n| `SENDER_ADDRESS` | The address magic links come from: a domain onboarded to Cloudflare Email Service |\n\nUnder `wrangler dev` the send-email binding is simulated — the link prints to the\nissuer's console, so that console is the inbox. Demo-only Alice/Bob routes are\ndeployment wrappers, not package APIs.\n\nThe issuer first admits sign-in requests through its singleton durable\n`auth_sign_in_budget` table: 20 requests per 60 seconds before creating accounts\nor delivering email. No rate-limiter binding is required. It then\npermits three sign-in emails per email in an hourly durable window.\nLater database or delivery failures do not refund global admission.\nUsing or expiring a link never refunds sends; failed delivery also spends the\nallowance. Links expire after 15 minutes. Operator sessions expire after 900\nseconds, ordinary sessions after at most 30 days. Retain retired public keys during\nrotation; the active private key signs every new session. Sign-in input trims and\nlowercases email before validation. `SENDER_ADDRESS` must already be canonical;\n`OPERATOR_EMAILS` entries are trimmed and lowercased before validation.\n\nThe scheduled sweep deletes expired links and closed per-user send windows, then\npending accounts only when neither a link nor send-budget row still references\nthem. Failed delivery deletes the unusable token but retains the spent allowance;\nit does not immediately remove the pending account. Verified accounts stay until\nexplicit erasure. The singleton admission row remains and resets its window on a\nlater admitted request.\n\nOpening `GET /auth/verify?token=…` renders a no-store confirmation page and does\nnot consume the link or change the session, so email scanners cannot sign in by\nfetching it. A deliberate click sends `POST /auth/verify` with JSON `{ token }`,\nthe request marker and an exact allowed origin or `PUBLIC_ORIGIN`.\nSuccess is 204 with Set-Cookie; the page then navigates to `/signed-in`. The\nredemption update and account sign-in stamp are trigger-backed and atomic.\nThe page removes the token from the address bar, loads no external assets, and\nsets a restrictive CSP and `Referrer-Policy: no-referrer`.\n\n`GET /auth/account` exports account metadata only. For full erasure, call\n`DELETE /auth/account` with `ACCOUNT_HEADER` (`X-Archive-Account`) set to the\nissuer account UUID captured from `GET /auth/account` before confirmation.\nThe issuer rejects missing or invalid UUIDs with 400 and a different signed-in\naccount with 409 `account-changed`, before any erasure. This guard applies even\nbefore publisher registration and must stay pinned across retries.\nThe issuer forwards the caller's session and publisher\nprecondition through `AUTH_ARCHIVE` to `DELETE /me/account`, requires exactly 204,\nthen deletes its\nown user and clears the cookie. Failure preserves the session for retry. Direct\n`DELETE /me` removes only the Archive profile and leaves the cookie intact.\n\nSeveral game frontends can share one issuer and catalog. Keep the cookie\nhost-only on the central API host, use `credentials: 'include'` from same-site\nHTTPS game subdomains, and allow each exact frontend origin in both Workers.\nDo not share cookies through a parent-domain `Domain` attribute. The\n[multi-collection recipe](./examples/multi-collection-deployment.md) describes\nthe topology, session revalidation and platform-wide account boundary.\n\n`DELETE /me/account` atomically revokes the issuer/subject and cascades profile\ndeletion. Its tombstone lasts 30 days plus ten seconds; old sessions cannot read\nprivate data or register again, including a racing registration. The dedicated\naccount-erasure route alone may bypass the issuer lookup, and only to retry with\nthe same principal's original signed session while its existing tombstone is\nvalid; signature and claims are still verified, and initial erasure validates the\nissuer. The issuer's `DELETE /auth/account` checks the current version before\nstarting erasure; after completed erasure a retry there returns 401. A fresh\nissuer account gets a new UUID subject. External issuers must use a new subject to\nre-enroll during the revocation window. Profile-only deletion does not revoke the\nsubject and permits re-registration with existing credentials.\n\nEach JWT also carries the issuer account's required safe-integer `sessionVersion`.\nThe issuer compares it with `auth_users.session_version` on every ordinary\nauthenticated route. A JWT with an old version is unauthenticated even before its\nexpiry; the catalog asks the issuer on every private request and does not cache a\nsuccessful verdict. A valid local JWT with an unavailable, malformed, 5xx or\ntimed-out (fixed 5000 ms, including body decoding) issuer response becomes\n`503 auth-unavailable`, not anonymous identity.\n\n## The database\n\nThe package ships the canonical catalog migration:\n`node_modules/@vielzeug/archive/migrations/0001_community_archive.sql` — the whole schema,\nmoderation included, as one designed file. The runtime SQL in the package is\nwritten against exactly this schema, and the package's own integration suite runs\nagainst the same file. Point wrangler at the directory:\n\n```jsonc\n// wrangler.jsonc — the deployment owns its D1 database, not its DDL.\n{\n \"d1_databases\": [\n {\n \"binding\": \"DB\",\n \"database_name\": \"my-app\",\n \"migrations_dir\": \"./node_modules/@vielzeug/archive/migrations\"\n }\n ]\n}\n```\n\nThe issuer points to\n`node_modules/@vielzeug/archive/migrations/auth/0001_auth.sql`. Both files are\nfresh-database initialization definitions, not upgrades. The issuer DDL initializes\n`auth_users.session_version` to `0` and stamps `auth_schema_meta.applied` as\n`AUTH_SCHEMA_VERSION` (`1`). Neither file automatically deletes or resets data.\nArchive's canonical initial schemas are edited directly. Reset and reseed a\ndevelopment database deliberately when its schema or collection semantics no\nlonger match; reapplying the initial file does not update an existing dataset.\nThe initialization file also stamps the storage schema\n(`archive_schema_meta`) at `ARCHIVE_SCHEMA_VERSION`, and health reads it back: a\ndatabase with a mismatched stamp answers `status: 'degraded'` with the\nobserved `dbSchemaVersion` and HTTP 503 (`reason: 'schema-mismatch'`).\nAn unavailable database produces HTTP 503 with `dbSchemaVersion: null` and\n`reason: 'database-unavailable'`. The debut storage stamp is `1`. A unit\ntest pins the shipped DDL's `CHECK` bounds to `ARCHIVE_LIMITS`, so the two can never\ndrift silently. The stamp does not detect changes in your collection's payload\nschema, vocabulary or indexing function; you own that dataset consistency.\n\n## Routes and wire names\n\n```\nGET /api/v1/health\nPOST /api/v1/sign-out\nGET /api/v1/auth/health\nGET /api/v1/auth/session (internal issuer check)\nPOST /api/v1/auth/sign-out-all (revoke every session)\nGET /api/v1/collections/:collection (self-description)\nGET /api/v1/collections/:collection/entries (author, filter, tag, search, sort, cursor, limit)\nPOST /api/v1/collections/:collection/entries\nGET /api/v1/collections/:collection/entries/:id\nPUT /api/v1/collections/:collection/entries/:id (expectedRevision)\nDELETE /api/v1/collections/:collection/entries/:id\nPUT /api/v1/collections/:collection/entries/:id/like\nDELETE /api/v1/collections/:collection/entries/:id/like\nPOST /api/v1/collections/:collection/entries/:id/report (reason)\nPUT /api/v1/collections/:collection/entries/:id/hidden (operator)\nDELETE /api/v1/collections/:collection/entries/:id/hidden (operator)\nGET /api/v1/collections/:collection/reports (operator: status, cursor, limit)\nPUT /api/v1/collections/:collection/reports/:id/resolved (operator: report ID)\nDELETE /api/v1/publishers/:id (operator: erase any publisher)\nGET /api/v1/moderation-log (operator: cursor, limit)\nGET /api/v1/me POST /me PUT /me DELETE /me\nGET /api/v1/me/publications?collection=:collection GET /api/v1/me/likes[?ids=…]\nGET /api/v1/me/reports (cursor, limit)\nGET /api/v1/auth/account\nGET /api/v1/auth/jwks.json\nPOST /api/v1/auth/sign-in (email)\nDELETE /api/v1/auth/account (coordinated full erasure)\nDELETE /api/v1/me/account (issuer erasure seam; revokes subject)\nGET /api/v1/auth/verify?token=… (confirmation only)\nPOST /api/v1/auth/verify (guarded {token} redemption)\n```\n\nPublishing is bounded by `maxPublications` live entries per `(collection, owner)`.\nThe quota check runs inside the guarded insert.\n\nEvery list — entry pages, `/me/likes` without `ids`, `/me/reports`, operator\nreports and `/moderation-log` — pages forward-only: follow `cursor` until `null`,\npreserving query context; a cursor minted for another query is a 400. `limit`\ndefaults to `pageSize` and is at most `maxPageSize`; there is no total export\nbound. `/me/likes?ids=…` remains a subset lookup returning `{ ids }`, not a\npage, and cannot combine `ids` with `cursor` or `limit`.\n\nShared wire names live in the package — `archive_session` cookie,\n`x-archive-publisher` and `x-archive-request` headers, `ARCHIVE_ERROR_CODES`, and\n`ARCHIVE_LIMITS` — so a client and the Worker cannot drift. Parse failures with the\ncollection's composed schemas; parse errors with `archiveErrorResponseSchema`, the\nsign-out body with `archiveOkSchema`, a collection's self-description with\n`archiveCollectionMetaSchema` and the operator queue with `archiveReportsSchema`.\nEvery entry's `schemaVersion` is the archive contract's version, not the\ncollection's; the collection's own format version lives in its hash envelope and in\neach entry's `contentVersion`.\n\nOnly anonymous entry lists carry a 30-second edge-cache contract\n(`publicCacheSeconds`). Detail is always `no-store`. Any Cookie or Authorization\nheader disables caching, regardless of validity; metadata, private and error\nresponses also use `no-store`.\n\nImplement UI ownership checks in your application by comparing the current\npublisher ID to the entry's `author.id`; there is no package action-policy helper.\nServer mutations remain authoritative.\n\n`POST /api/v1/sign-out` is deliberately local: it clears only this browser's\ncookie and does not contact the issuer or revoke copied sessions. To revoke every\nsession, post to `/api/v1/auth/sign-out-all` with the mutation marker, an exact\nallowed `Origin`, and the issuer account UUID captured from\n`GET /api/v1/auth/account` in `X-Archive-Account`. The issuer returns 409\n`account-changed` if the active subject differs. On success it atomically increments\n`session_version`, deletes outstanding links, preserves account and catalog data,\nand returns `{ ok: true }` with an expired cookie. Requests authorized before\nrevocation may finish; there is no cross-database cancellation guarantee.\n\n`GET /api/v1/auth/health` is public and read-only. It validates issuer\nconfiguration, key signing/verification, binding shapes, the `auth_schema_meta`\nstamp and expected tables, columns and triggers, returns `AuthHealth` through\n`archiveAuthHealthSchema`, and answers 503 `degraded` for configuration,\ndatabase or schema failures. It never writes, sends email or probes the catalog.\n\n## Moderation\n\nOperator authority is the session token's optional `roles` claim carrying\n`ARCHIVE_OPERATOR_ROLE` (`'archive-operator'`) — minted by your issuer, verified with\nthe same signature, audience and issuer checks as any session, and never a separate\ncredential. Moderation routes additionally require a registered publisher row: one\naccountable identity per action.\n\n- **Report** — any registered publisher reports a visible entry with a bounded\n free-text reason (`reportReasonChars`). Idempotent per (entry, reporter): the same\n report twice is the same 204. Missing or hidden entries answer 404 — there is\n nothing left to report.\n- **Hide** — an operator removes an entry from every public surface (list, detail,\n likes) with a soft-hide: the row, its likes and its retry key stay intact. The\n owner still sees the entry in `/me/publications` with `hidden: true`, edits and\n key replay are frozen (403), removal stays allowed, and unhiding restores\n everything. Already hidden or already visible answer the same 204.\n- **Queue** — the operator pages newest reports with entry title and reporter\n identity; `status` is `unresolved` (default) or `resolved`. Each report has its\n own `id` and nullable `resolvedAt`. Resolve it with\n `PUT /collections/:collection/reports/:id/resolved`; repeating is a 204 no-op.\n- **Log** — every effective moderation action (hide, unhide, removal, publisher\n erasure) appends one accountability row in the same transaction that applies it,\n and `GET /api/v1/moderation-log` pages the newest actions to\n operators. Rows carry only ids, the action kind and the time — no names (they\n change), no content — and no foreign keys: the audit must outlive the erasures it\n records. Idempotent repeats (hiding an already-hidden entry, erasing an\n already-erased publisher) answer the same 204 with no new row, including\n concurrent repeats: audit insertion tests the effective state inside the batch.\n\nLike and unlike both require a visible entry. Their SQL guards visibility during\nthe mutation, and the response checks it again; hiding or deleting an entry\ncannot turn a racing unlike into a successful response for unavailable content.\n\n## Observability\n\nThe core never logs. `worker.tap(handler, { signal? })` emits one `ArchiveEvent` per\nrequest — `{ type: 'request', code, ms, requestId, route, status, diagnostic? }` —\nand successful sweep — `{ type: 'scheduled', status: 'ok', removed }`. Failed\nsweeps emit `{ type: 'scheduled', status: 'failed', diagnostic }` and rethrow so\nthe runtime sees failure. Cache failures emit `{ type: 'cache-error', diagnostic }`.\nThe Worker swallows observer errors so\nobservability cannot affect the Worker. With no handlers registered there is zero\nwork. Route is a finite class like `/collections/:collection/entries`, never a\nraw path; no event carries a URL, cookie, subject, alias or token. Diagnostics are\ntyped sanitized categories, not raw exceptions. A pre-aborted tap signal is inert.\nThe sweep's `removed` counts expired retry keys and revocation tombstones together.\n\nThe issuer also exposes `tap()` with `AuthEvent` from `@vielzeug/archive/auth`.\nIt never logs directly. Events contain finite route labels, codes, counts,\nduration and sanitized `AuthDiagnostic` categories, never emails, tokens or raw\nexceptions. Scheduled failures emit then rethrow; pre-aborted taps are inert.\nIssuer sweep counts include expired tokens, closed per-user send windows and\nremoved pending accounts; the singleton admission row is not removed.\n\n```ts\nimport { createLogger } from '@vielzeug/rune';\nimport { createArchiveWorker } from '@vielzeug/archive/worker';\nimport { recipesCollection } from './recipes-collection';\n\nconst log = createLogger({ namespace: 'recipes' });\nconst worker = createArchiveWorker([recipesCollection]);\nworker.tap((event) => log.debug(event, `archive:${event.type}`));\nexport default worker;\n```\n\n## Testing\n\nThe package proves itself against real workerd + local D1:\n\n```sh\npnpm vitest run packages/archive/src/__tests__/ # fast unit suite\npnpm --filter @vielzeug/archive test:integration # real workerd + local D1 + issuer coverage\npnpm --filter @vielzeug/archive test:types # strict types over src and the integration suite\n```\n\nProve your own collection the way the reference deployment does: a slim deployment\nsuite that publishes over your real vocabulary and asserts the derivation, plus the\npackage's generic suite for the core behavior. A collection with an `index` that can\ndrift out of vocabulary should unit-test `derive` directly: ordinary invalid input\nreturns `null`, while implementation faults throw `ArchiveDerivationError`.\nDirect DDL tests exercise the shipped schema rather than a duplicate schema.\n\n## Framework Integration\n\nThe root entry is plain TypeScript with no framework coupling; Vue, React and Svelte\napps all consume the composed schemas the same way. A Vue 3 composable that pages a\ncollection:\n\n```ts\n// useRecipes.ts — one collection's typed client in any component tree.\nimport { recipesCollection } from './recipes-collection';\nimport { ref, shallowRef } from 'vue';\n\nexport function useRecipes() {\n const entries = shallowRef<ReturnType<typeof recipesCollection.contract.entry.parse>[]>([]);\n const cursor = ref<string | null>(null);\n async function load(sort: 'liked' | 'recent') {\n const params = new URLSearchParams({ sort });\n if (cursor.value !== null) params.set('cursor', cursor.value);\n const response = await fetch(`/api/v1/collections/recipes/entries?${params}`);\n if (!response.ok) throw new Error('The archive is unavailable.');\n const page = recipesCollection.contract.page.parse(await response.json());\n entries.value = [...entries.value, ...page.entries];\n cursor.value = page.cursor;\n }\n return { cursor, entries, load };\n}\n```\n\n## Working with Other Vielzeug Libraries\n\n- [Spell](/spell/) — the payload schema a collection declares; the core composes the\n envelope schemas from it, so the wire and the types cannot drift.\n- [Courier](/courier/) — the HTTP client shape an app's frontend mirrors to consume\n these routes: strict response schemas over one typed port.\n- [Rune](/rune/) — the structured logger a deployment points `tap()` events at.\n\n## Best Practices\n\n- Compose client schemas from `collection.contract`; never hand-write the envelope.\n- Declare exactly the vocabulary your `index` derives, with union types where you\n have them; a value outside it rejects loudly.\n- Keep `canonicalize` pure and schema-true: reordered lists in, canonical form out.\n- Initialize fresh databases with the shipped DDL and deliberately reset/reseed\n development data when collection or storage semantics change.\n- Treat `ARCHIVE_ERROR_CODES` and `archiveErrorResponseSchema` as one tested contract.\n- Pin the expected-publisher header from the identity you captured at sign-in, never from storage alone.\n- Grant the operator role claim only through your issuer, and only to accounts that\n are also registered publishers.\n- Wire a sanitized `tap()` sink and register the rate-limit pepper as a production secret.\n",
7
+ "examples": "---\ntitle: \"Archive: Examples\"\ndescription: \"Task-oriented recipes for @vielzeug/archive, from first deployment to erasure.\"\n---\n\n- [Stand Up a Content Archive](./examples/stand-up-a-content-archive.md)\n- [Content Identity and Versions](./examples/content-identity-and-versions.md)\n- [Multi-Collection Deployment](./examples/multi-collection-deployment.md)\n- [Moderate Content](./examples/moderate-content.md)\n- [Client Error Mapping](./examples/client-error-mapping.md)\n- [GDPR Erasure and Export](./examples/gdpr-erasure-and-export.md)\n"
8
+ },
9
+ "examples": [],
10
+ "typeSignatures": {
11
+ "ArchiveIndexing": "export interface ArchiveIndexing<F extends string = string, T extends string = string> {\n /** One scalar-filter value from the declared vocabulary, or null for \"no filter\". */\n filter: F | null;\n /** Tag values from the declared vocabulary; duplicates collapse in first-seen order. */\n tags: readonly T[];\n /** The searchable title; the core trims and bounds it. */\n title: string;\n}",
12
+ "ArchiveCollectionDefinition": "export interface ArchiveCollectionDefinition<\n P extends object,\n C extends number,\n F extends string = never,\n T extends string = never,\n> {\n /**\n * Normalizes a parsed payload to its storage form — fixed key order, sorted\n * order-insensitive lists — so equivalent content hashes identically. Omit when\n * the payload is already canonical; a result that leaves the payload schema is\n * a derivation fault at derive time, never stored. Must be idempotent.\n */\n canonicalize?(payload: P): P;\n /** The collection's payload-format version; stored and re-emitted with every entry. */\n contentVersion: C;\n /** Valid scalar-filter values; a query param outside the vocabulary is 400. */\n filters?: readonly F[];\n /** Derives the list dimensions and the searchable title from the canonical payload. */\n index(payload: P): ArchiveIndexing<NoInfer<F>, NoInfer<T>>;\n /** The URL + storage dimension this collection owns: lowercase `[a-z0-9-]`, at most 32 chars. */\n name: string;\n /** The strict payload schema: the whole content contract, parsed before anything derives. */\n payloadSchema: Schema<P, unknown>;\n /** Valid tag values. */\n tags?: readonly T[];\n}",
13
+ "ArchiveDerivation": "export interface ArchiveDerivation {\n /** The stored bytes: the canonical payload, serialized with fixed key order. */\n canonicalJson: string;\n /** SHA-256 hex over `{contentVersion, payload}`: identical content hashes identically. */\n contentHash: string;\n /** The collection's declared payload-format version. */\n contentVersion: number;\n filter: string | null;\n tags: readonly string[];\n title: string;\n}",
14
+ "ArchiveEntry": "export interface ArchiveEntry<P = unknown, C extends number = number> {\n author: { id: string; name: string };\n contentHash: string;\n /** The collection's payload-format version, literal-pinned by the definition. */\n contentVersion: C;\n id: string;\n likeCount: number;\n payload: P;\n publishedAt: string;\n revision: number;\n /** The archive wire-contract version, pinned by the core. */\n schemaVersion: typeof ARCHIVE_CONTRACT_VERSION;\n updatedAt: string;\n}",
15
+ "ArchiveOwnedEntry": "export interface ArchiveOwnedEntry<P = unknown, C extends number = number> extends ArchiveEntry<P, C> {\n /** The create retry key; public routes never emit it. */\n clientKey: string;\n /** True while an operator has removed the entry from public view (still owned, frozen for edits). */\n hidden: boolean;\n}",
16
+ "ArchiveContract": "export interface ArchiveContract<P = unknown, C extends number = number> {\n /** The public entry emitted by list, detail and publish/update responses. */\n entry: Schema<ArchiveEntry<P, C>>;\n /** The own-publications response: `{ entries: ArchiveOwnedEntry[] }`. */\n ownedEntries: Schema<{ entries: ArchiveOwnedEntry<P, C>[] }>;\n /** The private owner entry — the public entry plus the create retry key. */\n ownedEntry: Schema<ArchiveOwnedEntry<P, C>>;\n /** One list page: entries plus the forward/backward cursors around it. */\n page: Schema<ArchivePage<ArchiveEntry<P, C>>>;\n /** The publish request: `{ clientKey, payload }`. */\n publishInput: Schema<{ clientKey: string; payload: P }, unknown>;\n /** The update request: `{ expectedRevision, payload }`. */\n updateInput: Schema<{ expectedRevision: number; payload: P }, unknown>;\n}",
17
+ "ArchiveCollection": "export interface ArchiveCollection<P extends object = object, C extends number = number> {\n readonly contentVersion: C;\n /** The collection's wire schemas, shared with its client. */\n readonly contract: ArchiveContract<P, C>;\n /**\n * Validates an untrusted payload and derives everything the archive stores.\n * Null rejects invalid content: JSON/schema failures, title/tag bounds or the\n * canonical payload byte ceiling. Routes answer 422 `invalid-payload` for null.\n * Callback failures and deployment invariant violations throw ArchiveDerivationError.\n */\n readonly derive: (raw: unknown) => Promise<ArchiveDerivation | null>;\n readonly filters: readonly string[];\n readonly name: string;\n readonly tags: readonly string[];\n}",
18
+ "defineArchiveCollection": "export function defineArchiveCollection<\n P extends object,\n const C extends number,\n const F extends string = never,\n const T extends string = never,\n>(definition: ArchiveCollectionDefinition<P, C, F, T>): ArchiveCollection<P, C> {\n const { canonicalize, contentVersion, index, name, payloadSchema } = definition;\n if (\n typeof index !== 'function' ||\n !payloadSchema ||\n typeof payloadSchema.safeParse !== 'function' ||\n (canonicalize !== undefined && typeof canonicalize !== 'function')\n ) {\n throw new ArchiveDefinitionError(\n 'Collection requires a payload schema and synchronous indexing/canonicalization functions.',\n );\n }\n if (\n (definition.filters !== undefined && !Array.isArray(definition.filters)) ||\n (definition.tags !== undefined && !Array.isArray(definition.tags))\n ) {\n throw new ArchiveDefinitionError('Collection vocabularies must be arrays.');\n }\n const filters = Object.freeze([...(definition.filters ?? [])]);\n const tags = Object.freeze([...(definition.tags ?? [])]);\n if (typeof name !== 'string' || !ARCHIVE_COLLECTION_NAME_PATTERN.test(name)) {\n throw new ArchiveDefinitionError(`Archive collection name must match [a-z0-9-]{1,32}: ${JSON.stringify(name)}`);\n }\n if (!Number.isSafeInteger(contentVersion) || contentVersion < 1) {\n throw new ArchiveDefinitionError(`Archive collection ${name}: contentVersion must be a safe integer >= 1.`);\n }\n const validateVocabulary = (kind: 'filters' | 'tags', values: readonly string[]): void => {\n for (const value of values) {\n if (\n typeof value !== 'string' ||\n value.length === 0 ||\n value.length > ARCHIVE_LIMITS.vocabChars ||\n !isWellFormedText(value)\n ) {\n throw new ArchiveDefinitionError(\n `Archive collection ${name}: every ${kind} value must be well-formed text of 1..${ARCHIVE_LIMITS.vocabChars} chars.`,\n );\n }\n }\n };\n validateVocabulary('filters', filters);\n validateVocabulary('tags', tags);\n const filterValues = new Set<string>(filters);\n const tagValues = new Set<string>(tags);\n\n const derive = async (raw: unknown): Promise<ArchiveDerivation | null> => {\n let stage: ArchiveDerivationStage = 'schema';\n try {\n if (canonicalObjectJson(raw) === null) return null;\n const parsed = payloadSchema.safeParse(raw);\n if (!parsed.success) return null;\n const parsedJson = canonicalObjectJson(parsed.data, true, raw);\n if (parsedJson === null)\n throw new ArchiveDerivationError(stage, 'Payload schema produced invalid or lossy JSON.');\n stage = 'canonicalize';\n const canonical = canonicalize === undefined ? parsed.data : canonicalize(parsed.data);\n // A canonicalize step that leaves the payload schema is a collection bug, not data to store.\n const normalizedJson = canonicalObjectJson(canonical, true, JSON.parse(parsedJson));\n if (normalizedJson === null) throw new ArchiveDerivationError(stage, 'Canonicalization produced invalid JSON.');\n stage = 'schema';\n const revalidated = payloadSchema.safeParse(JSON.parse(normalizedJson));\n if (!revalidated.success)\n throw new ArchiveDerivationError(\n canonicalize === undefined ? 'schema' : 'canonicalize',\n 'Canonical payload is outside the payload schema.',\n { cause: revalidated.error },\n );\n const canonicalJson = canonicalObjectJson(revalidated.data, true, JSON.parse(normalizedJson));\n if (canonicalJson === null || canonicalJson !== normalizedJson)\n throw new ArchiveDerivationError(stage, 'Payload schema is not stable on canonical JSON.');\n if (canonicalize !== undefined) {\n const repeatedPayload = payloadSchema.parse(JSON.parse(canonicalJson));\n stage = 'canonicalize';\n const repeated = canonicalize(repeatedPayload);\n if (canonicalObjectJson(repeated, true) !== canonicalJson)\n throw new ArchiveDerivationError(stage, 'Canonicalization is not idempotent.');\n }\n\n stage = 'index';\n const { filter, tags: rawTags, title: rawTitle } = index(revalidated.data);\n // Indexing may read the final parsed value, but must not change the stored content.\n if (canonicalObjectJson(revalidated.data, true) !== canonicalJson)\n throw new ArchiveDerivationError(stage, 'Indexing mutated the canonical payload.');\n if (typeof rawTitle !== 'string' || !Array.isArray(rawTags))\n throw new ArchiveDerivationError(stage, 'Indexing must return a string title and a tags array.');\n const title = rawTitle.trim();\n if (title.length === 0 || title.length > ARCHIVE_LIMITS.titleChars || !isWellFormedText(title)) return null;\n if (filter !== null && (typeof filter !== 'string' || !isWellFormedText(filter)))\n throw new ArchiveDerivationError(stage, 'Indexing returned an invalid filter.');\n const uniqueTags: string[] = [];\n for (const tag of rawTags) {\n if (typeof tag !== 'string' || !isWellFormedText(tag))\n throw new ArchiveDerivationError(stage, 'Indexing returned an invalid tag.');\n if (!uniqueTags.includes(tag)) uniqueTags.push(tag);\n }\n // A derivation outside the declared vocabulary would store a row no list query\n // can reach — the query route 400s the very value the collection derived.\n if ((filter !== null && !filterValues.has(filter)) || uniqueTags.some((tag) => !tagValues.has(tag)))\n throw new ArchiveDerivationError(stage, 'Indexing returned a value outside the declared vocabularies.');\n if (uniqueTags.length > ARCHIVE_LIMITS.tagsPerEntry) return null;\n\n if (new TextEncoder().encode(canonicalJson).byteLength > ARCHIVE_LIMITS.payloadBytes) return null;\n stage = 'hash';\n const contentHash = await sha256Hex(`{\"contentVersion\":${contentVersion},\"payload\":${canonicalJson}}`);\n return { canonicalJson, contentHash, contentVersion, filter, tags: uniqueTags, title };\n } catch (cause) {\n if (cause instanceof ArchiveDerivationError) throw cause;\n throw new ArchiveDerivationError(stage, 'Collection content derivation failed.', { cause });\n }\n };\n\n // The cast pins the envelope's payload member to the collection's output type:\n // `payloadSchema` is `Schema<P, unknown>` (input unknown), and inference through\n // the generic shape helper would widen the member to `unknown`.\n const entrySchema: Schema<ArchiveEntry<P, C>> = s.object(envelopeShape(payloadSchema as Schema<P>, contentVersion));\n const ownedEntrySchema: Schema<ArchiveOwnedEntry<P, C>> = s.object({\n ...envelopeShape(payloadSchema as Schema<P>, contentVersion),\n clientKey: uuidSchema,\n hidden: s.boolean(),\n });\n const contract: ArchiveContract<P, C> = {\n entry: entrySchema,\n ownedEntries: s.object({ entries: s.array(ownedEntrySchema) }),\n ownedEntry: ownedEntrySchema,\n page: s.object({\n cursor: s.string().nullable(),\n entries: s.array(entrySchema),\n }),\n publishInput: s.object({ clientKey: uuidSchema, payload: payloadSchema }),\n updateInput: s.object({\n expectedRevision: s.number().int().min(1).max(Number.MAX_SAFE_INTEGER),\n payload: payloadSchema,\n }),\n };\n\n return Object.freeze({ contentVersion, contract: Object.freeze(contract), derive, filters, name, tags });\n}",
19
+ "SESSION_COOKIE_NAME": "export const SESSION_COOKIE_NAME = 'archive_session';",
20
+ "SESSION_COOKIE_PATH": "export const SESSION_COOKIE_PATH = '/api/v1';",
21
+ "SESSION_MAX_AGE_SECONDS": "export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;",
22
+ "OPERATOR_SESSION_MAX_AGE_SECONDS": "export const OPERATOR_SESSION_MAX_AGE_SECONDS = 15 * 60;",
23
+ "sessionCookieValue": "export const sessionCookieValue = (token: string, options: { maxAgeSeconds: number; secure: boolean }): string =>\n `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=${SESSION_COOKIE_PATH}; Max-Age=${options.maxAgeSeconds}${options.secure ? '; Secure' : ''}`;",
24
+ "PUBLISHER_HEADER": "export const PUBLISHER_HEADER = 'x-archive-publisher';",
25
+ "ACCOUNT_HEADER": "export const ACCOUNT_HEADER = 'X-Archive-Account';",
26
+ "REQUEST_MARKER_HEADER": "export const REQUEST_MARKER_HEADER = 'x-archive-request';",
27
+ "ARCHIVE_CONTRACT_VERSION": "export const ARCHIVE_CONTRACT_VERSION = 1;",
28
+ "ARCHIVE_SCHEMA_VERSION": "export const ARCHIVE_SCHEMA_VERSION = 1;",
29
+ "AUTH_SCHEMA_VERSION": "export const AUTH_SCHEMA_VERSION = 1;",
30
+ "ARCHIVE_OPERATOR_ROLE": "export const ARCHIVE_OPERATOR_ROLE = 'archive-operator';",
31
+ "ARCHIVE_COLLECTION_NAME_PATTERN": "export const ARCHIVE_COLLECTION_NAME_PATTERN = /^[a-z0-9-]{1,32}$/;",
32
+ "uuidSchema": "export const uuidSchema: Schema<string> = s.string().uuid();",
33
+ "isoTimestampSchema": "export const isoTimestampSchema: Schema<string> = s.string().isoDateTime();",
34
+ "hex64Schema": "export const hex64Schema: Schema<string> = s.string().regex(/^[0-9a-f]{64}$/);",
35
+ "ILL_FORMED_TEXT": "export const ILL_FORMED_TEXT = /[\\p{Cs}\\p{Cc}]/u;",
36
+ "isWellFormedText": "export const isWellFormedText = (value: string): boolean => !ILL_FORMED_TEXT.test(value);",
37
+ "archiveAliasSchema": "export const archiveAliasSchema: Schema<string> = textString().trim().max(24);",
38
+ "archiveAuthorSchema": "export const archiveAuthorSchema: Schema<{ id: string; name: string }> = s.object({\n id: uuidSchema,\n name: archiveAliasSchema,\n});",
39
+ "archiveIdentitySchema": "export const archiveIdentitySchema: Schema<{ id: string; name: string; operator: boolean } | null> = s\n .object({ id: uuidSchema, name: archiveAliasSchema, operator: s.boolean() })\n .nullable();",
40
+ "ArchiveIdentity": "export type ArchiveIdentity = InferOutput<typeof archiveIdentitySchema>;",
41
+ "archiveAccountSchema": "export const archiveAccountSchema: Schema<{\n createdAt: string;\n email: string;\n id: string;\n lastSignInAt: string | null;\n}> = s.object({\n createdAt: isoTimestampSchema,\n email: s.string().email(),\n id: uuidSchema,\n lastSignInAt: isoTimestampSchema.nullable(),\n});",
42
+ "ArchiveAccount": "export type ArchiveAccount = InferOutput<typeof archiveAccountSchema>;",
43
+ "archiveLikedIdsSchema": "export const archiveLikedIdsSchema: Schema<{ ids: string[] }> = s.object({ ids: s.array(uuidSchema) });",
44
+ "archiveLikesPageSchema": "export const archiveLikesPageSchema: Schema<{ cursor: string | null; ids: string[] }> = s.object({\n cursor: s.string().nullable(),\n ids: s.array(uuidSchema),\n});",
45
+ "ArchiveLikesPage": "export type ArchiveLikesPage = InferOutput<typeof archiveLikesPageSchema>;",
46
+ "archiveLikeStateSchema": "export const archiveLikeStateSchema: Schema<{ id: string; likeCount: number; liked: boolean }> = s.object({\n id: uuidSchema,\n likeCount: s.number().int().min(0).max(Number.MAX_SAFE_INTEGER),\n liked: s.boolean(),\n});",
47
+ "ARCHIVE_LIMITS": "export const ARCHIVE_LIMITS = Object.freeze({\n aliasChars: 24,\n cursorChars: 1024,\n idempotencyRetentionMs: 48 * 60 * 60 * 1000,\n likeStateIds: 50,\n maxPageSize: 50,\n maxPublications: 50,\n pageSize: 10,\n payloadBytes: 8192,\n publicCacheSeconds: 30,\n /** One free-text moderation reason: trimmed, non-empty, at most this many chars. */\n reportReasonChars: 256,\n requestBytes: 12 * 1024,\n searchChars: 64,\n searchTerms: 6,\n tagsPerEntry: 16,\n titleChars: 128,\n /** One declared filter or tag value: well-formed, non-empty, at most this many chars. */\n vocabChars: 64,\n});",
48
+ "archiveLimitsSchema": "export const archiveLimitsSchema: Schema<{\n aliasChars: number;\n cursorChars: number;\n idempotencyRetentionMs: number;\n likeStateIds: number;\n maxPageSize: number;\n maxPublications: number;\n pageSize: number;\n payloadBytes: number;\n publicCacheSeconds: number;\n reportReasonChars: number;\n requestBytes: number;\n searchChars: number;\n searchTerms: number;\n tagsPerEntry: number;\n titleChars: number;\n vocabChars: number;\n}> = s.object({\n aliasChars: s.number().int().min(1),\n cursorChars: s.number().int().min(1),\n idempotencyRetentionMs: s.number().int().min(1),\n likeStateIds: s.number().int().min(1),\n maxPageSize: s.number().int().min(1),\n maxPublications: s.number().int().min(1),\n pageSize: s.number().int().min(1),\n payloadBytes: s.number().int().min(1),\n publicCacheSeconds: s.number().int().min(0),\n reportReasonChars: s.number().int().min(1),\n requestBytes: s.number().int().min(1),\n searchChars: s.number().int().min(1),\n searchTerms: s.number().int().min(1),\n tagsPerEntry: s.number().int().min(1),\n titleChars: s.number().int().min(1),\n vocabChars: s.number().int().min(1),\n});",
49
+ "archiveHealthSchema": "export const archiveHealthSchema: Schema<{\n /** The storage schema stamp (`archive_schema_meta`) the database actually carries. */\n dbSchemaVersion: number | null;\n features: { likes: boolean; publishing: boolean };\n limits: InferOutput<typeof archiveLimitsSchema>;\n schemaVersion: 1;\n /** 'degraded' when the database's storage schema does not match the Worker's. */\n status: 'ok' | 'degraded';\n reason: 'database-unavailable' | 'schema-mismatch' | null;\n}> = s.object({\n dbSchemaVersion: s.number().int().min(0).nullable(),\n features: s.object({ likes: s.boolean(), publishing: s.boolean() }),\n limits: archiveLimitsSchema,\n reason: s.enum(['database-unavailable', 'schema-mismatch'] as const).nullable(),\n schemaVersion: s.literal(ARCHIVE_CONTRACT_VERSION),\n status: s.enum(['ok', 'degraded'] as const),\n});",
50
+ "ArchiveHealth": "export type ArchiveHealth = InferOutput<typeof archiveHealthSchema>;",
51
+ "archiveAuthHealthSchema": "export const archiveAuthHealthSchema: Schema<{\n dbSchemaVersion: number | null;\n schemaVersion: 1;\n status: 'ok' | 'degraded';\n reason: 'configuration-unavailable' | 'database-unavailable' | 'schema-mismatch' | null;\n}> = s.object({\n dbSchemaVersion: s.number().int().min(0).nullable(),\n reason: s.enum(['configuration-unavailable', 'database-unavailable', 'schema-mismatch'] as const).nullable(),\n schemaVersion: s.literal(AUTH_SCHEMA_VERSION),\n status: s.enum(['ok', 'degraded'] as const),\n});",
52
+ "AuthHealth": "export type AuthHealth = InferOutput<typeof archiveAuthHealthSchema>;",
53
+ "ARCHIVE_ERROR_CODES": "export const ARCHIVE_ERROR_CODES = [\n 'account-changed',\n 'auth-unavailable',\n 'bad-request',\n 'conflict',\n 'forbidden',\n 'gone',\n 'internal',\n 'invalid-payload',\n 'likes-disabled',\n 'method-not-allowed',\n 'not-found',\n 'publisher-changed',\n 'publishing-disabled',\n 'quota',\n 'rate-limited',\n 'record-unavailable',\n 'timeout',\n 'too-large',\n 'unauthenticated',\n 'unavailable',\n 'unsupported-media',\n] as const;",
54
+ "ArchiveErrorCode": "export type ArchiveErrorCode = (typeof ARCHIVE_ERROR_CODES)[number];",
55
+ "archiveErrorResponseSchema": "export const archiveErrorResponseSchema: Schema<{\n error: { code: ArchiveErrorCode; message: string; requestId: string };\n}> = s\n .object({\n error: s\n .object({\n code: s.enum(ARCHIVE_ERROR_CODES),\n message: s.string().min(1).max(256),\n requestId: uuidSchema,\n })\n .strict(),\n })\n .strict();",
56
+ "ArchiveErrorResponse": "export type ArchiveErrorResponse = InferOutput<typeof archiveErrorResponseSchema>;",
57
+ "archiveOkSchema": "export const archiveOkSchema: Schema<{ ok: true }> = s.object({ ok: s.literal(true) }).strict();",
58
+ "archiveCollectionMetaSchema": "export const archiveCollectionMetaSchema: Schema<{\n filters: string[];\n name: string;\n tags: string[];\n}> = s.object({\n filters: s.array(s.string().min(1).max(ARCHIVE_LIMITS.vocabChars)),\n name: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN),\n tags: s.array(s.string().min(1).max(ARCHIVE_LIMITS.vocabChars)),\n});",
59
+ "ArchiveCollectionMeta": "export type ArchiveCollectionMeta = InferOutput<typeof archiveCollectionMetaSchema>;",
60
+ "archiveReportsSchema": "export const archiveReportsSchema: Schema<{\n cursor: string | null;\n reports: {\n id: string;\n createdAt: string;\n entryId: string;\n entryTitle: string;\n reason: string;\n reporter: { id: string; name: string };\n resolvedAt: string | null;\n }[];\n}> = s.object({\n cursor: s.string().nullable(),\n reports: s.array(\n s.object({\n createdAt: isoTimestampSchema,\n entryId: uuidSchema,\n entryTitle: s.string().min(1).max(ARCHIVE_LIMITS.titleChars),\n id: uuidSchema,\n reason: s.string().min(1).max(ARCHIVE_LIMITS.reportReasonChars),\n reporter: archiveAuthorSchema,\n resolvedAt: isoTimestampSchema.nullable(),\n }),\n ),\n});",
61
+ "ArchiveReports": "export type ArchiveReports = InferOutput<typeof archiveReportsSchema>;",
62
+ "archiveOwnReportsSchema": "export const archiveOwnReportsSchema: Schema<{\n cursor: string | null;\n reports: {\n collection: string;\n createdAt: string;\n entryId: string;\n id: string;\n reason: string;\n resolvedAt: string | null;\n }[];\n}> = s.object({\n cursor: s.string().nullable(),\n reports: s.array(\n s.object({\n collection: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN),\n createdAt: isoTimestampSchema,\n entryId: uuidSchema,\n id: uuidSchema,\n reason: s.string().min(1).max(ARCHIVE_LIMITS.reportReasonChars),\n resolvedAt: isoTimestampSchema.nullable(),\n }),\n ),\n});",
63
+ "ArchiveOwnReports": "export type ArchiveOwnReports = InferOutput<typeof archiveOwnReportsSchema>;",
64
+ "ARCHIVE_MODERATION_ACTIONS": "export const ARCHIVE_MODERATION_ACTIONS = ['hide', 'unhide', 'remove', 'erase-publisher'] as const;",
65
+ "ArchiveModerationAction": "export type ArchiveModerationAction = (typeof ARCHIVE_MODERATION_ACTIONS)[number];",
66
+ "archiveModerationLogSchema": "export const archiveModerationLogSchema: Schema<{\n cursor: string | null;\n actions: {\n id: string;\n action: ArchiveModerationAction;\n collection: string | null;\n createdAt: string;\n operatorId: string;\n targetId: string;\n }[];\n}> = s.object({\n actions: s.array(\n s.object({\n action: s.enum(ARCHIVE_MODERATION_ACTIONS),\n collection: s.string().regex(ARCHIVE_COLLECTION_NAME_PATTERN).nullable(),\n createdAt: isoTimestampSchema,\n id: uuidSchema,\n operatorId: uuidSchema,\n targetId: uuidSchema,\n }),\n ),\n cursor: s.string().nullable(),\n});",
67
+ "ArchiveModerationLog": "export type ArchiveModerationLog = InferOutput<typeof archiveModerationLogSchema>;",
68
+ "ArchiveSort": "export type ArchiveSort = 'liked' | 'recent';",
69
+ "archiveSortSchema": "export const archiveSortSchema: Schema<ArchiveSort> = s.enum(['liked', 'recent'] as const);",
70
+ "ArchivePage": "export interface ArchivePage<T> {\n /** Cursor for the next page; null after the last. */\n cursor: string | null;\n entries: readonly T[];\n}",
71
+ "ArchiveError": "export class ArchiveError extends Error {\n constructor(message: string, opts?: ErrorOptions) {\n super(message, opts);\n this.name = new.target.name;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}",
72
+ "ArchiveDefinitionError": "export class ArchiveDefinitionError extends ArchiveError {}",
73
+ "ArchiveDerivationStage": "export type ArchiveDerivationStage = 'canonicalize' | 'hash' | 'index' | 'schema';",
74
+ "ArchiveDerivationError": "export class ArchiveDerivationError extends ArchiveError {\n readonly stage: ArchiveDerivationStage;\n\n constructor(stage: ArchiveDerivationStage, message: string, opts?: ErrorOptions) {\n super(message, opts);\n this.stage = stage;\n }\n}"
75
+ }
76
+ }
@@ -1,10 +1,10 @@
1
1
  {
2
- "apiSource": "export * from './array/chunk';\nexport * from './array/filterMap';\nexport * from './array/groupBy';\nexport * from './array/indexBy';\nexport * from './array/partition';\nexport * from './array/sort';\nexport * from './array/uniq';\nexport * from './async/abortable';\nexport * from './async/abortError';\nexport * from './async/attempt';\nexport * from './async/backoff';\nexport * from './async/parallel';\nexport * from './async/retry';\nexport * from './async/sleep';\nexport * from './async/taskPool';\nexport * from './async/waitFor';\nexport * from './cache/cache';\nexport * from './cache/memo';\nexport * from './encoding/base64url';\nexport * from './errors';\nexport * from './function/debounce';\nexport * from './function/once';\nexport * from './function/pipe';\nexport * from './function/tapper';\nexport * from './function/throttle';\nexport * from './guards/combinators';\nexport * from './guards/isDefined';\nexport * from './guards/isEqual';\nexport * from './guards/isNil';\nexport * from './guards/isPlainObject';\nexport * from './math/allocate';\nexport * from './math/clamp';\nexport * from './math/range';\nexport * from './object/getPath';\nexport * from './object/hash';\nexport * from './object/omit';\nexport * from './object/pick';\nexport * from './random/random';\nexport * from './random/source';\nexport * from './string/camelCase';\nexport * from './types';\n",
2
+ "apiSource": "export * from './array/chunk';\nexport * from './array/filterMap';\nexport * from './array/groupBy';\nexport * from './array/indexBy';\nexport * from './array/partition';\nexport * from './array/sort';\nexport * from './array/uniq';\nexport * from './async/abortable';\nexport * from './async/abortError';\nexport * from './async/attempt';\nexport * from './async/backoff';\nexport * from './async/parallel';\nexport * from './async/retry';\nexport * from './async/sleep';\nexport * from './async/taskPool';\nexport * from './async/waitFor';\nexport * from './cache/cache';\nexport * from './cache/memo';\nexport * from './encoding/base64url';\nexport * from './encoding/compression';\nexport * from './encoding/share-code';\nexport * from './errors';\nexport * from './function/debounce';\nexport * from './function/once';\nexport * from './function/pipe';\nexport * from './function/tapper';\nexport * from './function/throttle';\nexport * from './guards/combinators';\nexport * from './guards/isDefined';\nexport * from './guards/isEqual';\nexport * from './guards/isNil';\nexport * from './guards/isPlainObject';\nexport * from './math/allocate';\nexport * from './math/clamp';\nexport * from './math/range';\nexport * from './object/getPath';\nexport * from './object/hash';\nexport * from './object/omit';\nexport * from './object/pick';\nexport * from './random/random';\nexport * from './random/source';\nexport * from './string/camelCase';\nexport * from './types';\n",
3
3
  "docs": {
4
4
  "index": "---\ntitle: 'Arsenal: Utility library for TypeScript'\ndescription: Tree-shakeable TypeScript utilities with focused category entry points for arrays, async work, caching, objects, strings, math, and guards.\npackage: arsenal\ncategory: utilities\nkeywords: [utility, array, string, object, math, async, debounce, throttle, cache]\nexports: [groupBy, retry, debounce, isEqual, taskPool, cache, fuzzyFilter, tryParseJson, allocate, hash]\nrelated: [tempo, sourcerer, spell, coins]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"arsenal\" />\n\n## Why Arsenal?\n\nArsenal provides typed, zero-dependency utilities that reduce repeated application code: collection transforms, numeric operations, retry and cancellation, caching, safe-path traversal, and deterministic serialization. The root stays curated while category entry points expose the broader toolkit. Exact platform aliases remain excluded.\n\n```ts\n// Before\nconst users = JSON.parse(raw).filter((user) => user.name.includes(query));\n\n// After\nimport { fuzzyFilter } from '@vielzeug/arsenal/array';\nimport { tryParseJson } from '@vielzeug/arsenal/object';\n\nconst parsed = tryParseJson(raw);\nconst users = parsed.ok ? fuzzyFilter(parsed.value as User[], query, { select: (user) => user.name }) : [];\n```\n\n| Feature | Arsenal | lodash-es | Remeda |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"arsenal\" type=\"size\" /> | ~72 kB | ~18 kB |\n| Typed root utilities | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Partial | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Category entry points | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Partial | Partial |\n| Async task pool and cache | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Arsenal when** you need one typed utility dependency with focused subpaths for specialized behavior.\n\n**Consider narrower alternatives when** you need only platform APIs or a small functional subset.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/arsenal\n```\n\n```sh [npm]\nnpm install @vielzeug/arsenal\n```\n\n```sh [yarn]\nyarn add @vielzeug/arsenal\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { groupBy, retry } from '@vielzeug/arsenal';\nimport { cache } from '@vielzeug/arsenal/cache';\nimport { taskPool } from '@vielzeug/arsenal/async';\n\nconst byRole = groupBy([{ role: 'admin' }, { role: 'user' }], (user) => user.role);\n\nconst pool = taskPool({ concurrency: 2 });\nconst health = await pool.run((signal) => retry(() => fetch('/health', { signal }).then((response) => response.json())));\n\nconst responses = cache<string, unknown>({ ttlMs: 60_000 });\nconst profile = await responses.getOrLoad('/profile', () => fetch('/profile').then((response) => response.json()));\n\npool.dispose();\nconsole.log(byRole, health, profile);\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **`retry`**: retry async work with cancellation support from package root\n- **`taskPool`**: bounded, disposable concurrent work from `/async`\n- **`cache`**: identity-keyed TTL cache with async load deduplication from `/cache`\n- **`fuzzyFilter`**: explicit-field fuzzy filtering from `/array`\n- **`tryParseJson`**: preserve JSON syntax failures from `/object`\n- **`allocate`**: lossless proportional distribution from `/math`\n- **`chunk`, `zip`, `compact`**: typed collection transforms from `/array`\n- **`range`, `average`, `median`**: common numeric operations from `/math`\n- **`assert`, `tap`**: typed control-flow helpers from `/function`\n- **`isEqual`**: structural equality from package root\n- **`hash`**: deterministic serialization for stable cache keys from `/object`\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Spell](/spell/): validate `unknown` JSON data after `tryParseJson`.\n- [Vault](/vault/): persistent storage; Arsenal cache is in-memory only.\n- [Tempo](/tempo/): date/time utilities kept outside Arsenal.\n- [Coins](/coins/): money formatting and currency conversion.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: 'Arsenal: API Reference'\ndescription: Reference for Arsenal root utilities and category entry points.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution | Common gotcha |\n| --- | --- | --- | --- |\n| `groupBy` | Group values by key (prototype-pollution guarded) | Sync | Root export |\n| `chunk` / `zip` / `compact` | Typed collection transforms | Sync | Available from `/array` |\n| `range` / `average` / `median` | Numeric generation and aggregation | Sync | Available from `/math` |\n| `assert` | Assert and narrow a condition | Sync | Available from `/function` |\n| `retry` | Retry async work | Async | Rethrows final error |\n| `taskPool` | Bound concurrent tasks | Async | Available from `/async` |\n| `cache` | In-memory identity-keyed cache | Async | Available from `/cache` |\n| `fuzzyFilter` | Filter string or selected object fields | Sync | Object collections require `select` |\n| `fuzzyScore` | Rank string or selected object fields | Sync | Object collections require `select` |\n| `tryParseJson` | Preserve JSON syntax result | Sync | Returns `unknown` on success |\n| `getPath` | Optional object lookup (safe-path guarded) | Sync | Available from `/object` |\n| `allocate` | Lossless proportional distribution | Sync | Available from `/math` |\n| `isEqual` | Structural equality | Sync | Root export |\n| `hash` | Deterministic serialization for cache keys | Sync | Available from `/object` |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/arsenal` | Curated common utilities |\n| `@vielzeug/arsenal/array` | Immutable transforms, set operations, sorting, fuzzy search |\n| `@vielzeug/arsenal/async` | Retry, cancellation, task pool, timing |\n| `@vielzeug/arsenal/cache` | In-memory cache and memoization |\n| `@vielzeug/arsenal/function` | Assertions, composition, timing, teardown |\n| `@vielzeug/arsenal/guards` | Type guards, predicate combinators, equality guards |\n| `@vielzeug/arsenal/math` | Ranges, aggregation, statistics, interpolation, exact allocation |\n| `@vielzeug/arsenal/object` | Paths, transforms, hash, JSON parse result |\n| `@vielzeug/arsenal/random` | Cryptographic-entropy selection and shuffle |\n| `@vielzeug/arsenal/string` | Unicode-aware case transforms and similarity |\n\n## Array\n\n### Typed transforms\n\n```ts\nchunk<T>(input: readonly T[], size?: number): T[][]\ncompact<T>(array: readonly T[]): Array<Exclude<T, false | '' | 0 | 0n | null | undefined>>\nfirst<T>(array: readonly T[], fallback?: T): T | undefined\nlast<T>(array: readonly T[], fallback?: T): T | undefined\nreplace<T>(array: readonly T[], predicate: (value: T) => boolean, value: T): T[]\nrotate<T>(array: readonly T[], positions: number, options?: { wrap?: boolean }): T[]\nzip(...arrays): Array<tuple>\nunzip(rows): tupleOfArrays\n```\n\n`take`, `takeLast`, `drop`, and `dropLast` return new arrays and normalize negative counts to zero. Set operations accept optional selectors for object identity.\n\n### fuzzyFilter / fuzzyScore\n\n```ts\nfuzzyFilter(strings: readonly string[], query: string, options?: FuzzyOptions): string[]\nfuzzyFilter<T>(items: readonly T[], query: string, options: FuzzySelection<T>): T[]\nfuzzyScore(strings: readonly string[], query: string, options?: FuzzyOptions): ScoredResult<string>[]\nfuzzyScore<T>(items: readonly T[], query: string, options: FuzzySelection<T>): ScoredResult<T>[]\n```\n\n`fuzzyFilter` preserves input order. `fuzzyScore` orders results by descending score.\n\n```ts\nimport { fuzzyFilter } from '@vielzeug/arsenal/array';\n\nconst users = [{ email: 'alice@example.com', name: 'Alice' }];\nconst matches = fuzzyFilter(users, 'alice', { select: (user) => [user.name, user.email] });\n```\n\n---\n\n## Async\n\n### taskPool\n\n```ts\ninterface TaskPool {\n run<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;\n idle(): Promise<void>;\n dispose(reason?: unknown): void;\n readonly active: number;\n readonly pending: number;\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n}\n\ntaskPool(options?: { concurrency?: number }): TaskPool\n```\n\n`dispose()` aborts running cooperative tasks and rejects pending tasks.\n\n```ts\nimport { taskPool } from '@vielzeug/arsenal/async';\n\nconst pool = taskPool({ concurrency: 2 });\nconst user = await pool.run((signal) => fetch('/user', { signal }).then((response) => response.json()));\npool.dispose();\n```\n\n---\n\n## Cache\n\n### cache\n\n```ts\ninterface Cache<K, T> {\n entries(): ReadonlyArray<readonly [K, T]>;\n get(key: K): T | undefined;\n set(key: K, value: T, options?: { ttlMs?: number }): void;\n getOrLoad(key: K, load: () => Promise<T>): Promise<T>;\n delete(key: K): boolean;\n clear(): void;\n readonly size: number;\n}\n\ncache<K, T>(options?: CacheOptions): Cache<K, T>\n```\n\nKeys use native `Map` identity. Expiry is lazy, evaluated by `get`, `getOrLoad`, `set`, and `entries`. `entries()` trims expired values and returns a new insertion-ordered snapshot without exposing internal storage. The `size` getter does not evict expired entries.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\nconst profiles = cache<string, Profile>({ ttlMs: 60_000 });\nconst profile = await profiles.getOrLoad('me', loadProfile);\n```\n\n---\n\n## Encoding\n\nURL-safe base64 (`base64url`) codecs without `btoa`/`Buffer`, so the same code runs in browsers, workers, and Node.\n\n```ts\nimport { base64UrlToBytes, base64UrlToText, bytesToBase64Url, textToBase64Url, utf8Bytes } from '@vielzeug/arsenal';\n\ntextToBase64Url('{\"build\":\"hero\"}'); // never contains '+', '/', or '='\nbase64UrlToText(textToBase64Url('League of Legends')); // round-trips any UTF-8 text\nbytesToBase64Url(Uint8Array.from([0, 1, 250])); // byte-level codec for binary payloads\nbase64UrlToBytes(code); // throws on characters outside the alphabet\nutf8Bytes('Hunter äöü'); // 11: byte length of a UTF-8 string\n```\n\n## Object\n\n### tryParseJson\n\n```ts\ntype JsonParseResult = { ok: true; value: unknown } | { error: SyntaxError; ok: false };\n\ntryParseJson(text: string): JsonParseResult\n```\n\nUse a schema validator after success to refine `unknown` data.\n\n```ts\nimport { tryParseJson } from '@vielzeug/arsenal/object';\n\nconst result = tryParseJson(raw);\nif (!result.ok) throw result.error;\n```\n\n### getPath\n\n```ts\ngetPath<T extends Record<string, unknown>, P extends string>(item: T, path: P): PathValue<T, P> | undefined\ngetPathOr<T extends Record<string, unknown>, P extends string, F>(item: T, path: P, fallback: F): PathValue<T, P> | F\nrequirePath<T extends Record<string, unknown>, P extends string>(item: T, path: P): Exclude<PathValue<T, P>, undefined>\n```\n\n### hash\n\n```ts\nhash(value: unknown, options?: HashOptions): string\n```\n\nProduces a deterministic, order-independent serialization. Handles `Date`, `RegExp`, `Set`, `Map`, and `bigint`. Circular references produce a sentinel. Useful for stable cache keys.\n\n---\n\n## Math\n\n### Numeric toolkit\n\n```ts\nrange(stop: number): number[]\nrange(start: number, stop: number, step?: number): number[]\nclamp(value: number, min?: number, max?: number): number\nsum<T>(values: readonly T[], select?: (value: T) => number): number\naverage<T>(values: readonly T[], select?: (value: T) => number): number | undefined\nmedian<T>(values: readonly T[], select?: (value: T) => number): number | undefined\nvariance<T>(values: readonly T[], select?: (value: T) => number): number\nstandardDeviation<T>(values: readonly T[], select?: (value: T) => number): number\n```\n\n`sum` and `average` reject non-finite values. `gcd` and `lcm` require safe integers. `linspace` requires finite bounds and a positive integer point count. `variance` and `standardDeviation` compute population statistics.\n\n### allocate\n\n```ts\nallocate(amount: number, ratios: number[] | number): number[]\nallocate(amount: bigint, ratios: number[] | number): bigint[]\n```\n\nDistributes an amount proportionally across ratios. The indivisible remainder is applied to the last bucket so the sum equals the original amount exactly: critical for financial operations.\n\n## Types\n\n```ts\ntype FuzzyOptions = {\n normalize?: boolean;\n threshold?: number;\n};\n\ntype FuzzySelection<T> = FuzzyOptions & {\n select: (item: T) => string | readonly string[];\n};\n\ntype ScoredResult<T> = { item: T; score: number };\n\ntype CacheOptions = {\n capacity?: number;\n now?: () => number;\n ttlMs?: number;\n};\n```\n\n## Errors\n\n- `RangeError`: invalid numeric bounds, capacity, concurrency, or retry count.\n- `TypeError`: invalid value types, unsupported comparison, or required path missing.\n- `ArsenalSerializationError`: memo or hash cannot serialize supplied input.\n",
6
- "usage": "---\ntitle: 'Arsenal: Usage Guide'\ndescription: Use Arsenal root utilities for common work and category entry points for specialized collection, async, cache, object, and string behavior.\n---\n\n[[toc]]\n\n## Basic Usage\n\nStart at package root for common utilities. Move to a category entry point for the broader typed toolkit. Arsenal keeps helpers that improve readability, narrowing, immutable updates, or edge-case consistency; exact platform aliases stay out.\n\n```ts\nimport { groupBy, retry } from '@vielzeug/arsenal';\n\nconst users = [\n { id: 'a1', role: 'admin' },\n { id: 'u1', role: 'user' },\n { id: 'u2', role: 'user' },\n];\n\nconst byRole = groupBy(users, (user) => user.role);\nconst health = await retry(() => fetch('/health').then((response) => response.json()));\n\nconsole.log(byRole, health);\n```\n\nUse category imports for APIs absent from root:\n\n```ts\nimport { fuzzyFilter } from '@vielzeug/arsenal/array';\nimport { taskPool } from '@vielzeug/arsenal/async';\nimport { cache } from '@vielzeug/arsenal/cache';\nimport { tryParseJson } from '@vielzeug/arsenal/object';\n```\n\n## Transform Collections\n\nUse `/array` for transforms that preserve input immutability. `filterMap` combines mapping and omission; `indexBy` and `groupBy` build lookup structures without mutation.\n\n```ts\nimport { filterMap, indexBy, sort } from '@vielzeug/arsenal/array';\n\nconst products = [\n { id: 'p1', price: 20, published: true },\n { id: 'p2', price: 10, published: false },\n { id: 'p3', price: 15, published: true },\n];\n\nconst publishedLabels = filterMap(products, (product) =>\n product.published ? `${product.id}: ${product.price}` : undefined,\n);\nconst byId = indexBy(products, (product) => product.id);\nconst byPrice = sort(products, (product) => product.price);\n\nconsole.log(publishedLabels, byId, byPrice);\n```\n\n## Search Explicit Fields\n\nSearch string arrays directly. Object collections require `select`, so callers define exactly what can match.\n\n```ts\nimport { fuzzyFilter, fuzzyScore } from '@vielzeug/arsenal/array';\n\nconst users = [\n { email: 'alice@example.com', name: 'Alice' },\n { email: 'bob@example.com', name: 'Bob' },\n];\n\nconst matches = fuzzyFilter(users, 'alice', { select: (user) => [user.name, user.email] });\nconst ranked = fuzzyScore(users, 'ali', { select: (user) => user.name });\n```\n\n## Work with Object Data\n\nUse `/object` for paths, key selection, stable cache keys, and object transforms.\n\n```ts\nimport { getPathOr, hash, omit, pick } from '@vielzeug/arsenal/object';\n\nconst config = { api: { host: 'localhost', port: 3000 }, debug: true };\nconst port = getPathOr(config, 'api.port', 8080);\nconst publicConfig = pick(config, ['api']);\nconst productionConfig = omit(config, ['debug']);\nconst key = hash({ port, productionConfig });\n\nconsole.log(publicConfig, key);\n```\n\n## Parse and Validate JSON\n\n`tryParseJson` distinguishes syntax failure from schema failure. Treat successful values as `unknown`, then validate with Spell or application code.\n\n```ts\nimport { tryParseJson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst User = s.object({ id: s.string(), name: s.string() });\nconst parsed = tryParseJson(raw);\n\nif (!parsed.ok) throw parsed.error;\n\nconst user = User.parse(parsed.value);\n```\n\n## Bound Concurrent Work\n\nUse `parallel` for one finite collection. Use `taskPool` when tasks arrive over time or need disposal.\n\n```ts\nimport { parallel, taskPool } from '@vielzeug/arsenal/async';\n\nconst metadata = await parallel(urls, (url) => fetch(url).then((response) => response.json()), { limit: 4 });\n\nconst pool = taskPool({ concurrency: 2 });\nconst profile = await pool.run((signal) => fetch('/profile', { signal }).then((response) => response.json()));\n\nawait pool.idle();\npool.dispose();\n\nconsole.log(metadata, profile);\n```\n\n## Cache Loaded Values\n\nUse `cache` for process-local values. Keys retain native `Map` identity. `getOrLoad` deduplicates concurrent loads for one key.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\ntype Profile = { id: string; name: string };\n\nconst profiles = cache<string, Profile>({ capacity: 100, ttlMs: 60_000 });\nconst profile = await profiles.getOrLoad('me', () => fetch('/profile').then((response) => response.json()));\n\nconsole.log(profiles.entries());\nprofiles.delete('me');\nconst freshProfile = await profiles.getOrLoad('me', () => fetch('/profile').then((response) => response.json()));\n\nconsole.log(profile, freshProfile);\n```\n\n## Test Deterministic Randomness\n\nRandom helpers use cryptographic entropy by default. Pass `RandomSource` in tests when output must be deterministic.\n\n```ts\nimport { random, randomFloat, type RandomSource } from '@vielzeug/arsenal/random';\n\nconst source: RandomSource = { next: () => 0.5 };\n\nrandomFloat(source); // 0.5\nrandom(1, 4, source); // 3\n```\n\n## Encode Text for URLs and QR Codes\n\nShare codes ride in URLs and QR payloads, where `+`, `/`, and padding `=` break consumers. The base64url codecs are environment-independent: no `btoa`, no `Buffer`, so the same encode/decode pair works in the browser, in workers, and in Node.\n\n```ts\nimport { base64UrlToText, textToBase64Url } from '@vielzeug/arsenal';\n\nconst code = textToBase64Url(JSON.stringify({ seat: 4, version: 2 }));\n// eyJzZWF0Ijo0LCJ2ZXJzaW9uIjoyfQ: safe in a query string or QR\n\nconst payload = JSON.parse(base64UrlToText(code));\n```\n\nDecoding throws on characters outside the base64url alphabet, so corrupted input fails loudly instead of decoding to garbage.\n\n## Working with Other Vielzeug Libraries\n\nUse Spell after `tryParseJson` for typed external data. Use Vault instead of `cache` when data must survive reloads or process restart.\n\n```ts\nimport { tryParseJson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst Settings = s.object({ theme: s.string() });\nconst parsed = tryParseJson(rawSettings);\nconst settings = parsed.ok ? Settings.parse(parsed.value) : { theme: 'system' };\n```\n\n## Best Practices\n\n- Import common utilities from package root.\n- Import the broader toolkit from category entry points.\n- Prefer Arsenal when a helper improves typing, intent, immutable updates, or edge-case behavior.\n- Use the platform directly for exact aliases such as `crypto.randomUUID()` and `Array.prototype.flat()`.\n- Pass `select` for every fuzzy search over objects.\n- Validate parsed JSON before using it as application data.\n- Use `parallel` for finite batches and `taskPool` for ongoing work.\n- Dispose task pools when their owner ends.\n- Use `cache` only for in-memory data.\n- Inject `RandomSource` in deterministic tests.\n",
7
- "examples": "---\ntitle: 'Arsenal: Examples'\ndescription: Practical examples and recipes for arsenal.\n---\n\n## Examples\n\n- [Array utilities](./examples/array.md)\n- [Async utilities](./examples/async.md)\n- [Cache utilities](./examples/cache.md)\n- [Function utilities](./examples/function.md)\n- [Guards / Typed predicates](./examples/typed.md)\n- [Math utilities](./examples/math.md)\n- [Object utilities](./examples/object.md): includes `getPath`, `tryParseJson`, `hash`, `diff`, `deepMerge`\n- [Random utilities](./examples/random.md)\n- [String utilities](./examples/string.md)\n"
5
+ "api": "---\ntitle: 'Arsenal: API Reference'\ndescription: Reference for Arsenal root utilities and category entry points.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution | Common gotcha |\n| --- | --- | --- | --- |\n| `groupBy` | Group values by key (prototype-pollution guarded) | Sync | Root export |\n| `chunk` / `zip` / `compact` | Typed collection transforms | Sync | Available from `/array` |\n| `range` / `average` / `median` | Numeric generation and aggregation | Sync | Available from `/math` |\n| `assert` | Assert and narrow a condition | Sync | Available from `/function` |\n| `retry` | Retry async work | Async | Rethrows final error |\n| `taskPool` | Bound concurrent tasks | Async | Available from `/async` |\n| `cache` | In-memory identity-keyed cache | Async | Available from `/cache` |\n| `fuzzyFilter` | Filter string or selected object fields | Sync | Object collections require `select` |\n| `fuzzyScore` | Rank string or selected object fields | Sync | Object collections require `select` |\n| `tryParseJson` | Preserve JSON syntax result | Sync | Returns `unknown` on success |\n| `getPath` | Optional object lookup (safe-path guarded) | Sync | Available from `/object` |\n| `allocate` | Lossless proportional distribution | Sync | Available from `/math` |\n| `isEqual` | Structural equality | Sync | Root export |\n| `hash` | Deterministic serialization for cache keys | Sync | Available from `/object` |\n| `compressBytes` / `decompressBytes` | Native byte compression with bounded decompression | Async | Requires native compression streams; decoding requires an output limit |\n| `pack` / `unpack` | Versioned, compressed, URL-safe payload envelope | Async | Both sides bound the payload with `maxBytes`; compression falls back to raw |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/arsenal` | Curated common utilities |\n| `@vielzeug/arsenal/array` | Immutable transforms, set operations, sorting, fuzzy search |\n| `@vielzeug/arsenal/async` | Retry, cancellation, task pool, timing |\n| `@vielzeug/arsenal/cache` | In-memory cache and memoization |\n| `@vielzeug/arsenal/encoding` | Base64url and native byte compression |\n| `@vielzeug/arsenal/function` | Assertions, composition, timing, teardown |\n| `@vielzeug/arsenal/guards` | Type guards, predicate combinators, equality guards |\n| `@vielzeug/arsenal/math` | Ranges, aggregation, statistics, interpolation, exact allocation |\n| `@vielzeug/arsenal/object` | Paths, transforms, hash, JSON parse result |\n| `@vielzeug/arsenal/random` | Cryptographic-entropy selection and shuffle |\n| `@vielzeug/arsenal/string` | Unicode-aware case transforms and similarity |\n\n## Array\n\n### Typed transforms\n\n```ts\nchunk<T>(input: readonly T[], size?: number): T[][]\ncompact<T>(array: readonly T[]): Array<Exclude<T, false | '' | 0 | 0n | null | undefined>>\nfirst<T>(array: readonly T[], fallback?: T): T | undefined\nlast<T>(array: readonly T[], fallback?: T): T | undefined\nreplace<T>(array: readonly T[], predicate: (value: T) => boolean, value: T): T[]\nrotate<T>(array: readonly T[], positions: number, options?: { wrap?: boolean }): T[]\nzip(...arrays): Array<tuple>\nunzip(rows): tupleOfArrays\n```\n\n`take`, `takeLast`, `drop`, and `dropLast` return new arrays and normalize negative counts to zero. Set operations accept optional selectors for object identity.\n\n### fuzzyFilter / fuzzyScore\n\n```ts\nfuzzyFilter(strings: readonly string[], query: string, options?: FuzzyOptions): string[]\nfuzzyFilter<T>(items: readonly T[], query: string, options: FuzzySelection<T>): T[]\nfuzzyScore(strings: readonly string[], query: string, options?: FuzzyOptions): ScoredResult<string>[]\nfuzzyScore<T>(items: readonly T[], query: string, options: FuzzySelection<T>): ScoredResult<T>[]\n```\n\n`fuzzyFilter` preserves input order. `fuzzyScore` orders results by descending score.\n\n```ts\nimport { fuzzyFilter } from '@vielzeug/arsenal/array';\n\nconst users = [{ email: 'alice@example.com', name: 'Alice' }];\nconst matches = fuzzyFilter(users, 'alice', { select: (user) => [user.name, user.email] });\n```\n\n---\n\n## Async\n\n### taskPool\n\n```ts\ninterface TaskPool {\n run<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;\n idle(): Promise<void>;\n dispose(reason?: unknown): void;\n readonly active: number;\n readonly pending: number;\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n}\n\ntaskPool(options?: { concurrency?: number }): TaskPool\n```\n\n`dispose()` aborts running cooperative tasks and rejects pending tasks.\n\n```ts\nimport { taskPool } from '@vielzeug/arsenal/async';\n\nconst pool = taskPool({ concurrency: 2 });\nconst user = await pool.run((signal) => fetch('/user', { signal }).then((response) => response.json()));\npool.dispose();\n```\n\n---\n\n## Cache\n\n### cache\n\n```ts\ninterface Cache<K, T> {\n entries(): ReadonlyArray<readonly [K, T]>;\n get(key: K): T | undefined;\n set(key: K, value: T, options?: { ttlMs?: number }): void;\n getOrLoad(key: K, load: () => Promise<T>): Promise<T>;\n delete(key: K): boolean;\n clear(): void;\n readonly size: number;\n}\n\ncache<K, T>(options?: CacheOptions): Cache<K, T>\n```\n\nKeys use native `Map` identity. Expiry is lazy, evaluated by `get`, `getOrLoad`, `set`, and `entries`. `entries()` trims expired values and returns a new insertion-ordered snapshot without exposing internal storage. The `size` getter does not evict expired entries.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\nconst profiles = cache<string, Profile>({ ttlMs: 60_000 });\nconst profile = await profiles.getOrLoad('me', loadProfile);\n```\n\n---\n\n## Encoding\n\nURL-safe base64 (`base64url`) codecs without `btoa`/`Buffer`, so the same code runs in browsers, workers, and Node.\n\n```ts\nimport { base64UrlToBytes, base64UrlToText, bytesToBase64Url, textToBase64Url, utf8Bytes } from '@vielzeug/arsenal';\n\ntextToBase64Url('{\"build\":\"hero\"}'); // never contains '+', '/', or '='\nbase64UrlToText(textToBase64Url('League of Legends')); // round-trips any UTF-8 text\nbytesToBase64Url(Uint8Array.from([0, 1, 250])); // byte-level codec for binary payloads\nbase64UrlToBytes(code); // throws on characters outside the alphabet\nutf8Bytes('Hunter äöü'); // 11: byte length of a UTF-8 string\n```\n\n### compressBytes\n\n```ts\ncompressBytes(bytes: Uint8Array, options?: CompressionOptions): Promise<Uint8Array>\n```\n\nCompresses bytes using the native `CompressionStream` API without modifying the input.\n\n| Option | Type | Default | Meaning |\n| --- | --- | --- | --- |\n| `format` | `CompressionFormat` | `'deflate-raw'` | Compression format; the receiver must use the same format |\n\n**Returns:** Compressed bytes. Rejects with `ArsenalSerializationError` when the format or capability is unavailable, or stream processing fails. Does not fall back to another format.\n\n**Example:** Encode compressed bytes for a URL.\n\n```ts\nimport { bytesToBase64Url, compressBytes } from '@vielzeug/arsenal';\n\nconst bytes = new TextEncoder().encode(JSON.stringify({ name: 'Hunters' }));\nconst code = bytesToBase64Url(await compressBytes(bytes));\n```\n\n---\n\n### decompressBytes\n\n```ts\ndecompressBytes(bytes: Uint8Array, options: DecompressionOptions): Promise<Uint8Array>\n```\n\nDecompresses bytes using the native `DecompressionStream` API, enforcing the output limit while reading chunks.\n\n| Option | Type | Default | Meaning |\n| --- | --- | --- | --- |\n| `format` | `CompressionFormat` | `'deflate-raw'` | Format used by the sender |\n| `maxOutputBytes` | `number` | Required | Non-negative safe integer; maximum decoded byte length |\n\n**Returns:** Decompressed bytes. Rejects with `ArsenalSerializationError` for invalid limits, corrupt or truncated data, unavailable formats/capabilities, or output exceeding the limit. A failed stream is canceled and its reader lock released. Bound encoded input separately before calling this function.\n\n**Example:** Decode text within an application-defined byte limit.\n\n```ts\nimport { compressBytes, decompressBytes } from '@vielzeug/arsenal';\n\nconst compressed = await compressBytes(new TextEncoder().encode('Hunters'));\nconst bytes = await decompressBytes(compressed, { maxOutputBytes: 8192 });\nconst text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);\n```\n\n---\n\n### pack\n\n```ts\npack(payload: unknown, options: ShareCodeOptions): Promise<string>\n```\n\nSerializes `payload` as JSON and encodes it into a compact share code: `prefix` + mode marker + base64url bytes, compressed with raw DEFLATE when the compressed form is shorter and the runtime supports the streams.\n\n| Option | Type | Meaning |\n| --- | --- | --- |\n| `maxBytes` | `number` | Positive safe integer; maximum serialized payload size |\n| `prefix` | `string` | 1–8 alphanumeric characters identifying the code family and version |\n\n**Returns:** The shorter of the raw (`prefix` + `r`) and compressed (`prefix` + `d`) representations. Throws `ShareCodeError` (`too-large`, `invalid`) for oversized payloads and misconfigured options.\n\n**Example:** Encode a shareable build as a code small enough for a URL.\n\n```ts\nimport { pack } from '@vielzeug/arsenal';\n\nconst code = await pack({ cards: ['slash', 'guard'] }, { maxBytes: 8192, prefix: 'ab1' });\n```\n\n---\n\n### unpack\n\n```ts\nunpack(code: string, options: ShareCodeOptions): Promise<unknown>\n```\n\nDecodes a share code produced by `pack` with the same options. Both sides are bounded: input longer than `maxBytes` could carry is refused up front, streamed decompression stops at `maxBytes`, and non-canonical base64url is rejected by round-trip.\n\n**Returns:** The parsed JSON payload. Throws `ShareCodeError` (`invalid` for foreign or malformed codes, `too-large` for budget overruns, `compression-unavailable` when a compressed code arrives on a runtime without `DecompressionStream`). Underlying compression failures surface as `ArsenalSerializationError`.\n\n**Example:** Decode untrusted input within the byte budget.\n\n```ts\nimport { unpack } from '@vielzeug/arsenal';\n\nconst payload = await unpack(code, { maxBytes: 8192, prefix: 'ab1' });\n```\n\n## Object\n\n### tryParseJson\n\n```ts\ntype JsonParseResult = { ok: true; value: unknown } | { error: SyntaxError; ok: false };\n\ntryParseJson(text: string): JsonParseResult\n```\n\nUse a schema validator after success to refine `unknown` data.\n\n```ts\nimport { tryParseJson } from '@vielzeug/arsenal/object';\n\nconst result = tryParseJson(raw);\nif (!result.ok) throw result.error;\n```\n\n### getPath\n\n```ts\ngetPath<T extends Record<string, unknown>, P extends string>(item: T, path: P): PathValue<T, P> | undefined\ngetPathOr<T extends Record<string, unknown>, P extends string, F>(item: T, path: P, fallback: F): PathValue<T, P> | F\nrequirePath<T extends Record<string, unknown>, P extends string>(item: T, path: P): Exclude<PathValue<T, P>, undefined>\n```\n\n### hash\n\n```ts\nhash(value: unknown, options?: HashOptions): string\n```\n\nProduces a deterministic, order-independent serialization. Handles `Date`, `RegExp`, `Set`, `Map`, and `bigint`. Circular references produce a sentinel. Useful for stable cache keys.\n\n---\n\n## Math\n\n### Numeric toolkit\n\n```ts\nrange(stop: number): number[]\nrange(start: number, stop: number, step?: number): number[]\nclamp(value: number, min?: number, max?: number): number\nsum<T>(values: readonly T[], select?: (value: T) => number): number\naverage<T>(values: readonly T[], select?: (value: T) => number): number | undefined\nmedian<T>(values: readonly T[], select?: (value: T) => number): number | undefined\nvariance<T>(values: readonly T[], select?: (value: T) => number): number\nstandardDeviation<T>(values: readonly T[], select?: (value: T) => number): number\n```\n\n`sum` and `average` reject non-finite values. `gcd` and `lcm` require safe integers. `linspace` requires finite bounds and a positive integer point count. `variance` and `standardDeviation` compute population statistics.\n\n### allocate\n\n```ts\nallocate(amount: number, ratios: number[] | number): number[]\nallocate(amount: bigint, ratios: number[] | number): bigint[]\n```\n\nDistributes an amount proportionally across ratios. The indivisible remainder is applied to the last bucket so the sum equals the original amount exactly: critical for financial operations.\n\n## Types\n\n```ts\ntype FuzzyOptions = {\n normalize?: boolean;\n threshold?: number;\n};\n\ntype FuzzySelection<T> = FuzzyOptions & {\n select: (item: T) => string | readonly string[];\n};\n\ntype ScoredResult<T> = { item: T; score: number };\n\ntype CacheOptions = {\n capacity?: number;\n now?: () => number;\n ttlMs?: number;\n};\n\ninterface CompressionOptions {\n format?: CompressionFormat;\n}\n\ninterface DecompressionOptions extends CompressionOptions {\n maxOutputBytes: number;\n}\n```\n\n## Errors\n\n- `RangeError`: invalid numeric bounds, capacity, concurrency, or retry count.\n- `TypeError`: invalid value types, unsupported comparison, or required path missing.\n- `ArsenalSerializationError`: memo or hash cannot serialize supplied input; compression fails, is unavailable, receives an invalid output limit, or exceeds that limit. Stream failures retain the original error as `cause`.\n- `ShareCodeError`: a share code cannot be encoded or decoded; the `reason` field is `invalid`, `too-large`, or `compression-unavailable`.\n",
6
+ "usage": "---\ntitle: 'Arsenal: Usage Guide'\ndescription: Use Arsenal root utilities for common work and category entry points for specialized collection, async, cache, object, and string behavior.\n---\n\n[[toc]]\n\n## Basic Usage\n\nStart at package root for common utilities. Move to a category entry point for the broader typed toolkit. Arsenal keeps helpers that improve readability, narrowing, immutable updates, or edge-case consistency; exact platform aliases stay out.\n\n```ts\nimport { groupBy, retry } from '@vielzeug/arsenal';\n\nconst users = [\n { id: 'a1', role: 'admin' },\n { id: 'u1', role: 'user' },\n { id: 'u2', role: 'user' },\n];\n\nconst byRole = groupBy(users, (user) => user.role);\nconst health = await retry(() => fetch('/health').then((response) => response.json()));\n\nconsole.log(byRole, health);\n```\n\nUse category imports for APIs absent from root:\n\n```ts\nimport { fuzzyFilter } from '@vielzeug/arsenal/array';\nimport { taskPool } from '@vielzeug/arsenal/async';\nimport { cache } from '@vielzeug/arsenal/cache';\nimport { tryParseJson } from '@vielzeug/arsenal/object';\n```\n\n## Transform Collections\n\nUse `/array` for transforms that preserve input immutability. `filterMap` combines mapping and omission; `indexBy` and `groupBy` build lookup structures without mutation.\n\n```ts\nimport { filterMap, indexBy, sort } from '@vielzeug/arsenal/array';\n\nconst products = [\n { id: 'p1', price: 20, published: true },\n { id: 'p2', price: 10, published: false },\n { id: 'p3', price: 15, published: true },\n];\n\nconst publishedLabels = filterMap(products, (product) =>\n product.published ? `${product.id}: ${product.price}` : undefined,\n);\nconst byId = indexBy(products, (product) => product.id);\nconst byPrice = sort(products, (product) => product.price);\n\nconsole.log(publishedLabels, byId, byPrice);\n```\n\n## Search Explicit Fields\n\nSearch string arrays directly. Object collections require `select`, so callers define exactly what can match.\n\n```ts\nimport { fuzzyFilter, fuzzyScore } from '@vielzeug/arsenal/array';\n\nconst users = [\n { email: 'alice@example.com', name: 'Alice' },\n { email: 'bob@example.com', name: 'Bob' },\n];\n\nconst matches = fuzzyFilter(users, 'alice', { select: (user) => [user.name, user.email] });\nconst ranked = fuzzyScore(users, 'ali', { select: (user) => user.name });\n```\n\n## Work with Object Data\n\nUse `/object` for paths, key selection, stable cache keys, and object transforms.\n\n```ts\nimport { getPathOr, hash, omit, pick } from '@vielzeug/arsenal/object';\n\nconst config = { api: { host: 'localhost', port: 3000 }, debug: true };\nconst port = getPathOr(config, 'api.port', 8080);\nconst publicConfig = pick(config, ['api']);\nconst productionConfig = omit(config, ['debug']);\nconst key = hash({ port, productionConfig });\n\nconsole.log(publicConfig, key);\n```\n\n## Parse and Validate JSON\n\n`tryParseJson` distinguishes syntax failure from schema failure. Treat successful values as `unknown`, then validate with Spell or application code.\n\n```ts\nimport { tryParseJson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst User = s.object({ id: s.string(), name: s.string() });\nconst parsed = tryParseJson(raw);\n\nif (!parsed.ok) throw parsed.error;\n\nconst user = User.parse(parsed.value);\n```\n\n## Bound Concurrent Work\n\nUse `parallel` for one finite collection. Use `taskPool` when tasks arrive over time or need disposal.\n\n```ts\nimport { parallel, taskPool } from '@vielzeug/arsenal/async';\n\nconst metadata = await parallel(urls, (url) => fetch(url).then((response) => response.json()), { limit: 4 });\n\nconst pool = taskPool({ concurrency: 2 });\nconst profile = await pool.run((signal) => fetch('/profile', { signal }).then((response) => response.json()));\n\nawait pool.idle();\npool.dispose();\n\nconsole.log(metadata, profile);\n```\n\n## Cache Loaded Values\n\nUse `cache` for process-local values. Keys retain native `Map` identity. `getOrLoad` deduplicates concurrent loads for one key.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\ntype Profile = { id: string; name: string };\n\nconst profiles = cache<string, Profile>({ capacity: 100, ttlMs: 60_000 });\nconst profile = await profiles.getOrLoad('me', () => fetch('/profile').then((response) => response.json()));\n\nconsole.log(profiles.entries());\nprofiles.delete('me');\nconst freshProfile = await profiles.getOrLoad('me', () => fetch('/profile').then((response) => response.json()));\n\nconsole.log(profile, freshProfile);\n```\n\n## Test Deterministic Randomness\n\nRandom helpers use cryptographic entropy by default. Pass `RandomSource` in tests when output must be deterministic.\n\n```ts\nimport { random, randomFloat, type RandomSource } from '@vielzeug/arsenal/random';\n\nconst source: RandomSource = { next: () => 0.5 };\n\nrandomFloat(source); // 0.5\nrandom(1, 4, source); // 3\n```\n\n## Encode Text for URLs and QR Codes\n\nShare codes ride in URLs and QR payloads, where `+`, `/`, and padding `=` break consumers. The base64url codecs are environment-independent: no `btoa`, no `Buffer`, so the same encode/decode pair works in the browser, in workers, and in Node.\n\n```ts\nimport { base64UrlToText, textToBase64Url } from '@vielzeug/arsenal';\n\nconst code = textToBase64Url(JSON.stringify({ seat: 4, version: 2 }));\n// eyJzZWF0Ijo0LCJ2ZXJzaW9uIjoyfQ: safe in a query string or QR\n\nconst payload = JSON.parse(base64UrlToText(code));\n```\n\nDecoding throws on characters outside the base64url alphabet, so corrupted input fails loudly instead of decoding to garbage.\n\n## Compress Share Payloads\n\nCompress UTF-8 bytes before Base64url encoding. Set a decoded-byte limit before accepting compressed input from another device.\n\n```ts\nimport { base64UrlToBytes, bytesToBase64Url, compressBytes, decompressBytes } from '@vielzeug/arsenal';\n\nconst payload = JSON.stringify({ name: 'Hunters', notes: 'An expedition through Alborea. '.repeat(20) });\nconst compressed = await compressBytes(new TextEncoder().encode(payload));\nconst code = bytesToBase64Url(compressed);\nconst bytes = await decompressBytes(base64UrlToBytes(code), { maxOutputBytes: 8192 });\nconst restored = new TextDecoder('utf-8', { fatal: true }).decode(bytes);\nconsole.log(restored === payload);\n```\n\nBoth helpers default to `deflate-raw`. You can explicitly select `gzip` or `deflate`; use the same format at both ends. The helpers reject when native streams do not support the selected format. They do not choose a fallback or define a wire protocol.\n\nFor a complete versioned envelope over these primitives — prefix, compression mode marker, byte budgets on both sides — see [`pack` / `unpack`](./api.md#pack).\n\nCompression can enlarge small inputs. Compare the final encoded strings, including any format marker, before choosing a representation. Bound input size before decoding Base64url and validate decompressed JSON against your schema. The decompression limit bounds retained output, not native stream working memory.\n\n## Working with Other Vielzeug Libraries\n\nUse Spell after `tryParseJson` for typed external data. Use Vault instead of `cache` when data must survive reloads or process restart.\n\n```ts\nimport { tryParseJson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst Settings = s.object({ theme: s.string() });\nconst parsed = tryParseJson(rawSettings);\nconst settings = parsed.ok ? Settings.parse(parsed.value) : { theme: 'system' };\n```\n\n## Best Practices\n\n- Import common utilities from package root.\n- Import the broader toolkit from category entry points.\n- Prefer Arsenal when a helper improves typing, intent, immutable updates, or edge-case behavior.\n- Use the platform directly for exact aliases such as `crypto.randomUUID()` and `Array.prototype.flat()`.\n- Pass `select` for every fuzzy search over objects.\n- Validate parsed JSON before using it as application data.\n- Use `parallel` for finite batches and `taskPool` for ongoing work.\n- Dispose task pools when their owner ends.\n- Use `cache` only for in-memory data.\n- Inject `RandomSource` in deterministic tests.\n",
7
+ "examples": "---\ntitle: 'Arsenal: Examples'\ndescription: Practical examples and recipes for arsenal.\n---\n\n## Examples\n\n- [Array utilities](./examples/array.md)\n- [Async utilities](./examples/async.md)\n- [Cache utilities](./examples/cache.md)\n- [Encoding utilities](./examples/encoding.md)\n- [Function utilities](./examples/function.md)\n- [Guards / Typed predicates](./examples/typed.md)\n- [Math utilities](./examples/math.md)\n- [Object utilities](./examples/object.md): includes `getPath`, `tryParseJson`, `hash`, `diff`, `deepMerge`\n- [Random utilities](./examples/random.md)\n- [String utilities](./examples/string.md)\n"
8
8
  },
9
9
  "examples": [
10
10
  {
@@ -72,6 +72,11 @@
72
72
  "code": "import { waitFor } from '@vielzeug/arsenal/async'\n\n// Simulate a value that becomes ready after a short delay\nlet ready = false\nsetTimeout(() => { ready = true }, 200)\n\nconsole.log('Waiting for ready...')\nawait waitFor(() => ready, { interval: 50, timeout: 2000 })\nconsole.log('Ready!')\n\n// Abort early with an external signal\nconst ac = new AbortController()\nsetTimeout(() => ac.abort(new Error('user cancelled')), 100)\n\ntry {\n await waitFor(() => false, {\n interval: 50,\n signal: ac.signal,\n timeout: 5000,\n })\n} catch (err) {\n console.log('Aborted:', err.message) // 'user cancelled'\n}",
73
73
  "name": "waitFor - Poll until condition is true or timeout/abort fires"
74
74
  },
75
+ {
76
+ "id": "encoding-compression",
77
+ "code": "import { base64UrlToBytes, bytesToBase64Url, compressBytes, decompressBytes } from '@vielzeug/arsenal'\n\nconst text = JSON.stringify({ notes: 'An expedition through Alborea. '.repeat(20) })\nconst original = new TextEncoder().encode(text)\nconst code = bytesToBase64Url(await compressBytes(original))\nconst restored = await decompressBytes(base64UrlToBytes(code), { maxOutputBytes: 8192 })\n\nconsole.log('Original bytes:', original.length)\nconsole.log('Compressed code characters:', code.length)\nconsole.log('Matches original:', new TextDecoder('utf-8', { fatal: true }).decode(restored) === text)",
78
+ "name": "compressBytes - Bounded share payload compression"
79
+ },
75
80
  {
76
81
  "id": "function-debounce",
77
82
  "code": "import { debounce } from '@vielzeug/arsenal/function'\n\n// --- Trailing (default) ---\nlet trailingCount = 0\nconst onSearch = debounce((q) => {\n trailingCount++\n console.log(`Trailing #${trailingCount}: \"${q}\"`)\n}, 200)\n\nonSearch('c')\nonSearch('ca')\nonSearch('cat') // only this fires after 200ms\n\n// --- Leading-only ---\nlet leadingCount = 0\nconst onSubmit = debounce((q) => {\n leadingCount++\n console.log(`Leading #${leadingCount}: \"${q}\"`)\n}, 200, { leading: true, trailing: false })\n\nonSubmit('first') // fires immediately\nonSubmit('second') // silenced (within 200ms window)\nonSubmit('third') // silenced\n\n// --- Leading + Trailing: fires on both edges ---\nlet bothCount = 0\nconst onBoth = debounce(() => {\n bothCount++\n console.log(`Both edge #${bothCount}`)\n}, 200, { leading: true, trailing: true })\n\nonBoth() // fires immediately (leading)\n// trailing edge fires after 200ms (bothCount becomes 2)\n\nsetTimeout(() => {\n console.log('Trailing fires:', trailingCount, '| Leading fires:', leadingCount, '| Both fires:', bothCount)\n // trailing: 1 | leading: 1 | both: 2\n}, 400)",
@@ -194,8 +199,17 @@
194
199
  "textToBase64Url": "export function textToBase64Url(text: string): string {\n return bytesToBase64Url(encoder.encode(text));\n}",
195
200
  "base64UrlToText": "export function base64UrlToText(text: string): string {\n return decoder.decode(base64UrlToBytes(text));\n}",
196
201
  "utf8Bytes": "export function utf8Bytes(text: string): number {\n return encoder.encode(text).length;\n}",
202
+ "CompressionOptions": "export interface CompressionOptions {\n format?: CompressionFormat;\n}",
203
+ "DecompressionOptions": "export interface DecompressionOptions extends CompressionOptions {\n maxOutputBytes: number;\n}",
204
+ "compressBytes": "export async function compressBytes(bytes: Uint8Array, options: CompressionOptions = {}): Promise<Uint8Array> {\n try {\n return await transformBytes(bytes, new CompressionStream(options.format ?? 'deflate-raw'), Infinity);\n } catch (cause) {\n if (cause instanceof ArsenalSerializationError) throw cause;\n throw new ArsenalSerializationError('Compression is unavailable for this format.', { cause });\n }\n}",
205
+ "decompressBytes": "export async function decompressBytes(bytes: Uint8Array, options: DecompressionOptions): Promise<Uint8Array> {\n if (!Number.isSafeInteger(options.maxOutputBytes) || options.maxOutputBytes < 0)\n throw new ArsenalSerializationError('maxOutputBytes must be a non-negative safe integer.');\n try {\n return await transformBytes(\n bytes,\n new DecompressionStream(options.format ?? 'deflate-raw'),\n options.maxOutputBytes,\n );\n } catch (cause) {\n if (cause instanceof ArsenalSerializationError) throw cause;\n throw new ArsenalSerializationError('Decompression is unavailable for this format.', { cause });\n }\n}",
206
+ "ShareCodeOptions": "export interface ShareCodeOptions {\n /** Maximum serialized payload size in bytes; decoding also refuses codes that could carry more. */\n readonly maxBytes: number;\n /** Versioned identifier at the start of every code, e.g. `'pb1'`: 1–8 alphanumeric characters. */\n readonly prefix: string;\n}",
207
+ "pack": "export async function pack(payload: unknown, options: ShareCodeOptions): Promise<string> {\n assertOptions(options);\n const { maxBytes, prefix } = options;\n let serialized: string | undefined;\n try {\n serialized = JSON.stringify(payload);\n } catch (error) {\n throw new ShareCodeError('invalid', 'The share payload is not JSON-serializable.', { cause: error });\n }\n if (serialized === undefined) {\n throw new ShareCodeError('invalid', 'The share payload is not JSON-serializable.');\n }\n const bytes = encoder.encode(serialized);\n if (bytes.length > maxBytes) {\n throw new ShareCodeError('too-large', 'The share payload exceeds the byte limit.');\n }\n const raw = `${prefix}r.${bytesToBase64Url(bytes)}`;\n if (!streamsAvailable()) return raw;\n // Compression can enlarge small payloads: ship whichever representation is shorter.\n const compressed = `${prefix}d.${bytesToBase64Url(await compressBytes(bytes))}`;\n return compressed.length < raw.length ? compressed : raw;\n}",
208
+ "unpack": "export async function unpack(code: string, options: ShareCodeOptions): Promise<unknown> {\n assertOptions(options);\n const { maxBytes, prefix } = options;\n const text = code.trim();\n // A code can only carry so many bytes when base64url-encoded: refuse longer input up front.\n const maxCodeLength = Math.ceil((maxBytes * 4) / 3) + prefix.length + 2;\n if (text.length > maxCodeLength) {\n throw new ShareCodeError('too-large', 'The share code exceeds the length limit.');\n }\n const mode = text.slice(prefix.length, prefix.length + 1);\n if (!text.startsWith(prefix) || (mode !== 'r' && mode !== 'd') || text[prefix.length + 1] !== '.') {\n throw new ShareCodeError('invalid', 'That share code format is not supported.');\n }\n const encoded = text.slice(prefix.length + 2);\n if (!/^[A-Za-z0-9_-]+$/.test(encoded)) {\n throw new ShareCodeError('invalid', 'Malformed share code.');\n }\n const bytes = base64UrlToBytes(encoded);\n // Base64url is not injective: round-trip so crafted non-canonical encodings fail.\n if (bytesToBase64Url(bytes) !== encoded) {\n throw new ShareCodeError('invalid', 'Malformed share code.');\n }\n if (mode === 'd' && !decompressionAvailable()) {\n throw new ShareCodeError('compression-unavailable', 'Raw-DEFLATE decompression is unavailable in this browser.');\n }\n // Decompression enforces its own output budget; its typed error carries that failure.\n const decoded = mode === 'd' ? await decompressBytes(bytes, { maxOutputBytes: maxBytes }) : bytes;\n if (decoded.length > maxBytes) {\n throw new ShareCodeError('too-large', 'The share payload exceeds the byte limit.');\n }\n try {\n return JSON.parse(decoder.decode(decoded));\n } catch (error) {\n throw new ShareCodeError('invalid', 'The share code payload is not valid UTF-8 JSON.', { cause: error });\n }\n}",
197
209
  "ArsenalError": "export class ArsenalError extends Error {\n constructor(message: string, opts?: ErrorOptions) {\n super(message, opts);\n this.name = new.target.name;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}",
198
210
  "ArsenalSerializationError": "export class ArsenalSerializationError extends ArsenalError {}",
211
+ "ShareCodeReason": "export type ShareCodeReason = 'compression-unavailable' | 'invalid' | 'too-large';",
212
+ "ShareCodeError": "export class ShareCodeError extends ArsenalError {\n readonly reason: ShareCodeReason;\n\n constructor(reason: ShareCodeReason, message: string, opts?: ErrorOptions) {\n super(message, opts);\n this.reason = reason;\n }\n}",
199
213
  "DebounceOptions": "export type DebounceOptions = {\n /** Invoke on the leading edge of the delay window. Default: `false`. */\n leading?: boolean;\n /** Invoke on the trailing edge of the delay window. Default: `true`. */\n trailing?: boolean;\n};",
200
214
  "Debounced": "export type Debounced<T extends Fn> = ((...args: Parameters<T>) => ReturnType<T> | undefined) & {\n cancel(): void;\n flush(): ReturnType<T> | undefined;\n pending(): boolean;\n};",
201
215
  "debounce": "export function debounce<T extends Fn>(\n fn: T,\n delay = 300,\n options: DebounceOptions = { leading: false, trailing: true },\n): Debounced<T> {\n const leading = options.leading ?? false;\n const trailing = options.trailing ?? true;\n\n if (!leading && !trailing) {\n warn('debounce: both `leading` and `trailing` are false: the wrapped function will never be invoked.');\n }\n\n let timerId: ReturnType<typeof setTimeout> | undefined;\n let lastArgs: Parameters<T> | undefined;\n let lastResult: ReturnType<T> | undefined;\n let leadingFired = false;\n\n const clearTimer = () => {\n if (timerId !== undefined) {\n clearTimeout(timerId);\n timerId = undefined;\n }\n };\n\n const invokeTrailing = () => {\n clearTimer();\n leadingFired = false;\n\n if (!lastArgs) return undefined;\n\n const args = lastArgs;\n\n lastArgs = undefined;\n lastResult = fn(...args) as ReturnType<T>;\n\n return lastResult;\n };\n\n return Object.assign(\n (...args: Parameters<T>): ReturnType<T> | undefined => {\n lastArgs = args;\n\n if (leading && !leadingFired) {\n leadingFired = true;\n lastResult = fn(...args) as ReturnType<T>;\n // Clear args regardless of `trailing`: the leading call already consumed this\n // invocation. The trailing timer below only re-invokes `fn` if a *further* call\n // arrives during the cooldown window and repopulates `lastArgs`.\n lastArgs = undefined;\n\n if (!trailing) {\n // Leading-only: start cooldown: no trailing call needed\n timerId = setTimeout(() => {\n timerId = undefined;\n leadingFired = false;\n }, delay);\n\n return lastResult;\n }\n }\n\n // When leading-only, ignore calls within the cooldown window\n if (leading && !trailing && leadingFired) return lastResult;\n\n clearTimer();\n timerId = setTimeout(invokeTrailing, delay);\n\n return lastResult;\n },\n {\n cancel: () => {\n clearTimer();\n lastArgs = undefined;\n leadingFired = false;\n },\n flush: (): ReturnType<T> | undefined => invokeTrailing(),\n pending: () => timerId !== undefined,\n },\n );\n}",