@vielzeug/codex 2.2.9 → 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.
@@ -1,6 +1,6 @@
1
1
  # Vielzeug — Full Documentation
2
2
 
3
- > Complete documentation for 36 packages. Version: 2.2.8
3
+ > Complete documentation for 37 packages. Version: 2.3.0
4
4
 
5
5
  ---
6
6
 
@@ -977,7 +977,7 @@ actor.send({ type: 'INC' });
977
977
  | `Machine.transition()` | Resolve a pure next snapshot | Sync | Does not run effects, invokes, or timers |
978
978
  | `Machine.createActor()` | Create a runtime owner | Sync | Fresh and restored actors have different entry behavior |
979
979
  | `Actor.send()` | Dispatch an event | Sync | Returns `void`; re-entrant events queue internally |
980
- | `debugActor()` | Observe committed snapshots | Sync | Observes only; it does not trace sends or errors |
980
+ | `Actor.subscribe()` | Observe committed snapshots | Sync | Observes only; it does not trace sends or errors |
981
981
  | `ClockworkError` | Report definition and snapshot validation failures | Sync | Use `code`, not message text |
982
982
 
983
983
  ## Package Entry Points
@@ -985,7 +985,6 @@ actor.send({ type: 'INC' });
985
985
  | Import | Purpose |
986
986
  | --- | --- |
987
987
  | `@vielzeug/clockwork` | Machine compiler, actor runtime, errors, and types |
988
- | `@vielzeug/clockwork/devtools` | Opt-in snapshot observation through `debugActor()` |
989
988
 
990
989
  ## Core Functions
991
990
 
@@ -1018,24 +1017,18 @@ Throws `ClockworkError` when a definition has an invalid context, initial state,
1018
1017
 
1019
1018
  ---
1020
1019
 
1021
- ### `debugActor()`
1020
+ ### `Actor.subscribe()`
1022
1021
 
1023
1022
  ```ts
1024
- function debugActor, Event extends MachineEvent>(
1025
- actor: Actor,
1026
- options?: DebugActorOptions,
1027
- ): () => void;
1023
+ subscribe(listener: (snapshot: ActorSnapshot) => void): () => void;
1028
1024
  ```
1029
1025
 
1030
- Subscribes to committed actor snapshots and logs each one with `console.debug` by default. It does not modify actor behavior and does not observe dispatched events or runtime errors.
1031
-
1032
- **Returns:** An unsubscribe cleanup function.
1026
+ Subscribes to committed actor snapshots. Returns an unsubscribe function. The listener receives the current snapshot immediately on subscribe, then on every committed transition. It does not observe dispatched events or runtime errors.
1033
1027
 
1034
1028
  **Example:**
1035
1029
 
1036
1030
  ```ts
1037
1031
  import { defineMachine } from '@vielzeug/clockwork';
1038
- import { debugActor } from '@vielzeug/clockwork/devtools';
1039
1032
 
1040
1033
  const machine = defineMachine, { type: 'NEXT' }>()({
1041
1034
  initial: 'idle',
@@ -1043,9 +1036,9 @@ const machine = defineMachine, { type: 'NEXT' }>()({
1043
1036
  });
1044
1037
 
1045
1038
  const actor = machine.createActor();
1046
- const stopDebugging = debugActor(actor);
1039
+ const stop = actor.subscribe((snapshot) => console.debug(snapshot));
1047
1040
  actor.send({ type: 'NEXT' });
1048
- stopDebugging();
1041
+ stop();
1049
1042
  actor.dispose();
1050
1043
  ```
1051
1044
 
