@vielzeug/codex 2.3.0 → 2.3.1
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/catalog.json +38 -37
- package/data/llms-full.txt +46 -4
- package/data/llms.txt +1 -1
- package/data/manifest.json +1 -1
- package/data/packages/postmaster.json +33 -27
- package/data/refine.json +1777 -1817
- package/data/search.json +9 -5
- package/package.json +1 -1
package/data/search.json
CHANGED
|
@@ -910,15 +910,19 @@
|
|
|
910
910
|
"category": "async",
|
|
911
911
|
"description": "typed durable job outbox with leased processing, retries, and dead letter recovery for browser applications.",
|
|
912
912
|
"docs": {
|
|
913
|
-
"index": " \ntitle: postmaster — durable job outbox\ndescription: typed durable job outbox with leased processing, retries, and dead letter recovery for browser applications.\npackage: postmaster\ncategory: async\nkeywords: [durable, outbox, jobs, retry, dead letter, idempotency, indexeddb, lease]\nrelated: [courier, vault, sentinel, familiar, ripple]\nexports: [createpostmaster, definejobs, createindexeddbpostmasterstore, creatememorypostmasterstore, postmastererror, postmasterdisposederror, postmasterjoberror]\nenvironments: [browser, node]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"postmaster\" />\n\n## why postmaster?\n\napplication jobs that touch a remote service — posting a form, syncing state, sending analytics — must survive page reloads, resume later, retry according to an explicit policy, and retain terminal failures for recovery. postmaster coordinates that delivery with typed job definitions, leased processing, and a dead letter queue, all backed by indexeddb.\n\n```ts\n// before\nasync function createtodo(payload: { id: string; title: string }) {\n // lost on reload. no retry. no recovery. silent failure.\n await fetch('/api/todos', { method: 'post', body: json.stringify(payload) });\n}\n\n// after\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\nimport { s } from '@vielzeug/spell';\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: s.object({ id: s.string(), title: s.string() }),\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n\nconst store = createindexeddbpostmasterstore({ name: 'my app outbox' });\nconst postmaster = createpostmaster({ jobs, store });\n\nawait postmaster.enqueue('createtodo', { id: crypto.randomuuid(), title: 'buy milk' });\nawait postmaster.start();\n```\n\n| feature | postmaster | ad hoc outbox | familiar |\n| | | | |\n| bundle size | <packageinfo package=\"postmaster\" type=\"size\" /> | application defined | <packageinfo package=\"familiar\" type=\"size\" /> |\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| survives page reload | <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| leased cross tab processing | <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| dead letter recovery | <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| typed job payloads | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use postmaster when** application jobs must survive reloads, retry explicitly, and remain recoverable after terminal failure.\n\n**consider familiar when** jobs are cpu bound, in memory only, and never need to survive a page reload.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/postmaster\n```\n\n```sh [npm]\nnpm install @vielzeug/postmaster\n```\n\n```sh [yarn]\nyarn add @vielzeug/postmaster\n```\n\n:::\n\nfor browser persistence, also install `@vielzeug/vault` (a workspace peer of the indexeddb adapter):\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/postmaster @vielzeug/vault\n```\n\n```sh [npm]\nnpm install @vielzeug/postmaster @vielzeug/vault\n```\n\n```sh [yarn]\nyarn add @vielzeug/postmaster @vielzeug/vault\n```\n\n:::\n\n## quick start\n\ndefine typed jobs, create a durable store, enqueue work, and start the processor. dispose both the processor and the store when the page lifetime ends.\n\n```ts\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n retry: { maxattempts: 5, shouldretry: () => true },\n },\n});\n\nconst store = createindexeddbpostmasterstore({ name: 'my app outbox' });\nconst postmaster = createpostmaster({ jobs, store });\n\nawait postmaster.enqueue('createtodo', { id: crypto.randomuuid(), title: 'buy milk' });\nawait postmaster.start();\n\n// on page unload:\nawait postmaster.dispose();\nawait store.dispose();\n```\n\n<div class=\"features grid\">\n\n## features\n\n `definejobs()` — typed job registry with payload inference and validation.\n `createpostmaster()` — processor with leased claims, heartbeat renewal, and crash recovery.\n `enqueue()` — persist a job and wake the processor
|
|
914
|
-
"api": " \ntitle: postmaster — api reference\ndescription: job definitions, processor, store contracts, events, errors, and entry points for postmaster.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `definejobs()` | typed job registry with validation | sync | throws on invalid version, missing fields, or bad retry config |\n| `createpostmaster()` | processor with leased claims and retry | sync | store is borrowed, not disposed with the processor |\n| `createindexeddbpostmasterstore()` | durable browser store | sync | requires `@vielzeug/vault` as a workspace peer |\n| `creatememorypostmasterstore()` | deterministic in memory store | sync | use for tests only |\n| `postmastererror` | base class for package errors | sync | catch a subtype when recovery is specific |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/postmaster` | job definitions, processor, store contract, events, errors |\n| `@vielzeug/postmaster/indexeddb` | durable browser store backed by vault indexeddb |\n| `@vielzeug/postmaster/testing` | deterministic in memory store and test helpers |\n\n## factories\n\n### `definejobs()`\n\n```ts\nfunction definejobs<const j extends jobdefinitions>(jobs: j): j;\n```\n\nreturns the job registry after validating each definition. rejects invalid versions, missing `execute`/`key`, and retry configurations with non positive `maxattempts`.\n\n| parameter | type | description |\n| | | |\n| `jobs` | `j extends jobdefinitions` | map of job name to definition |\n\n**returns:** `j` — the same registry, typed for payload inference.\n\n**example**\n\n```ts\nimport { definejobs } from '@vielzeug/postmaster';\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n```\n\n \n\n### `createpostmaster()`\n\n```ts\nfunction createpostmaster<j extends jobdefinitions>(options: createpostmasteroptions<j>): postmaster<j>;\n```\n\nreturns a postmaster processor that claims, executes, retries, and dead letters jobs from the borrowed store.\n\n| parameter | type | description |\n| | | |\n| `options.jobs` | `j` | job registry from `definejobs()` |\n| `options.store` | `postmasterstore` | borrowed store; not disposed with the processor |\n| `options.leaseduration` | `number` | lease duration in ms (default 30000, minimum 1000) |\n| `options.clock` | `() => number` | deterministic clock for tests (default `date.now`) |\n| `options.signal` | `abortsignal` | external signal that disposes the processor |\n\n**returns:** `postmaster<j>`.\n\n**example**\n\n```ts\nimport { createpostmaster } from '@vielzeug/postmaster';\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n\nconst store = createindexeddbpostmasterstore({ name: 'outbox' });\nconst postmaster = createpostmaster({ jobs, store });\n\nawait postmaster.start();\nawait postmaster.dispose();\nawait store.dispose();\n```\n\n \n\n### `createindexeddbpostmasterstore()`\n\n```ts\nfunction createindexeddbpostmasterstore(options: { name: string }): postmasterstore;\n```\n\nreturns a durable postmaster store backed by vault indexeddb. uses one internal table indexed by `status`, `availableat`, and `leaseexpiresat`. all operations run inside vault transactions.\n\n| parameter | type | description |\n| | | |\n| `options.name` | `string` | indexeddb database name |\n\n**returns:** `postmasterstore`.\n\n**example**\n\n```ts\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n\nconst store = createindexeddbpostmasterstore({ name: 'my app outbox' });\nawait store.dispose();\n```\n\n \n\n### `creatememorypostmasterstore()`\n\n```ts\nfunction creatememorypostmasterstore(entries?: readonly storedjob[]): postmasterstore;\n```\n\nreturns a deterministic in memory store for tests. serializes all operations through a promise chain.\n\n| parameter | type | description |\n| | | |\n| `entries` | `readonly storedjob[]` | initial records (default empty) |\n\n**returns:** `postmasterstore`.\n\n**example**\n\n```ts\nimport { creatememorypostmasterstore } from '@vielzeug/postmaster/testing';\n\nconst store = creatememorypostmasterstore();\nawait store.dispose();\n```\n\n## postmaster methods\n\n### `enqueue()`\n\n```ts\nenqueue<k extends keyof j & string>(name: k, payload: inferjobpayload<j[k]>): promise<postmasterentry>;\n```\n\nvalidates the payload (if `validate` is defined), derives the key, persists the job, and wakes the processor. throws `postmastererror` for an empty key or non json serializable payload.\n\n \n\n### `start()`\n\n```ts\nstart(): promise<void>;\n```\n\nbegins background processing. idempotent.\n\n \n\n### `flush()`\n\n```ts\nflush(options?: { signal?: abortsignal }): promise<flushresult>;\n```\n\nprocesses every available job until the queue is empty or the signal aborts. concurrent `flush()` calls join the same drain. returns counts of processed, completed, dead lettered, and retry scheduled jobs.\n\n \n\n### `list()`\n\n```ts\nlist(filter?: entryfilter): promise<postmasterentry[]>;\n```\n\nreturns entries ordered by `createdat`. filter by `status` optionally.\n\n \n\n### `stats()`\n\n```ts\nstats(): promise<postmasterstats>;\n```\n\nreturns counts of queued, running, and dead letter jobs.\n\n \n\n### `retry()`\n\n```ts\nretry(id: string): promise<retryresult>;\n```\n\nmoves a dead letter job back to queued. returns a discriminated result: `retried`, `not found`, `not dead letter`, or `running`.\n\n \n\n### `remove()`\n\n```ts\nremove(id: string): promise<removeresult>;\n```\n\ndeletes a queued or dead letter job. returns a discriminated result: `removed`, `not found`, or `running`.\n\n \n\n### `tap()`\n\n```ts\ntap(handler: (event: postmasterevent) => void, options?: { signal?: abortsignal }): () => void;\n```\n\nobserve runtime events (enqueued, started, completed, retry scheduled, dead lettered, removed, lease lost, processor error, dispose). handler errors are swallowed — observability never affects processing. returns an unsubscribe function. pass `{ signal }` to auto detach on abort.\n\n \n\n### `dispose()`\n\n```ts\ndispose(): promise<void>;\n[symbol.asyncdispose](): promise<void>;\n```\n\naborts owned work, releases all active leases, and tears down subscriptions. idempotent. does not dispose the borrowed store.\n\n## types\n\n### `jobdefinition<t>`\n\n```ts\ninterface jobdefinition<t> {\n readonly version: number;\n readonly validate?: validate<t>;\n readonly key: (payload: t) => string;\n readonly execute: (payload: t, context: jobcontext) => promise<void>;\n readonly retry?: retrypolicy;\n readonly migrate?: (payload: unknown, fromversion: number) => unknown;\n}\n```\n\n`validate` is optional. accepts a function `(value: unknown) => t` or any structural parser with `parse(value: unknown): t` (spell schemas, zod schemas, etc). called once at enqueue. if omitted, payload trusted as is.\n\n \n\n### `validate<t>`\n\n```ts\ntype validate<t> = ((value: unknown) => t) | { parse(value: unknown): t };\n```\n\naccepts either a plain validation function or any object with a `parse(value: unknown): t` method. spell's `schema` and `s.object(...)` satisfy this contract directly — no adapter needed.\n\n \n\n### `jobcontext`\n\n```ts\ninterface jobcontext {\n readonly attempt: number;\n readonly entryid: string;\n readonly key: string;\n readonly signal: abortsignal;\n}\n```\n\n \n\n### `retrypolicy`\n\n```ts\ninterface retrypolicy {\n readonly maxattempts: number;\n readonly shouldretry: (error: unknown, attempt: number) => boolean;\n readonly delay?: (attempt: number) => number;\n}\n```\n\n`maxattempts` is total executions including the first. `shouldretry` is required when retries are enabled. default delay uses arsenal's `backoff(attempt)`.\n\n \n\n### `storedjob`\n\n```ts\ninterface storedjob {\n readonly id: string;\n readonly name: string;\n readonly version: number;\n readonly payload: jsonvalue;\n readonly key: string;\n readonly status: 'queued' | 'running' | 'dead letter';\n readonly attempts: number;\n readonly createdat: number;\n readonly updatedat: number;\n readonly availableat: number;\n readonly ownerid?: string;\n readonly leaseexpiresat?: number;\n readonly failure?: storedfailure;\n}\n```\n\n \n\n### `storedfailure`\n\n```ts\ninterface storedfailure {\n readonly name: string;\n readonly message: string;\n readonly occurredat: number;\n}\n```\n\nonly a bounded error name/message/timestamp is persisted. never persist arbitrary error objects, response bodies, headers, or stacks.\n\n \n\n### `postmasterentry`\n\n```ts\ntype postmasterentry = pick<storedjob,\n 'attempts' | 'availableat' | 'createdat' | 'failure' | 'id' |\n 'key' | 'name' | 'status' | 'updatedat' | 'version'\n>;\n```\n\nthe public entry view excludes `payload`, `ownerid`, and `leaseexpiresat`.\n\n \n\n### `postmasterstore`\n\n```ts\ninterface postmasterstore {\n transact<t>(fn: (tx: storetx) => promise<t>): promise<t>;\n list(filter?: entryfilter): promise<storedjob[]>;\n subscribe(listener: () => void): () => void;\n dispose(): promise<void>;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n [symbol.asyncdispose](): promise<void>;\n}\n\ninterface storetx {\n get(id: string): promise<storedjob | undefined>;\n put(entry: storedjob): promise<void>;\n delete(id: string): promise<void>;\n findclaimable(now: number): promise<storedjob | undefined>;\n findnextwake(now: number): promise<number | undefined>;\n countbystatus(): promise<postmasterstats>;\n}\n```\n\nthe store exposes transactional primitives. the processor owns all ownership and transition logic — stores implement storage, not the job state machine. `transact` wraps all operations in an atomic transaction. `findclaimable` returns the earliest eligible job (queued with `availableat <= now`, or running with expired lease). `findnextwake` returns the earliest future wake time across queued and running jobs.\n\n \n\n### `postmasterevent`\n\n```ts\ntype postmasterevent =\n | { readonly type: 'enqueued' | 'started' | 'completed' | 'retry scheduled' | 'dead lettered'; readonly entry: postmasterentry }\n | { readonly type: 'removed' | 'lease lost'; readonly id: string }\n | { readonly type: 'processor error'; readonly error: error }\n | { readonly type: 'dispose' };\n```\n\n \n\n### `flushresult`\n\n```ts\ninterface flushresult {\n readonly processed: number;\n readonly completed: number;\n readonly deadlettered: number;\n readonly retryscheduled: number;\n}\n```\n\n \n\n### `retryresult` / `removeresult`\n\n```ts\ntype retryresult =\n | { readonly status: 'not found' | 'not dead letter' | 'running' }\n | { readonly status: 'retried'; readonly entry: postmasterentry };\n\ntype removeresult =\n | { readonly status: 'not found' | 'running' }\n | { readonly status: 'removed'; readonly id: string };\n```\n\n## errors\n\n### `postmastererror`\n\n```ts\nclass postmastererror extends error {\n constructor(message: string, options?: erroroptions);\n}\n```\n\nbase class for package defined errors. use `instanceof postmastererror` to narrow to the hierarchy. covers configuration errors, serialization errors, and store failures.\n\n \n\n### `postmasterdisposederror`\n\n```ts\nclass postmasterdisposederror extends postmastererror {}\n```\n\nthrown when a public method is called after disposal.\n\n \n\n### `postmasterjoberror`\n\n```ts\nclass postmasterjoberror extends postmastererror {}\n```\n\nthrown when a job definition is missing, a version is incompatible, or a migration fails. these errors move the job to dead letter rather than rejecting the public call.\n",
|
|
915
|
-
"usage": " \ntitle: postmaster — usage guide\ndescription: define durable jobs, process them with leases, retry failures, and recover dead letter work.\n \n\n[[toc]]\n\n## basic usage\n\ndefine typed jobs, create a durable store, enqueue work, and start the processor. dispose both handles when the owner ends.\n\n```ts\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n\nconst store = createindexeddbpostmasterstore({ name: 'my app outbox' });\nconst postmaster = createpostmaster({ jobs, store });\n\nawait postmaster.enqueue('createtodo', { id: crypto.randomuuid(), title: 'buy milk' });\nawait postmaster.start();\n\n// on page unload:\nawait postmaster.dispose();\nawait store.dispose();\n```\n\nthe store is borrowed by `createpostmaster()` and is not disposed with the processor. dispose both explicitly.\n\n## at least once delivery and idempotency\n\npostmaster provides **at least once delivery**. a crash after the remote write but before local completion can repeat the job. every job must derive a stable idempotency key, and handlers must send or otherwise enforce that key.\n\n```ts\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n```\n\nnever assume exactly once execution. design handlers so a repeated delivery is safe.\n\n## postmaster jobs vs courier mutations\n\ncourier performs immediate http requests and cache reconciliation. postmaster coordinates durable delivery. use courier inside a postmaster job when the write must survive reloads.\n\n```ts\nimport { createcourier, couriernetworkerror } from '@vielzeug/courier';\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await courier.mutate({\n request: () =>\n courier.post('/todos', {\n body: payload,\n headers: { 'idempotency key': key },\n signal,\n }),\n invalidatekeys: [['todos']],\n });\n },\n retry: { maxattempts: 5, shouldretry: (error) => error instanceof couriernetworkerror },\n },\n});\n```\n\npostmaster does not import courier. the integration happens in your job definitions.\n\n## payload and version migration\n\neach job declares a `version` and an optional `validate` function. when a stored job's version is older than the registered version, postmaster calls `migrate()` before validating. `validate` is called once at enqueue; omit it to accept the payload as is. `validate` accepts a plain function `(value: unknown) => t` or any structural parser with `parse(value: unknown): t` — spell schemas work directly:\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst jobs = definejobs({\n createtodo: {\n version: 2,\n validate: s.object({ id: s.string(), title: s.string(), priority: s.number().optional() }),\n key: (p) => p.id,\n migrate: (payload, fromversion) => {\n if (fromversion === 1) return { ...(payload as { id: string; title: string }), priority: 0 };\n return payload;\n },\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n```\n\nunknown job names, incompatible versions, failed migrations, and invalid persisted payloads move to dead letter rather than being executed.\n\n## retry semantics\n\nretries are opt in and explicitly classified. no `retry` block means one attempt followed by dead letter.\n\n```ts\nconst jobs = definejobs({\n synctodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string },\n key: (p) => p.id,\n execute: async (payload, { signal }) => {\n await fetch(`/api/todos/${payload.id}/sync`, { signal });\n },\n retry: {\n maxattempts: 5,\n shouldretry: (error) => error instanceof typeerror, // network errors only\n },\n },\n});\n```\n\n `maxattempts` means total executions, including the first.\n `shouldretry` is required when retries are enabled. postmaster never guesses whether a write is safe to repeat.\n default delay uses arsenal's deterministic `backoff(attempt)` helper. override with `delay`.\n delay must be finite and non negative.\n lifecycle aborts caused by disposal are not classified as job failures.\n\n## dead letter recovery\n\njobs that exhaust retries or hit a terminal failure move to dead letter. inspect, retry, or remove them.\n\n```ts\nconst deadlettered = await postmaster.list({ status: 'dead letter' });\n\nfor (const entry of deadlettered) {\n console.log(entry.id, entry.name, entry.failure);\n}\n\n// retry a dead letter job back into the queue.\nawait postmaster.retry(entry.id);\n\n// or remove it permanently.\nawait postmaster.remove(entry.id);\n```\n\n`retry()` and `remove()` return discriminated results so callers can distinguish `not found`, `not dead letter`, `running`, and successful outcomes without exceptions.\n\n## lifecycle and disposal\n\n`start()` begins background processing. `dispose()` stops claiming new work, aborts owned work, and is idempotent. `flush()` processes every available job synchronously.\n\n```ts\nawait postmaster.start();\n// ...on unload\nawait postmaster.dispose();\nawait store.dispose();\n```\n\ndisposal aborts owned work, releases the active lease, and is idempotent. a controlled disposal abort does not consume the attempt — the job returns to queued.\n\n## events\n\ntap runtime events for observability. handler errors are swallowed — observability never affects processing.\n\n```ts\nconst unsubscribe = postmaster.tap((event) => {\n switch (event.type) {\n case 'enqueued':\n console.log('enqueued', event.entry.id);\n break;\n case 'completed':\n console.log('completed', event.entry.id);\n break;\n case 'dead lettered':\n console.error('dead lettered', event.entry.id, event.entry.failure);\n break;\n case 'processor error':\n console.error('processor error', event.error);\n break;\n }\n});\n```\n\npass an `abortsignal` to auto detach:\n\n```ts\nconst controller = new abortcontroller();\npostmaster.tap(handler, { signal: controller.signal });\ncontroller.abort(); // stops tapping\n```\n\n## testing\n\nuse the in memory store for deterministic tests.\n\n```ts\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { creatememorypostmasterstore } from '@vielzeug/postmaster/testing';\n\nconst store = creatememorypostmasterstore();\nconst postmaster = createpostmaster({\n jobs: definejobs({\n send: {\n version: 1,\n validate: (v: unknown) => string(v),\n key: (p) => p,\n execute: async () => {},\n },\n }),\n store,\n});\n\nawait postmaster.enqueue('send', 'hello');\nawait postmaster.flush();\nawait postmaster.dispose();\n```\n\ninject a deterministic clock to control retry scheduling.\n\n```ts\nlet now = 0;\nconst postmaster = createpostmaster({ clock: () => now, jobs, store });\n```\n\n## framework integration\n\ncreate the postmaster after the component mounts, start processing, and dispose on unmount.\n\n::: code group\n\n```tsx [react]\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\nimport { createpostmaster, definejobs, type postmaster } from '@vielzeug/postmaster';\nimport { useeffect } from 'react';\n\nconst jobs = definejobs({\n sync: {\n version: 1,\n validate: (v: unknown) => v as { id: string },\n key: (p) => p.id,\n execute: async (payload, { signal }) => {\n await fetch(`/api/sync/${payload.id}`, { signal });\n },\n },\n});\n\nexport function outboxprovider() {\n useeffect(() => {\n const store = createindexeddbpostmasterstore({ name: 'outbox' });\n const postmaster = createpostmaster({ jobs, store });\n void postmaster.start();\n\n return () => {\n void postmaster.dispose();\n void store.dispose();\n };\n }, []);\n\n return null;\n}\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { onmounted, onunmounted } from 'vue';\n\nconst jobs = definejobs({\n sync: {\n version: 1,\n validate: (v: unknown) => v as { id: string },\n key: (p) => p.id,\n execute: async (payload, { signal }) => {\n await fetch(`/api/sync/${payload.id}`, { signal });\n },\n },\n});\n\nlet postmaster: returntype<typeof createpostmaster> | undefined;\nlet store: returntype<typeof createindexeddbpostmasterstore> | undefined;\n\nonmounted(() => {\n store = createindexeddbpostmasterstore({ name: 'outbox' });\n postmaster = createpostmaster({ jobs, store });\n void postmaster.start();\n});\n\nonunmounted(() => {\n void postmaster?.dispose();\n void store?.dispose();\n});\n</script>\n\n<template>\n <slot />\n</template>\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n import { createpostmaster, definejobs } from '@vielzeug/postmaster';\n import { onmount } from 'svelte';\n\n const jobs = definejobs({\n sync: {\n version: 1,\n validate: (v: unknown) => v as { id: string },\n key: (p) => p.id,\n execute: async (payload, { signal }) => {\n await fetch(`/api/sync/${payload.id}`, { signal });\n },\n },\n });\n\n onmount(() => {\n const store = createindexeddbpostmasterstore({ name: 'outbox' });\n const postmaster = createpostmaster({ jobs, store });\n void postmaster.start();\n\n return () => {\n void postmaster.dispose();\n void store.dispose();\n };\n });\n</script>\n\n<slot />\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### postmaster + courier\n\nuse courier inside job handlers for http transport and cache invalidation. postmaster coordinates delivery; courier performs the request.\n\n```ts\nimport { createcourier, couriernetworkerror } from '@vielzeug/courier';\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await courier.mutate({\n request: () =>\n courier.post('/todos', {\n body: payload,\n headers: { 'idempotency key': key },\n signal,\n }),\n invalidatekeys: [['todos']],\n });\n },\n retry: { maxattempts: 5, shouldretry: (e) => e instanceof couriernetworkerror },\n },\n});\n```\n\n### postmaster + sentinel\n\nflush the outbox when the network returns. sentinel reports online state; postmaster does the rest.\n\n```ts\nimport { createnetwork } from '@vielzeug/sentinel';\nimport { createpostmaster } from '@vielzeug/postmaster';\n\nconst network = createnetwork();\nconst postmaster = createpostmaster({ jobs, store });\n\nconst unsubscribe = network.subscribe(() => {\n if (network.value.online) void postmaster.flush();\n});\n\n// on teardown:\nunsubscribe();\nnetwork.dispose();\nawait postmaster.dispose();\n```\n\n### postmaster + vault\n\nthe indexeddb adapter is built on vault. use vault directly for unrelated storage; the postmaster store owns its own database name.\n\n## best practices\n\n **derive** a stable idempotency key from every job payload and send it with the remote write.\n **dispose** both the processor and the store explicitly; the processor does not own the store.\n **classify** retryable errors explicitly with `shouldretry`; never let postmaster guess.\n **migrate** persisted payloads when job versions change; test migrations against stored fixtures.\n **inspect** the dead letter queue regularly and retry or remove terminal failures.\n **avoid** persisting sensitive data in payloads or failure messages; indexeddb is per origin but not encrypted.\n **flush** the outbox when sentinel reports the network returns.\n **test** with the in memory store and a deterministic clock for reproducible retry timing.\n",
|
|
916
|
-
"examples": " \ntitle: postmaster — examples\ndescription: durable outbox recipes for offline mutations, network recovery, and dead letter handling.\n \n\n## examples\n\n [queue offline courier mutations](./examples/queue offline courier mutations.md)\n [resume when network returns](./examples/resume when network returns.md)\n [recover dead letter jobs](./examples/recover dead letter jobs.md)\n [service worker background sync](./examples/service worker background sync.md)\n"
|
|
913
|
+
"index": " \ntitle: postmaster — durable job outbox\ndescription: typed durable job outbox with leased processing, retries, and dead letter recovery for browser applications.\npackage: postmaster\ncategory: async\nkeywords: [durable, outbox, jobs, retry, dead letter, idempotency, indexeddb, lease]\nrelated: [courier, vault, sentinel, familiar, ripple]\nexports: [createpostmaster, definejobs, createindexeddbpostmasterstore, creatememorypostmasterstore, postmastererror, postmasterdisposederror, postmasterjoberror]\nenvironments: [browser, node]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"postmaster\" />\n\n## why postmaster?\n\napplication jobs that touch a remote service — posting a form, syncing state, sending analytics — must survive page reloads, resume later, retry according to an explicit policy, and retain terminal failures for recovery. postmaster coordinates that delivery with typed job definitions, leased processing, and a dead letter queue, all backed by indexeddb.\n\n```ts\n// before\nasync function createtodo(payload: { id: string; title: string }) {\n // lost on reload. no retry. no recovery. silent failure.\n await fetch('/api/todos', { method: 'post', body: json.stringify(payload) });\n}\n\n// after\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\nimport { s } from '@vielzeug/spell';\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: s.object({ id: s.string(), title: s.string() }),\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n\nconst store = createindexeddbpostmasterstore({ name: 'my app outbox' });\nconst postmaster = createpostmaster({ jobs, store });\n\nawait postmaster.enqueue('createtodo', { id: crypto.randomuuid(), title: 'buy milk' });\nawait postmaster.start();\n```\n\n| feature | postmaster | ad hoc outbox | familiar |\n| | | | |\n| bundle size | <packageinfo package=\"postmaster\" type=\"size\" /> | application defined | <packageinfo package=\"familiar\" type=\"size\" /> |\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| survives page reload | <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| leased cross tab processing | <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| dead letter recovery | <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| typed job payloads | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use postmaster when** application jobs must survive reloads, retry explicitly, and remain recoverable after terminal failure.\n\n**consider familiar when** jobs are cpu bound, in memory only, and never need to survive a page reload.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/postmaster\n```\n\n```sh [npm]\nnpm install @vielzeug/postmaster\n```\n\n```sh [yarn]\nyarn add @vielzeug/postmaster\n```\n\n:::\n\nfor browser persistence, also install `@vielzeug/vault` (a workspace peer of the indexeddb adapter):\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/postmaster @vielzeug/vault\n```\n\n```sh [npm]\nnpm install @vielzeug/postmaster @vielzeug/vault\n```\n\n```sh [yarn]\nyarn add @vielzeug/postmaster @vielzeug/vault\n```\n\n:::\n\n## quick start\n\ndefine typed jobs, create a durable store, enqueue work, and start the processor. dispose both the processor and the store when the page lifetime ends.\n\n```ts\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n retry: { maxattempts: 5, shouldretry: () => true },\n },\n});\n\nconst store = createindexeddbpostmasterstore({ name: 'my app outbox' });\nconst postmaster = createpostmaster({ jobs, store });\n\nawait postmaster.enqueue('createtodo', { id: crypto.randomuuid(), title: 'buy milk' });\nawait postmaster.start();\n\n// on page unload:\nawait postmaster.dispose();\nawait store.dispose();\n```\n\n<div class=\"features grid\">\n\n## features\n\n `definejobs()` — typed job registry with payload inference and validation.\n `createpostmaster()` — processor with leased claims, heartbeat renewal, and crash recovery.\n `enqueue()` — persist a job and wake the processor, with optional delayed eligibility via `availableat`.\n `flush()` — process every available job until the queue is empty.\n `retry()` / `remove()` — recover or discard dead letter jobs.\n `tap()` — typed runtime events for enqueued, started, completed, retry scheduled, dead lettered, removed, lease lost, and processor error.\n `createindexeddbpostmasterstore()` — durable browser store backed by vault indexeddb.\n `creatememorypostmasterstore()` — deterministic in memory store for tests.\n\n</div>\n\n<div class=\"doc links\">\n\n## documentation\n\n [**usage guide**](./usage.md)\n [**api reference**](./api.md)\n [**examples**](./examples.md)\n\n</div>\n\n<div class=\"see also\">\n\n## see also\n\n [@vielzeug/courier](../courier/) — perform the http requests postmaster jobs coordinate.\n [@vielzeug/vault](../vault/) — indexeddb storage primitive backing the durable store.\n [@vielzeug/sentinel](../sentinel/) — flush the outbox when the network returns.\n [@vielzeug/familiar](../familiar/) — in memory web worker pool for cpu bound tasks.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
914
|
+
"api": " \ntitle: postmaster — api reference\ndescription: job definitions, processor, store contracts, events, errors, and entry points for postmaster.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `definejobs()` | typed job registry with validation | sync | throws on invalid version, missing fields, or bad retry config |\n| `createpostmaster()` | processor with leased claims and retry | sync | store is borrowed, not disposed with the processor |\n| `createindexeddbpostmasterstore()` | durable browser store | sync | requires `@vielzeug/vault` as a workspace peer |\n| `creatememorypostmasterstore()` | deterministic in memory store | sync | use for tests only |\n| `postmastererror` | base class for package errors | sync | catch a subtype when recovery is specific |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/postmaster` | job definitions, processor, store contract, events, errors |\n| `@vielzeug/postmaster/indexeddb` | durable browser store backed by vault indexeddb |\n| `@vielzeug/postmaster/testing` | deterministic in memory store and test helpers |\n\n## factories\n\n### `definejobs()`\n\n```ts\nfunction definejobs<const j extends jobdefinitions>(jobs: j): j;\n```\n\nreturns the job registry after validating each definition. rejects invalid versions, missing `execute`/`key`, and retry configurations with non positive `maxattempts`.\n\n| parameter | type | description |\n| | | |\n| `jobs` | `j extends jobdefinitions` | map of job name to definition |\n\n**returns:** `j` — the same registry, typed for payload inference.\n\n**example**\n\n```ts\nimport { definejobs } from '@vielzeug/postmaster';\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n```\n\n \n\n### `createpostmaster()`\n\n```ts\nfunction createpostmaster<j extends jobdefinitions>(options: createpostmasteroptions<j>): postmaster<j>;\n```\n\nreturns a postmaster processor that claims, executes, retries, and dead letters jobs from the borrowed store.\n\n| parameter | type | description |\n| | | |\n| `options.jobs` | `j` | job registry from `definejobs()` |\n| `options.store` | `postmasterstore` | borrowed store; not disposed with the processor |\n| `options.leaseduration` | `number` | lease duration in ms (default 30000, minimum 1000) |\n| `options.clock` | `() => number` | deterministic clock for tests (default `date.now`) |\n| `options.signal` | `abortsignal` | external signal that disposes the processor |\n\n**returns:** `postmaster<j>`.\n\n**example**\n\n```ts\nimport { createpostmaster } from '@vielzeug/postmaster';\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n\nconst store = createindexeddbpostmasterstore({ name: 'outbox' });\nconst postmaster = createpostmaster({ jobs, store });\n\nawait postmaster.start();\nawait postmaster.dispose();\nawait store.dispose();\n```\n\n \n\n### `createindexeddbpostmasterstore()`\n\n```ts\nfunction createindexeddbpostmasterstore(options: { name: string }): postmasterstore;\n```\n\nreturns a durable postmaster store backed by vault indexeddb. uses one internal table indexed by `status`, `availableat`, and `leaseexpiresat`. all operations run inside vault transactions.\n\n| parameter | type | description |\n| | | |\n| `options.name` | `string` | indexeddb database name |\n\n**returns:** `postmasterstore`.\n\n**example**\n\n```ts\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n\nconst store = createindexeddbpostmasterstore({ name: 'my app outbox' });\nawait store.dispose();\n```\n\n \n\n### `creatememorypostmasterstore()`\n\n```ts\nfunction creatememorypostmasterstore(entries?: readonly storedjob[]): postmasterstore;\n```\n\nreturns a deterministic in memory store for tests. serializes all operations through a promise chain.\n\n| parameter | type | description |\n| | | |\n| `entries` | `readonly storedjob[]` | initial records (default empty) |\n\n**returns:** `postmasterstore`.\n\n**example**\n\n```ts\nimport { creatememorypostmasterstore } from '@vielzeug/postmaster/testing';\n\nconst store = creatememorypostmasterstore();\nawait store.dispose();\n```\n\n## postmaster methods\n\n### `enqueue()`\n\n```ts\nenqueue<k extends keyof j & string>(\n name: k,\n payload: inferjobpayload<j[k]>,\n options?: enqueueoptions,\n): promise<postmasterentry>;\n```\n\nvalidates the payload (if `validate` is defined), derives the key, persists the job, and wakes the processor. throws `postmastererror` for an empty key, non json serializable payload, or invalid `availableat`.\n\n| parameter | type | description |\n| | | |\n| `name` | `k` | registered job name |\n| `payload` | `inferjobpayload<j[k]>` | job payload (validated if `validate` is defined) |\n| `options.availableat` | `number` | earliest epoch timestamp (ms) the job may be claimed. defaults to the postmaster clock. must be a finite non negative safe integer. |\n\n**delayed eligibility.** the job persists immediately but cannot be claimed before `availableat`. postmaster does not guarantee execution at that time — only that the job will not be claimed earlier. a live processor (`start()` or `flush()`) is required for execution. past timestamps remain immediately eligible.\n\n**example**\n\n```ts\nawait postmaster.enqueue('senddigest', { userid }, { availableat: date.now() + 60_000 });\n```\n\n \n\n### `start()`\n\n```ts\nstart(): promise<void>;\n```\n\nbegins background processing. idempotent.\n\n \n\n### `flush()`\n\n```ts\nflush(options?: { signal?: abortsignal }): promise<flushresult>;\n```\n\nprocesses every available job until the queue is empty or the signal aborts. concurrent `flush()` calls join the same drain. returns counts of processed, completed, dead lettered, and retry scheduled jobs.\n\n \n\n### `list()`\n\n```ts\nlist(filter?: entryfilter): promise<postmasterentry[]>;\n```\n\nreturns entries ordered by `createdat`. filter by `status` optionally.\n\n \n\n### `stats()`\n\n```ts\nstats(): promise<postmasterstats>;\n```\n\nreturns counts of queued, running, and dead letter jobs.\n\n \n\n### `retry()`\n\n```ts\nretry(id: string): promise<retryresult>;\n```\n\nmoves a dead letter job back to queued. returns a discriminated result: `retried`, `not found`, `not dead letter`, or `running`.\n\n \n\n### `remove()`\n\n```ts\nremove(id: string): promise<removeresult>;\n```\n\ndeletes a queued or dead letter job. returns a discriminated result: `removed`, `not found`, or `running`.\n\n \n\n### `tap()`\n\n```ts\ntap(handler: (event: postmasterevent) => void, options?: { signal?: abortsignal }): () => void;\n```\n\nobserve runtime events (enqueued, started, completed, retry scheduled, dead lettered, removed, lease lost, processor error, dispose). handler errors are swallowed — observability never affects processing. returns an unsubscribe function. pass `{ signal }` to auto detach on abort.\n\n \n\n### `dispose()`\n\n```ts\ndispose(): promise<void>;\n[symbol.asyncdispose](): promise<void>;\n```\n\naborts owned work, releases all active leases, and tears down subscriptions. idempotent. does not dispose the borrowed store.\n\n## types\n\n### `enqueueoptions`\n\n```ts\ninterface enqueueoptions {\n readonly availableat?: number;\n}\n```\n\noptions for `enqueue()`. `availableat` is the earliest epoch timestamp (ms) at which the job may be claimed. defaults to the postmaster clock at enqueue time. past timestamps remain immediately eligible. postmaster does not guarantee execution at the requested time — only that the job will not be claimed before it. a live processor is required for execution.\n\n \n\n### `jobdefinition<t>`\n\n```ts\ninterface jobdefinition<t> {\n readonly version: number;\n readonly validate?: validate<t>;\n readonly key: (payload: t) => string;\n readonly execute: (payload: t, context: jobcontext) => promise<void>;\n readonly retry?: retrypolicy;\n readonly migrate?: (payload: unknown, fromversion: number) => unknown;\n}\n```\n\n`validate` is optional. accepts a function `(value: unknown) => t` or any structural parser with `parse(value: unknown): t` (spell schemas, zod schemas, etc). called once at enqueue. if omitted, payload trusted as is.\n\n \n\n### `validate<t>`\n\n```ts\ntype validate<t> = ((value: unknown) => t) | { parse(value: unknown): t };\n```\n\naccepts either a plain validation function or any object with a `parse(value: unknown): t` method. spell's `schema` and `s.object(...)` satisfy this contract directly — no adapter needed.\n\n \n\n### `jobcontext`\n\n```ts\ninterface jobcontext {\n readonly attempt: number;\n readonly entryid: string;\n readonly key: string;\n readonly signal: abortsignal;\n}\n```\n\n \n\n### `retrypolicy`\n\n```ts\ninterface retrypolicy {\n readonly maxattempts: number;\n readonly shouldretry: (error: unknown, attempt: number) => boolean;\n readonly delay?: (attempt: number) => number;\n}\n```\n\n`maxattempts` is total executions including the first. `shouldretry` is required when retries are enabled. default delay uses arsenal's `backoff(attempt)`.\n\n \n\n### `storedjob`\n\n```ts\ninterface storedjob {\n readonly id: string;\n readonly name: string;\n readonly version: number;\n readonly payload: jsonvalue;\n readonly key: string;\n readonly status: 'queued' | 'running' | 'dead letter';\n readonly attempts: number;\n readonly createdat: number;\n readonly updatedat: number;\n readonly availableat: number;\n readonly ownerid?: string;\n readonly leaseexpiresat?: number;\n readonly failure?: storedfailure;\n}\n```\n\n \n\n### `storedfailure`\n\n```ts\ninterface storedfailure {\n readonly name: string;\n readonly message: string;\n readonly occurredat: number;\n}\n```\n\nonly a bounded error name/message/timestamp is persisted. never persist arbitrary error objects, response bodies, headers, or stacks.\n\n \n\n### `postmasterentry`\n\n```ts\ntype postmasterentry = pick<storedjob,\n 'attempts' | 'availableat' | 'createdat' | 'failure' | 'id' |\n 'key' | 'name' | 'status' | 'updatedat' | 'version'\n>;\n```\n\nthe public entry view excludes `payload`, `ownerid`, and `leaseexpiresat`.\n\n \n\n### `postmasterstore`\n\n```ts\ninterface postmasterstore {\n transact<t>(fn: (tx: storetx) => promise<t>): promise<t>;\n list(filter?: entryfilter): promise<storedjob[]>;\n subscribe(listener: () => void): () => void;\n dispose(): promise<void>;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n [symbol.asyncdispose](): promise<void>;\n}\n\ninterface storetx {\n get(id: string): promise<storedjob | undefined>;\n put(entry: storedjob): promise<void>;\n delete(id: string): promise<void>;\n findclaimable(now: number): promise<storedjob | undefined>;\n findnextwake(now: number): promise<number | undefined>;\n countbystatus(): promise<postmasterstats>;\n}\n```\n\nthe store exposes transactional primitives. the processor owns all ownership and transition logic — stores implement storage, not the job state machine. `transact` wraps all operations in an atomic transaction. `findclaimable` returns the earliest eligible job (queued with `availableat <= now`, or running with expired lease). `findnextwake` returns the earliest future wake time across queued and running jobs.\n\n \n\n### `postmasterevent`\n\n```ts\ntype postmasterevent =\n | { readonly type: 'enqueued' | 'started' | 'completed' | 'retry scheduled' | 'dead lettered'; readonly entry: postmasterentry }\n | { readonly type: 'removed' | 'lease lost'; readonly id: string }\n | { readonly type: 'processor error'; readonly error: error }\n | { readonly type: 'dispose' };\n```\n\n \n\n### `flushresult`\n\n```ts\ninterface flushresult {\n readonly processed: number;\n readonly completed: number;\n readonly deadlettered: number;\n readonly retryscheduled: number;\n}\n```\n\n \n\n### `retryresult` / `removeresult`\n\n```ts\ntype retryresult =\n | { readonly status: 'not found' | 'not dead letter' | 'running' }\n | { readonly status: 'retried'; readonly entry: postmasterentry };\n\ntype removeresult =\n | { readonly status: 'not found' | 'running' }\n | { readonly status: 'removed'; readonly id: string };\n```\n\n## errors\n\n### `postmastererror`\n\n```ts\nclass postmastererror extends error {\n constructor(message: string, options?: erroroptions);\n}\n```\n\nbase class for package defined errors. use `instanceof postmastererror` to narrow to the hierarchy. covers configuration errors, serialization errors, and store failures.\n\n \n\n### `postmasterdisposederror`\n\n```ts\nclass postmasterdisposederror extends postmastererror {}\n```\n\nthrown when a public method is called after disposal.\n\n \n\n### `postmasterjoberror`\n\n```ts\nclass postmasterjoberror extends postmastererror {}\n```\n\nthrown when a job definition is missing, a version is incompatible, or a migration fails. these errors move the job to dead letter rather than rejecting the public call.\n",
|
|
915
|
+
"usage": " \ntitle: postmaster — usage guide\ndescription: define durable jobs, process them with leases, retry failures, and recover dead letter work.\n \n\n[[toc]]\n\n## basic usage\n\ndefine typed jobs, create a durable store, enqueue work, and start the processor. dispose both handles when the owner ends.\n\n```ts\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n\nconst store = createindexeddbpostmasterstore({ name: 'my app outbox' });\nconst postmaster = createpostmaster({ jobs, store });\n\nawait postmaster.enqueue('createtodo', { id: crypto.randomuuid(), title: 'buy milk' });\nawait postmaster.start();\n\n// on page unload:\nawait postmaster.dispose();\nawait store.dispose();\n```\n\nthe store is borrowed by `createpostmaster()` and is not disposed with the processor. dispose both explicitly.\n\n## at least once delivery and idempotency\n\npostmaster provides **at least once delivery**. a crash after the remote write but before local completion can repeat the job. every job must derive a stable idempotency key, and handlers must send or otherwise enforce that key.\n\n```ts\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n```\n\nnever assume exactly once execution. design handlers so a repeated delivery is safe.\n\n## postmaster jobs vs courier mutations\n\ncourier performs immediate http requests and cache reconciliation. postmaster coordinates durable delivery. use courier inside a postmaster job when the write must survive reloads.\n\n```ts\nimport { createcourier, couriernetworkerror } from '@vielzeug/courier';\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await courier.mutate({\n request: () =>\n courier.post('/todos', {\n body: payload,\n headers: { 'idempotency key': key },\n signal,\n }),\n invalidatekeys: [['todos']],\n });\n },\n retry: { maxattempts: 5, shouldretry: (error) => error instanceof couriernetworkerror },\n },\n});\n```\n\npostmaster does not import courier. the integration happens in your job definitions.\n\n## payload and version migration\n\neach job declares a `version` and an optional `validate` function. when a stored job's version is older than the registered version, postmaster calls `migrate()` before validating. `validate` is called once at enqueue; omit it to accept the payload as is. `validate` accepts a plain function `(value: unknown) => t` or any structural parser with `parse(value: unknown): t` — spell schemas work directly:\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst jobs = definejobs({\n createtodo: {\n version: 2,\n validate: s.object({ id: s.string(), title: s.string(), priority: s.number().optional() }),\n key: (p) => p.id,\n migrate: (payload, fromversion) => {\n if (fromversion === 1) return { ...(payload as { id: string; title: string }), priority: 0 };\n return payload;\n },\n execute: async (payload, { key, signal }) => {\n await fetch('/api/todos', {\n method: 'post',\n body: json.stringify(payload),\n headers: { 'idempotency key': key },\n signal,\n });\n },\n },\n});\n```\n\nunknown job names, incompatible versions, failed migrations, and invalid persisted payloads move to dead letter rather than being executed.\n\n## retry semantics\n\nretries are opt in and explicitly classified. no `retry` block means one attempt followed by dead letter.\n\n```ts\nconst jobs = definejobs({\n synctodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string },\n key: (p) => p.id,\n execute: async (payload, { signal }) => {\n await fetch(`/api/todos/${payload.id}/sync`, { signal });\n },\n retry: {\n maxattempts: 5,\n shouldretry: (error) => error instanceof typeerror, // network errors only\n },\n },\n});\n```\n\n `maxattempts` means total executions, including the first.\n `shouldretry` is required when retries are enabled. postmaster never guesses whether a write is safe to repeat.\n default delay uses arsenal's deterministic `backoff(attempt)` helper. override with `delay`.\n delay must be finite and non negative.\n lifecycle aborts caused by disposal are not classified as job failures.\n\n## delayed eligibility\n\n`enqueue()` accepts an optional `availableat` timestamp. the job persists immediately but cannot be claimed before that time. use this for scheduled writes, cooldowns, or any work that must survive a reload but should not run yet.\n\n```ts\nawait postmaster.enqueue('senddigest', { userid }, { availableat: date.now() + 60_000 });\n```\n\npostmaster does not guarantee execution at `availableat` — only that the job will not be claimed earlier. a live processor (`start()` or `flush()`) is required for execution. in a browser, a closed page or suspended service worker will run the job when the processor next becomes active. past timestamps remain immediately eligible. the same mechanism already backs retry delays, so delayed eligibility reuses the existing claim, wake, and persistence paths.\n\n## dead letter recovery\n\njobs that exhaust retries or hit a terminal failure move to dead letter. inspect, retry, or remove them.\n\n```ts\nconst deadlettered = await postmaster.list({ status: 'dead letter' });\n\nfor (const entry of deadlettered) {\n console.log(entry.id, entry.name, entry.failure);\n}\n\n// retry a dead letter job back into the queue.\nawait postmaster.retry(entry.id);\n\n// or remove it permanently.\nawait postmaster.remove(entry.id);\n```\n\n`retry()` and `remove()` return discriminated results so callers can distinguish `not found`, `not dead letter`, `running`, and successful outcomes without exceptions.\n\n## lifecycle and disposal\n\n`start()` begins background processing. `dispose()` stops claiming new work, aborts owned work, and is idempotent. `flush()` processes every available job synchronously.\n\n```ts\nawait postmaster.start();\n// ...on unload\nawait postmaster.dispose();\nawait store.dispose();\n```\n\ndisposal aborts owned work, releases the active lease, and is idempotent. a controlled disposal abort does not consume the attempt — the job returns to queued.\n\n## events\n\ntap runtime events for observability. handler errors are swallowed — observability never affects processing.\n\n```ts\nconst unsubscribe = postmaster.tap((event) => {\n switch (event.type) {\n case 'enqueued':\n console.log('enqueued', event.entry.id);\n break;\n case 'completed':\n console.log('completed', event.entry.id);\n break;\n case 'dead lettered':\n console.error('dead lettered', event.entry.id, event.entry.failure);\n break;\n case 'processor error':\n console.error('processor error', event.error);\n break;\n }\n});\n```\n\npass an `abortsignal` to auto detach:\n\n```ts\nconst controller = new abortcontroller();\npostmaster.tap(handler, { signal: controller.signal });\ncontroller.abort(); // stops tapping\n```\n\n## testing\n\nuse the in memory store for deterministic tests.\n\n```ts\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { creatememorypostmasterstore } from '@vielzeug/postmaster/testing';\n\nconst store = creatememorypostmasterstore();\nconst postmaster = createpostmaster({\n jobs: definejobs({\n send: {\n version: 1,\n validate: (v: unknown) => string(v),\n key: (p) => p,\n execute: async () => {},\n },\n }),\n store,\n});\n\nawait postmaster.enqueue('send', 'hello');\nawait postmaster.flush();\nawait postmaster.dispose();\n```\n\ninject a deterministic clock to control retry scheduling.\n\n```ts\nlet now = 0;\nconst postmaster = createpostmaster({ clock: () => now, jobs, store });\n```\n\n## framework integration\n\ncreate the postmaster after the component mounts, start processing, and dispose on unmount.\n\n::: code group\n\n```tsx [react]\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\nimport { createpostmaster, definejobs, type postmaster } from '@vielzeug/postmaster';\nimport { useeffect } from 'react';\n\nconst jobs = definejobs({\n sync: {\n version: 1,\n validate: (v: unknown) => v as { id: string },\n key: (p) => p.id,\n execute: async (payload, { signal }) => {\n await fetch(`/api/sync/${payload.id}`, { signal });\n },\n },\n});\n\nexport function outboxprovider() {\n useeffect(() => {\n const store = createindexeddbpostmasterstore({ name: 'outbox' });\n const postmaster = createpostmaster({ jobs, store });\n void postmaster.start();\n\n return () => {\n void postmaster.dispose();\n void store.dispose();\n };\n }, []);\n\n return null;\n}\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nimport { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\nimport { onmounted, onunmounted } from 'vue';\n\nconst jobs = definejobs({\n sync: {\n version: 1,\n validate: (v: unknown) => v as { id: string },\n key: (p) => p.id,\n execute: async (payload, { signal }) => {\n await fetch(`/api/sync/${payload.id}`, { signal });\n },\n },\n});\n\nlet postmaster: returntype<typeof createpostmaster> | undefined;\nlet store: returntype<typeof createindexeddbpostmasterstore> | undefined;\n\nonmounted(() => {\n store = createindexeddbpostmasterstore({ name: 'outbox' });\n postmaster = createpostmaster({ jobs, store });\n void postmaster.start();\n});\n\nonunmounted(() => {\n void postmaster?.dispose();\n void store?.dispose();\n});\n</script>\n\n<template>\n <slot />\n</template>\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { createindexeddbpostmasterstore } from '@vielzeug/postmaster/indexeddb';\n import { createpostmaster, definejobs } from '@vielzeug/postmaster';\n import { onmount } from 'svelte';\n\n const jobs = definejobs({\n sync: {\n version: 1,\n validate: (v: unknown) => v as { id: string },\n key: (p) => p.id,\n execute: async (payload, { signal }) => {\n await fetch(`/api/sync/${payload.id}`, { signal });\n },\n },\n });\n\n onmount(() => {\n const store = createindexeddbpostmasterstore({ name: 'outbox' });\n const postmaster = createpostmaster({ jobs, store });\n void postmaster.start();\n\n return () => {\n void postmaster.dispose();\n void store.dispose();\n };\n });\n</script>\n\n<slot />\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### postmaster + courier\n\nuse courier inside job handlers for http transport and cache invalidation. postmaster coordinates delivery; courier performs the request.\n\n```ts\nimport { createcourier, couriernetworkerror } from '@vielzeug/courier';\nimport { createpostmaster, definejobs } from '@vielzeug/postmaster';\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\n\nconst jobs = definejobs({\n createtodo: {\n version: 1,\n validate: (v: unknown) => v as { id: string; title: string },\n key: (p) => p.id,\n execute: async (payload, { key, signal }) => {\n await courier.mutate({\n request: () =>\n courier.post('/todos', {\n body: payload,\n headers: { 'idempotency key': key },\n signal,\n }),\n invalidatekeys: [['todos']],\n });\n },\n retry: { maxattempts: 5, shouldretry: (e) => e instanceof couriernetworkerror },\n },\n});\n```\n\n### postmaster + sentinel\n\nflush the outbox when the network returns. sentinel reports online state; postmaster does the rest.\n\n```ts\nimport { createnetwork } from '@vielzeug/sentinel';\nimport { createpostmaster } from '@vielzeug/postmaster';\n\nconst network = createnetwork();\nconst postmaster = createpostmaster({ jobs, store });\n\nconst unsubscribe = network.subscribe(() => {\n if (network.value.online) void postmaster.flush();\n});\n\n// on teardown:\nunsubscribe();\nnetwork.dispose();\nawait postmaster.dispose();\n```\n\n### postmaster + vault\n\nthe indexeddb adapter is built on vault. use vault directly for unrelated storage; the postmaster store owns its own database name.\n\n## best practices\n\n **derive** a stable idempotency key from every job payload and send it with the remote write.\n **dispose** both the processor and the store explicitly; the processor does not own the store.\n **classify** retryable errors explicitly with `shouldretry`; never let postmaster guess.\n **migrate** persisted payloads when job versions change; test migrations against stored fixtures.\n **inspect** the dead letter queue regularly and retry or remove terminal failures.\n **avoid** persisting sensitive data in payloads or failure messages; indexeddb is per origin but not encrypted.\n **flush** the outbox when sentinel reports the network returns.\n **test** with the in memory store and a deterministic clock for reproducible retry timing.\n",
|
|
916
|
+
"examples": " \ntitle: postmaster — examples\ndescription: durable outbox recipes for offline mutations, network recovery, delayed eligibility, and dead letter handling.\n \n\n## examples\n\n [queue offline courier mutations](./examples/queue offline courier mutations.md)\n [resume when network returns](./examples/resume when network returns.md)\n [delayed eligibility](./examples/delayed eligibility.md)\n [recover dead letter jobs](./examples/recover dead letter jobs.md)\n [service worker background sync](./examples/service worker background sync.md)\n"
|
|
917
917
|
},
|
|
918
918
|
"examples": [
|
|
919
919
|
{
|
|
920
920
|
"id": "define-jobs",
|
|
921
921
|
"text": "definejobs basic outbox import { createpostmaster, definejobs } from '@vielzeug/postmaster'\nimport { creatememorypostmasterstore } from '@vielzeug/postmaster/testing'\n\nconst jobs = definejobs({\n send: {\n version: 1,\n validate: (v) => string(v),\n key: (p) => `send:${p}`,\n execute: async (payload, { key, attempt }) => {\n console.log(`delivering \"${payload}\" (attempt ${attempt}, key ${key})`)\n },\n },\n})\n\nconst store = creatememorypostmasterstore()\nconst postmaster = createpostmaster({ jobs, store })\n\nawait postmaster.enqueue('send', 'hello')\nconst result = await postmaster.flush()\nconsole.log('flush result:', result)\nawait postmaster.dispose()"
|
|
922
|
+
},
|
|
923
|
+
{
|
|
924
|
+
"id": "delayed-enqueue",
|
|
925
|
+
"text": "delayedenqueue delayed eligibility import { createpostmaster, definejobs } from '@vielzeug/postmaster'\nimport { creatememorypostmasterstore } from '@vielzeug/postmaster/testing'\n\nconst jobs = definejobs({\n send: {\n version: 1,\n validate: (v) => string(v),\n key: (p) => `send:${p}`,\n execute: async (payload, { key, attempt }) => {\n console.log(`delivering \"${payload}\" (attempt ${attempt}, key ${key})`)\n },\n },\n})\n\nlet now = 0\nconst store = creatememorypostmasterstore()\nconst postmaster = createpostmaster({ clock: () => now, jobs, store })\n\n// persist now, but the job is not claimable until availableat.\nconst entry = await postmaster.enqueue('send', 'hello', { availableat: 60_000 })\nconsole.log('enqueued with availableat:', entry.availableat)\n\n// nothing eligible yet.\nlet result = await postmaster.flush()\nconsole.log('flush before eligible:', result)\n\n// advance the clock past availableat.\nnow = 60_000\nresult = await postmaster.flush()\nconsole.log('flush after eligible:', result)\nawait postmaster.dispose()"
|
|
922
926
|
}
|
|
923
927
|
],
|
|
924
928
|
"exports": "createpostmaster definejobs createindexeddbpostmasterstore creatememorypostmasterstore postmastererror postmasterdisposederror postmasterjoberror",
|
|
@@ -926,7 +930,7 @@
|
|
|
926
930
|
"name": "@vielzeug/postmaster",
|
|
927
931
|
"related": "courier vault sentinel familiar ripple",
|
|
928
932
|
"slug": "postmaster",
|
|
929
|
-
"source": "export { definejobs } from './definitions.ts';\nexport { postmasterdisposederror, postmastererror, postmasterjoberror } from './errors.ts';\nexport { createpostmaster } from './postmaster.ts';\nexport type {\n createpostmasteroptions,\n entryfilter,\n entrystatus,\n flushresult,\n inferjobpayload,\n jobcontext,\n jobdefinition,\n jobdefinitions,\n jsonprimitive,\n jsonvalue,\n postmaster,\n postmasterentry,\n postmasterevent,\n postmasterstats,\n postmasterstore,\n removeresult,\n retrypolicy,\n retryresult,\n storedfailure,\n storedjob,\n storetx,\n validate,\n} from './types.ts';\n"
|
|
933
|
+
"source": "export { definejobs } from './definitions.ts';\nexport { postmasterdisposederror, postmastererror, postmasterjoberror } from './errors.ts';\nexport { createpostmaster } from './postmaster.ts';\nexport type {\n createpostmasteroptions,\n enqueueoptions,\n entryfilter,\n entrystatus,\n flushresult,\n inferjobpayload,\n jobcontext,\n jobdefinition,\n jobdefinitions,\n jsonprimitive,\n jsonvalue,\n postmaster,\n postmasterentry,\n postmasterevent,\n postmasterstats,\n postmasterstore,\n removeresult,\n retrypolicy,\n retryresult,\n storedfailure,\n storedjob,\n storetx,\n validate,\n} from './types.ts';\n"
|
|
930
934
|
},
|
|
931
935
|
{
|
|
932
936
|
"category": "ui",
|
package/package.json
CHANGED