@@ -1364,16 +1357,6 @@ type Machine, Event extends MachineEvent> = {
1364
1357
 
1365
1358
  A compiled, reusable machine. Its transition lookup is map-based, so unknown or poison event names such as `__proto__` are safely ignored when no transition exists.
1366
1359
 
1367
- ### `DebugActorOptions`
1368
-
1369
- ```ts
1370
- type DebugActorOptions> = {
1371
- readonly logger?: (snapshot: MachineSnapshot) => void;
1372
- };
1373
- ```
1374
-
1375
- Optional logger for `debugActor()`. Logger failures are ignored so observation cannot affect the actor's error policy.
1376
-
1377
1360
  ## Errors
1378
1361
 
1379
1362
  ### `ClockworkErrorCode`
@@ -1622,19 +1605,17 @@ Without `onError`, an actor disposes silently. Return `'dispose'` explicitly whe
1622
1605
 
1623
1606
  ## Debugging
1624
1607
 
1625
- Use opt-in snapshot logging during development. `debugActor()` observes committed snapshots only; it does not trace dispatched events or runtime errors.
1608
+ Use `actor.subscribe()` to observe committed snapshots during development. It observes snapshots only; it does not trace dispatched events or runtime errors.
1626
1609
 
1627
1610
  ```ts
1628
- import { debugActor } from '@vielzeug/clockwork/devtools';
1629
-
1630
1611
  const actor = machine.createActor();
1631
- const stopDebugging = debugActor(actor);
1612
+ const stop = actor.subscribe((snapshot) => console.debug(snapshot));
1632
1613
  actor.send({ type: 'NEXT' });
1633
- stopDebugging();
1614
+ stop();
1634
1615
  actor.dispose();
1635
1616
  ```
1636
1617
 
1637
- For richer inspection, subscribe to snapshots and record them in application devtools. Clockwork intentionally has no internal trace buffer.
1618
+ For richer inspection, route snapshots to application devtools. Clockwork intentionally has no internal trace buffer.
1638
1619
 
1639
1620
  ## Flat state maps
1640
1621
 
@@ -2804,7 +2785,7 @@ interface Container {
2804
2785
 
2805
2786
  ## Errors
2806
2787
 
2807
- - `ConduitError` — base class; `ConduitError.is(error)` narrows package errors.
2788
+ - `ConduitError` — base class; use `instanceof ConduitError` to narrow package errors.
2808
2789
  - `ConduitProviderNotFoundError` — dependency has no registration.
2809
2790
  - `ConduitCircularDependencyError` — static factory tuple graph contains a cycle.
2810
2791
  - `ConduitDuplicateRegistrationError` — token registered twice in one container.
@@ -3035,6 +3016,7 @@ try {
3035
3016
  - **`queries.fetch()`** — key-based cached reads, subscriptions, invalidation with refetch, and automatic garbage collection.
3036
3017
  - **`mutate()`** — direct write operation with `invalidateKeys` for one-step cache refetch, without hidden retries or a second state store.
3037
3018
  - **`events()` / `read()`** — abortable SSE, text, and NDJSON iteration with normalized request errors.
3019
+ - **`tap()`** — runtime observability for request lifecycle events (start, success, error).
3038
3020
  - **`withBearerAuth()` / `withRequestId()` / `withLogging()`** — composable transport policies.
3039
3021
 
3040
3022
  ## Documentation
@@ -3195,6 +3177,42 @@ fields; it does not retain event IDs or reconnect.
3195
3177
 
3196
3178
  ---
3197
3179
 
3180
+ ## Observability
3181
+
3182
+ ### `tap()`
3183
+
3184
+ ```ts
3185
+ tap(handler: (event: CourierEvent) => void, options?: { signal?: AbortSignal }): () => void;
3186
+ ```
3187
+
3188
+ Observe request lifecycle events without affecting courier behavior. Handler errors are swallowed. Returns an unsubscribe function.
3189
+
3190
+ ```ts
3191
+ type CourierEvent =
3192
+ | { type: 'request-start'; method: string; url: string }
3193
+ | { type: 'request-success'; method: string; url: string; status: number; duration: number }
3194
+ | { type: 'request-error'; method: string; url: string; error: unknown }
3195
+ | { type: 'dispose' };
3196
+ ```
3197
+
3198
+ **Example:**
3199
+
3200
+ ```ts
3201
+ const courier = createCourier({ baseUrl: '/api' });
3202
+ courier.tap((event) => {
3203
+ if (event.type === 'request-error') console.error(event.method, event.url, event.error);
3204
+ if (event.type === 'request-success') console.debug(event.method, event.url, event.duration);
3205
+ });
3206
+ ```
3207
+
3208
+ For structured logging, route tap events to rune:
3209
+
3210
+ ```ts
3211
+ import { createLogger } from '@vielzeug/rune';
3212
+ const log = createLogger({ name: 'courier' });
3213
+ courier.tap((event) => log.debug(event, `courier:${event.type}`));
3214
+ ```
3215
+
3198
3216
  ## Interceptors
3199
3217
 
3200
3218
  ### Interceptor helpers
@@ -3775,7 +3793,7 @@ using sortable = createSortable({
3775
3793
  | `DropZoneOptions.accept` | Filter file types before processing | Sync | Mismatch between MIME and extension can reject files unexpectedly |
3776
3794
  | `DropZoneOptions.maxFiles` | Cap accepted files per drop | Sync | Excess accepted files become rejected; `onDropRejected` is called |
3777
3795
  | `matchesAccept()` | Test a single `File` against an accept list | Sync | Extension patterns are case-insensitive; empty list accepts all |
3778
- | `DndError` | Base class for Dnd errors | Sync | Use `DndError.is()` to narrow unknown errors |
3796
+ | `DndError` | Base class for Dnd errors | Sync | Use `instanceof DndError` to narrow unknown errors |
3779
3797
 
3780
3798
  ## Package Entry Point
3781
3799
 
@@ -4250,7 +4268,7 @@ const next = applyReorder(items, orderedIds, (item) => item.id);
4250
4268
 
4251
4269
  | Error | Trigger | Notable property |
4252
4270
  | --- | --- | --- |
4253
- | `DndError` | Base class for package errors | `DndError.is(error)` |
4271
+ | `DndError` | Base class for package errors | Use `instanceof DndError` to narrow |
4254
4272
  | `DndScopeError` | A sortable receives a scope not created by `createSortableScope()` | — |
4255
4273
 
4256
4274
  ### Usage Guide
@@ -5257,7 +5275,7 @@ type StreamHandler = (input: TInput) => AsyncIterable | Promise>;
5257
5275
 
5258
5276
  | Error | Trigger | Notable property |
5259
5277
  | --- | --- | --- |
5260
- | `FamiliarError` | Base class for all Familiar errors | `FamiliarError.is(error)` |
5278
+ | `FamiliarError` | Base class for all Familiar errors | Use `instanceof FamiliarError` to narrow |
5261
5279
  | `FamiliarInvalidOptionsError` | Invalid factory or test options | — |
5262
5280
  | `FamiliarQueueFullError` | Queue limit reached with `onFull: 'reject'` | `maxQueue` |
5263
5281
  | `FamiliarTaskError` | Worker handler throws or payload cannot clone | `cause` |
@@ -7211,7 +7229,7 @@ type FormDraftCodec, S extends AnySchema, K extends keyof S & string> = Readonly
7211
7229
 
7212
7230
  | Error | Trigger | Notable properties |
7213
7231
  | --- | --- | --- |
7214
- | `ForgeError` | Base Forge error | `ForgeError.is(error)` narrows unknown values. |
7232
+ | `ForgeError` | Base Forge error | Use `instanceof ForgeError` to narrow unknown values. |
7215
7233
  | `ForgeConfigError` | Unsafe key or unsupported form value | Extends `ForgeError`. |
7216
7234
  | `ForgeDisposedError` | Operation or subscription after disposal | Message names the attempted operation. |
7217
7235
  | `ForgeSubmitError` | Concurrent `submit()` call | Extends `ForgeError`. |
@@ -7832,7 +7850,7 @@ Gesture tracks a constrained pointer pan. Dnd owns draggable items, sortable lis
7832
7850
 
7833
7851
  ## Examples
7834
7852
 
7835
- - [Carousel Swipe Navigation](./examples/carousel-swipe-navigation.md)
7853
+ - [Carousel Pan Navigation](./examples/carousel-swipe-navigation.md)
7836
7854
  - [Swipe-to-Dismiss Notifications](./examples/swipe-dismiss-notifications.md)
7837
7855
 
7838
7856
  ### REPL Examples
@@ -7920,12 +7938,12 @@ bus.dispose();
7920
7938
 
7921
7939
  - `on()` / `once()` — typed subscriptions with explicit teardown
7922
7940
  - `onAny()` — cross-cutting event observation
7941
+ - `tap()` — observe bus activity for logging and diagnostics
7923
7942
  - `wait()` / `waitAny()` — one-shot async coordination
7924
7943
  - `events()` — bounded async event streams
7925
7944
  - `pipeEvents()` — compatible cross-bus forwarding
7926
7945
  - `AbortSignal` — cancellation and disposal ownership
7927
7946
  - `createTestBus()` — emitted-payload recording for tests
7928
- - `debugBus()` — development logging from `/devtools`
7929
7947
 
7930
7948
  ## Documentation
7931
7949
 
@@ -7950,7 +7968,6 @@ bus.dispose();
7950
7968
  | `pipeEvents()` | Forward compatible source events | Sync | Payloads must be assignable to target event |
7951
7969
  | `combineSignals()` | Abort when any input aborts | Sync | Public composition has no manual teardown |
7952
7970
  | `createTestBus()` | Record dispatched test events | Sync | Available from `/testing` only |
7953
- | `debugBus()` | Create console-debug instrumented bus | Sync | Available from `/devtools` only |
7954
7971
 
7955
7972
  ## Package Entry Point
7956
7973
 
@@ -7958,7 +7975,6 @@ bus.dispose();
7958
7975
  | --- | --- |
7959
7976
  | `@vielzeug/herald` | Runtime bus, pipes, public types, and errors |
7960
7977
  | `@vielzeug/herald/testing` | `createTestBus()` and `TestBus` |
7961
- | `@vielzeug/herald/devtools` | `debugBus()` |
7962
7978
 
7963
7979
  ## Core Functions
7964
7980
 
@@ -7974,7 +7990,7 @@ Creates a synchronous bus for future event delivery.
7974
7990
 
7975
7991
  | Parameter | Type | Description |
7976
7992
  | --- | --- | --- |
7977
- | `options` | `BusOptions` | Optional middleware, validation, error handling, logging, and listener threshold configuration. |
7993
+ | `options` | `BusOptions` | Optional middleware, validation, error handling, and listener threshold configuration. |
7978
7994
 
7979
7995
  **Returns:** `Bus`.
7980
7996
 
@@ -8073,7 +8089,6 @@ type EventKey = Extract;
8073
8089
 
8074
8090
  ```ts
8075
8091
  type BusOptions = {
8076
- logger?: BusLogger;
8077
8092
  maxListeners?: number;
8078
8093
  middleware?: readonly Middleware[];
8079
8094
  name?: string;
@@ -8084,10 +8099,9 @@ type BusOptions = {
8084
8099
 
8085
8100
  | Field | Description |
8086
8101
  | --- | --- |
8087
- | `logger` | Optional debug and warning output. |
8088
8102
  | `maxListeners` | Warn when one event exceeds this active-listener count. |
8089
8103
  | `middleware` | Synchronous dispatch middleware. |
8090
- | `name` | Display name in debug logs and disposal errors. |
8104
+ | `name` | Display name in disposal errors. |
8091
8105
  | `onError` | Handles listener and validation errors instead of rethrowing. |
8092
8106
  | `validatePayload` | Runs before middleware and listeners. |
8093
8107
 
@@ -8108,6 +8122,7 @@ type Bus = {
8108
8122
  on>(event: K, listener: Listener, opts?: SubscribeOptions): Unsubscribe;
8109
8123
  onAny(listener: (event: EventKey, payload: unknown) => void, opts?: SubscribeOptions): Unsubscribe;
8110
8124
  once>(event: K, listener: Listener, opts?: { signal?: AbortSignal }): Unsubscribe;
8125
+ tap(handler: (event: HeraldEvent) => void, options?: { signal?: AbortSignal }): Unsubscribe;
8111
8126
  wait>(event: K, opts?: { signal?: AbortSignal }): Promise;
8112
8127
  waitAny, EventKey, ...EventKey[]]>(
8113
8128
  events: K,
@@ -8119,16 +8134,20 @@ type Bus = {
8119
8134
 
8120
8135
  `emit()` returns listener count or `0` after disposal, blocked middleware, or handled validation rejection.
8121
8136
 
8137
+ `tap()` receives every `emit`, `subscribe`, `unsubscribe`, `listener-error`, and `dispose` event as a `HeraldEvent`. It is the supported way to observe bus activity for logging and diagnostics. The returned `Unsubscribe` stops the tap; pass `{ signal }` to bind its lifetime to an `AbortSignal`.
8138
+
8139
+ ```ts
8140
+ import { createBus } from '@vielzeug/herald';
8141
+
8142
+ const bus = createBus();
8143
+ const stop = bus.tap((event) => console.debug(`herald:${event.type}`, event));
8144
+ ```
8145
+
8122
8146
  ---
8123
8147
 
8124
- ### `BusLogger`, `Listener`, `SubscribeOptions`, and `Unsubscribe`
8148
+ ### `Listener`, `SubscribeOptions`, and `Unsubscribe`
8125
8149
 
8126
8150
  ```ts
8127
- type BusLogger = {
8128
- debug?: (message: string) => void;
8129
- warn?: (message: string) => void;
8130
- };
8131
-
8132
8151
  type Listener = (payload: T) => void;
8133
8152
  type SubscribeOptions = { once?: boolean; signal?: AbortSignal };
8134
8153
  type Unsubscribe = () => void;
@@ -8136,6 +8155,21 @@ type Unsubscribe = () => void;
8136
8155
 
8137
8156
  ---
8138
8157
 
8158
+ ### `HeraldEvent`
8159
+
8160
+ ```ts
8161
+ type HeraldEvent =
8162
+ | { type: 'emit'; event: EventKey; payload: unknown; timestamp: number }
8163
+ | { type: 'subscribe'; event: EventKey; timestamp: number }
8164
+ | { type: 'unsubscribe'; event: EventKey; timestamp: number }
8165
+ | { type: 'listener-error'; event: EventKey; err: unknown; timestamp: number }
8166
+ | { type: 'dispose'; timestamp: number };
8167
+ ```
8168
+
8169
+ Discriminated union delivered to `tap()` handlers. Narrow on `event.type` to access type-specific fields.
8170
+
8171
+ ---
8172
+
8139
8173
  ### `EmissionErrorContext` and `Middleware`
8140
8174
 
8141
8175
  ```ts
@@ -8187,7 +8221,7 @@ type PipeEntry =
8187
8221
  | RenamedPipeEntry;
8188
8222
  ```
8189
8223
 
8190
- ## Testing and Devtools
8224
+ ## Testing
8191
8225
 
8192
8226
  ### `createTestBus()`
8193
8227
 
@@ -8212,16 +8246,6 @@ type TestBus = Bus & {
8212
8246
  };
8213
8247
  ```
8214
8248
 
8215
- ### `debugBus()`
8216
-
8217
- ```ts
8218
- function debugBus(
8219
- options?: Omit, 'logger'> & { logger?: { warn?: BusLogger['warn'] } },
8220
- ): Bus;
8221
- ```
8222
-
8223
- Creates a bus with `console.debug` logging. Import from `@vielzeug/herald/devtools`.
8224
-
8225
8249
  ## Errors
8226
8250
 
8227
8251
  | Error | Trigger | Notable properties |
@@ -8285,10 +8309,22 @@ bus.dispose();
8285
8309
 
8286
8310
  ## Debugging
8287
8311
 
8312
+ `tap()` observes every bus activity as a `HeraldEvent` — use it for logging and diagnostics.
8313
+
8288
8314
  ```ts
8289
- import { debugBus } from '@vielzeug/herald/devtools';
8315
+ import { createBus } from '@vielzeug/herald';
8316
+
8317
+ const bus = createBus();
8318
+ bus.tap((event) => console.debug(`herald:${event.type}`, event));
8319
+ ```
8320
+
8321
+ Integrate with the Rune logger:
8322
+
8323
+ ```ts
8324
+ import { createLogger } from '@vielzeug/rune';
8290
8325
 
8291
- const bus = debugBus({ name: 'cart' });
8326
+ const log = createLogger({ name: 'herald' });
8327
+ bus.tap((event) => log.debug(event, `herald:${event.type}`));
8292
8328
  ```
8293
8329
 
8294
8330
  ## Working with Other Vielzeug Libraries
@@ -10042,7 +10078,7 @@ const map = createKeymap(
10042
10078
 
10043
10079
  | Error | Trigger | Notable properties |
10044
10080
  | --- | --- | --- |
10045
- | `KeymapError` | Lifecycle operation after disposal | `KeymapError.is(error)` narrows Keymap errors. |
10081
+ | `KeymapError` | Lifecycle operation after disposal | Use `instanceof KeymapError` to narrow Keymap errors. |
10046
10082
  | `KeymapParseError` | Strict shortcut parser receives invalid input | Extends `KeymapError`. |
10047
10083
 
10048
10084
  ### Usage Guide
@@ -11084,7 +11120,7 @@ try {
11084
11120
  | `createFormatter()` | Format Intl values from `/format` | Sync | Import from subpath |
11085
11121
  | `validateCatalog()` | Check explicit plural forms from `/validate` | Sync | Import from subpath |
11086
11122
  | `compareCatalogs()` | Compare key parity across locales from `/validate` | Sync | First locale is the base; import from subpath |
11087
- | `LinguaError` | Base class for Lingua errors | Sync | Use `LinguaError.is()` for broad narrowing |
11123
+ | `LinguaError` | Base class for Lingua errors | Sync | Use `instanceof LinguaError` for broad narrowing |
11088
11124
 
11089
11125
  ## Package Entry Point
11090
11126
 
@@ -11922,7 +11958,7 @@ Necromancer owns explicit WAAPI keyframes. It does not generate CSS keyframes, o
11922
11958
  | `animate()` | Animate one element | Sync | Defaults to a visible `180ms` duration |
11923
11959
  | `animateEach()` | Animate a unique element group | Sync | Non-zero `stagger` needs numeric `delay` |
11924
11960
  | `captureLayout()` | Capture positions and create a one-shot FLIP transition | Sync | Capture before changing layout |
11925
- | `NecromancerError` | Base package error | Sync | Use `NecromancerError.is()` to narrow unknown errors |
11961
+ | `NecromancerError` | Base package error | Sync | Use `instanceof NecromancerError` to narrow unknown errors |
11926
11962
 
11927
11963
  ## Package Entry Point
11928
11964
 
@@ -13695,7 +13731,6 @@ type OreErrorPhase = 'each-reconcile' | 'form-reset' | 'mounted' | 'setup';
13695
13731
  ## Errors
13696
13732
 
13697
13733
  `OreError` is the base class for every Ore error class — `err instanceof OreError` catches all of them.
13698
- `OreError.is(err)` is the equivalent static type-guard.
13699
13734
 
13700
13735
  - **`OreApiError`** — thrown when the `ore` API itself is misused: calling `define()` with a duplicate tag, calling a lifecycle hook (`inject`, `onMounted`, `onCleanup`, `onEvent`, …) outside of `setup()`, or passing an invalid prop definition to `define()`.
13701
13736
  - **`OreInternalError`** — thrown when an Ore invariant fails, indicating a package bug rather than invalid application code.
@@ -14192,7 +14227,1036 @@ function App() {
14192
14227
  import './x-toggle'; // wherever define('x-toggle', { ... }) is called
14193
14228
  import { ref } from 'vue';
14194
14229
 
14195
- const open = ref(false);
14230
+ const open = ref(false);
14231
+
14232
+
14233
+
14234
+ ```
14235
+
14236
+ ```svelte [Svelte]
14237
+
14238
+ import './x-toggle'; // wherever define('x-toggle', { ... }) is called
14239
+
14240
+ function handleClick() {
14241
+ console.log('toggled');
14242
+ }
14243
+
14244
+ ```
14245
+
14246
+ ## Working with Other Vielzeug Libraries
14247
+
14248
+ ### With Ripple
14249
+
14250
+ Import ripple primitives directly from `@vielzeug/ripple` for standalone reactive state outside components.
14251
+
14252
+ ```ts
14253
+ import { signal, computed } from '@vielzeug/ripple';
14254
+ import { define, html } from '@vielzeug/ore';
14255
+
14256
+ // Shared state created outside any component
14257
+ const theme = signal('light');
14258
+ const isDark = computed(() => theme.value === 'dark');
14259
+
14260
+ define('theme-toggle', {
14261
+ setup() {
14262
+ return html`
14263
+ (theme.value = isDark.value ? 'light' : 'dark')}>
14264
+ ${() =>
14265
+ isDark.value ? '' : ''}
14266
+
14267
+ `;
14268
+ },
14269
+ });
14270
+ ```
14271
+
14272
+ ### With Forge
14273
+
14274
+ Use `@vielzeug/forge` for typed form state. `useField()` remains intentionally narrow: it connects a form-associated
14275
+ custom element to native `ElementInternals` without imposing submission, validation, or dirty-state policy.
14276
+
14277
+ ```ts
14278
+ import { createForm } from '@vielzeug/forge';
14279
+ import { define, html } from '@vielzeug/ore';
14280
+
14281
+ define('signup-form', {
14282
+ setup(_props) {
14283
+ const form = createForm({ initialValues: { email: '' } });
14284
+
14285
+ return html`
14286
+ {
14287
+ event.preventDefault();
14288
+ void form.submit(async (values) => {
14289
+ console.log(values);
14290
+ });
14291
+ }}>
14292
+
14293
+
14294
+ `;
14295
+ },
14296
+ });
14297
+ ```
14298
+
14299
+ ## Best Practices
14300
+
14301
+ - Setup returns `html\`...\`` directly — not a function wrapping the template.
14302
+ - Use `watchEffect()` for reactive subscriptions tied to component lifetime — it auto-registers cleanup on disconnect.
14303
+ - Use `onElement(ref, cb)` instead of `onMounted` when the work is tied to a single DOM node.
14304
+ - Bind host attributes and classes via `bind()` rather than mutating the element directly.
14305
+ - Provide context at the nearest ancestor — avoid global context singletons.
14306
+ - Call `onCleanup()` for every resource allocated in `setup()` (WebSockets, intervals, external subscriptions).
14307
+ - Use `live(signal)` for form inputs to prevent clobbering user-in-progress edits.
14308
+ - Extract composable helper functions freely — `onMounted`/`onCleanup`/`bind`/... resolve the active component through implicit context, so they work from any function called (transitively) during `setup()`, with no need to pass them in as parameters.
14309
+ - Test component mounting and lifecycle with `@vielzeug/ore/testing`; import generic DOM events, queries, and waits
14310
+ from `@vielzeug/assay`.
14311
+
14312
+ ### Examples
14313
+
14314
+ ## Examples
14315
+
14316
+ - [Counter Component](./examples/counter-component.md)
14317
+ - [Typed Props And Emits](./examples/typed-props-and-emits.md)
14318
+ - [Observers In onMounted()](./examples/observers-in-onmount.md)
14319
+ - [Search List With Directives](./examples/search-list-with-directives.md)
14320
+ - [Context Provider And Consumer](./examples/context-provider-and-consumer.md)
14321
+ - [Prop Helpers And Raw PropDef](./examples/propsof-builder-api.md)
14322
+ - [Form Associated Rating Input](./examples/form-associated-rating-input.md)
14323
+ - [Test Example With @vielzeug/ore/testing](./examples/test-example-at-vielzeug-ore-testing.md)
14324
+
14325
+ ---
14326
+
14327
+ ## @vielzeug/postmaster
14328
+
14329
+ **Category:** Async
14330
+
14331
+ ### Overview
14332
+
14333
+ ## Why Postmaster?
14334
+
14335
+ Application 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.
14336
+
14337
+ ```ts
14338
+ // Before
14339
+ async function createTodo(payload: { id: string; title: string }) {
14340
+ // Lost on reload. No retry. No recovery. Silent failure.
14341
+ await fetch('/api/todos', { method: 'POST', body: JSON.stringify(payload) });
14342
+ }
14343
+
14344
+ // After
14345
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
14346
+ import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb';
14347
+ import { s } from '@vielzeug/spell';
14348
+
14349
+ const jobs = defineJobs({
14350
+ createTodo: {
14351
+ version: 1,
14352
+ validate: s.object({ id: s.string(), title: s.string() }),
14353
+ key: (p) => p.id,
14354
+ execute: async (payload, { key, signal }) => {
14355
+ await fetch('/api/todos', {
14356
+ method: 'POST',
14357
+ body: JSON.stringify(payload),
14358
+ headers: { 'Idempotency-Key': key },
14359
+ signal,
14360
+ });
14361
+ },
14362
+ },
14363
+ });
14364
+
14365
+ const store = createIndexedDbPostmasterStore({ name: 'my-app-outbox' });
14366
+ const postmaster = createPostmaster({ jobs, store });
14367
+
14368
+ await postmaster.enqueue('createTodo', { id: crypto.randomUUID(), title: 'Buy milk' });
14369
+ await postmaster.start();
14370
+ ```
14371
+
14372
+ | Feature | Postmaster | Ad hoc outbox | Familiar |
14373
+ | --- | --- | --- | --- |
14374
+ | Bundle size | | Application-defined | |
14375
+ | Zero dependencies | | | |
14376
+ | Survives page reload | | | |
14377
+ | Leased cross-tab processing | | | |
14378
+ | Dead-letter recovery | | | |
14379
+ | Typed job payloads | | | |
14380
+
14381
+ **Use Postmaster when** application jobs must survive reloads, retry explicitly, and remain recoverable after terminal failure.
14382
+
14383
+ **Consider Familiar when** jobs are CPU-bound, in-memory only, and never need to survive a page reload.
14384
+
14385
+ ## Installation
14386
+
14387
+ ```sh [pnpm]
14388
+ pnpm add @vielzeug/postmaster
14389
+ ```
14390
+
14391
+ ```sh [npm]
14392
+ npm install @vielzeug/postmaster
14393
+ ```
14394
+
14395
+ ```sh [yarn]
14396
+ yarn add @vielzeug/postmaster
14397
+ ```
14398
+
14399
+ For browser persistence, also install `@vielzeug/vault` (a workspace peer of the IndexedDB adapter):
14400
+
14401
+ ```sh [pnpm]
14402
+ pnpm add @vielzeug/postmaster @vielzeug/vault
14403
+ ```
14404
+
14405
+ ```sh [npm]
14406
+ npm install @vielzeug/postmaster @vielzeug/vault
14407
+ ```
14408
+
14409
+ ```sh [yarn]
14410
+ yarn add @vielzeug/postmaster @vielzeug/vault
14411
+ ```
14412
+
14413
+ ## Quick Start
14414
+
14415
+ Define typed jobs, create a durable store, enqueue work, and start the processor. Dispose both the processor and the store when the page lifetime ends.
14416
+
14417
+ ```ts
14418
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
14419
+ import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb';
14420
+
14421
+ const jobs = defineJobs({
14422
+ createTodo: {
14423
+ version: 1,
14424
+ validate: (v: unknown) => v as { id: string; title: string },
14425
+ key: (p) => p.id,
14426
+ execute: async (payload, { key, signal }) => {
14427
+ await fetch('/api/todos', {
14428
+ method: 'POST',
14429
+ body: JSON.stringify(payload),
14430
+ headers: { 'Idempotency-Key': key },
14431
+ signal,
14432
+ });
14433
+ },
14434
+ retry: { maxAttempts: 5, shouldRetry: () => true },
14435
+ },
14436
+ });
14437
+
14438
+ const store = createIndexedDbPostmasterStore({ name: 'my-app-outbox' });
14439
+ const postmaster = createPostmaster({ jobs, store });
14440
+
14441
+ await postmaster.enqueue('createTodo', { id: crypto.randomUUID(), title: 'Buy milk' });
14442
+ await postmaster.start();
14443
+
14444
+ // On page unload:
14445
+ await postmaster.dispose();
14446
+ await store.dispose();
14447
+ ```
14448
+
14449
+ ## Features
14450
+
14451
+ - `defineJobs()` — Typed job registry with payload inference and validation.
14452
+ - `createPostmaster()` — Processor with leased claims, heartbeat renewal, and crash recovery.
14453
+ - `enqueue()` — Persist a job and wake the processor, with optional delayed eligibility via `availableAt`.
14454
+ - `flush()` — Process every available job until the queue is empty.
14455
+ - `retry()` / `remove()` — Recover or discard dead-letter jobs.
14456
+ - `tap()` — Typed runtime events for enqueued, started, completed, retry-scheduled, dead-lettered, removed, lease-lost, and processor-error.
14457
+ - `createIndexedDbPostmasterStore()` — Durable browser store backed by Vault IndexedDB.
14458
+ - `createMemoryPostmasterStore()` — Deterministic in-memory store for tests.
14459
+
14460
+ ## Documentation
14461
+
14462
+ - [**Usage Guide**](./usage.md)
14463
+ - [**API Reference**](./api.md)
14464
+ - [**Examples**](./examples.md)
14465
+
14466
+ ## See Also
14467
+
14468
+ - [@vielzeug/courier](../courier/) — Perform the HTTP requests Postmaster jobs coordinate.
14469
+ - [@vielzeug/vault](../vault/) — IndexedDB storage primitive backing the durable store.
14470
+ - [@vielzeug/sentinel](../sentinel/) — Flush the outbox when the network returns.
14471
+ - [@vielzeug/familiar](../familiar/) — In-memory Web Worker pool for CPU-bound tasks.
14472
+
14473
+ ### API Reference
14474
+
14475
+ ## API Overview
14476
+
14477
+ | Symbol | Purpose | Execution mode | Common gotcha |
14478
+ | --- | --- | --- | --- |
14479
+ | `defineJobs()` | Typed job registry with validation | Sync | Throws on invalid version, missing fields, or bad retry config |
14480
+ | `createPostmaster()` | Processor with leased claims and retry | Sync | Store is borrowed, not disposed with the processor |
14481
+ | `createIndexedDbPostmasterStore()` | Durable browser store | Sync | Requires `@vielzeug/vault` as a workspace peer |
14482
+ | `createMemoryPostmasterStore()` | Deterministic in-memory store | Sync | Use for tests only |
14483
+ | `PostmasterError` | Base class for package errors | Sync | Catch a subtype when recovery is specific |
14484
+
14485
+ ## Package Entry Point
14486
+
14487
+ | Import | Purpose |
14488
+ | --- | --- |
14489
+ | `@vielzeug/postmaster` | Job definitions, processor, store contract, events, errors |
14490
+ | `@vielzeug/postmaster/indexeddb` | Durable browser store backed by Vault IndexedDB |
14491
+ | `@vielzeug/postmaster/testing` | Deterministic in-memory store and test helpers |
14492
+
14493
+ ## Factories
14494
+
14495
+ ### `defineJobs()`
14496
+
14497
+ ```ts
14498
+ function defineJobs(jobs: J): J;
14499
+ ```
14500
+
14501
+ Returns the job registry after validating each definition. Rejects invalid versions, missing `execute`/`key`, and retry configurations with non-positive `maxAttempts`.
14502
+
14503
+ | Parameter | Type | Description |
14504
+ | --- | --- | --- |
14505
+ | `jobs` | `J extends JobDefinitions` | Map of job name to definition |
14506
+
14507
+ **Returns:** `J` — the same registry, typed for payload inference.
14508
+
14509
+ **Example**
14510
+
14511
+ ```ts
14512
+ import { defineJobs } from '@vielzeug/postmaster';
14513
+
14514
+ const jobs = defineJobs({
14515
+ createTodo: {
14516
+ version: 1,
14517
+ validate: (v) => v as { id: string; title: string },
14518
+ key: (p) => p.id,
14519
+ execute: async (payload, { key, signal }) => {
14520
+ await fetch('/api/todos', {
14521
+ method: 'POST',
14522
+ body: JSON.stringify(payload),
14523
+ headers: { 'Idempotency-Key': key },
14524
+ signal,
14525
+ });
14526
+ },
14527
+ },
14528
+ });
14529
+ ```
14530
+
14531
+ ---
14532
+
14533
+ ### `createPostmaster()`
14534
+
14535
+ ```ts
14536
+ function createPostmaster(options: CreatePostmasterOptions): Postmaster;
14537
+ ```
14538
+
14539
+ Returns a Postmaster processor that claims, executes, retries, and dead-letters jobs from the borrowed store.
14540
+
14541
+ | Parameter | Type | Description |
14542
+ | --- | --- | --- |
14543
+ | `options.jobs` | `J` | Job registry from `defineJobs()` |
14544
+ | `options.store` | `PostmasterStore` | Borrowed store; not disposed with the processor |
14545
+ | `options.leaseDuration` | `number` | Lease duration in ms (default 30000, minimum 1000) |
14546
+ | `options.clock` | `() => number` | Deterministic clock for tests (default `Date.now`) |
14547
+ | `options.signal` | `AbortSignal` | External signal that disposes the processor |
14548
+
14549
+ **Returns:** `Postmaster`.
14550
+
14551
+ **Example**
14552
+
14553
+ ```ts
14554
+ import { createPostmaster } from '@vielzeug/postmaster';
14555
+ import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb';
14556
+
14557
+ const store = createIndexedDbPostmasterStore({ name: 'outbox' });
14558
+ const postmaster = createPostmaster({ jobs, store });
14559
+
14560
+ await postmaster.start();
14561
+ await postmaster.dispose();
14562
+ await store.dispose();
14563
+ ```
14564
+
14565
+ ---
14566
+
14567
+ ### `createIndexedDbPostmasterStore()`
14568
+
14569
+ ```ts
14570
+ function createIndexedDbPostmasterStore(options: { name: string }): PostmasterStore;
14571
+ ```
14572
+
14573
+ Returns a durable Postmaster store backed by Vault IndexedDB. Uses one internal table indexed by `status`, `availableAt`, and `leaseExpiresAt`. All operations run inside Vault transactions.
14574
+
14575
+ | Parameter | Type | Description |
14576
+ | --- | --- | --- |
14577
+ | `options.name` | `string` | IndexedDB database name |
14578
+
14579
+ **Returns:** `PostmasterStore`.
14580
+
14581
+ **Example**
14582
+
14583
+ ```ts
14584
+ import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb';
14585
+
14586
+ const store = createIndexedDbPostmasterStore({ name: 'my-app-outbox' });
14587
+ await store.dispose();
14588
+ ```
14589
+
14590
+ ---
14591
+
14592
+ ### `createMemoryPostmasterStore()`
14593
+
14594
+ ```ts
14595
+ function createMemoryPostmasterStore(entries?: readonly StoredJob[]): PostmasterStore;
14596
+ ```
14597
+
14598
+ Returns a deterministic in-memory store for tests. Serializes all operations through a promise chain.
14599
+
14600
+ | Parameter | Type | Description |
14601
+ | --- | --- | --- |
14602
+ | `entries` | `readonly StoredJob[]` | Initial records (default empty) |
14603
+
14604
+ **Returns:** `PostmasterStore`.
14605
+
14606
+ **Example**
14607
+
14608
+ ```ts
14609
+ import { createMemoryPostmasterStore } from '@vielzeug/postmaster/testing';
14610
+
14611
+ const store = createMemoryPostmasterStore();
14612
+ await store.dispose();
14613
+ ```
14614
+
14615
+ ## Postmaster Methods
14616
+
14617
+ ### `enqueue()`
14618
+
14619
+ ```ts
14620
+ enqueue(
14621
+ name: K,
14622
+ payload: InferJobPayload,
14623
+ options?: EnqueueOptions,
14624
+ ): Promise;
14625
+ ```
14626
+
14627
+ Validates 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`.
14628
+
14629
+ | Parameter | Type | Description |
14630
+ | --- | --- | --- |
14631
+ | `name` | `K` | Registered job name |
14632
+ | `payload` | `InferJobPayload` | Job payload (validated if `validate` is defined) |
14633
+ | `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. |
14634
+
14635
+ **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.
14636
+
14637
+ **Example**
14638
+
14639
+ ```ts
14640
+ await postmaster.enqueue('sendDigest', { userId }, { availableAt: Date.now() + 60_000 });
14641
+ ```
14642
+
14643
+ ---
14644
+
14645
+ ### `start()`
14646
+
14647
+ ```ts
14648
+ start(): Promise;
14649
+ ```
14650
+
14651
+ Begins background processing. Idempotent.
14652
+
14653
+ ---
14654
+
14655
+ ### `flush()`
14656
+
14657
+ ```ts
14658
+ flush(options?: { signal?: AbortSignal }): Promise;
14659
+ ```
14660
+
14661
+ Processes 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.
14662
+
14663
+ ---
14664
+
14665
+ ### `list()`
14666
+
14667
+ ```ts
14668
+ list(filter?: EntryFilter): Promise;
14669
+ ```
14670
+
14671
+ Returns entries ordered by `createdAt`. Filter by `status` optionally.
14672
+
14673
+ ---
14674
+
14675
+ ### `stats()`
14676
+
14677
+ ```ts
14678
+ stats(): Promise;
14679
+ ```
14680
+
14681
+ Returns counts of queued, running, and dead-letter jobs.
14682
+
14683
+ ---
14684
+
14685
+ ### `retry()`
14686
+
14687
+ ```ts
14688
+ retry(id: string): Promise;
14689
+ ```
14690
+
14691
+ Moves a dead-letter job back to queued. Returns a discriminated result: `retried`, `not-found`, `not-dead-letter`, or `running`.
14692
+
14693
+ ---
14694
+
14695
+ ### `remove()`
14696
+
14697
+ ```ts
14698
+ remove(id: string): Promise;
14699
+ ```
14700
+
14701
+ Deletes a queued or dead-letter job. Returns a discriminated result: `removed`, `not-found`, or `running`.
14702
+
14703
+ ---
14704
+
14705
+ ### `tap()`
14706
+
14707
+ ```ts
14708
+ tap(handler: (event: PostmasterEvent) => void, options?: { signal?: AbortSignal }): () => void;
14709
+ ```
14710
+
14711
+ Observe 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.
14712
+
14713
+ ---
14714
+
14715
+ ### `dispose()`
14716
+
14717
+ ```ts
14718
+ dispose(): Promise;
14719
+ [Symbol.asyncDispose](): Promise;
14720
+ ```
14721
+
14722
+ Aborts owned work, releases all active leases, and tears down subscriptions. Idempotent. Does not dispose the borrowed store.
14723
+
14724
+ ## Types
14725
+
14726
+ ### `EnqueueOptions`
14727
+
14728
+ ```ts
14729
+ interface EnqueueOptions {
14730
+ readonly availableAt?: number;
14731
+ }
14732
+ ```
14733
+
14734
+ Options 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.
14735
+
14736
+ ---
14737
+
14738
+ ### `JobDefinition`
14739
+
14740
+ ```ts
14741
+ interface JobDefinition {
14742
+ readonly version: number;
14743
+ readonly validate?: Validate;
14744
+ readonly key: (payload: T) => string;
14745
+ readonly execute: (payload: T, context: JobContext) => Promise;
14746
+ readonly retry?: RetryPolicy;
14747
+ readonly migrate?: (payload: unknown, fromVersion: number) => unknown;
14748
+ }
14749
+ ```
14750
+
14751
+ `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.
14752
+
14753
+ ---
14754
+
14755
+ ### `Validate`
14756
+
14757
+ ```ts
14758
+ type Validate = ((value: unknown) => T) | { parse(value: unknown): T };
14759
+ ```
14760
+
14761
+ Accepts 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.
14762
+
14763
+ ---
14764
+
14765
+ ### `JobContext`
14766
+
14767
+ ```ts
14768
+ interface JobContext {
14769
+ readonly attempt: number;
14770
+ readonly entryId: string;
14771
+ readonly key: string;
14772
+ readonly signal: AbortSignal;
14773
+ }
14774
+ ```
14775
+
14776
+ ---
14777
+
14778
+ ### `RetryPolicy`
14779
+
14780
+ ```ts
14781
+ interface RetryPolicy {
14782
+ readonly maxAttempts: number;
14783
+ readonly shouldRetry: (error: unknown, attempt: number) => boolean;
14784
+ readonly delay?: (attempt: number) => number;
14785
+ }
14786
+ ```
14787
+
14788
+ `maxAttempts` is total executions including the first. `shouldRetry` is required when retries are enabled. Default delay uses Arsenal's `backoff(attempt)`.
14789
+
14790
+ ---
14791
+
14792
+ ### `StoredJob`
14793
+
14794
+ ```ts
14795
+ interface StoredJob {
14796
+ readonly id: string;
14797
+ readonly name: string;
14798
+ readonly version: number;
14799
+ readonly payload: JsonValue;
14800
+ readonly key: string;
14801
+ readonly status: 'queued' | 'running' | 'dead-letter';
14802
+ readonly attempts: number;
14803
+ readonly createdAt: number;
14804
+ readonly updatedAt: number;
14805
+ readonly availableAt: number;
14806
+ readonly ownerId?: string;
14807
+ readonly leaseExpiresAt?: number;
14808
+ readonly failure?: StoredFailure;
14809
+ }
14810
+ ```
14811
+
14812
+ ---
14813
+
14814
+ ### `StoredFailure`
14815
+
14816
+ ```ts
14817
+ interface StoredFailure {
14818
+ readonly name: string;
14819
+ readonly message: string;
14820
+ readonly occurredAt: number;
14821
+ }
14822
+ ```
14823
+
14824
+ Only a bounded error name/message/timestamp is persisted. Never persist arbitrary error objects, response bodies, headers, or stacks.
14825
+
14826
+ ---
14827
+
14828
+ ### `PostmasterEntry`
14829
+
14830
+ ```ts
14831
+ type PostmasterEntry = Pick;
14832
+ ```
14833
+
14834
+ The public entry view excludes `payload`, `ownerId`, and `leaseExpiresAt`.
14835
+
14836
+ ---
14837
+
14838
+ ### `PostmasterStore`
14839
+
14840
+ ```ts
14841
+ interface PostmasterStore {
14842
+ transact(fn: (tx: StoreTx) => Promise): Promise;
14843
+ list(filter?: EntryFilter): Promise;
14844
+ subscribe(listener: () => void): () => void;
14845
+ dispose(): Promise;
14846
+ readonly disposed: boolean;
14847
+ readonly disposalSignal: AbortSignal;
14848
+ [Symbol.asyncDispose](): Promise;
14849
+ }
14850
+
14851
+ interface StoreTx {
14852
+ get(id: string): Promise;
14853
+ put(entry: StoredJob): Promise;
14854
+ delete(id: string): Promise;
14855
+ findClaimable(now: number): Promise;
14856
+ findNextWake(now: number): Promise;
14857
+ countByStatus(): Promise;
14858
+ }
14859
+ ```
14860
+
14861
+ The 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.
14862
+
14863
+ ---
14864
+
14865
+ ### `PostmasterEvent`
14866
+
14867
+ ```ts
14868
+ type PostmasterEvent =
14869
+ | { readonly type: 'enqueued' | 'started' | 'completed' | 'retry-scheduled' | 'dead-lettered'; readonly entry: PostmasterEntry }
14870
+ | { readonly type: 'removed' | 'lease-lost'; readonly id: string }
14871
+ | { readonly type: 'processor-error'; readonly error: Error }
14872
+ | { readonly type: 'dispose' };
14873
+ ```
14874
+
14875
+ ---
14876
+
14877
+ ### `FlushResult`
14878
+
14879
+ ```ts
14880
+ interface FlushResult {
14881
+ readonly processed: number;
14882
+ readonly completed: number;
14883
+ readonly deadLettered: number;
14884
+ readonly retryScheduled: number;
14885
+ }
14886
+ ```
14887
+
14888
+ ---
14889
+
14890
+ ### `RetryResult` / `RemoveResult`
14891
+
14892
+ ```ts
14893
+ type RetryResult =
14894
+ | { readonly status: 'not-found' | 'not-dead-letter' | 'running' }
14895
+ | { readonly status: 'retried'; readonly entry: PostmasterEntry };
14896
+
14897
+ type RemoveResult =
14898
+ | { readonly status: 'not-found' | 'running' }
14899
+ | { readonly status: 'removed'; readonly id: string };
14900
+ ```
14901
+
14902
+ ## Errors
14903
+
14904
+ ### `PostmasterError`
14905
+
14906
+ ```ts
14907
+ class PostmasterError extends Error {
14908
+ constructor(message: string, options?: ErrorOptions);
14909
+ }
14910
+ ```
14911
+
14912
+ Base class for package-defined errors. Use `instanceof PostmasterError` to narrow to the hierarchy. Covers configuration errors, serialization errors, and store failures.
14913
+
14914
+ ---
14915
+
14916
+ ### `PostmasterDisposedError`
14917
+
14918
+ ```ts
14919
+ class PostmasterDisposedError extends PostmasterError {}
14920
+ ```
14921
+
14922
+ Thrown when a public method is called after disposal.
14923
+
14924
+ ---
14925
+
14926
+ ### `PostmasterJobError`
14927
+
14928
+ ```ts
14929
+ class PostmasterJobError extends PostmasterError {}
14930
+ ```
14931
+
14932
+ Thrown 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.
14933
+
14934
+ ### Usage Guide
14935
+
14936
+ ## Basic Usage
14937
+
14938
+ Define typed jobs, create a durable store, enqueue work, and start the processor. Dispose both handles when the owner ends.
14939
+
14940
+ ```ts
14941
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
14942
+ import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb';
14943
+
14944
+ const jobs = defineJobs({
14945
+ createTodo: {
14946
+ version: 1,
14947
+ validate: (v: unknown) => v as { id: string; title: string },
14948
+ key: (p) => p.id,
14949
+ execute: async (payload, { key, signal }) => {
14950
+ await fetch('/api/todos', {
14951
+ method: 'POST',
14952
+ body: JSON.stringify(payload),
14953
+ headers: { 'Idempotency-Key': key },
14954
+ signal,
14955
+ });
14956
+ },
14957
+ },
14958
+ });
14959
+
14960
+ const store = createIndexedDbPostmasterStore({ name: 'my-app-outbox' });
14961
+ const postmaster = createPostmaster({ jobs, store });
14962
+
14963
+ await postmaster.enqueue('createTodo', { id: crypto.randomUUID(), title: 'Buy milk' });
14964
+ await postmaster.start();
14965
+
14966
+ // On page unload:
14967
+ await postmaster.dispose();
14968
+ await store.dispose();
14969
+ ```
14970
+
14971
+ The store is borrowed by `createPostmaster()` and is not disposed with the processor. Dispose both explicitly.
14972
+
14973
+ ## At-least-once delivery and idempotency
14974
+
14975
+ Postmaster 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.
14976
+
14977
+ ```ts
14978
+ const jobs = defineJobs({
14979
+ createTodo: {
14980
+ version: 1,
14981
+ validate: (v: unknown) => v as { id: string; title: string },
14982
+ key: (p) => p.id,
14983
+ execute: async (payload, { key, signal }) => {
14984
+ await fetch('/api/todos', {
14985
+ method: 'POST',
14986
+ body: JSON.stringify(payload),
14987
+ headers: { 'Idempotency-Key': key },
14988
+ signal,
14989
+ });
14990
+ },
14991
+ },
14992
+ });
14993
+ ```
14994
+
14995
+ Never assume exactly-once execution. Design handlers so a repeated delivery is safe.
14996
+
14997
+ ## Postmaster jobs vs Courier mutations
14998
+
14999
+ Courier performs immediate HTTP requests and cache reconciliation. Postmaster coordinates durable delivery. Use Courier inside a Postmaster job when the write must survive reloads.
15000
+
15001
+ ```ts
15002
+ import { createCourier, CourierNetworkError } from '@vielzeug/courier';
15003
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
15004
+
15005
+ const courier = createCourier({ baseUrl: 'https://api.example.com' });
15006
+
15007
+ const jobs = defineJobs({
15008
+ createTodo: {
15009
+ version: 1,
15010
+ validate: (v: unknown) => v as { id: string; title: string },
15011
+ key: (p) => p.id,
15012
+ execute: async (payload, { key, signal }) => {
15013
+ await courier.mutate({
15014
+ request: () =>
15015
+ courier.post('/todos', {
15016
+ body: payload,
15017
+ headers: { 'Idempotency-Key': key },
15018
+ signal,
15019
+ }),
15020
+ invalidateKeys: [['todos']],
15021
+ });
15022
+ },
15023
+ retry: { maxAttempts: 5, shouldRetry: (error) => error instanceof CourierNetworkError },
15024
+ },
15025
+ });
15026
+ ```
15027
+
15028
+ Postmaster does not import Courier. The integration happens in your job definitions.
15029
+
15030
+ ## Payload and version migration
15031
+
15032
+ Each 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:
15033
+
15034
+ ```ts
15035
+ import { s } from '@vielzeug/spell';
15036
+
15037
+ const jobs = defineJobs({
15038
+ createTodo: {
15039
+ version: 2,
15040
+ validate: s.object({ id: s.string(), title: s.string(), priority: s.number().optional() }),
15041
+ key: (p) => p.id,
15042
+ migrate: (payload, fromVersion) => {
15043
+ if (fromVersion === 1) return { ...(payload as { id: string; title: string }), priority: 0 };
15044
+ return payload;
15045
+ },
15046
+ execute: async (payload, { key, signal }) => {
15047
+ await fetch('/api/todos', {
15048
+ method: 'POST',
15049
+ body: JSON.stringify(payload),
15050
+ headers: { 'Idempotency-Key': key },
15051
+ signal,
15052
+ });
15053
+ },
15054
+ },
15055
+ });
15056
+ ```
15057
+
15058
+ Unknown job names, incompatible versions, failed migrations, and invalid persisted payloads move to dead-letter rather than being executed.
15059
+
15060
+ ## Retry semantics
15061
+
15062
+ Retries are opt-in and explicitly classified. No `retry` block means one attempt followed by dead-letter.
15063
+
15064
+ ```ts
15065
+ const jobs = defineJobs({
15066
+ syncTodo: {
15067
+ version: 1,
15068
+ validate: (v: unknown) => v as { id: string },
15069
+ key: (p) => p.id,
15070
+ execute: async (payload, { signal }) => {
15071
+ await fetch(`/api/todos/${payload.id}/sync`, { signal });
15072
+ },
15073
+ retry: {
15074
+ maxAttempts: 5,
15075
+ shouldRetry: (error) => error instanceof TypeError, // network errors only
15076
+ },
15077
+ },
15078
+ });
15079
+ ```
15080
+
15081
+ - `maxAttempts` means total executions, including the first.
15082
+ - `shouldRetry` is required when retries are enabled. Postmaster never guesses whether a write is safe to repeat.
15083
+ - Default delay uses Arsenal's deterministic `backoff(attempt)` helper. Override with `delay`.
15084
+ - Delay must be finite and non-negative.
15085
+ - Lifecycle aborts caused by disposal are not classified as job failures.
15086
+
15087
+ ## Delayed eligibility
15088
+
15089
+ `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.
15090
+
15091
+ ```ts
15092
+ await postmaster.enqueue('sendDigest', { userId }, { availableAt: Date.now() + 60_000 });
15093
+ ```
15094
+
15095
+ Postmaster 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.
15096
+
15097
+ ## Dead-letter recovery
15098
+
15099
+ Jobs that exhaust retries or hit a terminal failure move to dead-letter. Inspect, retry, or remove them.
15100
+
15101
+ ```ts
15102
+ const deadLettered = await postmaster.list({ status: 'dead-letter' });
15103
+
15104
+ for (const entry of deadLettered) {
15105
+ console.log(entry.id, entry.name, entry.failure);
15106
+ }
15107
+
15108
+ // Retry a dead-letter job back into the queue.
15109
+ await postmaster.retry(entry.id);
15110
+
15111
+ // Or remove it permanently.
15112
+ await postmaster.remove(entry.id);
15113
+ ```
15114
+
15115
+ `retry()` and `remove()` return discriminated results so callers can distinguish `not-found`, `not-dead-letter`, `running`, and successful outcomes without exceptions.
15116
+
15117
+ ## Lifecycle and disposal
15118
+
15119
+ `start()` begins background processing. `dispose()` stops claiming new work, aborts owned work, and is idempotent. `flush()` processes every available job synchronously.
15120
+
15121
+ ```ts
15122
+ await postmaster.start();
15123
+ // ...on unload
15124
+ await postmaster.dispose();
15125
+ await store.dispose();
15126
+ ```
15127
+
15128
+ Disposal aborts owned work, releases the active lease, and is idempotent. A controlled disposal abort does not consume the attempt — the job returns to queued.
15129
+
15130
+ ## Events
15131
+
15132
+ Tap runtime events for observability. Handler errors are swallowed — observability never affects processing.
15133
+
15134
+ ```ts
15135
+ const unsubscribe = postmaster.tap((event) => {
15136
+ switch (event.type) {
15137
+ case 'enqueued':
15138
+ console.log('enqueued', event.entry.id);
15139
+ break;
15140
+ case 'completed':
15141
+ console.log('completed', event.entry.id);
15142
+ break;
15143
+ case 'dead-lettered':
15144
+ console.error('dead-lettered', event.entry.id, event.entry.failure);
15145
+ break;
15146
+ case 'processor-error':
15147
+ console.error('processor error', event.error);
15148
+ break;
15149
+ }
15150
+ });
15151
+ ```
15152
+
15153
+ Pass an `AbortSignal` to auto-detach:
15154
+
15155
+ ```ts
15156
+ const controller = new AbortController();
15157
+ postmaster.tap(handler, { signal: controller.signal });
15158
+ controller.abort(); // stops tapping
15159
+ ```
15160
+
15161
+ ## Testing
15162
+
15163
+ Use the in-memory store for deterministic tests.
15164
+
15165
+ ```ts
15166
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
15167
+ import { createMemoryPostmasterStore } from '@vielzeug/postmaster/testing';
15168
+
15169
+ const store = createMemoryPostmasterStore();
15170
+ const postmaster = createPostmaster({
15171
+ jobs: defineJobs({
15172
+ send: {
15173
+ version: 1,
15174
+ validate: (v: unknown) => String(v),
15175
+ key: (p) => p,
15176
+ execute: async () => {},
15177
+ },
15178
+ }),
15179
+ store,
15180
+ });
15181
+
15182
+ await postmaster.enqueue('send', 'hello');
15183
+ await postmaster.flush();
15184
+ await postmaster.dispose();
15185
+ ```
15186
+
15187
+ Inject a deterministic clock to control retry scheduling.
15188
+
15189
+ ```ts
15190
+ let now = 0;
15191
+ const postmaster = createPostmaster({ clock: () => now, jobs, store });
15192
+ ```
15193
+
15194
+ ## Framework Integration
15195
+
15196
+ Create the Postmaster after the component mounts, start processing, and dispose on unmount.
15197
+
15198
+ ```tsx [React]
15199
+ import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb';
15200
+ import { createPostmaster, defineJobs, type Postmaster } from '@vielzeug/postmaster';
15201
+ import { useEffect } from 'react';
15202
+
15203
+ const jobs = defineJobs({
15204
+ sync: {
15205
+ version: 1,
15206
+ validate: (v: unknown) => v as { id: string },
15207
+ key: (p) => p.id,
15208
+ execute: async (payload, { signal }) => {
15209
+ await fetch(`/api/sync/${payload.id}`, { signal });
15210
+ },
15211
+ },
15212
+ });
15213
+
15214
+ export function OutboxProvider() {
15215
+ useEffect(() => {
15216
+ const store = createIndexedDbPostmasterStore({ name: 'outbox' });
15217
+ const postmaster = createPostmaster({ jobs, store });
15218
+ void postmaster.start();
15219
+
15220
+ return () => {
15221
+ void postmaster.dispose();
15222
+ void store.dispose();
15223
+ };
15224
+ }, []);
15225
+
15226
+ return null;
15227
+ }
15228
+ ```
15229
+
15230
+ ```vue [Vue 3]
15231
+
15232
+ import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb';
15233
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
15234
+ import { onMounted, onUnmounted } from 'vue';
15235
+
15236
+ const jobs = defineJobs({
15237
+ sync: {
15238
+ version: 1,
15239
+ validate: (v: unknown) => v as { id: string },
15240
+ key: (p) => p.id,
15241
+ execute: async (payload, { signal }) => {
15242
+ await fetch(`/api/sync/${payload.id}`, { signal });
15243
+ },
15244
+ },
15245
+ });
15246
+
15247
+ let postmaster: ReturnType | undefined;
15248
+ let store: ReturnType | undefined;
15249
+
15250
+ onMounted(() => {
15251
+ store = createIndexedDbPostmasterStore({ name: 'outbox' });
15252
+ postmaster = createPostmaster({ jobs, store });
15253
+ void postmaster.start();
15254
+ });
15255
+
15256
+ onUnmounted(() => {
15257
+ void postmaster?.dispose();
15258
+ void store?.dispose();
15259
+ });
14196
15260
 
14197
15261
 
14198
15262
 
@@ -14200,92 +15264,117 @@ const open = ref(false);
14200
15264
 
14201
15265
  ```svelte [Svelte]
14202
15266
 
14203
- import './x-toggle'; // wherever define('x-toggle', { ... }) is called
15267
+ import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb';
15268
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
15269
+ import { onMount } from 'svelte';
14204
15270
 
14205
- function handleClick() {
14206
- console.log('toggled');
14207
- }
15271
+ const jobs = defineJobs({
15272
+ sync: {
15273
+ version: 1,
15274
+ validate: (v: unknown) => v as { id: string },
15275
+ key: (p) => p.id,
15276
+ execute: async (payload, { signal }) => {
15277
+ await fetch(`/api/sync/${payload.id}`, { signal });
15278
+ },
15279
+ },
15280
+ });
15281
+
15282
+ onMount(() => {
15283
+ const store = createIndexedDbPostmasterStore({ name: 'outbox' });
15284
+ const postmaster = createPostmaster({ jobs, store });
15285
+ void postmaster.start();
15286
+
15287
+ return () => {
15288
+ void postmaster.dispose();
15289
+ void store.dispose();
15290
+ };
15291
+ });
14208
15292
 
14209
15293
  ```
14210
15294
 
14211
15295
  ## Working with Other Vielzeug Libraries
14212
15296
 
14213
- ### With Ripple
15297
+ ### Postmaster + Courier
14214
15298
 
14215
- Import ripple primitives directly from `@vielzeug/ripple` for standalone reactive state outside components.
15299
+ Use Courier inside job handlers for HTTP transport and cache invalidation. Postmaster coordinates delivery; Courier performs the request.
14216
15300
 
14217
15301
  ```ts
14218
- import { signal, computed } from '@vielzeug/ripple';
14219
- import { define, html } from '@vielzeug/ore';
15302
+ import { createCourier, CourierNetworkError } from '@vielzeug/courier';
15303
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
14220
15304
 
14221
- // Shared state created outside any component
14222
- const theme = signal('light');
14223
- const isDark = computed(() => theme.value === 'dark');
15305
+ const courier = createCourier({ baseUrl: 'https://api.example.com' });
14224
15306
 
14225
- define('theme-toggle', {
14226
- setup() {
14227
- return html`
14228
- (theme.value = isDark.value ? 'light' : 'dark')}>
14229
- ${() =>
14230
- isDark.value ? '' : ''}
14231
-
14232
- `;
15307
+ const jobs = defineJobs({
15308
+ createTodo: {
15309
+ version: 1,
15310
+ validate: (v: unknown) => v as { id: string; title: string },
15311
+ key: (p) => p.id,
15312
+ execute: async (payload, { key, signal }) => {
15313
+ await courier.mutate({
15314
+ request: () =>
15315
+ courier.post('/todos', {
15316
+ body: payload,
15317
+ headers: { 'Idempotency-Key': key },
15318
+ signal,
15319
+ }),
15320
+ invalidateKeys: [['todos']],
15321
+ });
15322
+ },
15323
+ retry: { maxAttempts: 5, shouldRetry: (e) => e instanceof CourierNetworkError },
14233
15324
  },
14234
15325
  });
14235
15326
  ```
14236
15327
 
14237
- ### With Forge
15328
+ ### Postmaster + Sentinel
14238
15329
 
14239
- Use `@vielzeug/forge` for typed form state. `useField()` remains intentionally narrow: it connects a form-associated
14240
- custom element to native `ElementInternals` without imposing submission, validation, or dirty-state policy.
15330
+ Flush the outbox when the network returns. Sentinel reports online state; Postmaster does the rest.
14241
15331
 
14242
15332
  ```ts
14243
- import { createForm } from '@vielzeug/forge';
14244
- import { define, html } from '@vielzeug/ore';
15333
+ import { createNetwork } from '@vielzeug/sentinel';
15334
+ import { createPostmaster } from '@vielzeug/postmaster';
14245
15335
 
14246
- define('signup-form', {
14247
- setup(_props) {
14248
- const form = createForm({ initialValues: { email: '' } });
15336
+ const network = createNetwork();
15337
+ const postmaster = createPostmaster({ jobs, store });
14249
15338
 
14250
- return html`
14251
- {
14252
- event.preventDefault();
14253
- void form.submit(async (values) => {
14254
- console.log(values);
14255
- });
14256
- }}>
14257
-
14258
-
14259
- `;
14260
- },
15339
+ const unsubscribe = network.subscribe(() => {
15340
+ if (network.value.online) void postmaster.flush();
14261
15341
  });
15342
+
15343
+ // On teardown:
15344
+ unsubscribe();
15345
+ network.dispose();
15346
+ await postmaster.dispose();
14262
15347
  ```
14263
15348
 
15349
+ ### Postmaster + Vault
15350
+
15351
+ The IndexedDB adapter is built on Vault. Use Vault directly for unrelated storage; the Postmaster store owns its own database name.
15352
+
14264
15353
  ## Best Practices
14265
15354
 
14266
- - Setup returns `html\`...\`` directly not a function wrapping the template.
14267
- - Use `watchEffect()` for reactive subscriptions tied to component lifetime it auto-registers cleanup on disconnect.
14268
- - Use `onElement(ref, cb)` instead of `onMounted` when the work is tied to a single DOM node.
14269
- - Bind host attributes and classes via `bind()` rather than mutating the element directly.
14270
- - Provide context at the nearest ancestor avoid global context singletons.
14271
- - Call `onCleanup()` for every resource allocated in `setup()` (WebSockets, intervals, external subscriptions).
14272
- - Use `live(signal)` for form inputs to prevent clobbering user-in-progress edits.
14273
- - Extract composable helper functions freely — `onMounted`/`onCleanup`/`bind`/... resolve the active component through implicit context, so they work from any function called (transitively) during `setup()`, with no need to pass them in as parameters.
14274
- - Test component mounting and lifecycle with `@vielzeug/ore/testing`; import generic DOM events, queries, and waits
14275
- from `@vielzeug/assay`.
15355
+ - **Derive** a stable idempotency key from every job payload and send it with the remote write.
15356
+ - **Dispose** both the processor and the store explicitly; the processor does not own the store.
15357
+ - **Classify** retryable errors explicitly with `shouldRetry`; never let Postmaster guess.
15358
+ - **Migrate** persisted payloads when job versions change; test migrations against stored fixtures.
15359
+ - **Inspect** the dead-letter queue regularly and retry or remove terminal failures.
15360
+ - **Avoid** persisting sensitive data in payloads or failure messages; IndexedDB is per-origin but not encrypted.
15361
+ - **Flush** the outbox when Sentinel reports the network returns.
15362
+ - **Test** with the in-memory store and a deterministic clock for reproducible retry timing.
14276
15363
 
14277
15364
  ### Examples
14278
15365
 
14279
15366
  ## Examples
14280
15367
 
14281
- - [Counter Component](./examples/counter-component.md)
14282
- - [Typed Props And Emits](./examples/typed-props-and-emits.md)
14283
- - [Observers In onMounted()](./examples/observers-in-onmount.md)
14284
- - [Search List With Directives](./examples/search-list-with-directives.md)
14285
- - [Context Provider And Consumer](./examples/context-provider-and-consumer.md)
14286
- - [Prop Helpers And Raw PropDef](./examples/propsof-builder-api.md)
14287
- - [Form Associated Rating Input](./examples/form-associated-rating-input.md)
14288
- - [Test Example With @vielzeug/ore/testing](./examples/test-example-at-vielzeug-ore-testing.md)
15368
+ - [Queue Offline Courier Mutations](./examples/queue-offline-courier-mutations.md)
15369
+ - [Resume When Network Returns](./examples/resume-when-network-returns.md)
15370
+ - [Delayed Eligibility](./examples/delayed-eligibility.md)
15371
+ - [Recover Dead-Letter Jobs](./examples/recover-dead-letter-jobs.md)
15372
+ - [Service Worker Background Sync](./examples/service-worker-background-sync.md)
15373
+
15374
+ ### REPL Examples
15375
+
15376
+ - defineJobs - Basic Outbox (id: `define-jobs`)
15377
+ - delayedEnqueue - Delayed Eligibility (id: `delayed-enqueue`)
14289
15378
 
14290
15379
  ---
14291
15380
 
@@ -17067,7 +18156,10 @@ type Schema = {
17067
18156
 
17068
18157
  const pulse = createPulse('wss://api.example.com/ws', {
17069
18158
  reconnect: true,
17070
- onError: (error) => console.error(error),
18159
+ });
18160
+ pulse.tap((event) => {
18161
+ if (event.type === 'error') console.error(event.error);
18162
+ if (event.type === 'status-change') console.log('status:', event.status);
17071
18163
  });
17072
18164
  const chat = pulse.channel('chat');
17073
18165
  const lobby = pulse.room('lobby');
@@ -17091,7 +18183,7 @@ pulse.dispose();
17091
18183
  - **`room()`** — named, schema-bound ref-counted room scopes with optional reactive presence. The first scope sends `join`; the last disposal sends `leave`.
17092
18184
  - **`reconnect`** — ordered restoration of channel subscriptions, room memberships, and local presence state.
17093
18185
  - **`transform`** — one synchronous transform or filter for application messages.
17094
- - **`onError`** — typed connection and protocol errors.
18186
+ - **`tap()`** — subscribe to lifecycle events (status changes, errors, disposal) via a typed `PulseEvent` stream.
17095
18187
  - **`heartbeat`** — ping/pong liveness detection that uses the same reconnect controller.
17096
18188
  - **`status` and `rooms`** — ripple readables for transport and confirmed membership state.
17097
18189
 
@@ -17120,7 +18212,7 @@ pulse.dispose();
17120
18212
  | `PulseChannel` | Scoped channel namespace with independent disposal. | Sync methods, async `wait()` | Each call returns a new scope; ref-counted subscription. |
17121
18213
  | `RoomScope` | Ref-counted room membership with optional presence. | Sync methods, async `joined` | `joined` rejects on transport close or timeout. |
17122
18214
  | `PulseSchema` | Declares server/client events, channels, and rooms. | Type-only | Infer all named scope types from this schema. |
17123
- | `PulseOptions` | Configuration: heartbeat, reconnect, transform, onError. | Type-only | `reconnect` and `heartbeat` default to `false`. |
18215
+ | `PulseOptions` | Configuration: heartbeat, reconnect, transform. | Type-only | `reconnect` and `heartbeat` default to `false`. |
17124
18216
  | `PulseError` | Base class for all Pulse errors. | Runtime | Check `instanceof` against subclasses. |
17125
18217
 
17126
18218
  ## Package Entry Point
@@ -17183,7 +18275,6 @@ Declare all protocol surfaces once at construction. Named scopes infer their typ
17183
18275
  ```ts
17184
18276
  type PulseOptions = {
17185
18277
  heartbeat?: boolean | HeartbeatOptions;
17186
- onError?: (error: PulseError) => void;
17187
18278
  protocols?: string | string[];
17188
18279
  reconnect?: boolean | ReconnectOptions;
17189
18280
  transform?: OutgoingTransform;
@@ -17193,7 +18284,6 @@ type PulseOptions = {
17193
18284
  | Option | Type | Default | Description |
17194
18285
  | --- | --- | --- | --- |
17195
18286
  | `heartbeat` | `boolean \| HeartbeatOptions` | `false` | Ping/pong keep-alive. |
17196
- | `onError` | `(error: PulseError) => void` | — | Receives typed transport and protocol errors. |
17197
18287
  | `protocols` | `string \| string[]` | — | Sub-protocols passed to the WebSocket constructor. |
17198
18288
  | `reconnect` | `boolean \| ReconnectOptions` | `false` | Auto-reconnect on unexpected close. |
17199
18289
  | `transform` | `OutgoingTransform` | — | Transform or filter outgoing application messages. |
@@ -17283,6 +18373,9 @@ type Pulse = {
17283
18373
  // Status
17284
18374
  readonly status: Readable;
17285
18375
 
18376
+ // Tap
18377
+ tap(handler: (event: PulseEvent) => void, options?: { signal?: AbortSignal }): () => void;
18378
+
17286
18379
  [Symbol.dispose](): void;
17287
18380
  };
17288
18381
  ```
@@ -17331,6 +18424,42 @@ Reactive set of rooms the client is currently a confirmed member of.
17331
18424
 
17332
18425
  Reactive connection status: `'connecting' | 'open' | 'reconnecting' | 'closed'`.
17333
18426
 
18427
+ ### `tap(handler, options?)`
18428
+
18429
+ Subscribes to lifecycle events emitted by the Pulse instance. The handler receives a discriminated-union `PulseEvent`. Returns an unsubscribe function.
18430
+
18431
+ | Parameter | Type | Description |
18432
+ | --- | --- | --- |
18433
+ | `handler` | `(event: PulseEvent) => void` | Called for each lifecycle event. |
18434
+ | `options.signal` | `AbortSignal` | Optional signal to stop the subscription. |
18435
+
18436
+ ```ts
18437
+ const pulse = createPulse(url, { reconnect: true });
18438
+ pulse.tap((event) => {
18439
+ if (event.type === 'error') console.error(event.error);
18440
+ if (event.type === 'status-change') console.log('status:', event.status);
18441
+ });
18442
+ ```
18443
+
18444
+ ---
18445
+
18446
+ ## `PulseEvent`
18447
+
18448
+ ```ts
18449
+ type PulseEvent =
18450
+ | { type: 'status-change'; status: PulseStatus }
18451
+ | { type: 'error'; error: PulseError }
18452
+ | { type: 'dispose' };
18453
+ ```
18454
+
18455
+ A discriminated union of lifecycle events emitted by a `Pulse` instance. Inspect `event.type` to narrow the payload.
18456
+
18457
+ | `type` | Payload | When |
18458
+ | --- | --- | --- |
18459
+ | `status-change` | `status: PulseStatus` | The connection status transitions. |
18460
+ | `error` | `error: PulseError` | A typed transport or protocol error occurs. |
18461
+ | `dispose` | — | The instance is disposed. |
18462
+
17334
18463
  ---
17335
18464
 
17336
18465
  ## `PulseChannel`
@@ -17561,7 +18690,11 @@ type Schema = {
17561
18690
  const pulse = createPulse('wss://api.example.com/ws', {
17562
18691
  reconnect: { delay: 1_000, maxAttempts: 5 },
17563
18692
  heartbeat: { interval: 30_000, timeout: 5_000 },
17564
- onError: (error) => console.error(error),
18693
+ });
18694
+
18695
+ pulse.tap((event) => {
18696
+ if (event.type === 'error') console.error(event.error);
18697
+ if (event.type === 'status-change') console.log('status:', event.status);
17565
18698
  });
17566
18699
 
17567
18700
  try {
@@ -17733,13 +18866,17 @@ Disposal is idempotent. It closes the connection, rejects pending room joins, cl
17733
18866
 
17734
18867
  ```ts
17735
18868
  const pulse = createPulse('wss://api.example.com/ws', {
17736
- onError: (error) => {
17737
- if (error instanceof PulseConnectionError) {
17738
- console.error('Connection error:', error);
17739
- } else if (error instanceof PulseProtocolError) {
17740
- console.error('Protocol error:', error);
18869
+ reconnect: true,
18870
+ });
18871
+
18872
+ pulse.tap((event) => {
18873
+ if (event.type === 'error') {
18874
+ if (event.error instanceof PulseConnectionError) {
18875
+ console.error('Connection error:', event.error);
18876
+ } else if (event.error instanceof PulseProtocolError) {
18877
+ console.error('Protocol error:', event.error);
17741
18878
  }
17742
- },
18879
+ }
17743
18880
  });
17744
18881
  ```
17745
18882
 
@@ -17758,7 +18895,7 @@ const pulse = createPulse('wss://api.example.com/ws', {
17758
18895
  - Define the full schema at `createPulse()` so named scopes are type-safe without per-call generics.
17759
18896
  - Use `using` declarations for channel and room scopes so disposal is automatic at block exit.
17760
18897
  - Always call `dispose()` when done — it closes the connection, rejects pending joins, and clears listeners.
17761
- - Provide an `onError` handler; Pulse reports transport and protocol errors there rather than throwing asynchronously.
18898
+ - Call `tap()` to observe lifecycle events; Pulse reports transport and protocol errors there rather than throwing asynchronously.
17762
18899
  - Read `pulse.rooms` for post-reconnect membership; `joined` rejects on transport close.
17763
18900
  - Set a `timeout` on room scopes when the server may never confirm membership.
17764
18901
  - Keep `transform` synchronous; resolve async policy decisions before calling `send()`.
@@ -21258,7 +22395,7 @@ console.log(results[0]?.item.name); // Ada Lovelace
21258
22395
  - Incremental updates — `add()` / `remove()` / `reindex()` patch individual items in O(field_length)
21259
22396
  - `onMutate()` — Subscribe to index mutations; powers `createSearch()`'s reactivity and bulk reconciliation
21260
22397
  - `segmentWords()` — Split unsegmented-script text (CJK, Thai, ...) into words via native `Intl.Segmenter`
21261
- - Debug logging via `debugSearch()` (`@vielzeug/scout/devtools`) logs query/results transitions, tree-shaken from production bundles
22398
+ - Event subscription via `search.tap()` — observe `query`/`isSearching`/`results`/`dispose` transitions; returns an unsubscribe function
21262
22399
 
21263
22400
  ## Documentation
21264
22401
 
@@ -21294,14 +22431,13 @@ console.log(results[0]?.item.name); // Ada Lovelace
21294
22431
  | `toSearchMatcher()` | Adapt `ScoutIndex` to Sourcerer's `match` callback | Sync | Recomputes cached query matches after index mutation |
21295
22432
  | `toFilterPredicate()` | Snapshot predicate from a one-time query | Sync | Re-call when query or corpus changes |
21296
22433
  | `segmentWords()` | Split unsegmented-script text (CJK, Thai, ...) into words | Sync | Uses native `Intl.Segmenter` — not applied inside `tokenize()` itself (see Pitfalls) |
21297
- | `debugSearch()` | Log a `SearchState`'s query/results transitions | Sync | Import from `@vielzeug/scout/devtools`, not the main entry point |
22434
+ | `SearchState.tap()` | Subscribe to `query`/`isSearching`/`results`/`dispose` events | Sync | Returns an unsubscribe function; pass `{ signal }` to tie to an external lifecycle |
21298
22435
 
21299
22436
  ## Package Entry Point
21300
22437
 
21301
22438
  | Import | Purpose |
21302
22439
  | --- | --- |
21303
- | `@vielzeug/scout` | All exports — index/search/highlighting/adapters, `ScoutConfigurationError`, `ScoutDisposedError`, `ScoutError`, and all types |
21304
- | `@vielzeug/scout/devtools` | `debugSearch` — reactive search state logger (dev only) |
22440
+ | `@vielzeug/scout` | All exports — index/search/highlighting/adapters, `ScoutConfigurationError`, `ScoutDisposedError`, `ScoutError`, `ScoutEvent`, and all types |
21305
22441
 
21306
22442
  ---
21307
22443
 
@@ -21454,6 +22590,7 @@ function createSearch(index: ScoutIndex, options?: CreateSearchOptions): SearchS
21454
22590
  | `disposed` | `boolean` | `true` after `dispose()` has been called. |
21455
22591
  | `clear()` | `() => void` | Resets query, cancels debounce, clears results synchronously. |
21456
22592
  | `dispose()` | `() => void` | Releases all reactive subscriptions. |
22593
+ | `tap()` | `(handler, options?) => () => void` | Subscribe to `ScoutEvent` transitions; returns an unsubscribe function. |
21457
22594
  | `[Symbol.dispose]()` | `() => void` | `using`-compatible disposal. |
21458
22595
 
21459
22596
  **Example**
@@ -21664,35 +22801,39 @@ const index = createIndex(documents, {
21664
22801
 
21665
22802
  ---
21666
22803
 
21667
- ## `debugSearch(search)`
22804
+ ## `search.tap(handler, options?)`
22805
+
22806
+ Subscribes `handler` to `ScoutEvent` transitions emitted by a `SearchState` — `query` changes, `isSearching` transitions, `results` changes, and `dispose`. Returns an unsubscribe function; calling it removes the handler. Pass `{ signal }` to tie the subscription to an external `AbortSignal` — when the signal aborts (or `dispose()` is called, which aborts `disposalSignal`) the handler is removed automatically.
21668
22807
 
21669
22808
  ```ts
21670
- debugSearch(search: SearchState): () => void
22809
+ tap(
22810
+ handler: (event: ScoutEvent) => void,
22811
+ options?: { signal?: AbortSignal },
22812
+ ): () => void
21671
22813
  ```
21672
22814
 
21673
- Logs `query` → `isSearching` → `results` transitions of a `SearchState` to `console.debug`. Returns a function that unsubscribes all listeners installed by this call. Import from the dedicated sub-path so it's tree-shaken from production bundles.
21674
-
21675
- Logs the full, literal search query string — if your queries may carry PII (names, emails, medical/financial terms typed by end users), don't enable this in production.
21676
-
21677
22815
  **Example**
21678
22816
 
21679
22817
  ```ts
21680
22818
  import { createIndex, createSearch } from '@vielzeug/scout';
21681
- import { debugSearch } from '@vielzeug/scout/devtools';
21682
22819
 
21683
22820
  const index = createIndex([{ name: 'Ada Lovelace' }], { fields: ['name'] });
21684
22821
  const search = createSearch(index);
21685
- const stopDebugging = debugSearch(search);
22822
+
22823
+ const unsubscribe = search.tap((event) => {
22824
+ if (event.type === 'query-change') console.debug('query:', event.query);
22825
+ if (event.type === 'results-change') console.debug('results:', event.results.length);
22826
+ });
21686
22827
 
21687
22828
  search.query.value = 'alice';
21688
- // [scout:search] query -> "alice"
21689
- // [scout:search] isSearching -> true
21690
- // [scout:search] isSearching -> false
21691
- // [scout:search] results -> 1 item(s)
22829
+ // query: alice
22830
+ // results: 1
21692
22831
 
21693
- stopDebugging();
22832
+ unsubscribe();
21694
22833
  ```
21695
22834
 
22835
+ If your queries may carry PII (names, emails, medical/financial terms typed by end users), don't log `query-change` events in production.
22836
+
21696
22837
  ---
21697
22838
 
21698
22839
  ## Types
@@ -21778,12 +22919,32 @@ type SearchState = {
21778
22919
  readonly disposed: boolean;
21779
22920
  clear(): void;
21780
22921
  dispose(): void;
22922
+ tap(handler: (event: ScoutEvent) => void, options?: { signal?: AbortSignal }): () => void;
21781
22923
  [Symbol.dispose](): void;
21782
22924
  };
21783
22925
  ```
21784
22926
 
21785
22927
  See `createSearch()` above for member descriptions.
21786
22928
 
22929
+ ### `ScoutEvent`
22930
+
22931
+ Discriminated union of events emitted by `SearchState.tap()`. Each variant carries a `type` discriminant; narrow with a `switch` or `if` on `event.type`.
22932
+
22933
+ ```ts
22934
+ type ScoutEvent =
22935
+ | { type: 'query-change'; query: string }
22936
+ | { type: 'searching-change'; isSearching: boolean }
22937
+ | { type: 'results-change'; results: readonly SearchResult[] }
22938
+ | { type: 'dispose' };
22939
+ ```
22940
+
22941
+ | `type` | Payload | Emitted when |
22942
+ | --- | --- | --- |
22943
+ | `query-change` | `query: string` | The writable `query` signal's value changes. |
22944
+ | `searching-change` | `isSearching: boolean` | The debounce window opens (`true`) or closes (`false`). |
22945
+ | `results-change` | `results: readonly SearchResult[]` | Committed results change after debounce. |
22946
+ | `dispose` | — | `dispose()` is called on the `SearchState`. |
22947
+
21787
22948
  ### `ReactiveSearch`
21788
22949
 
21789
22950
  ```ts
@@ -22126,25 +23287,29 @@ const parts = highlight(result.item.name, nameMatch?.ranges ?? []);
22126
23287
 
22127
23288
  ## Debug Logging
22128
23289
 
22129
- Import `debugSearch` from the dedicated `/devtools` sub-path to log a `SearchState`'s `query` `isSearching` `results` transitions to `console.debug`. The sub-path is tree-shaken from production bundles when not imported.
22130
-
22131
- `debugSearch()` logs the full, literal search query string — if your queries may carry PII (names, emails, medical/financial terms typed by end users), don't enable this in production.
23290
+ `search.tap()` subscribes a handler to `ScoutEvent` transitions emitted by a `SearchState` `query` changes, `isSearching` transitions, `results` changes, and `dispose`. It returns an unsubscribe function. Pass `{ signal }` to tie the subscription to an external `AbortSignal` (or to `search.disposalSignal`, which aborts when `dispose()` is called).
22132
23291
 
22133
23292
  ```ts
22134
- import { debugSearch } from '@vielzeug/scout/devtools';
23293
+ import { createIndex, createSearch } from '@vielzeug/scout';
22135
23294
 
22136
23295
  const search = createSearch(index, { debounce: 150 });
22137
- const stopDebugging = debugSearch(search);
23296
+ const unsubscribe = search.tap((event) => {
23297
+ if (event.type === 'query-change') console.debug('query:', event.query);
23298
+ if (event.type === 'searching-change') console.debug('isSearching:', event.isSearching);
23299
+ if (event.type === 'results-change') console.debug('results:', event.results.length);
23300
+ });
22138
23301
 
22139
23302
  search.query.value = 'alice';
22140
- // [scout:search] query -> "alice"
22141
- // [scout:search] isSearching -> true
22142
- // [scout:search] isSearching -> false
22143
- // [scout:search] results -> 1 item(s)
23303
+ // query: alice
23304
+ // isSearching: true
23305
+ // isSearching: false
23306
+ // results: 1
22144
23307
 
22145
- stopDebugging();
23308
+ unsubscribe();
22146
23309
  ```
22147
23310
 
23311
+ `query-change` events carry the full, literal search query string — if your queries may carry PII (names, emails, medical/financial terms typed by end users), don't log them in production.
23312
+
22148
23313
  ## Framework Integration
22149
23314
 
22150
23315
  ```tsx [React]
@@ -23543,7 +24708,7 @@ interface GridVirtualizer {
23543
24708
 
23544
24709
  | Class | Thrown when | Notable properties |
23545
24710
  | --- | --- | --- |
23546
- | `ScrollError` | Base class for every Scroll error. | `ScrollError.is(error)` narrows errors from this package. |
24711
+ | `ScrollError` | Base class for every Scroll error. | Use `instanceof ScrollError` to narrow unknown errors narrows errors from this package. |
23547
24712
  | `ScrollConfigurationError` | A constructor or `update()` receives invalid static configuration. | Extends `ScrollError`; malformed JavaScript values also use this class. |
23548
24713
  | `ScrollRangeError` | A DOM virtual-list render detects that a caller mutated its items array without calling `setItems()` again. | Extends `ScrollError`; message includes stale index and current item count. |
23549
24714
 
@@ -24577,9 +25742,9 @@ const stopObserving = observeViewport();
24577
25742
 
24578
25743
  ## Documentation
24579
25744
 
24580
- - [**Usage Guide**](./usage.md) — Apply Sentinel lifecycles, framework bindings, and Ripple composition.
24581
- - [**API Reference**](./api.md) — Review factory signatures, options, state types, and errors.
24582
- - [**Examples**](./examples.md) — Follow focused browser observation examples.
25745
+ - [**Usage Guide**](./usage.md)
25746
+ - [**API Reference**](./api.md)
25747
+ - [**Examples**](./examples.md)
24583
25748
 
24584
25749
  ## See Also
24585
25750
 
@@ -26167,7 +27332,7 @@ if (!result.success) {
26167
27332
 
26168
27333
  ## Errors
26169
27334
 
26170
- - `SpellError` — base class. Use `SpellError.is(error)` for cross-boundary narrowing.
27335
+ - `SpellError` — base class. Use `instanceof SpellError` for cross-boundary narrowing.
26171
27336
  - `SpellValidationError` — validation failure with `issues`, `bestMatch()`, `messagesAt()`, `flatten()`, and `flattenFirst()`.
26172
27337
  - `SpellDefinitionError` — schema cannot create portable definition.
26173
27338
 
@@ -27505,10 +28670,10 @@ try {
27505
28670
  - `/memory`, `/local-storage`, and `/session-storage` return portable `VaultStore` instances without loading other adapters.
27506
28671
  - `observe()` emits current and changed table snapshots.
27507
28672
  - `ttl` creates validated expiration durations.
27508
- - `/indexeddb` returns `IndexedDbVaultStore` with `batch()` and `iterate()`.
28673
+ - `/indexeddb` returns `TransactionalVaultStore` with `batch()` and `iterate()`.
27509
28674
  - `createSQLite()` is an opt-in, driver-neutral subpath for Node, Bun, and Deno SQLite drivers.
27510
28675
  - `/indexeddb` also exports `defineMigration()` for schema upgrades.
27511
- - `scheduleExpiredPrune()` removes stale TTL entries on an owned schedule.
28676
+ - `pruneExpired()` removes stale TTL entries on demand.
27512
28677
 
27513
28678
  ## Documentation
27514
28679
 
@@ -27533,9 +28698,10 @@ try {
27533
28698
  | `createLocalStorage()` / `createSessionStorage()` | Web Storage-backed portable stores | Async API | Available only where the corresponding Web API exists |
27534
28699
  | `createIndexedDB()` | Browser transactions and cursor iteration | Async API | Import from `/indexeddb` |
27535
28700
  | `createSQLite()` | Driver-neutral SQLite store | Async API over a synchronous driver | Import from `/sqlite` |
28701
+ | `defineMigration()` | Declarative IndexedDB schema upgrade | Sync | Import from `/indexeddb` |
27536
28702
  | `table()` | Typed record schema | Sync | The key field must be a string or finite number |
27537
28703
  | `ttl` | Valid expiration durations | Sync | Durations must be positive |
27538
- | `scheduleExpiredPrune()` | Periodic TTL cleanup | Sync setup, async work | Pass `disposalSignal` to auto-cancel |
28704
+ | `isExpired()` | Check an expiration timestamp | Sync | Returns `false` when no expiry is set |
27539
28705
 
27540
28706
  ## Package Entry Points
27541
28707
 
@@ -27622,23 +28788,20 @@ if (isExpired(record.expiresAt)) console.log('expired');
27622
28788
 
27623
28789
  ## Factories
27624
28790
 
27625
- All factory options accept `schema`, plus optional `validators`, `logger`, and `onMetrics`. The root entry does not export any factory.
28791
+ All factory options accept `schema` and optional `validators`. The root entry does not export any factory.
27626
28792
 
27627
28793
  ### `createMemory()`
27628
28794
 
27629
28795
  ```ts
27630
- function createMemory(options: {
27631
- name?: string;
27632
- schema: S;
27633
- } & BaseAdapterOptions): VaultStore;
28796
+ function createMemory(options: BaseAdapterOptions): VaultStore;
27634
28797
  ```
27635
28798
 
27636
- Creates an in-memory portable store. A `name` enables same-origin `BroadcastChannel` observation between memory stores when the platform provides it.
28799
+ Creates an in-memory portable store.
27637
28800
 
27638
28801
  | Parameter | Description |
27639
28802
  | --- | --- |
27640
28803
  | `schema` | Tables created by `table()` |
27641
- | `name` | Optional shared memory-store namespace |
28804
+ | `validators` | Optional per-table validators with a `parse(value): T` method |
27642
28805
 
27643
28806
  **Returns:** `VaultStore`.
27644
28807
 
@@ -27654,20 +28817,20 @@ const store = createMemory({ schema: { users: table('id') } });
27654
28817
  ### `createLocalStorage()`
27655
28818
 
27656
28819
  ```ts
27657
- function createLocalStorage(options: {
28820
+ function createLocalStorage(options: BaseAdapterOptions & {
27658
28821
  name: string;
27659
28822
  onQuotaExceeded?: (table: keyof S, error: VaultQuotaError) => 'ignore' | 'throw';
27660
- schema: S;
27661
- } & BaseAdapterOptions): VaultStore;
28823
+ }): VaultStore;
27662
28824
  ```
27663
28825
 
27664
28826
  Creates a namespaced `localStorage` store.
27665
28827
 
27666
28828
  | Parameter | Description |
27667
28829
  | --- | --- |
28830
+ | `schema` | Tables created by `table()` |
28831
+ | `validators` | Optional per-table validators |
27668
28832
  | `name` | Required storage namespace |
27669
28833
  | `onQuotaExceeded` | Handles a Web Storage quota error; returning `'ignore'` drops that write |
27670
- | `schema` | Tables created by `table()` |
27671
28834
 
27672
28835
  **Returns:** `VaultStore`.
27673
28836
 
@@ -27683,11 +28846,10 @@ const store = createLocalStorage({ name: 'app', schema: { settings: table('id')
27683
28846
  ### `createSessionStorage()`
27684
28847
 
27685
28848
  ```ts
27686
- function createSessionStorage(options: {
28849
+ function createSessionStorage(options: BaseAdapterOptions & {
27687
28850
  name: string;
27688
28851
  onQuotaExceeded?: (table: keyof S, error: VaultQuotaError) => 'ignore' | 'throw';
27689
- schema: S;
27690
- } & BaseAdapterOptions): VaultStore;
28852
+ }): VaultStore;
27691
28853
  ```
27692
28854
 
27693
28855
  Creates a namespaced `sessionStorage` store. Its options and return type match `createLocalStorage()`.
@@ -27706,24 +28868,24 @@ const store = createSessionStorage({ name: 'checkout', schema: { cart: table('id
27706
28868
  ### `createIndexedDB()`
27707
28869
 
27708
28870
  ```ts
27709
- function createIndexedDB(options: {
28871
+ function createIndexedDB(options: BaseAdapterOptions & {
27710
28872
  migrate?: MigrationFn;
27711
28873
  name: string;
27712
- schema: S;
27713
28874
  version?: number;
27714
- } & BaseAdapterOptions): IndexedDbVaultStore;
28875
+ }): TransactionalVaultStore;
27715
28876
  ```
27716
28877
 
27717
28878
  Creates an IndexedDB store with atomic batches, lazy cursor iteration, and optional schema migrations.
27718
28879
 
27719
28880
  | Parameter | Description |
27720
28881
  | --- | --- |
27721
- | `name` | Required database name |
27722
28882
  | `schema` | Tables and IndexedDB secondary indexes |
28883
+ | `validators` | Optional per-table validators |
28884
+ | `name` | Required database name |
27723
28885
  | `version` | Positive schema version; defaults to `1` |
27724
28886
  | `migrate` | Synchronous upgrade callback for version changes |
27725
28887
 
27726
- **Returns:** `IndexedDbVaultStore`.
28888
+ **Returns:** `TransactionalVaultStore`.
27727
28889
 
27728
28890
  ```ts
27729
28891
  import { table } from '@vielzeug/vault';
@@ -27737,19 +28899,20 @@ const store = createIndexedDB({ name: 'app', schema: { users: table('id') } });
27737
28899
  ### `createSQLite()`
27738
28900
 
27739
28901
  ```ts
27740
- function createSQLite(options: SQLiteVaultOptions): SQLiteVaultStore;
28902
+ function createSQLite(options: SQLiteVaultOptions): TransactionalVaultStore;
27741
28903
  ```
27742
28904
 
27743
28905
  Creates a namespaced SQLite store with atomic batches and keyset-paginated iteration. It accepts an application-provided positional-parameter driver and never opens or imports a runtime driver.
27744
28906
 
27745
28907
  | Parameter | Description |
27746
28908
  | --- | --- |
28909
+ | `schema` | Tables created by `table()` |
28910
+ | `validators` | Optional per-table validators |
27747
28911
  | `database` | Caller-provided `SQLiteDatabase` connection |
27748
28912
  | `name` | Namespace within the connection |
27749
- | `schema`, `validators`, `logger`, `onMetrics` | Shared factory options |
27750
28913
  | `closeOnDispose` | Closes the connection during disposal; defaults to `false` |
27751
28914
 
27752
- **Returns:** `SQLiteVaultStore`.
28915
+ **Returns:** `TransactionalVaultStore`.
27753
28916
 
27754
28917
  ```ts
27755
28918
  import { DatabaseSync } from 'node:sqlite';
@@ -27776,11 +28939,9 @@ interface VaultStore {
27776
28939
  count(table: K): Promise;
27777
28940
  delete(table: K, key: KeyOf): Promise;
27778
28941
  deleteMany(table: K, keys: KeyOf[]): Promise;
27779
- entries(table: K): Promise, RecordOf]>>;
27780
28942
  get(table: K, key: KeyOf): Promise | undefined>;
27781
28943
  getAll(table: K): Promise[]>;
27782
28944
  getMany(table: K, keys: KeyOf[]): Promise | undefined>>;
27783
- getOrDefault(table: K, key: KeyOf, defaultFn: () => RecordOf, ttl?: number): Promise>;
27784
28945
  has(table: K, key: KeyOf): Promise;
27785
28946
  isEmpty(table: K): Promise;
27786
28947
  keys(table: K, filter?: (record: RecordOf) => boolean): Promise[]>;
@@ -27790,7 +28951,6 @@ interface VaultStore {
27790
28951
  update(table: K, key: KeyOf, changes: Partial>, ttl?: number): Promise | undefined>;
27791
28952
  upsert(table: K, key: KeyOf, fn: (existing: RecordOf | undefined) => RecordOf, ttl?: number): Promise>;
27792
28953
  pruneExpired(): Promise>;
27793
- debug(): Promise>;
27794
28954
  observe(table: K, listener: Observer>, options?: { immediate?: boolean; signal?: AbortSignal }): Unsubscribe;
27795
28955
  dispose(): Promise;
27796
28956
  readonly disposed: boolean;
@@ -27803,7 +28963,7 @@ The portable store API is returned by every factory. `observe()` emits the curre
27803
28963
 
27804
28964
  ---
27805
28965
 
27806
- ### `batch()`
28966
+ ### `batch()` and `iterate()`
27807
28967
 
27808
28968
  ```ts
27809
28969
  interface TransactionalVaultStore extends VaultStore {
@@ -27811,10 +28971,11 @@ interface TransactionalVaultStore extends VaultStore {
27811
28971
  tables: readonly K[],
27812
28972
  fn: (tx: TransactionContext) => Promise,
27813
28973
  ): Promise;
28974
+ iterate(table: K): AsyncIterable>;
27814
28975
  }
27815
28976
  ```
27816
28977
 
27817
- Runs a scoped atomic callback. `IndexedDbVaultStore` and `SQLiteVaultStore` provide it.
28978
+ `batch()` runs a scoped atomic callback. `iterate()` lazily yields table records — IndexedDB uses a cursor, SQLite uses keyset pagination. Both are provided by `createIndexedDB()` and `createSQLite()`.
27818
28979
 
27819
28980
  | Parameter | Description |
27820
28981
  | --- | --- |
@@ -27827,43 +28988,24 @@ Runs a scoped atomic callback. `IndexedDbVaultStore` and `SQLiteVaultStore` prov
27827
28988
  await store.batch(['users'], async (tx) => {
27828
28989
  await tx.put('users', { id: 1, name: 'Ada' });
27829
28990
  });
27830
- ```
27831
-
27832
- ---
27833
-
27834
- ### `iterate()`
27835
-
27836
- ```ts
27837
- interface IterableVaultStore extends VaultStore {
27838
- iterate(table: K): AsyncIterable>;
27839
- }
27840
- ```
27841
-
27842
- Lazily yields table records. `IndexedDbVaultStore` uses a cursor; `SQLiteVaultStore` uses keyset pagination.
27843
28991
 
27844
- **Returns:** An `AsyncIterable` of records.
27845
-
27846
- ```ts
27847
28992
  for await (const user of store.iterate('users')) console.log(user);
27848
28993
  ```
27849
28994
 
27850
- ## Queries, Pruning, and Migrations
28995
+ ## Queries and Migrations
27851
28996
 
27852
28997
  ### `QueryBuilder`
27853
28998
 
27854
28999
  ```ts
27855
29000
  interface QueryBuilder {
27856
- between(field: string, lower: number | string, upper: number | string): QueryBuilder;
27857
29001
  count(): Promise;
27858
29002
  delete(): Promise;
27859
- equals(field: K, value: V): QueryBuilder>;
27860
- exists(): Promise;
27861
- filter(fn: (value: N, index: number, array: N[]) => boolean): QueryBuilder;
29003
+ equals(field: K, value: V): QueryBuilder;
29004
+ filter(fn: (value: T, index: number, array: T[]) => boolean): QueryBuilder;
27862
29005
  first(): Promise;
27863
29006
  limit(n: number): QueryBuilder;
27864
29007
  offset(n: number): QueryBuilder;
27865
29008
  orderBy(field: K, direction?: 'asc' | 'desc'): QueryBuilder;
27866
- startsWith(field: keyof T, prefix: string, options?: { ignoreCase?: boolean }): QueryBuilder;
27867
29009
  toArray(): Promise;
27868
29010
  }
27869
29011
  ```
@@ -27871,36 +29013,7 @@ interface QueryBuilder {
27871
29013
  Builds a lazy table query. `count()` ignores `limit()`, `offset()`, and `orderBy()` — it always returns the full filtered-set size.
27872
29014
 
27873
29015
  ```ts
27874
- const page = await store.query('users').startsWith('name', 'A').orderBy('name').limit(20).toArray();
27875
- ```
27876
-
27877
- ---
27878
-
27879
- ### `scheduleExpiredPrune()`
27880
-
27881
- ```ts
27882
- function scheduleExpiredPrune(
27883
- adapter: Pick, 'pruneExpired'>,
27884
- options: {
27885
- interval: number;
27886
- onError?: (error: unknown) => void;
27887
- signal?: AbortSignal;
27888
- },
27889
- ): () => void;
27890
- ```
27891
-
27892
- Schedules `pruneExpired()` at a finite, positive interval. Pass `signal: store.disposalSignal` to auto-cancel when the store is torn down.
27893
-
27894
- **Returns:** A stop function.
27895
-
27896
- ```ts
27897
- import { scheduleExpiredPrune, ttl } from '@vielzeug/vault';
27898
-
27899
- const stop = scheduleExpiredPrune(store, {
27900
- interval: ttl.hours(1),
27901
- signal: store.disposalSignal,
27902
- });
27903
- stop();
29016
+ const page = await store.query('users').equals('role', 'admin').orderBy('name').limit(20).toArray();
27904
29017
  ```
27905
29018
 
27906
29019
  ---
@@ -27942,16 +29055,10 @@ type KeyOf =
27942
29055
 
27943
29056
  ```ts
27944
29057
  type BaseAdapterOptions = {
27945
- logger?: VaultLogger;
27946
- onMetrics?: (event: MetricsEvent) => void;
27947
29058
  schema: S;
27948
29059
  validators?: TableValidators;
27949
29060
  };
27950
29061
 
27951
- type VaultLogger = {
27952
- error(message: string, context?: Error | Record): void;
27953
- };
27954
-
27955
29062
  type RecordValidator = {
27956
29063
  parse(value: unknown): T;
27957
29064
  };
@@ -27959,23 +29066,9 @@ type RecordValidator = {
27959
29066
  type TableValidators = {
27960
29067
  [K in keyof S]?: RecordValidator>;
27961
29068
  };
27962
-
27963
- type MetricsEvent = {
27964
- duration: number;
27965
- operation: 'batch' | 'clear' | 'count' | 'delete' | 'deleteMany' | 'entries' | 'get' | 'getAll' |
27966
- 'getMany' | 'getOrDefault' | 'has' | 'isEmpty' | 'keys' | 'put' | 'putAll' | 'query' |
27967
- 'queryDelete' | 'update' | 'upsert';
27968
- table: string;
27969
- };
27970
-
27971
- type DebugStats = { expiredCount: number; recordCount: number };
27972
- type DebugInfo = { tables: Array };
27973
29069
  ```
27974
29070
 
27975
29071
  ```ts
27976
- interface IndexedDbVaultStore
27977
- extends TransactionalVaultStore, IterableVaultStore {}
27978
-
27979
29072
  type MigrationContext = {
27980
29073
  db: IDBDatabase;
27981
29074
  newVersion: number | null;
@@ -28015,20 +29108,57 @@ type SQLiteVaultOptions = BaseAdapterOptions & {
28015
29108
  database: SQLiteDatabase;
28016
29109
  name: string;
28017
29110
  };
29111
+ ```
29112
+
29113
+ ```ts
29114
+ interface TransactionalVaultStore extends VaultStore {
29115
+ batch(
29116
+ tables: readonly K[],
29117
+ fn: (tx: TransactionContext) => Promise,
29118
+ ): Promise;
29119
+ iterate(table: K): AsyncIterable>;
29120
+ }
29121
+ ```
29122
+
29123
+ Import `TransactionalVaultStore` from `@vielzeug/vault`.
28018
29124
 
28019
- interface SQLiteVaultStore
28020
- extends TransactionalVaultStore, IterableVaultStore {}
29125
+ ```ts
29126
+ interface TransactionContext {
29127
+ clear(table: T): Promise;
29128
+ count(table: T): Promise;
29129
+ delete(table: T, key: KeyOf): Promise;
29130
+ deleteMany(table: T, keys: KeyOf[]): Promise;
29131
+ get(table: T, key: KeyOf): Promise | undefined>;
29132
+ getAll(table: T): Promise[]>;
29133
+ getMany(table: T, keys: KeyOf[]): Promise | undefined>>;
29134
+ has(table: T, key: KeyOf): Promise;
29135
+ isEmpty(table: T): Promise;
29136
+ keys(table: T, filter?: (record: RecordOf) => boolean): Promise[]>;
29137
+ put(table: T, value: RecordOf, ttl?: number): Promise;
29138
+ putAll(table: T, values: RecordOf[], ttl?: number): Promise;
29139
+ query(table: T): QueryBuilder>;
29140
+ update(table: T, key: KeyOf, changes: Partial>, ttl?: number): Promise | undefined>;
29141
+ upsert(table: T, key: KeyOf, fn: (existing: RecordOf | undefined) => RecordOf, ttl?: number): Promise>;
29142
+ }
28021
29143
  ```
28022
29144
 
28023
29145
  `TransactionContext` has the same CRUD, query, and TTL methods as `VaultStore`, narrowed to the tables declared in `batch()`. Import it from `@vielzeug/vault/indexeddb` or `@vielzeug/vault/sqlite`.
28024
29146
 
29147
+ ```ts
29148
+ // Adapter-specific type aliases — both resolve to TransactionalVaultStore.
29149
+ type SQLiteVaultStore = TransactionalVaultStore;
29150
+ type IndexedDbVaultStore = TransactionalVaultStore;
29151
+ ```
29152
+
29153
+ `SQLiteVaultStore` is exported from `@vielzeug/vault/sqlite`. `IndexedDbVaultStore` is exported from `@vielzeug/vault/indexeddb`.
29154
+
28025
29155
  ## Errors
28026
29156
 
28027
29157
  | Error | Trigger |
28028
29158
  | --- | --- |
28029
29159
  | `VaultError` | Any Vault-originated validation, serialization, storage, or query error |
28030
29160
  | `VaultDisposedError` | An operation after the store or observer hub is disposed |
28031
- | `VaultScopeError` | An IndexedDB transaction accesses a table outside its declared batch scope |
29161
+ | `VaultScopeError` | A `batch()` callback accesses a table outside its declared scope |
28032
29162
  | `VaultQuotaError` | A LocalStorage or SessionStorage write exceeds the browser quota |
28033
29163
  | `VaultMigrationError` | An IndexedDB migration callback throws |
28034
29164
 
@@ -28095,29 +29225,27 @@ console.log(updated);
28095
29225
  Build a query from a table, then finish it with a terminal method. `count()` ignores pagination, which makes it suitable for page controls.
28096
29226
 
28097
29227
  ```ts
28098
- const query = store.query('preferences').startsWith('id', 'theme');
29228
+ const query = store.query('preferences').filter((p) => p.id.startsWith('theme'));
28099
29229
  const preferences = await query.orderBy('id').limit(10).toArray();
28100
29230
  const total = await query.count();
28101
29231
 
28102
29232
  console.log({ preferences, total });
28103
29233
  ```
28104
29234
 
28105
- Memory and Web Storage queries scan the table. IndexedDB can use declared secondary indexes, while SQLite pushes primary-key equality, range, and case-sensitive prefix filters to the database.
29235
+ Queries scan the table in memory. Use `equals()` for exact field matches and `filter()` for custom predicates. For large tables, prefer `iterate()` on IndexedDB or SQLite instead of materializing every record.
28106
29236
 
28107
29237
  ## Use TTL and Pruning
28108
29238
 
28109
- Use `ttl.*` helpers for expiring rows. Schedule pruning when stale rows can accumulate without reads.
29239
+ Use `ttl.*` helpers for expiring rows. Call `pruneExpired()` to reclaim storage from stale rows that accumulate without reads.
28110
29240
 
28111
29241
  ```ts
28112
- import { scheduleExpiredPrune, ttl } from '@vielzeug/vault';
29242
+ import { ttl } from '@vielzeug/vault';
28113
29243
 
28114
29244
  await store.put('preferences', { id: 'temporary', theme: 'dark' }, ttl.hours(1));
28115
- const stopPrune = scheduleExpiredPrune(store, {
28116
- interval: ttl.hours(6),
28117
- signal: store.disposalSignal,
28118
- });
28119
29245
 
28120
- stopPrune();
29246
+ // Reclaim expired rows on a schedule owned by the application.
29247
+ const pruneInterval = setInterval(() => store.pruneExpired(), ttl.hours(6));
29248
+ store.disposalSignal.addEventListener('abort', () => clearInterval(pruneInterval));
28121
29249
  ```
28122
29250
 
28123
29251
  ## Observe a Table
@@ -28245,7 +29373,7 @@ Use Forge’s Vault helpers for explicit form-draft persistence. Keep Ripple sig
28245
29373
  - Use IndexedDB or SQLite for atomic work.
28246
29374
  - Keep external asynchronous work outside `batch()` callbacks.
28247
29375
  - Use `ttl.*` instead of raw durations.
28248
- - Keep SQLite scans and writes off latency-sensitive event loops, and dispose stores with their owner.
29376
+ - Keep SQLite scans and writes off latency-sensitive event loops.
28249
29377
  - Dispose stores when their owner ends.
28250
29378
 
28251
29379
  ### Examples
@@ -28263,10 +29391,10 @@ Use Forge’s Vault helpers for explicit form-draft persistence. Keep Ripple sig
28263
29391
 
28264
29392
  - Basic Setup - Initialize Vault (id: `basic-setup`)
28265
29393
  - Bulk Operations (id: `bulk-operations`)
28266
- - Cache-First with getOrDefault (id: `cache-first`)
29394
+ - Cache-First with get + put (id: `cache-first`)
28267
29395
  - CRUD Operations (id: `crud-operations`)
28268
29396
  - IndexedDB — Atomic Batch & iterate() (id: `indexed-db`)
28269
- - TTL — scheduleExpiredPrune with disposalSignal (id: `prune-schedule`)
29397
+ - TTL — pruneExpired with disposalSignal (id: `prune-schedule`)
28270
29398
  - Query Builder — Filters, Pagination, count (id: `query-builder`)
28271
29399
  - Reactive — observe() (id: `reactive-observe`)
28272
29400
  - TTL & Expiration (id: `ttl-expiration`)
@@ -28352,6 +29480,7 @@ else console.log(decision.reason);
28352
29480
  - `WILDCARD` and `ANONYMOUS` model broad or unauthenticated access explicitly.
28353
29481
  - `owns()` and `predicate` constrain rules with synchronous request data.
28354
29482
  - `explain()`, `trace()`, and `detectConflicts()` make policy decisions diagnosable.
29483
+ - `tap()` subscribes to decision events for logging and diagnostics.
28355
29484
  - `forUser()` creates a principal-bound view for repeated checks.
28356
29485
  - `checkAll()` evaluates multiple resource/action pairs in one call.
28357
29486
 
@@ -28377,10 +29506,10 @@ else console.log(decision.reason);
28377
29506
  | `createWard` | Creates immutable policy | Sync | Rules cannot be mutated after creation |
28378
29507
  | `allow` / `deny` / `ruleFor` | Builds policy rules | Sync | Priority wins before specificity |
28379
29508
  | `Ward.explain` | Returns one decision | Sync | Pass resource data for predicate rules |
28380
- | `Ward.trace` | Inspects decision candidates | Sync | Does not invoke the logger |
29509
+ | `Ward.trace` | Inspects decision candidates | Sync | Does not fire a `decision` event |
28381
29510
  | `Ward.forUser` | Binds a principal | Sync | Rebind when identity or roles change |
28382
29511
  | `Ward.checkAll` | Batch permission checks | Sync | Pass resource data for predicate rules |
28383
- | `Ward.allowedActions` | Filters known actions to allowed set | Sync | Does not invoke the logger |
29512
+ | `Ward.allowedActions` | Filters known actions to allowed set | Sync | Does not fire a `decision` event |
28384
29513
  | `Ward.rulesInScope` | Lists rules matching a principal/resource | Sync | Pass data to evaluate predicates |
28385
29514
  | `Ward.detectConflicts` | Detects duplicate/shadowed rules | Sync | O(n²) — use `maxConflicts` for large policies |
28386
29515
  | `predicate.owns` / `owns` | Ownership predicate on resource data | Sync | Skipped for anonymous principals |
@@ -28392,7 +29521,6 @@ else console.log(decision.reason);
28392
29521
  | Import | Purpose |
28393
29522
  | --- | --- |
28394
29523
  | `@vielzeug/ward` | Rules, factory, predicates, pattern helpers, errors, and public types |
28395
- | `@vielzeug/ward/devtools` | `debugWard()` diagnostic factory |
28396
29524
 
28397
29525
  ## Core Factory
28398
29526
 
@@ -28405,14 +29533,13 @@ createWard(
28405
29533
  ): Ward;
28406
29534
  ```
28407
29535
 
28408
- Creates an immutable ward instance. `rules` accepts a flat mix of single rules and rule arrays — `allow()`/`deny()`/`ruleFor()` results can be passed directly without spread. Validates `logger`, `onConflict`, and `maxConflicts` options before compiling rules; invalid values throw `WardConfigError`.
29536
+ Creates an immutable ward instance. `rules` accepts a flat mix of single rules and rule arrays — `allow()`/`deny()`/`ruleFor()` results can be passed directly without spread. Validates `onConflict` and `maxConflicts` options before compiling rules; invalid values throw `WardConfigError`.
28409
29537
 
28410
29538
  **Parameters:**
28411
29539
 
28412
29540
  | Name | Type | Description |
28413
29541
  | --- | --- | --- |
28414
29542
  | `rules` | `readonly (WardRule \| readonly WardRule[])[]` | Rule list. Single rules and rule arrays can be mixed. |
28415
- | `options.logger` | `(ctx: WardLoggerContext) => void` | Called for `explain()` and `checkAll()` decisions. |
28416
29543
  | `options.onConflict` | `(conflict: WardConflict) => void` | Called synchronously per conflict at creation time. |
28417
29544
  | `options.strict` | `boolean` | Throws `WardConfigError` on the first conflict. |
28418
29545
  | `options.maxConflicts` | `number` | Caps the number of conflicts returned by `detectConflicts()`. |
@@ -28498,7 +29625,7 @@ checkAll(
28498
29625
  ): WardDecisionResult[];
28499
29626
  ```
28500
29627
 
28501
- Evaluates multiple resource/action pairs for one principal. Invokes the logger for each decision.
29628
+ Evaluates multiple resource/action pairs for one principal. Fires a `decision` event for each result via `tap()`.
28502
29629
 
28503
29630
  **Returns:** `WardDecisionResult[]` — each entry carries `action`, `resource`, and the decision.
28504
29631
 
@@ -28521,7 +29648,7 @@ explain(input: WardDecisionInput): WardDecision;
28521
29648
  }
28522
29649
  ```
28523
29650
 
28524
- Returns one decision. Invokes the logger.
29651
+ Returns one decision. Fires a `decision` event via `tap()`.
28525
29652
 
28526
29653
  **Returns:** `WardDecision` — `{ allowed: true; rule }` or `{ allowed: false; reason: 'explicit-deny'; rule }` or `{ allowed: false; reason: 'no-matching-rule' }`.
28527
29654
 
@@ -28533,7 +29660,7 @@ Returns one decision. Invokes the logger.
28533
29660
  trace(input: WardDecisionInput): WardTrace;
28534
29661
  ```
28535
29662
 
28536
- Same request shape as `explain()`. Returns winner + candidate list. Does not fire the logger.
29663
+ Same request shape as `explain()`. Returns winner + candidate list. Does not fire a `decision` event.
28537
29664
 
28538
29665
  **Returns:** `WardTrace` — `{ candidates: WardTraceCandidate[]; decision: WardDecision }`.
28539
29666
 
@@ -28556,7 +29683,7 @@ Input shape:
28556
29683
  }
28557
29684
  ```
28558
29685
 
28559
- Filters the provided `knownActions` list to those the principal may perform. Does not invoke the logger.
29686
+ Filters the provided `knownActions` list to those the principal may perform. Does not fire a `decision` event.
28560
29687
 
28561
29688
  **Returns:** `TAction[]` — the subset of `knownActions` that `explain()` would allow.
28562
29689
 
@@ -28712,17 +29839,37 @@ Tests whether the `broad` pattern covers the `narrow` pattern. `'*'` covers ever
28712
29839
 
28713
29840
  ---
28714
29841
 
28715
- ## Devtools
29842
+ ## Observability
29843
+
29844
+ ### `tap(handler, options?)`
29845
+
29846
+ ```ts
29847
+ tap(
29848
+ handler: (event: WardEvent) => void,
29849
+ options?: { signal?: AbortSignal },
29850
+ ): () => void;
29851
+ ```
29852
+
29853
+ Subscribes a handler to ward events. Each `explain()` and `checkAll()` decision fires a `decision` event. `trace()` and `allowedActions()` do not fire events.
29854
+
29855
+ Pass an `AbortSignal` to unsubscribe automatically; the returned function unsubscribes manually.
28716
29856
 
28717
- ### `debugWard(rules, options?)`
29857
+ **Returns:** `() => void` — call to unsubscribe the handler.
28718
29858
 
28719
- Sub-path import: `@vielzeug/ward/devtools`.
29859
+ **Example:**
28720
29860
 
28721
29861
  ```ts
28722
- import { debugWard } from '@vielzeug/ward/devtools';
29862
+ const ward = createWard(rules);
29863
+ ward.tap((event) => console.debug(`ward:${event.type}`, event.decision));
28723
29864
  ```
28724
29865
 
28725
- Diagnostic factory for development inspection.
29866
+ With a logger from `@vielzeug/rune`:
29867
+
29868
+ ```ts
29869
+ import { createLogger } from '@vielzeug/rune';
29870
+ const log = createLogger({ name: 'ward' });
29871
+ ward.tap((event) => log.debug(event, 'ward:decision'));
29872
+ ```
28726
29873
 
28727
29874
  ---
28728
29875
 
@@ -28847,6 +29994,7 @@ export type Ward = {
28847
29994
  explain(input: WardDecisionInput): WardDecision;
28848
29995
  forUser(principal: UserPrincipal): BoundWard;
28849
29996
  rulesInScope(input: WardRulesInScopeInput): ReadonlyArray>>;
29997
+ tap(handler: (event: WardEvent) => void, options?: { signal?: AbortSignal }): () => void;
28850
29998
  trace(input: WardDecisionInput): WardTrace;
28851
29999
  };
28852
30000
 
@@ -28858,7 +30006,9 @@ export type BoundWard = {
28858
30006
  trace(input: BoundWardDecisionInput): WardTrace;
28859
30007
  };
28860
30008
 
28861
- export type WardLoggerContext = WardDecision & {
30009
+ export type WardEvent = {
30010
+ type: 'decision';
30011
+ decision: WardDecision;
28862
30012
  action: TAction;
28863
30013
  data?: TData;
28864
30014
  principal: Principal;
@@ -28866,7 +30016,6 @@ export type WardLoggerContext = WardDecision & {
28866
30016
  };
28867
30017
 
28868
30018
  export type WardOptions = {
28869
- logger?: (context: WardLoggerContext) => void;
28870
30019
  maxConflicts?: number;
28871
30020
  onConflict?: (conflict: WardConflict) => void;
28872
30021
  strict?: boolean;
@@ -28877,12 +30026,12 @@ export type WardOptions = {
28877
30026
 
28878
30027
  `Ward`, `BoundWard`, `WardDecision`, `WardDecisionResult`, `WardTrace`, `WardTraceCandidate`, `WardConflict`,
28879
30028
  `NormalizedWardRule`, `WardOptions`, `WardCheck`, `WardAllowedActionsInput`, `WardRulesInScopeInput`, `RuleContext`,
28880
- `WardLoggerContext`, `WardPredicate`, and `ConflictKind` are exported from the root entry point.
30029
+ `WardEvent`, `WardPredicate`, and `ConflictKind` are exported from the root entry point.
28881
30030
 
28882
30031
  ## Errors
28883
30032
 
28884
- - `WardError` is the base error class; use `WardError.is(value)` for narrowing.
28885
- - `WardConfigError` reports malformed rules, invalid `createWard` options (`logger`, `onConflict`, `maxConflicts`), invalid principals, and strict conflict initialization.
30033
+ - `WardError` is the base error class; use `instanceof WardError` for narrowing.
30034
+ - `WardConfigError` reports malformed rules, invalid `createWard` options (`onConflict`, `maxConflicts`), invalid principals, and strict conflict initialization.
28886
30035
  - `WardPredicateError` reports a throwing synchronous predicate and includes its `ruleIndex` and cause.
28887
30036
 
28888
30037
  ### Usage Guide
@@ -28952,7 +30101,7 @@ const actions = ward.allowedActions({
28952
30101
  });
28953
30102
  ```
28954
30103
 
28955
- It does not fire the logger.
30104
+ It does not fire a `decision` event.
28956
30105
 
28957
30106
  ## Rule Introspection
28958
30107
 
@@ -28979,7 +30128,34 @@ trace.candidates.forEach((c) => {
28979
30128
  });
28980
30129
  ```
28981
30130
 
28982
- `trace()` does not fire the logger.
30131
+ `trace()` does not fire a `decision` event.
30132
+
30133
+ ## Observing Decisions
30134
+
30135
+ `tap()` subscribes a handler to ward events. Each `explain()` and `checkAll()` decision fires a `decision` event; `trace()` and `allowedActions()` do not.
30136
+
30137
+ ```ts
30138
+ const ward = createWard(rules);
30139
+ ward.tap((event) => console.debug(`ward:${event.type}`, event.decision));
30140
+ ```
30141
+
30142
+ Pass an `AbortSignal` to unsubscribe automatically, or call the returned function to unsubscribe manually:
30143
+
30144
+ ```ts
30145
+ const controller = new AbortController();
30146
+ const unsubscribe = ward.tap((event) => console.debug(event), { signal: controller.signal });
30147
+
30148
+ // later
30149
+ unsubscribe(); // or controller.abort();
30150
+ ```
30151
+
30152
+ For structured logging, forward events to a `@vielzeug/rune` logger:
30153
+
30154
+ ```ts
30155
+ import { createLogger } from '@vielzeug/rune';
30156
+ const log = createLogger({ name: 'ward' });
30157
+ ward.tap((event) => log.debug(event, 'ward:decision'));
30158
+ ```
28983
30159
 
28984
30160
  ## Predicate Helpers
28985
30161
 
@@ -29079,7 +30255,7 @@ container.register('ward', ward);
29079
30255
  - [Priority and Overrides](./examples/inheritance-and-overrides.md)
29080
30256
  - [Bound Guard in UI Layer](./examples/bound-guard-in-ui-layer.md)
29081
30257
  - [Rule Specificity](./examples/disabling-wildcard-fallback.md)
29082
- - [Logger for Auditing](./examples/logger-for-auditing.md)
30258
+ - [Auditing Decisions](./examples/logger-for-auditing.md)
29083
30259
  - [Fresh Ward Per Test](./examples/snapshot-restore-for-test-isolation.md)
29084
30260
  - [Conflict Detection](./examples/conflict-detection.md)
29085
30261
  - [Trace a Decision](./examples/trace-decision.md)
@@ -29213,7 +30389,7 @@ router.dispose();
29213
30389
  - `match()` / `load()` — Inspect routes synchronously or load route data without navigation.
29214
30390
  - `preload()` — Warms route data for a later matching navigation.
29215
30391
  - `createMemoryHistory()` — Runs routers in tests and non-browser environments.
29216
- - `debugRouter()` — Logs navigation state from `@vielzeug/wayfinder/devtools`.
30392
+ - `subscribe()` — Reactive subscription to navigation state changes.
29217
30393
 
29218
30394
  ## Documentation
29219
30395
 
@@ -29253,10 +30429,9 @@ router.dispose();
29253
30429
 
29254
30430
  ## Package Entry Points
29255
30431
 
29256
- | Import | Purpose |
29257
- | ------------------------------ | -------------------------------------------- |
29258
- | `@vielzeug/wayfinder` | Main exports and types |
29259
- | `@vielzeug/wayfinder/devtools` | `debugRouter` — navigation logger (dev only) |
30432
+ | Import | Purpose |
30433
+ | --------------------- | ---------------------- |
30434
+ | `@vielzeug/wayfinder` | Main exports and types |
29260
30435
 
29261
30436
  ## `createRouter(options)`
29262
30437
 
@@ -30062,44 +31237,6 @@ Thrown on middleware misuse — currently only when a middleware function calls
30062
31237
  | `/files/:rest*` | `/files/a/b/c` | Wildcard suffix captured as one named param |
30063
31238
  | `*` | anything | Global catch-all |
30064
31239
 
30065
- ## `debugRouter(options)`
30066
-
30067
- ```ts
30068
- import { debugRouter } from '@vielzeug/wayfinder/devtools';
30069
-
30070
- const router = debugRouter({ routes });
30071
- // [wayfinder:nav] idle / [home] ← logged when initial navigation settles
30072
- // [wayfinder:nav] loading /dashboard
30073
- // [wayfinder:nav] idle /dashboard [dashboard.index]
30074
- ```
30075
-
30076
- Wraps `createRouter()` and attaches a `subscribe` listener that logs every navigation state change to `console.debug`. Returns the same `Router` instance — all methods are identical to `createRouter()`. The first logged entry appears when the initial navigation completes (not synchronously at construction).
30077
-
30078
- Import from the dedicated sub-path so the `console.debug` reference is tree-shaken from production bundles when not imported.
30079
-
30080
- ### `DebugRouterOptions`
30081
-
30082
- Extends `RouterOptions` with one additional field:
30083
-
30084
- | Option | Type | Default | Description |
30085
- | ------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------- |
30086
- | `label` | `string` | `'nav'` | Label used in log prefixes. Produces `[wayfinder:]`. Useful when running multiple routers simultaneously. |
30087
-
30088
- ```ts
30089
- // Multi-router setup — distinguish logs by label:
30090
- const main = debugRouter({ routes, label: 'main' });
30091
- const modal = debugRouter({ routes: modalRoutes, label: 'modal' });
30092
- // [wayfinder:main] idle /dashboard
30093
- // [wayfinder:modal] loading /confirm
30094
- ```
30095
-
30096
- | Log format | When |
30097
- | ------------------------------------------------------- | -------------------------------------- |
30098
- | `[wayfinder:nav] idle /path [routeName]` | Navigation settled |
30099
- | `[wayfinder:nav] loading /path` | Data loaders in flight |
30100
- | `[wayfinder:nav] streaming /path [routeName]` | Streaming loader emitting partial data |
30101
- | `[wayfinder:nav] error /path [routeName] ` | Navigation error |
30102
-
30103
31240
  ## Design Notes
30104
31241
 
30105
31242
  - Wayfinder no longer exposes imperative registration methods like `on()`, `group()`, or `use()`.
@@ -30741,43 +31878,50 @@ export function useRouter() {
30741
31878
 
30742
31879
  For full RouterView and RouterLink patterns, see [React Integration](./examples/react-integration.md), [Vue Integration](./examples/vue-integration.md), and [Svelte Integration](./examples/svelte-integration.md).
30743
31880
 
30744
- ## Debug Mode
31881
+ ## Debug Logging
30745
31882
 
30746
- Import `debugRouter` from the dedicated sub-path to create a router with navigation logging pre-enabled. The sub-path is tree-shaken from production bundles when not imported.
31883
+ `router.subscribe()` is the reactive subscription API it receives every state change, including `loading`, `streaming`, and `error` transitions. Attach a listener that logs to `console.debug` to inspect navigation without any dedicated debug tooling.
30747
31884
 
30748
31885
  ```ts
30749
- import { debugRouter } from '@vielzeug/wayfinder/devtools';
31886
+ import { createRouter } from '@vielzeug/wayfinder';
30750
31887
 
30751
- const router = debugRouter({
30752
- routes: {
30753
- home: { path: '/' },
30754
- dashboard: { path: '/dashboard', data: () => fetchDashboard() },
30755
- },
31888
+ const router = createRouter({ routes });
31889
+ const stop = router.subscribe((state) => {
31890
+ console.debug(`[wayfinder] ${state.status} ${state.location.pathname}`);
30756
31891
  });
30757
31892
 
30758
31893
  // Logged once the initial navigation completes:
30759
- // [wayfinder:nav] idle / [home]
31894
+ // [wayfinder] idle /
30760
31895
 
30761
31896
  // On navigate({ name: 'dashboard' }):
30762
- // [wayfinder:nav] loading /dashboard
30763
- // [wayfinder:nav] idle /dashboard [dashboard]
31897
+ // [wayfinder] loading /dashboard
31898
+ // [wayfinder] idle /dashboard
30764
31899
  ```
30765
31900
 
30766
- The router returned is identical to `createRouter()`all methods (`navigate`, `subscribe`, `waitFor`, etc.) work the same way.
31901
+ The returned function unsubscribes the listenercall it when the logger is no longer needed (e.g. on teardown):
30767
31902
 
30768
- Errors are logged with the error object appended:
31903
+ ```ts
31904
+ stop();
31905
+ ```
31906
+
31907
+ Errors are surfaced on the state object, so you can log them explicitly:
30769
31908
 
30770
31909
  ```ts
30771
- // [wayfinder:nav] error /dashboard [dashboard] Error: fetch failed
31910
+ router.subscribe((state) => {
31911
+ if (state.status === 'error') {
31912
+ console.error(`[wayfinder] ${state.location.pathname}`, state.error);
31913
+ }
31914
+ });
30772
31915
  ```
30773
31916
 
30774
- Use the `label` option when running multiple routers to distinguish their log output:
31917
+ Use a label when running multiple routers to distinguish their log output:
30775
31918
 
30776
31919
  ```ts
30777
- const main = debugRouter({ routes, label: 'main' });
30778
- const modal = debugRouter({ routes: modalRoutes, label: 'modal' });
30779
- // [wayfinder:main] loading /products
30780
- // [wayfinder:modal] loading /confirm
31920
+ const main = createRouter({ routes });
31921
+ main.subscribe((state) => console.debug(`[wayfinder:main] ${state.status} ${state.location.pathname}`));
31922
+
31923
+ const modal = createRouter({ routes: modalRoutes });
31924
+ modal.subscribe((state) => console.debug(`[wayfinder:modal] ${state.status} ${state.location.pathname}`));
30781
31925
  ```
30782
31926
 
30783
31927
  Debug logging has no effect on behavior and should not be enabled in production.
@@ -30861,7 +32005,7 @@ router.subscribe((state) => {
30861
32005
  ### REPL Examples
30862
32006
 
30863
32007
  - Basic Routing — Route State and Navigation (id: `basic-routing`)
30864
- - Debug Router — Navigation Logging (id: `debug-router`)
32008
+ - Navigation Logging (id: `debug-router`)
30865
32009
  - Guards and Redirects — Auth Flows (id: `middleware-auth`)
30866
32010
  - Middleware Chain — Execution Flow (id: `middleware-chain`)
30867
32011
  - Named Routes — Type-Safe Navigation (id: `named-routes`)