@vielzeug/codex 2.2.9 → 2.3.0

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.2.9
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,996 @@ 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.
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(name: K, payload: InferJobPayload): Promise;
14621
+ ```
14622
+
14623
+ Validates the payload (if `validate` is defined), derives the key, persists the job, and wakes the processor. Throws `PostmasterError` for an empty key or non-JSON-serializable payload.
14624
+
14625
+ ---
14626
+
14627
+ ### `start()`
14628
+
14629
+ ```ts
14630
+ start(): Promise;
14631
+ ```
14632
+
14633
+ Begins background processing. Idempotent.
14634
+
14635
+ ---
14636
+
14637
+ ### `flush()`
14638
+
14639
+ ```ts
14640
+ flush(options?: { signal?: AbortSignal }): Promise;
14641
+ ```
14642
+
14643
+ 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.
14644
+
14645
+ ---
14646
+
14647
+ ### `list()`
14648
+
14649
+ ```ts
14650
+ list(filter?: EntryFilter): Promise;
14651
+ ```
14652
+
14653
+ Returns entries ordered by `createdAt`. Filter by `status` optionally.
14654
+
14655
+ ---
14656
+
14657
+ ### `stats()`
14658
+
14659
+ ```ts
14660
+ stats(): Promise;
14661
+ ```
14662
+
14663
+ Returns counts of queued, running, and dead-letter jobs.
14664
+
14665
+ ---
14666
+
14667
+ ### `retry()`
14668
+
14669
+ ```ts
14670
+ retry(id: string): Promise;
14671
+ ```
14672
+
14673
+ Moves a dead-letter job back to queued. Returns a discriminated result: `retried`, `not-found`, `not-dead-letter`, or `running`.
14674
+
14675
+ ---
14676
+
14677
+ ### `remove()`
14678
+
14679
+ ```ts
14680
+ remove(id: string): Promise;
14681
+ ```
14682
+
14683
+ Deletes a queued or dead-letter job. Returns a discriminated result: `removed`, `not-found`, or `running`.
14684
+
14685
+ ---
14686
+
14687
+ ### `tap()`
14688
+
14689
+ ```ts
14690
+ tap(handler: (event: PostmasterEvent) => void, options?: { signal?: AbortSignal }): () => void;
14691
+ ```
14692
+
14693
+ 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.
14694
+
14695
+ ---
14696
+
14697
+ ### `dispose()`
14698
+
14699
+ ```ts
14700
+ dispose(): Promise;
14701
+ [Symbol.asyncDispose](): Promise;
14702
+ ```
14703
+
14704
+ Aborts owned work, releases all active leases, and tears down subscriptions. Idempotent. Does not dispose the borrowed store.
14705
+
14706
+ ## Types
14707
+
14708
+ ### `JobDefinition`
14709
+
14710
+ ```ts
14711
+ interface JobDefinition {
14712
+ readonly version: number;
14713
+ readonly validate?: Validate;
14714
+ readonly key: (payload: T) => string;
14715
+ readonly execute: (payload: T, context: JobContext) => Promise;
14716
+ readonly retry?: RetryPolicy;
14717
+ readonly migrate?: (payload: unknown, fromVersion: number) => unknown;
14718
+ }
14719
+ ```
14720
+
14721
+ `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.
14722
+
14723
+ ---
14724
+
14725
+ ### `Validate`
14726
+
14727
+ ```ts
14728
+ type Validate = ((value: unknown) => T) | { parse(value: unknown): T };
14729
+ ```
14730
+
14731
+ 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.
14732
+
14733
+ ---
14734
+
14735
+ ### `JobContext`
14736
+
14737
+ ```ts
14738
+ interface JobContext {
14739
+ readonly attempt: number;
14740
+ readonly entryId: string;
14741
+ readonly key: string;
14742
+ readonly signal: AbortSignal;
14743
+ }
14744
+ ```
14745
+
14746
+ ---
14747
+
14748
+ ### `RetryPolicy`
14749
+
14750
+ ```ts
14751
+ interface RetryPolicy {
14752
+ readonly maxAttempts: number;
14753
+ readonly shouldRetry: (error: unknown, attempt: number) => boolean;
14754
+ readonly delay?: (attempt: number) => number;
14755
+ }
14756
+ ```
14757
+
14758
+ `maxAttempts` is total executions including the first. `shouldRetry` is required when retries are enabled. Default delay uses Arsenal's `backoff(attempt)`.
14759
+
14760
+ ---
14761
+
14762
+ ### `StoredJob`
14763
+
14764
+ ```ts
14765
+ interface StoredJob {
14766
+ readonly id: string;
14767
+ readonly name: string;
14768
+ readonly version: number;
14769
+ readonly payload: JsonValue;
14770
+ readonly key: string;
14771
+ readonly status: 'queued' | 'running' | 'dead-letter';
14772
+ readonly attempts: number;
14773
+ readonly createdAt: number;
14774
+ readonly updatedAt: number;
14775
+ readonly availableAt: number;
14776
+ readonly ownerId?: string;
14777
+ readonly leaseExpiresAt?: number;
14778
+ readonly failure?: StoredFailure;
14779
+ }
14780
+ ```
14781
+
14782
+ ---
14783
+
14784
+ ### `StoredFailure`
14785
+
14786
+ ```ts
14787
+ interface StoredFailure {
14788
+ readonly name: string;
14789
+ readonly message: string;
14790
+ readonly occurredAt: number;
14791
+ }
14792
+ ```
14793
+
14794
+ Only a bounded error name/message/timestamp is persisted. Never persist arbitrary error objects, response bodies, headers, or stacks.
14795
+
14796
+ ---
14797
+
14798
+ ### `PostmasterEntry`
14799
+
14800
+ ```ts
14801
+ type PostmasterEntry = Pick;
14802
+ ```
14803
+
14804
+ The public entry view excludes `payload`, `ownerId`, and `leaseExpiresAt`.
14805
+
14806
+ ---
14807
+
14808
+ ### `PostmasterStore`
14809
+
14810
+ ```ts
14811
+ interface PostmasterStore {
14812
+ transact(fn: (tx: StoreTx) => Promise): Promise;
14813
+ list(filter?: EntryFilter): Promise;
14814
+ subscribe(listener: () => void): () => void;
14815
+ dispose(): Promise;
14816
+ readonly disposed: boolean;
14817
+ readonly disposalSignal: AbortSignal;
14818
+ [Symbol.asyncDispose](): Promise;
14819
+ }
14820
+
14821
+ interface StoreTx {
14822
+ get(id: string): Promise;
14823
+ put(entry: StoredJob): Promise;
14824
+ delete(id: string): Promise;
14825
+ findClaimable(now: number): Promise;
14826
+ findNextWake(now: number): Promise;
14827
+ countByStatus(): Promise;
14828
+ }
14829
+ ```
14830
+
14831
+ 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.
14832
+
14833
+ ---
14834
+
14835
+ ### `PostmasterEvent`
14836
+
14837
+ ```ts
14838
+ type PostmasterEvent =
14839
+ | { readonly type: 'enqueued' | 'started' | 'completed' | 'retry-scheduled' | 'dead-lettered'; readonly entry: PostmasterEntry }
14840
+ | { readonly type: 'removed' | 'lease-lost'; readonly id: string }
14841
+ | { readonly type: 'processor-error'; readonly error: Error }
14842
+ | { readonly type: 'dispose' };
14843
+ ```
14844
+
14845
+ ---
14846
+
14847
+ ### `FlushResult`
14848
+
14849
+ ```ts
14850
+ interface FlushResult {
14851
+ readonly processed: number;
14852
+ readonly completed: number;
14853
+ readonly deadLettered: number;
14854
+ readonly retryScheduled: number;
14855
+ }
14856
+ ```
14857
+
14858
+ ---
14859
+
14860
+ ### `RetryResult` / `RemoveResult`
14861
+
14862
+ ```ts
14863
+ type RetryResult =
14864
+ | { readonly status: 'not-found' | 'not-dead-letter' | 'running' }
14865
+ | { readonly status: 'retried'; readonly entry: PostmasterEntry };
14866
+
14867
+ type RemoveResult =
14868
+ | { readonly status: 'not-found' | 'running' }
14869
+ | { readonly status: 'removed'; readonly id: string };
14870
+ ```
14871
+
14872
+ ## Errors
14873
+
14874
+ ### `PostmasterError`
14875
+
14876
+ ```ts
14877
+ class PostmasterError extends Error {
14878
+ constructor(message: string, options?: ErrorOptions);
14879
+ }
14880
+ ```
14881
+
14882
+ Base class for package-defined errors. Use `instanceof PostmasterError` to narrow to the hierarchy. Covers configuration errors, serialization errors, and store failures.
14883
+
14884
+ ---
14885
+
14886
+ ### `PostmasterDisposedError`
14887
+
14888
+ ```ts
14889
+ class PostmasterDisposedError extends PostmasterError {}
14890
+ ```
14891
+
14892
+ Thrown when a public method is called after disposal.
14893
+
14894
+ ---
14895
+
14896
+ ### `PostmasterJobError`
14897
+
14898
+ ```ts
14899
+ class PostmasterJobError extends PostmasterError {}
14900
+ ```
14901
+
14902
+ 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.
14903
+
14904
+ ### Usage Guide
14905
+
14906
+ ## Basic Usage
14907
+
14908
+ Define typed jobs, create a durable store, enqueue work, and start the processor. Dispose both handles when the owner ends.
14909
+
14910
+ ```ts
14911
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
14912
+ import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb';
14913
+
14914
+ const jobs = defineJobs({
14915
+ createTodo: {
14916
+ version: 1,
14917
+ validate: (v: unknown) => v as { id: string; title: string },
14918
+ key: (p) => p.id,
14919
+ execute: async (payload, { key, signal }) => {
14920
+ await fetch('/api/todos', {
14921
+ method: 'POST',
14922
+ body: JSON.stringify(payload),
14923
+ headers: { 'Idempotency-Key': key },
14924
+ signal,
14925
+ });
14926
+ },
14927
+ },
14928
+ });
14929
+
14930
+ const store = createIndexedDbPostmasterStore({ name: 'my-app-outbox' });
14931
+ const postmaster = createPostmaster({ jobs, store });
14932
+
14933
+ await postmaster.enqueue('createTodo', { id: crypto.randomUUID(), title: 'Buy milk' });
14934
+ await postmaster.start();
14935
+
14936
+ // On page unload:
14937
+ await postmaster.dispose();
14938
+ await store.dispose();
14939
+ ```
14940
+
14941
+ The store is borrowed by `createPostmaster()` and is not disposed with the processor. Dispose both explicitly.
14942
+
14943
+ ## At-least-once delivery and idempotency
14944
+
14945
+ 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.
14946
+
14947
+ ```ts
14948
+ const jobs = defineJobs({
14949
+ createTodo: {
14950
+ version: 1,
14951
+ validate: (v: unknown) => v as { id: string; title: string },
14952
+ key: (p) => p.id,
14953
+ execute: async (payload, { key, signal }) => {
14954
+ await fetch('/api/todos', {
14955
+ method: 'POST',
14956
+ body: JSON.stringify(payload),
14957
+ headers: { 'Idempotency-Key': key },
14958
+ signal,
14959
+ });
14960
+ },
14961
+ },
14962
+ });
14963
+ ```
14964
+
14965
+ Never assume exactly-once execution. Design handlers so a repeated delivery is safe.
14966
+
14967
+ ## Postmaster jobs vs Courier mutations
14968
+
14969
+ Courier performs immediate HTTP requests and cache reconciliation. Postmaster coordinates durable delivery. Use Courier inside a Postmaster job when the write must survive reloads.
14970
+
14971
+ ```ts
14972
+ import { createCourier, CourierNetworkError } from '@vielzeug/courier';
14973
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
14974
+
14975
+ const courier = createCourier({ baseUrl: 'https://api.example.com' });
14976
+
14977
+ const jobs = defineJobs({
14978
+ createTodo: {
14979
+ version: 1,
14980
+ validate: (v: unknown) => v as { id: string; title: string },
14981
+ key: (p) => p.id,
14982
+ execute: async (payload, { key, signal }) => {
14983
+ await courier.mutate({
14984
+ request: () =>
14985
+ courier.post('/todos', {
14986
+ body: payload,
14987
+ headers: { 'Idempotency-Key': key },
14988
+ signal,
14989
+ }),
14990
+ invalidateKeys: [['todos']],
14991
+ });
14992
+ },
14993
+ retry: { maxAttempts: 5, shouldRetry: (error) => error instanceof CourierNetworkError },
14994
+ },
14995
+ });
14996
+ ```
14997
+
14998
+ Postmaster does not import Courier. The integration happens in your job definitions.
14999
+
15000
+ ## Payload and version migration
15001
+
15002
+ 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:
15003
+
15004
+ ```ts
15005
+ import { s } from '@vielzeug/spell';
15006
+
15007
+ const jobs = defineJobs({
15008
+ createTodo: {
15009
+ version: 2,
15010
+ validate: s.object({ id: s.string(), title: s.string(), priority: s.number().optional() }),
15011
+ key: (p) => p.id,
15012
+ migrate: (payload, fromVersion) => {
15013
+ if (fromVersion === 1) return { ...(payload as { id: string; title: string }), priority: 0 };
15014
+ return payload;
15015
+ },
15016
+ execute: async (payload, { key, signal }) => {
15017
+ await fetch('/api/todos', {
15018
+ method: 'POST',
15019
+ body: JSON.stringify(payload),
15020
+ headers: { 'Idempotency-Key': key },
15021
+ signal,
15022
+ });
15023
+ },
15024
+ },
15025
+ });
15026
+ ```
15027
+
15028
+ Unknown job names, incompatible versions, failed migrations, and invalid persisted payloads move to dead-letter rather than being executed.
15029
+
15030
+ ## Retry semantics
15031
+
15032
+ Retries are opt-in and explicitly classified. No `retry` block means one attempt followed by dead-letter.
15033
+
15034
+ ```ts
15035
+ const jobs = defineJobs({
15036
+ syncTodo: {
15037
+ version: 1,
15038
+ validate: (v: unknown) => v as { id: string },
15039
+ key: (p) => p.id,
15040
+ execute: async (payload, { signal }) => {
15041
+ await fetch(`/api/todos/${payload.id}/sync`, { signal });
15042
+ },
15043
+ retry: {
15044
+ maxAttempts: 5,
15045
+ shouldRetry: (error) => error instanceof TypeError, // network errors only
15046
+ },
15047
+ },
15048
+ });
15049
+ ```
15050
+
15051
+ - `maxAttempts` means total executions, including the first.
15052
+ - `shouldRetry` is required when retries are enabled. Postmaster never guesses whether a write is safe to repeat.
15053
+ - Default delay uses Arsenal's deterministic `backoff(attempt)` helper. Override with `delay`.
15054
+ - Delay must be finite and non-negative.
15055
+ - Lifecycle aborts caused by disposal are not classified as job failures.
15056
+
15057
+ ## Dead-letter recovery
15058
+
15059
+ Jobs that exhaust retries or hit a terminal failure move to dead-letter. Inspect, retry, or remove them.
15060
+
15061
+ ```ts
15062
+ const deadLettered = await postmaster.list({ status: 'dead-letter' });
15063
+
15064
+ for (const entry of deadLettered) {
15065
+ console.log(entry.id, entry.name, entry.failure);
15066
+ }
15067
+
15068
+ // Retry a dead-letter job back into the queue.
15069
+ await postmaster.retry(entry.id);
15070
+
15071
+ // Or remove it permanently.
15072
+ await postmaster.remove(entry.id);
15073
+ ```
15074
+
15075
+ `retry()` and `remove()` return discriminated results so callers can distinguish `not-found`, `not-dead-letter`, `running`, and successful outcomes without exceptions.
15076
+
15077
+ ## Lifecycle and disposal
15078
+
15079
+ `start()` begins background processing. `dispose()` stops claiming new work, aborts owned work, and is idempotent. `flush()` processes every available job synchronously.
15080
+
15081
+ ```ts
15082
+ await postmaster.start();
15083
+ // ...on unload
15084
+ await postmaster.dispose();
15085
+ await store.dispose();
15086
+ ```
15087
+
15088
+ 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.
15089
+
15090
+ ## Events
15091
+
15092
+ Tap runtime events for observability. Handler errors are swallowed — observability never affects processing.
15093
+
15094
+ ```ts
15095
+ const unsubscribe = postmaster.tap((event) => {
15096
+ switch (event.type) {
15097
+ case 'enqueued':
15098
+ console.log('enqueued', event.entry.id);
15099
+ break;
15100
+ case 'completed':
15101
+ console.log('completed', event.entry.id);
15102
+ break;
15103
+ case 'dead-lettered':
15104
+ console.error('dead-lettered', event.entry.id, event.entry.failure);
15105
+ break;
15106
+ case 'processor-error':
15107
+ console.error('processor error', event.error);
15108
+ break;
15109
+ }
15110
+ });
15111
+ ```
15112
+
15113
+ Pass an `AbortSignal` to auto-detach:
15114
+
15115
+ ```ts
15116
+ const controller = new AbortController();
15117
+ postmaster.tap(handler, { signal: controller.signal });
15118
+ controller.abort(); // stops tapping
15119
+ ```
15120
+
15121
+ ## Testing
15122
+
15123
+ Use the in-memory store for deterministic tests.
15124
+
15125
+ ```ts
15126
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
15127
+ import { createMemoryPostmasterStore } from '@vielzeug/postmaster/testing';
15128
+
15129
+ const store = createMemoryPostmasterStore();
15130
+ const postmaster = createPostmaster({
15131
+ jobs: defineJobs({
15132
+ send: {
15133
+ version: 1,
15134
+ validate: (v: unknown) => String(v),
15135
+ key: (p) => p,
15136
+ execute: async () => {},
15137
+ },
15138
+ }),
15139
+ store,
15140
+ });
15141
+
15142
+ await postmaster.enqueue('send', 'hello');
15143
+ await postmaster.flush();
15144
+ await postmaster.dispose();
15145
+ ```
15146
+
15147
+ Inject a deterministic clock to control retry scheduling.
15148
+
15149
+ ```ts
15150
+ let now = 0;
15151
+ const postmaster = createPostmaster({ clock: () => now, jobs, store });
15152
+ ```
15153
+
15154
+ ## Framework Integration
15155
+
15156
+ Create the Postmaster after the component mounts, start processing, and dispose on unmount.
15157
+
15158
+ ```tsx [React]
15159
+ import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb';
15160
+ import { createPostmaster, defineJobs, type Postmaster } from '@vielzeug/postmaster';
15161
+ import { useEffect } from 'react';
15162
+
15163
+ const jobs = defineJobs({
15164
+ sync: {
15165
+ version: 1,
15166
+ validate: (v: unknown) => v as { id: string },
15167
+ key: (p) => p.id,
15168
+ execute: async (payload, { signal }) => {
15169
+ await fetch(`/api/sync/${payload.id}`, { signal });
15170
+ },
15171
+ },
15172
+ });
15173
+
15174
+ export function OutboxProvider() {
15175
+ useEffect(() => {
15176
+ const store = createIndexedDbPostmasterStore({ name: 'outbox' });
15177
+ const postmaster = createPostmaster({ jobs, store });
15178
+ void postmaster.start();
15179
+
15180
+ return () => {
15181
+ void postmaster.dispose();
15182
+ void store.dispose();
15183
+ };
15184
+ }, []);
15185
+
15186
+ return null;
15187
+ }
15188
+ ```
15189
+
15190
+ ```vue [Vue 3]
15191
+
15192
+ import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb';
15193
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
15194
+ import { onMounted, onUnmounted } from 'vue';
15195
+
15196
+ const jobs = defineJobs({
15197
+ sync: {
15198
+ version: 1,
15199
+ validate: (v: unknown) => v as { id: string },
15200
+ key: (p) => p.id,
15201
+ execute: async (payload, { signal }) => {
15202
+ await fetch(`/api/sync/${payload.id}`, { signal });
15203
+ },
15204
+ },
15205
+ });
15206
+
15207
+ let postmaster: ReturnType | undefined;
15208
+ let store: ReturnType | undefined;
15209
+
15210
+ onMounted(() => {
15211
+ store = createIndexedDbPostmasterStore({ name: 'outbox' });
15212
+ postmaster = createPostmaster({ jobs, store });
15213
+ void postmaster.start();
15214
+ });
15215
+
15216
+ onUnmounted(() => {
15217
+ void postmaster?.dispose();
15218
+ void store?.dispose();
15219
+ });
14196
15220
 
14197
15221
 
14198
15222
 
@@ -14200,92 +15224,115 @@ const open = ref(false);
14200
15224
 
14201
15225
  ```svelte [Svelte]
14202
15226
 
14203
- import './x-toggle'; // wherever define('x-toggle', { ... }) is called
15227
+ import { createIndexedDbPostmasterStore } from '@vielzeug/postmaster/indexeddb';
15228
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
15229
+ import { onMount } from 'svelte';
14204
15230
 
14205
- function handleClick() {
14206
- console.log('toggled');
14207
- }
15231
+ const jobs = defineJobs({
15232
+ sync: {
15233
+ version: 1,
15234
+ validate: (v: unknown) => v as { id: string },
15235
+ key: (p) => p.id,
15236
+ execute: async (payload, { signal }) => {
15237
+ await fetch(`/api/sync/${payload.id}`, { signal });
15238
+ },
15239
+ },
15240
+ });
15241
+
15242
+ onMount(() => {
15243
+ const store = createIndexedDbPostmasterStore({ name: 'outbox' });
15244
+ const postmaster = createPostmaster({ jobs, store });
15245
+ void postmaster.start();
15246
+
15247
+ return () => {
15248
+ void postmaster.dispose();
15249
+ void store.dispose();
15250
+ };
15251
+ });
14208
15252
 
14209
15253
  ```
14210
15254
 
14211
15255
  ## Working with Other Vielzeug Libraries
14212
15256
 
14213
- ### With Ripple
15257
+ ### Postmaster + Courier
14214
15258
 
14215
- Import ripple primitives directly from `@vielzeug/ripple` for standalone reactive state outside components.
15259
+ Use Courier inside job handlers for HTTP transport and cache invalidation. Postmaster coordinates delivery; Courier performs the request.
14216
15260
 
14217
15261
  ```ts
14218
- import { signal, computed } from '@vielzeug/ripple';
14219
- import { define, html } from '@vielzeug/ore';
15262
+ import { createCourier, CourierNetworkError } from '@vielzeug/courier';
15263
+ import { createPostmaster, defineJobs } from '@vielzeug/postmaster';
14220
15264
 
14221
- // Shared state created outside any component
14222
- const theme = signal('light');
14223
- const isDark = computed(() => theme.value === 'dark');
15265
+ const courier = createCourier({ baseUrl: 'https://api.example.com' });
14224
15266
 
14225
- define('theme-toggle', {
14226
- setup() {
14227
- return html`
14228
- (theme.value = isDark.value ? 'light' : 'dark')}>
14229
- ${() =>
14230
- isDark.value ? '' : ''}
14231
-
14232
- `;
15267
+ const jobs = defineJobs({
15268
+ createTodo: {
15269
+ version: 1,
15270
+ validate: (v: unknown) => v as { id: string; title: string },
15271
+ key: (p) => p.id,
15272
+ execute: async (payload, { key, signal }) => {
15273
+ await courier.mutate({
15274
+ request: () =>
15275
+ courier.post('/todos', {
15276
+ body: payload,
15277
+ headers: { 'Idempotency-Key': key },
15278
+ signal,
15279
+ }),
15280
+ invalidateKeys: [['todos']],
15281
+ });
15282
+ },
15283
+ retry: { maxAttempts: 5, shouldRetry: (e) => e instanceof CourierNetworkError },
14233
15284
  },
14234
15285
  });
14235
15286
  ```
14236
15287
 
14237
- ### With Forge
15288
+ ### Postmaster + Sentinel
14238
15289
 
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.
15290
+ Flush the outbox when the network returns. Sentinel reports online state; Postmaster does the rest.
14241
15291
 
14242
15292
  ```ts
14243
- import { createForm } from '@vielzeug/forge';
14244
- import { define, html } from '@vielzeug/ore';
15293
+ import { createNetwork } from '@vielzeug/sentinel';
15294
+ import { createPostmaster } from '@vielzeug/postmaster';
14245
15295
 
14246
- define('signup-form', {
14247
- setup(_props) {
14248
- const form = createForm({ initialValues: { email: '' } });
15296
+ const network = createNetwork();
15297
+ const postmaster = createPostmaster({ jobs, store });
14249
15298
 
14250
- return html`
14251
- {
14252
- event.preventDefault();
14253
- void form.submit(async (values) => {
14254
- console.log(values);
14255
- });
14256
- }}>
14257
-
14258
-
14259
- `;
14260
- },
15299
+ const unsubscribe = network.subscribe(() => {
15300
+ if (network.value.online) void postmaster.flush();
14261
15301
  });
15302
+
15303
+ // On teardown:
15304
+ unsubscribe();
15305
+ network.dispose();
15306
+ await postmaster.dispose();
14262
15307
  ```
14263
15308
 
15309
+ ### Postmaster + Vault
15310
+
15311
+ The IndexedDB adapter is built on Vault. Use Vault directly for unrelated storage; the Postmaster store owns its own database name.
15312
+
14264
15313
  ## Best Practices
14265
15314
 
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`.
15315
+ - **Derive** a stable idempotency key from every job payload and send it with the remote write.
15316
+ - **Dispose** both the processor and the store explicitly; the processor does not own the store.
15317
+ - **Classify** retryable errors explicitly with `shouldRetry`; never let Postmaster guess.
15318
+ - **Migrate** persisted payloads when job versions change; test migrations against stored fixtures.
15319
+ - **Inspect** the dead-letter queue regularly and retry or remove terminal failures.
15320
+ - **Avoid** persisting sensitive data in payloads or failure messages; IndexedDB is per-origin but not encrypted.
15321
+ - **Flush** the outbox when Sentinel reports the network returns.
15322
+ - **Test** with the in-memory store and a deterministic clock for reproducible retry timing.
14276
15323
 
14277
15324
  ### Examples
14278
15325
 
14279
15326
  ## Examples
14280
15327
 
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)
15328
+ - [Queue Offline Courier Mutations](./examples/queue-offline-courier-mutations.md)
15329
+ - [Resume When Network Returns](./examples/resume-when-network-returns.md)
15330
+ - [Recover Dead-Letter Jobs](./examples/recover-dead-letter-jobs.md)
15331
+ - [Service Worker Background Sync](./examples/service-worker-background-sync.md)
15332
+
15333
+ ### REPL Examples
15334
+
15335
+ - defineJobs - Basic Outbox (id: `define-jobs`)
14289
15336
 
14290
15337
  ---
14291
15338
 
@@ -17067,7 +18114,10 @@ type Schema = {
17067
18114
 
17068
18115
  const pulse = createPulse('wss://api.example.com/ws', {
17069
18116
  reconnect: true,
17070
- onError: (error) => console.error(error),
18117
+ });
18118
+ pulse.tap((event) => {
18119
+ if (event.type === 'error') console.error(event.error);
18120
+ if (event.type === 'status-change') console.log('status:', event.status);
17071
18121
  });
17072
18122
  const chat = pulse.channel('chat');
17073
18123
  const lobby = pulse.room('lobby');
@@ -17091,7 +18141,7 @@ pulse.dispose();
17091
18141
  - **`room()`** — named, schema-bound ref-counted room scopes with optional reactive presence. The first scope sends `join`; the last disposal sends `leave`.
17092
18142
  - **`reconnect`** — ordered restoration of channel subscriptions, room memberships, and local presence state.
17093
18143
  - **`transform`** — one synchronous transform or filter for application messages.
17094
- - **`onError`** — typed connection and protocol errors.
18144
+ - **`tap()`** — subscribe to lifecycle events (status changes, errors, disposal) via a typed `PulseEvent` stream.
17095
18145
  - **`heartbeat`** — ping/pong liveness detection that uses the same reconnect controller.
17096
18146
  - **`status` and `rooms`** — ripple readables for transport and confirmed membership state.
17097
18147
 
@@ -17120,7 +18170,7 @@ pulse.dispose();
17120
18170
  | `PulseChannel` | Scoped channel namespace with independent disposal. | Sync methods, async `wait()` | Each call returns a new scope; ref-counted subscription. |
17121
18171
  | `RoomScope` | Ref-counted room membership with optional presence. | Sync methods, async `joined` | `joined` rejects on transport close or timeout. |
17122
18172
  | `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`. |
18173
+ | `PulseOptions` | Configuration: heartbeat, reconnect, transform. | Type-only | `reconnect` and `heartbeat` default to `false`. |
17124
18174
  | `PulseError` | Base class for all Pulse errors. | Runtime | Check `instanceof` against subclasses. |
17125
18175
 
17126
18176
  ## Package Entry Point
@@ -17183,7 +18233,6 @@ Declare all protocol surfaces once at construction. Named scopes infer their typ
17183
18233
  ```ts
17184
18234
  type PulseOptions = {
17185
18235
  heartbeat?: boolean | HeartbeatOptions;
17186
- onError?: (error: PulseError) => void;
17187
18236
  protocols?: string | string[];
17188
18237
  reconnect?: boolean | ReconnectOptions;
17189
18238
  transform?: OutgoingTransform;
@@ -17193,7 +18242,6 @@ type PulseOptions = {
17193
18242
  | Option | Type | Default | Description |
17194
18243
  | --- | --- | --- | --- |
17195
18244
  | `heartbeat` | `boolean \| HeartbeatOptions` | `false` | Ping/pong keep-alive. |
17196
- | `onError` | `(error: PulseError) => void` | — | Receives typed transport and protocol errors. |
17197
18245
  | `protocols` | `string \| string[]` | — | Sub-protocols passed to the WebSocket constructor. |
17198
18246
  | `reconnect` | `boolean \| ReconnectOptions` | `false` | Auto-reconnect on unexpected close. |
17199
18247
  | `transform` | `OutgoingTransform` | — | Transform or filter outgoing application messages. |
@@ -17283,6 +18331,9 @@ type Pulse = {
17283
18331
  // Status
17284
18332
  readonly status: Readable;
17285
18333
 
18334
+ // Tap
18335
+ tap(handler: (event: PulseEvent) => void, options?: { signal?: AbortSignal }): () => void;
18336
+
17286
18337
  [Symbol.dispose](): void;
17287
18338
  };
17288
18339
  ```
@@ -17331,6 +18382,42 @@ Reactive set of rooms the client is currently a confirmed member of.
17331
18382
 
17332
18383
  Reactive connection status: `'connecting' | 'open' | 'reconnecting' | 'closed'`.
17333
18384
 
18385
+ ### `tap(handler, options?)`
18386
+
18387
+ Subscribes to lifecycle events emitted by the Pulse instance. The handler receives a discriminated-union `PulseEvent`. Returns an unsubscribe function.
18388
+
18389
+ | Parameter | Type | Description |
18390
+ | --- | --- | --- |
18391
+ | `handler` | `(event: PulseEvent) => void` | Called for each lifecycle event. |
18392
+ | `options.signal` | `AbortSignal` | Optional signal to stop the subscription. |
18393
+
18394
+ ```ts
18395
+ const pulse = createPulse(url, { reconnect: true });
18396
+ pulse.tap((event) => {
18397
+ if (event.type === 'error') console.error(event.error);
18398
+ if (event.type === 'status-change') console.log('status:', event.status);
18399
+ });
18400
+ ```
18401
+
18402
+ ---
18403
+
18404
+ ## `PulseEvent`
18405
+
18406
+ ```ts
18407
+ type PulseEvent =
18408
+ | { type: 'status-change'; status: PulseStatus }
18409
+ | { type: 'error'; error: PulseError }
18410
+ | { type: 'dispose' };
18411
+ ```
18412
+
18413
+ A discriminated union of lifecycle events emitted by a `Pulse` instance. Inspect `event.type` to narrow the payload.
18414
+
18415
+ | `type` | Payload | When |
18416
+ | --- | --- | --- |
18417
+ | `status-change` | `status: PulseStatus` | The connection status transitions. |
18418
+ | `error` | `error: PulseError` | A typed transport or protocol error occurs. |
18419
+ | `dispose` | — | The instance is disposed. |
18420
+
17334
18421
  ---
17335
18422
 
17336
18423
  ## `PulseChannel`
@@ -17561,7 +18648,11 @@ type Schema = {
17561
18648
  const pulse = createPulse('wss://api.example.com/ws', {
17562
18649
  reconnect: { delay: 1_000, maxAttempts: 5 },
17563
18650
  heartbeat: { interval: 30_000, timeout: 5_000 },
17564
- onError: (error) => console.error(error),
18651
+ });
18652
+
18653
+ pulse.tap((event) => {
18654
+ if (event.type === 'error') console.error(event.error);
18655
+ if (event.type === 'status-change') console.log('status:', event.status);
17565
18656
  });
17566
18657
 
17567
18658
  try {
@@ -17733,13 +18824,17 @@ Disposal is idempotent. It closes the connection, rejects pending room joins, cl
17733
18824
 
17734
18825
  ```ts
17735
18826
  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);
18827
+ reconnect: true,
18828
+ });
18829
+
18830
+ pulse.tap((event) => {
18831
+ if (event.type === 'error') {
18832
+ if (event.error instanceof PulseConnectionError) {
18833
+ console.error('Connection error:', event.error);
18834
+ } else if (event.error instanceof PulseProtocolError) {
18835
+ console.error('Protocol error:', event.error);
17741
18836
  }
17742
- },
18837
+ }
17743
18838
  });
17744
18839
  ```
17745
18840
 
@@ -17758,7 +18853,7 @@ const pulse = createPulse('wss://api.example.com/ws', {
17758
18853
  - Define the full schema at `createPulse()` so named scopes are type-safe without per-call generics.
17759
18854
  - Use `using` declarations for channel and room scopes so disposal is automatic at block exit.
17760
18855
  - 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.
18856
+ - Call `tap()` to observe lifecycle events; Pulse reports transport and protocol errors there rather than throwing asynchronously.
17762
18857
  - Read `pulse.rooms` for post-reconnect membership; `joined` rejects on transport close.
17763
18858
  - Set a `timeout` on room scopes when the server may never confirm membership.
17764
18859
  - Keep `transform` synchronous; resolve async policy decisions before calling `send()`.
@@ -21258,7 +22353,7 @@ console.log(results[0]?.item.name); // Ada Lovelace
21258
22353
  - Incremental updates — `add()` / `remove()` / `reindex()` patch individual items in O(field_length)
21259
22354
  - `onMutate()` — Subscribe to index mutations; powers `createSearch()`'s reactivity and bulk reconciliation
21260
22355
  - `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
22356
+ - Event subscription via `search.tap()` — observe `query`/`isSearching`/`results`/`dispose` transitions; returns an unsubscribe function
21262
22357
 
21263
22358
  ## Documentation
21264
22359
 
@@ -21294,14 +22389,13 @@ console.log(results[0]?.item.name); // Ada Lovelace
21294
22389
  | `toSearchMatcher()` | Adapt `ScoutIndex` to Sourcerer's `match` callback | Sync | Recomputes cached query matches after index mutation |
21295
22390
  | `toFilterPredicate()` | Snapshot predicate from a one-time query | Sync | Re-call when query or corpus changes |
21296
22391
  | `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 |
22392
+ | `SearchState.tap()` | Subscribe to `query`/`isSearching`/`results`/`dispose` events | Sync | Returns an unsubscribe function; pass `{ signal }` to tie to an external lifecycle |
21298
22393
 
21299
22394
  ## Package Entry Point
21300
22395
 
21301
22396
  | Import | Purpose |
21302
22397
  | --- | --- |
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) |
22398
+ | `@vielzeug/scout` | All exports — index/search/highlighting/adapters, `ScoutConfigurationError`, `ScoutDisposedError`, `ScoutError`, `ScoutEvent`, and all types |
21305
22399
 
21306
22400
  ---
21307
22401
 
@@ -21454,6 +22548,7 @@ function createSearch(index: ScoutIndex, options?: CreateSearchOptions): SearchS
21454
22548
  | `disposed` | `boolean` | `true` after `dispose()` has been called. |
21455
22549
  | `clear()` | `() => void` | Resets query, cancels debounce, clears results synchronously. |
21456
22550
  | `dispose()` | `() => void` | Releases all reactive subscriptions. |
22551
+ | `tap()` | `(handler, options?) => () => void` | Subscribe to `ScoutEvent` transitions; returns an unsubscribe function. |
21457
22552
  | `[Symbol.dispose]()` | `() => void` | `using`-compatible disposal. |
21458
22553
 
21459
22554
  **Example**
@@ -21664,35 +22759,39 @@ const index = createIndex(documents, {
21664
22759
 
21665
22760
  ---
21666
22761
 
21667
- ## `debugSearch(search)`
22762
+ ## `search.tap(handler, options?)`
22763
+
22764
+ 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
22765
 
21669
22766
  ```ts
21670
- debugSearch(search: SearchState): () => void
22767
+ tap(
22768
+ handler: (event: ScoutEvent) => void,
22769
+ options?: { signal?: AbortSignal },
22770
+ ): () => void
21671
22771
  ```
21672
22772
 
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
22773
  **Example**
21678
22774
 
21679
22775
  ```ts
21680
22776
  import { createIndex, createSearch } from '@vielzeug/scout';
21681
- import { debugSearch } from '@vielzeug/scout/devtools';
21682
22777
 
21683
22778
  const index = createIndex([{ name: 'Ada Lovelace' }], { fields: ['name'] });
21684
22779
  const search = createSearch(index);
21685
- const stopDebugging = debugSearch(search);
22780
+
22781
+ const unsubscribe = search.tap((event) => {
22782
+ if (event.type === 'query-change') console.debug('query:', event.query);
22783
+ if (event.type === 'results-change') console.debug('results:', event.results.length);
22784
+ });
21686
22785
 
21687
22786
  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)
22787
+ // query: alice
22788
+ // results: 1
21692
22789
 
21693
- stopDebugging();
22790
+ unsubscribe();
21694
22791
  ```
21695
22792
 
22793
+ If your queries may carry PII (names, emails, medical/financial terms typed by end users), don't log `query-change` events in production.
22794
+
21696
22795
  ---
21697
22796
 
21698
22797
  ## Types
@@ -21778,12 +22877,32 @@ type SearchState = {
21778
22877
  readonly disposed: boolean;
21779
22878
  clear(): void;
21780
22879
  dispose(): void;
22880
+ tap(handler: (event: ScoutEvent) => void, options?: { signal?: AbortSignal }): () => void;
21781
22881
  [Symbol.dispose](): void;
21782
22882
  };
21783
22883
  ```
21784
22884
 
21785
22885
  See `createSearch()` above for member descriptions.
21786
22886
 
22887
+ ### `ScoutEvent`
22888
+
22889
+ Discriminated union of events emitted by `SearchState.tap()`. Each variant carries a `type` discriminant; narrow with a `switch` or `if` on `event.type`.
22890
+
22891
+ ```ts
22892
+ type ScoutEvent =
22893
+ | { type: 'query-change'; query: string }
22894
+ | { type: 'searching-change'; isSearching: boolean }
22895
+ | { type: 'results-change'; results: readonly SearchResult[] }
22896
+ | { type: 'dispose' };
22897
+ ```
22898
+
22899
+ | `type` | Payload | Emitted when |
22900
+ | --- | --- | --- |
22901
+ | `query-change` | `query: string` | The writable `query` signal's value changes. |
22902
+ | `searching-change` | `isSearching: boolean` | The debounce window opens (`true`) or closes (`false`). |
22903
+ | `results-change` | `results: readonly SearchResult[]` | Committed results change after debounce. |
22904
+ | `dispose` | — | `dispose()` is called on the `SearchState`. |
22905
+
21787
22906
  ### `ReactiveSearch`
21788
22907
 
21789
22908
  ```ts
@@ -22126,25 +23245,29 @@ const parts = highlight(result.item.name, nameMatch?.ranges ?? []);
22126
23245
 
22127
23246
  ## Debug Logging
22128
23247
 
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.
23248
+ `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
23249
 
22133
23250
  ```ts
22134
- import { debugSearch } from '@vielzeug/scout/devtools';
23251
+ import { createIndex, createSearch } from '@vielzeug/scout';
22135
23252
 
22136
23253
  const search = createSearch(index, { debounce: 150 });
22137
- const stopDebugging = debugSearch(search);
23254
+ const unsubscribe = search.tap((event) => {
23255
+ if (event.type === 'query-change') console.debug('query:', event.query);
23256
+ if (event.type === 'searching-change') console.debug('isSearching:', event.isSearching);
23257
+ if (event.type === 'results-change') console.debug('results:', event.results.length);
23258
+ });
22138
23259
 
22139
23260
  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)
23261
+ // query: alice
23262
+ // isSearching: true
23263
+ // isSearching: false
23264
+ // results: 1
22144
23265
 
22145
- stopDebugging();
23266
+ unsubscribe();
22146
23267
  ```
22147
23268
 
23269
+ `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.
23270
+
22148
23271
  ## Framework Integration
22149
23272
 
22150
23273
  ```tsx [React]
@@ -23543,7 +24666,7 @@ interface GridVirtualizer {
23543
24666
 
23544
24667
  | Class | Thrown when | Notable properties |
23545
24668
  | --- | --- | --- |
23546
- | `ScrollError` | Base class for every Scroll error. | `ScrollError.is(error)` narrows errors from this package. |
24669
+ | `ScrollError` | Base class for every Scroll error. | Use `instanceof ScrollError` to narrow unknown errors narrows errors from this package. |
23547
24670
  | `ScrollConfigurationError` | A constructor or `update()` receives invalid static configuration. | Extends `ScrollError`; malformed JavaScript values also use this class. |
23548
24671
  | `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
24672
 
@@ -24577,9 +25700,9 @@ const stopObserving = observeViewport();
24577
25700
 
24578
25701
  ## Documentation
24579
25702
 
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.
25703
+ - [**Usage Guide**](./usage.md)
25704
+ - [**API Reference**](./api.md)
25705
+ - [**Examples**](./examples.md)
24583
25706
 
24584
25707
  ## See Also
24585
25708
 
@@ -26167,7 +27290,7 @@ if (!result.success) {
26167
27290
 
26168
27291
  ## Errors
26169
27292
 
26170
- - `SpellError` — base class. Use `SpellError.is(error)` for cross-boundary narrowing.
27293
+ - `SpellError` — base class. Use `instanceof SpellError` for cross-boundary narrowing.
26171
27294
  - `SpellValidationError` — validation failure with `issues`, `bestMatch()`, `messagesAt()`, `flatten()`, and `flattenFirst()`.
26172
27295
  - `SpellDefinitionError` — schema cannot create portable definition.
26173
27296
 
@@ -27505,10 +28628,10 @@ try {
27505
28628
  - `/memory`, `/local-storage`, and `/session-storage` return portable `VaultStore` instances without loading other adapters.
27506
28629
  - `observe()` emits current and changed table snapshots.
27507
28630
  - `ttl` creates validated expiration durations.
27508
- - `/indexeddb` returns `IndexedDbVaultStore` with `batch()` and `iterate()`.
28631
+ - `/indexeddb` returns `TransactionalVaultStore` with `batch()` and `iterate()`.
27509
28632
  - `createSQLite()` is an opt-in, driver-neutral subpath for Node, Bun, and Deno SQLite drivers.
27510
28633
  - `/indexeddb` also exports `defineMigration()` for schema upgrades.
27511
- - `scheduleExpiredPrune()` removes stale TTL entries on an owned schedule.
28634
+ - `pruneExpired()` removes stale TTL entries on demand.
27512
28635
 
27513
28636
  ## Documentation
27514
28637
 
@@ -27533,9 +28656,10 @@ try {
27533
28656
  | `createLocalStorage()` / `createSessionStorage()` | Web Storage-backed portable stores | Async API | Available only where the corresponding Web API exists |
27534
28657
  | `createIndexedDB()` | Browser transactions and cursor iteration | Async API | Import from `/indexeddb` |
27535
28658
  | `createSQLite()` | Driver-neutral SQLite store | Async API over a synchronous driver | Import from `/sqlite` |
28659
+ | `defineMigration()` | Declarative IndexedDB schema upgrade | Sync | Import from `/indexeddb` |
27536
28660
  | `table()` | Typed record schema | Sync | The key field must be a string or finite number |
27537
28661
  | `ttl` | Valid expiration durations | Sync | Durations must be positive |
27538
- | `scheduleExpiredPrune()` | Periodic TTL cleanup | Sync setup, async work | Pass `disposalSignal` to auto-cancel |
28662
+ | `isExpired()` | Check an expiration timestamp | Sync | Returns `false` when no expiry is set |
27539
28663
 
27540
28664
  ## Package Entry Points
27541
28665
 
@@ -27622,23 +28746,20 @@ if (isExpired(record.expiresAt)) console.log('expired');
27622
28746
 
27623
28747
  ## Factories
27624
28748
 
27625
- All factory options accept `schema`, plus optional `validators`, `logger`, and `onMetrics`. The root entry does not export any factory.
28749
+ All factory options accept `schema` and optional `validators`. The root entry does not export any factory.
27626
28750
 
27627
28751
  ### `createMemory()`
27628
28752
 
27629
28753
  ```ts
27630
- function createMemory(options: {
27631
- name?: string;
27632
- schema: S;
27633
- } & BaseAdapterOptions): VaultStore;
28754
+ function createMemory(options: BaseAdapterOptions): VaultStore;
27634
28755
  ```
27635
28756
 
27636
- Creates an in-memory portable store. A `name` enables same-origin `BroadcastChannel` observation between memory stores when the platform provides it.
28757
+ Creates an in-memory portable store.
27637
28758
 
27638
28759
  | Parameter | Description |
27639
28760
  | --- | --- |
27640
28761
  | `schema` | Tables created by `table()` |
27641
- | `name` | Optional shared memory-store namespace |
28762
+ | `validators` | Optional per-table validators with a `parse(value): T` method |
27642
28763
 
27643
28764
  **Returns:** `VaultStore`.
27644
28765
 
@@ -27654,20 +28775,20 @@ const store = createMemory({ schema: { users: table('id') } });
27654
28775
  ### `createLocalStorage()`
27655
28776
 
27656
28777
  ```ts
27657
- function createLocalStorage(options: {
28778
+ function createLocalStorage(options: BaseAdapterOptions & {
27658
28779
  name: string;
27659
28780
  onQuotaExceeded?: (table: keyof S, error: VaultQuotaError) => 'ignore' | 'throw';
27660
- schema: S;
27661
- } & BaseAdapterOptions): VaultStore;
28781
+ }): VaultStore;
27662
28782
  ```
27663
28783
 
27664
28784
  Creates a namespaced `localStorage` store.
27665
28785
 
27666
28786
  | Parameter | Description |
27667
28787
  | --- | --- |
28788
+ | `schema` | Tables created by `table()` |
28789
+ | `validators` | Optional per-table validators |
27668
28790
  | `name` | Required storage namespace |
27669
28791
  | `onQuotaExceeded` | Handles a Web Storage quota error; returning `'ignore'` drops that write |
27670
- | `schema` | Tables created by `table()` |
27671
28792
 
27672
28793
  **Returns:** `VaultStore`.
27673
28794
 
@@ -27683,11 +28804,10 @@ const store = createLocalStorage({ name: 'app', schema: { settings: table('id')
27683
28804
  ### `createSessionStorage()`
27684
28805
 
27685
28806
  ```ts
27686
- function createSessionStorage(options: {
28807
+ function createSessionStorage(options: BaseAdapterOptions & {
27687
28808
  name: string;
27688
28809
  onQuotaExceeded?: (table: keyof S, error: VaultQuotaError) => 'ignore' | 'throw';
27689
- schema: S;
27690
- } & BaseAdapterOptions): VaultStore;
28810
+ }): VaultStore;
27691
28811
  ```
27692
28812
 
27693
28813
  Creates a namespaced `sessionStorage` store. Its options and return type match `createLocalStorage()`.
@@ -27706,24 +28826,24 @@ const store = createSessionStorage({ name: 'checkout', schema: { cart: table('id
27706
28826
  ### `createIndexedDB()`
27707
28827
 
27708
28828
  ```ts
27709
- function createIndexedDB(options: {
28829
+ function createIndexedDB(options: BaseAdapterOptions & {
27710
28830
  migrate?: MigrationFn;
27711
28831
  name: string;
27712
- schema: S;
27713
28832
  version?: number;
27714
- } & BaseAdapterOptions): IndexedDbVaultStore;
28833
+ }): TransactionalVaultStore;
27715
28834
  ```
27716
28835
 
27717
28836
  Creates an IndexedDB store with atomic batches, lazy cursor iteration, and optional schema migrations.
27718
28837
 
27719
28838
  | Parameter | Description |
27720
28839
  | --- | --- |
27721
- | `name` | Required database name |
27722
28840
  | `schema` | Tables and IndexedDB secondary indexes |
28841
+ | `validators` | Optional per-table validators |
28842
+ | `name` | Required database name |
27723
28843
  | `version` | Positive schema version; defaults to `1` |
27724
28844
  | `migrate` | Synchronous upgrade callback for version changes |
27725
28845
 
27726
- **Returns:** `IndexedDbVaultStore`.
28846
+ **Returns:** `TransactionalVaultStore`.
27727
28847
 
27728
28848
  ```ts
27729
28849
  import { table } from '@vielzeug/vault';
@@ -27737,19 +28857,20 @@ const store = createIndexedDB({ name: 'app', schema: { users: table('id') } });
27737
28857
  ### `createSQLite()`
27738
28858
 
27739
28859
  ```ts
27740
- function createSQLite(options: SQLiteVaultOptions): SQLiteVaultStore;
28860
+ function createSQLite(options: SQLiteVaultOptions): TransactionalVaultStore;
27741
28861
  ```
27742
28862
 
27743
28863
  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
28864
 
27745
28865
  | Parameter | Description |
27746
28866
  | --- | --- |
28867
+ | `schema` | Tables created by `table()` |
28868
+ | `validators` | Optional per-table validators |
27747
28869
  | `database` | Caller-provided `SQLiteDatabase` connection |
27748
28870
  | `name` | Namespace within the connection |
27749
- | `schema`, `validators`, `logger`, `onMetrics` | Shared factory options |
27750
28871
  | `closeOnDispose` | Closes the connection during disposal; defaults to `false` |
27751
28872
 
27752
- **Returns:** `SQLiteVaultStore`.
28873
+ **Returns:** `TransactionalVaultStore`.
27753
28874
 
27754
28875
  ```ts
27755
28876
  import { DatabaseSync } from 'node:sqlite';
@@ -27776,11 +28897,9 @@ interface VaultStore {
27776
28897
  count(table: K): Promise;
27777
28898
  delete(table: K, key: KeyOf): Promise;
27778
28899
  deleteMany(table: K, keys: KeyOf[]): Promise;
27779
- entries(table: K): Promise, RecordOf]>>;
27780
28900
  get(table: K, key: KeyOf): Promise | undefined>;
27781
28901
  getAll(table: K): Promise[]>;
27782
28902
  getMany(table: K, keys: KeyOf[]): Promise | undefined>>;
27783
- getOrDefault(table: K, key: KeyOf, defaultFn: () => RecordOf, ttl?: number): Promise>;
27784
28903
  has(table: K, key: KeyOf): Promise;
27785
28904
  isEmpty(table: K): Promise;
27786
28905
  keys(table: K, filter?: (record: RecordOf) => boolean): Promise[]>;
@@ -27790,7 +28909,6 @@ interface VaultStore {
27790
28909
  update(table: K, key: KeyOf, changes: Partial>, ttl?: number): Promise | undefined>;
27791
28910
  upsert(table: K, key: KeyOf, fn: (existing: RecordOf | undefined) => RecordOf, ttl?: number): Promise>;
27792
28911
  pruneExpired(): Promise>;
27793
- debug(): Promise>;
27794
28912
  observe(table: K, listener: Observer>, options?: { immediate?: boolean; signal?: AbortSignal }): Unsubscribe;
27795
28913
  dispose(): Promise;
27796
28914
  readonly disposed: boolean;
@@ -27803,7 +28921,7 @@ The portable store API is returned by every factory. `observe()` emits the curre
27803
28921
 
27804
28922
  ---
27805
28923
 
27806
- ### `batch()`
28924
+ ### `batch()` and `iterate()`
27807
28925
 
27808
28926
  ```ts
27809
28927
  interface TransactionalVaultStore extends VaultStore {
@@ -27811,10 +28929,11 @@ interface TransactionalVaultStore extends VaultStore {
27811
28929
  tables: readonly K[],
27812
28930
  fn: (tx: TransactionContext) => Promise,
27813
28931
  ): Promise;
28932
+ iterate(table: K): AsyncIterable>;
27814
28933
  }
27815
28934
  ```
27816
28935
 
27817
- Runs a scoped atomic callback. `IndexedDbVaultStore` and `SQLiteVaultStore` provide it.
28936
+ `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
28937
 
27819
28938
  | Parameter | Description |
27820
28939
  | --- | --- |
@@ -27827,43 +28946,24 @@ Runs a scoped atomic callback. `IndexedDbVaultStore` and `SQLiteVaultStore` prov
27827
28946
  await store.batch(['users'], async (tx) => {
27828
28947
  await tx.put('users', { id: 1, name: 'Ada' });
27829
28948
  });
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
28949
 
27844
- **Returns:** An `AsyncIterable` of records.
27845
-
27846
- ```ts
27847
28950
  for await (const user of store.iterate('users')) console.log(user);
27848
28951
  ```
27849
28952
 
27850
- ## Queries, Pruning, and Migrations
28953
+ ## Queries and Migrations
27851
28954
 
27852
28955
  ### `QueryBuilder`
27853
28956
 
27854
28957
  ```ts
27855
28958
  interface QueryBuilder {
27856
- between(field: string, lower: number | string, upper: number | string): QueryBuilder;
27857
28959
  count(): Promise;
27858
28960
  delete(): Promise;
27859
- equals(field: K, value: V): QueryBuilder>;
27860
- exists(): Promise;
27861
- filter(fn: (value: N, index: number, array: N[]) => boolean): QueryBuilder;
28961
+ equals(field: K, value: V): QueryBuilder;
28962
+ filter(fn: (value: T, index: number, array: T[]) => boolean): QueryBuilder;
27862
28963
  first(): Promise;
27863
28964
  limit(n: number): QueryBuilder;
27864
28965
  offset(n: number): QueryBuilder;
27865
28966
  orderBy(field: K, direction?: 'asc' | 'desc'): QueryBuilder;
27866
- startsWith(field: keyof T, prefix: string, options?: { ignoreCase?: boolean }): QueryBuilder;
27867
28967
  toArray(): Promise;
27868
28968
  }
27869
28969
  ```
@@ -27871,36 +28971,7 @@ interface QueryBuilder {
27871
28971
  Builds a lazy table query. `count()` ignores `limit()`, `offset()`, and `orderBy()` — it always returns the full filtered-set size.
27872
28972
 
27873
28973
  ```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();
28974
+ const page = await store.query('users').equals('role', 'admin').orderBy('name').limit(20).toArray();
27904
28975
  ```
27905
28976
 
27906
28977
  ---
@@ -27942,16 +29013,10 @@ type KeyOf =
27942
29013
 
27943
29014
  ```ts
27944
29015
  type BaseAdapterOptions = {
27945
- logger?: VaultLogger;
27946
- onMetrics?: (event: MetricsEvent) => void;
27947
29016
  schema: S;
27948
29017
  validators?: TableValidators;
27949
29018
  };
27950
29019
 
27951
- type VaultLogger = {
27952
- error(message: string, context?: Error | Record): void;
27953
- };
27954
-
27955
29020
  type RecordValidator = {
27956
29021
  parse(value: unknown): T;
27957
29022
  };
@@ -27959,23 +29024,9 @@ type RecordValidator = {
27959
29024
  type TableValidators = {
27960
29025
  [K in keyof S]?: RecordValidator>;
27961
29026
  };
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
29027
  ```
27974
29028
 
27975
29029
  ```ts
27976
- interface IndexedDbVaultStore
27977
- extends TransactionalVaultStore, IterableVaultStore {}
27978
-
27979
29030
  type MigrationContext = {
27980
29031
  db: IDBDatabase;
27981
29032
  newVersion: number | null;
@@ -28015,20 +29066,57 @@ type SQLiteVaultOptions = BaseAdapterOptions & {
28015
29066
  database: SQLiteDatabase;
28016
29067
  name: string;
28017
29068
  };
29069
+ ```
29070
+
29071
+ ```ts
29072
+ interface TransactionalVaultStore extends VaultStore {
29073
+ batch(
29074
+ tables: readonly K[],
29075
+ fn: (tx: TransactionContext) => Promise,
29076
+ ): Promise;
29077
+ iterate(table: K): AsyncIterable>;
29078
+ }
29079
+ ```
29080
+
29081
+ Import `TransactionalVaultStore` from `@vielzeug/vault`.
28018
29082
 
28019
- interface SQLiteVaultStore
28020
- extends TransactionalVaultStore, IterableVaultStore {}
29083
+ ```ts
29084
+ interface TransactionContext {
29085
+ clear(table: T): Promise;
29086
+ count(table: T): Promise;
29087
+ delete(table: T, key: KeyOf): Promise;
29088
+ deleteMany(table: T, keys: KeyOf[]): Promise;
29089
+ get(table: T, key: KeyOf): Promise | undefined>;
29090
+ getAll(table: T): Promise[]>;
29091
+ getMany(table: T, keys: KeyOf[]): Promise | undefined>>;
29092
+ has(table: T, key: KeyOf): Promise;
29093
+ isEmpty(table: T): Promise;
29094
+ keys(table: T, filter?: (record: RecordOf) => boolean): Promise[]>;
29095
+ put(table: T, value: RecordOf, ttl?: number): Promise;
29096
+ putAll(table: T, values: RecordOf[], ttl?: number): Promise;
29097
+ query(table: T): QueryBuilder>;
29098
+ update(table: T, key: KeyOf, changes: Partial>, ttl?: number): Promise | undefined>;
29099
+ upsert(table: T, key: KeyOf, fn: (existing: RecordOf | undefined) => RecordOf, ttl?: number): Promise>;
29100
+ }
28021
29101
  ```
28022
29102
 
28023
29103
  `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
29104
 
29105
+ ```ts
29106
+ // Adapter-specific type aliases — both resolve to TransactionalVaultStore.
29107
+ type SQLiteVaultStore = TransactionalVaultStore;
29108
+ type IndexedDbVaultStore = TransactionalVaultStore;
29109
+ ```
29110
+
29111
+ `SQLiteVaultStore` is exported from `@vielzeug/vault/sqlite`. `IndexedDbVaultStore` is exported from `@vielzeug/vault/indexeddb`.
29112
+
28025
29113
  ## Errors
28026
29114
 
28027
29115
  | Error | Trigger |
28028
29116
  | --- | --- |
28029
29117
  | `VaultError` | Any Vault-originated validation, serialization, storage, or query error |
28030
29118
  | `VaultDisposedError` | An operation after the store or observer hub is disposed |
28031
- | `VaultScopeError` | An IndexedDB transaction accesses a table outside its declared batch scope |
29119
+ | `VaultScopeError` | A `batch()` callback accesses a table outside its declared scope |
28032
29120
  | `VaultQuotaError` | A LocalStorage or SessionStorage write exceeds the browser quota |
28033
29121
  | `VaultMigrationError` | An IndexedDB migration callback throws |
28034
29122
 
@@ -28095,29 +29183,27 @@ console.log(updated);
28095
29183
  Build a query from a table, then finish it with a terminal method. `count()` ignores pagination, which makes it suitable for page controls.
28096
29184
 
28097
29185
  ```ts
28098
- const query = store.query('preferences').startsWith('id', 'theme');
29186
+ const query = store.query('preferences').filter((p) => p.id.startsWith('theme'));
28099
29187
  const preferences = await query.orderBy('id').limit(10).toArray();
28100
29188
  const total = await query.count();
28101
29189
 
28102
29190
  console.log({ preferences, total });
28103
29191
  ```
28104
29192
 
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.
29193
+ 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
29194
 
28107
29195
  ## Use TTL and Pruning
28108
29196
 
28109
- Use `ttl.*` helpers for expiring rows. Schedule pruning when stale rows can accumulate without reads.
29197
+ Use `ttl.*` helpers for expiring rows. Call `pruneExpired()` to reclaim storage from stale rows that accumulate without reads.
28110
29198
 
28111
29199
  ```ts
28112
- import { scheduleExpiredPrune, ttl } from '@vielzeug/vault';
29200
+ import { ttl } from '@vielzeug/vault';
28113
29201
 
28114
29202
  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
29203
 
28120
- stopPrune();
29204
+ // Reclaim expired rows on a schedule owned by the application.
29205
+ const pruneInterval = setInterval(() => store.pruneExpired(), ttl.hours(6));
29206
+ store.disposalSignal.addEventListener('abort', () => clearInterval(pruneInterval));
28121
29207
  ```
28122
29208
 
28123
29209
  ## Observe a Table
@@ -28245,7 +29331,7 @@ Use Forge’s Vault helpers for explicit form-draft persistence. Keep Ripple sig
28245
29331
  - Use IndexedDB or SQLite for atomic work.
28246
29332
  - Keep external asynchronous work outside `batch()` callbacks.
28247
29333
  - Use `ttl.*` instead of raw durations.
28248
- - Keep SQLite scans and writes off latency-sensitive event loops, and dispose stores with their owner.
29334
+ - Keep SQLite scans and writes off latency-sensitive event loops.
28249
29335
  - Dispose stores when their owner ends.
28250
29336
 
28251
29337
  ### Examples
@@ -28263,10 +29349,10 @@ Use Forge’s Vault helpers for explicit form-draft persistence. Keep Ripple sig
28263
29349
 
28264
29350
  - Basic Setup - Initialize Vault (id: `basic-setup`)
28265
29351
  - Bulk Operations (id: `bulk-operations`)
28266
- - Cache-First with getOrDefault (id: `cache-first`)
29352
+ - Cache-First with get + put (id: `cache-first`)
28267
29353
  - CRUD Operations (id: `crud-operations`)
28268
29354
  - IndexedDB — Atomic Batch & iterate() (id: `indexed-db`)
28269
- - TTL — scheduleExpiredPrune with disposalSignal (id: `prune-schedule`)
29355
+ - TTL — pruneExpired with disposalSignal (id: `prune-schedule`)
28270
29356
  - Query Builder — Filters, Pagination, count (id: `query-builder`)
28271
29357
  - Reactive — observe() (id: `reactive-observe`)
28272
29358
  - TTL & Expiration (id: `ttl-expiration`)
@@ -28352,6 +29438,7 @@ else console.log(decision.reason);
28352
29438
  - `WILDCARD` and `ANONYMOUS` model broad or unauthenticated access explicitly.
28353
29439
  - `owns()` and `predicate` constrain rules with synchronous request data.
28354
29440
  - `explain()`, `trace()`, and `detectConflicts()` make policy decisions diagnosable.
29441
+ - `tap()` subscribes to decision events for logging and diagnostics.
28355
29442
  - `forUser()` creates a principal-bound view for repeated checks.
28356
29443
  - `checkAll()` evaluates multiple resource/action pairs in one call.
28357
29444
 
@@ -28377,10 +29464,10 @@ else console.log(decision.reason);
28377
29464
  | `createWard` | Creates immutable policy | Sync | Rules cannot be mutated after creation |
28378
29465
  | `allow` / `deny` / `ruleFor` | Builds policy rules | Sync | Priority wins before specificity |
28379
29466
  | `Ward.explain` | Returns one decision | Sync | Pass resource data for predicate rules |
28380
- | `Ward.trace` | Inspects decision candidates | Sync | Does not invoke the logger |
29467
+ | `Ward.trace` | Inspects decision candidates | Sync | Does not fire a `decision` event |
28381
29468
  | `Ward.forUser` | Binds a principal | Sync | Rebind when identity or roles change |
28382
29469
  | `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 |
29470
+ | `Ward.allowedActions` | Filters known actions to allowed set | Sync | Does not fire a `decision` event |
28384
29471
  | `Ward.rulesInScope` | Lists rules matching a principal/resource | Sync | Pass data to evaluate predicates |
28385
29472
  | `Ward.detectConflicts` | Detects duplicate/shadowed rules | Sync | O(n²) — use `maxConflicts` for large policies |
28386
29473
  | `predicate.owns` / `owns` | Ownership predicate on resource data | Sync | Skipped for anonymous principals |
@@ -28392,7 +29479,6 @@ else console.log(decision.reason);
28392
29479
  | Import | Purpose |
28393
29480
  | --- | --- |
28394
29481
  | `@vielzeug/ward` | Rules, factory, predicates, pattern helpers, errors, and public types |
28395
- | `@vielzeug/ward/devtools` | `debugWard()` diagnostic factory |
28396
29482
 
28397
29483
  ## Core Factory
28398
29484
 
@@ -28405,14 +29491,13 @@ createWard(
28405
29491
  ): Ward;
28406
29492
  ```
28407
29493
 
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`.
29494
+ 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
29495
 
28410
29496
  **Parameters:**
28411
29497
 
28412
29498
  | Name | Type | Description |
28413
29499
  | --- | --- | --- |
28414
29500
  | `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
29501
  | `options.onConflict` | `(conflict: WardConflict) => void` | Called synchronously per conflict at creation time. |
28417
29502
  | `options.strict` | `boolean` | Throws `WardConfigError` on the first conflict. |
28418
29503
  | `options.maxConflicts` | `number` | Caps the number of conflicts returned by `detectConflicts()`. |
@@ -28498,7 +29583,7 @@ checkAll(
28498
29583
  ): WardDecisionResult[];
28499
29584
  ```
28500
29585
 
28501
- Evaluates multiple resource/action pairs for one principal. Invokes the logger for each decision.
29586
+ Evaluates multiple resource/action pairs for one principal. Fires a `decision` event for each result via `tap()`.
28502
29587
 
28503
29588
  **Returns:** `WardDecisionResult[]` — each entry carries `action`, `resource`, and the decision.
28504
29589
 
@@ -28521,7 +29606,7 @@ explain(input: WardDecisionInput): WardDecision;
28521
29606
  }
28522
29607
  ```
28523
29608
 
28524
- Returns one decision. Invokes the logger.
29609
+ Returns one decision. Fires a `decision` event via `tap()`.
28525
29610
 
28526
29611
  **Returns:** `WardDecision` — `{ allowed: true; rule }` or `{ allowed: false; reason: 'explicit-deny'; rule }` or `{ allowed: false; reason: 'no-matching-rule' }`.
28527
29612
 
@@ -28533,7 +29618,7 @@ Returns one decision. Invokes the logger.
28533
29618
  trace(input: WardDecisionInput): WardTrace;
28534
29619
  ```
28535
29620
 
28536
- Same request shape as `explain()`. Returns winner + candidate list. Does not fire the logger.
29621
+ Same request shape as `explain()`. Returns winner + candidate list. Does not fire a `decision` event.
28537
29622
 
28538
29623
  **Returns:** `WardTrace` — `{ candidates: WardTraceCandidate[]; decision: WardDecision }`.
28539
29624
 
@@ -28556,7 +29641,7 @@ Input shape:
28556
29641
  }
28557
29642
  ```
28558
29643
 
28559
- Filters the provided `knownActions` list to those the principal may perform. Does not invoke the logger.
29644
+ Filters the provided `knownActions` list to those the principal may perform. Does not fire a `decision` event.
28560
29645
 
28561
29646
  **Returns:** `TAction[]` — the subset of `knownActions` that `explain()` would allow.
28562
29647
 
@@ -28712,17 +29797,37 @@ Tests whether the `broad` pattern covers the `narrow` pattern. `'*'` covers ever
28712
29797
 
28713
29798
  ---
28714
29799
 
28715
- ## Devtools
29800
+ ## Observability
29801
+
29802
+ ### `tap(handler, options?)`
29803
+
29804
+ ```ts
29805
+ tap(
29806
+ handler: (event: WardEvent) => void,
29807
+ options?: { signal?: AbortSignal },
29808
+ ): () => void;
29809
+ ```
29810
+
29811
+ Subscribes a handler to ward events. Each `explain()` and `checkAll()` decision fires a `decision` event. `trace()` and `allowedActions()` do not fire events.
29812
+
29813
+ Pass an `AbortSignal` to unsubscribe automatically; the returned function unsubscribes manually.
28716
29814
 
28717
- ### `debugWard(rules, options?)`
29815
+ **Returns:** `() => void` — call to unsubscribe the handler.
28718
29816
 
28719
- Sub-path import: `@vielzeug/ward/devtools`.
29817
+ **Example:**
28720
29818
 
28721
29819
  ```ts
28722
- import { debugWard } from '@vielzeug/ward/devtools';
29820
+ const ward = createWard(rules);
29821
+ ward.tap((event) => console.debug(`ward:${event.type}`, event.decision));
28723
29822
  ```
28724
29823
 
28725
- Diagnostic factory for development inspection.
29824
+ With a logger from `@vielzeug/rune`:
29825
+
29826
+ ```ts
29827
+ import { createLogger } from '@vielzeug/rune';
29828
+ const log = createLogger({ name: 'ward' });
29829
+ ward.tap((event) => log.debug(event, 'ward:decision'));
29830
+ ```
28726
29831
 
28727
29832
  ---
28728
29833
 
@@ -28847,6 +29952,7 @@ export type Ward = {
28847
29952
  explain(input: WardDecisionInput): WardDecision;
28848
29953
  forUser(principal: UserPrincipal): BoundWard;
28849
29954
  rulesInScope(input: WardRulesInScopeInput): ReadonlyArray>>;
29955
+ tap(handler: (event: WardEvent) => void, options?: { signal?: AbortSignal }): () => void;
28850
29956
  trace(input: WardDecisionInput): WardTrace;
28851
29957
  };
28852
29958
 
@@ -28858,7 +29964,9 @@ export type BoundWard = {
28858
29964
  trace(input: BoundWardDecisionInput): WardTrace;
28859
29965
  };
28860
29966
 
28861
- export type WardLoggerContext = WardDecision & {
29967
+ export type WardEvent = {
29968
+ type: 'decision';
29969
+ decision: WardDecision;
28862
29970
  action: TAction;
28863
29971
  data?: TData;
28864
29972
  principal: Principal;
@@ -28866,7 +29974,6 @@ export type WardLoggerContext = WardDecision & {
28866
29974
  };
28867
29975
 
28868
29976
  export type WardOptions = {
28869
- logger?: (context: WardLoggerContext) => void;
28870
29977
  maxConflicts?: number;
28871
29978
  onConflict?: (conflict: WardConflict) => void;
28872
29979
  strict?: boolean;
@@ -28877,12 +29984,12 @@ export type WardOptions = {
28877
29984
 
28878
29985
  `Ward`, `BoundWard`, `WardDecision`, `WardDecisionResult`, `WardTrace`, `WardTraceCandidate`, `WardConflict`,
28879
29986
  `NormalizedWardRule`, `WardOptions`, `WardCheck`, `WardAllowedActionsInput`, `WardRulesInScopeInput`, `RuleContext`,
28880
- `WardLoggerContext`, `WardPredicate`, and `ConflictKind` are exported from the root entry point.
29987
+ `WardEvent`, `WardPredicate`, and `ConflictKind` are exported from the root entry point.
28881
29988
 
28882
29989
  ## Errors
28883
29990
 
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.
29991
+ - `WardError` is the base error class; use `instanceof WardError` for narrowing.
29992
+ - `WardConfigError` reports malformed rules, invalid `createWard` options (`onConflict`, `maxConflicts`), invalid principals, and strict conflict initialization.
28886
29993
  - `WardPredicateError` reports a throwing synchronous predicate and includes its `ruleIndex` and cause.
28887
29994
 
28888
29995
  ### Usage Guide
@@ -28952,7 +30059,7 @@ const actions = ward.allowedActions({
28952
30059
  });
28953
30060
  ```
28954
30061
 
28955
- It does not fire the logger.
30062
+ It does not fire a `decision` event.
28956
30063
 
28957
30064
  ## Rule Introspection
28958
30065
 
@@ -28979,7 +30086,34 @@ trace.candidates.forEach((c) => {
28979
30086
  });
28980
30087
  ```
28981
30088
 
28982
- `trace()` does not fire the logger.
30089
+ `trace()` does not fire a `decision` event.
30090
+
30091
+ ## Observing Decisions
30092
+
30093
+ `tap()` subscribes a handler to ward events. Each `explain()` and `checkAll()` decision fires a `decision` event; `trace()` and `allowedActions()` do not.
30094
+
30095
+ ```ts
30096
+ const ward = createWard(rules);
30097
+ ward.tap((event) => console.debug(`ward:${event.type}`, event.decision));
30098
+ ```
30099
+
30100
+ Pass an `AbortSignal` to unsubscribe automatically, or call the returned function to unsubscribe manually:
30101
+
30102
+ ```ts
30103
+ const controller = new AbortController();
30104
+ const unsubscribe = ward.tap((event) => console.debug(event), { signal: controller.signal });
30105
+
30106
+ // later
30107
+ unsubscribe(); // or controller.abort();
30108
+ ```
30109
+
30110
+ For structured logging, forward events to a `@vielzeug/rune` logger:
30111
+
30112
+ ```ts
30113
+ import { createLogger } from '@vielzeug/rune';
30114
+ const log = createLogger({ name: 'ward' });
30115
+ ward.tap((event) => log.debug(event, 'ward:decision'));
30116
+ ```
28983
30117
 
28984
30118
  ## Predicate Helpers
28985
30119
 
@@ -29079,7 +30213,7 @@ container.register('ward', ward);
29079
30213
  - [Priority and Overrides](./examples/inheritance-and-overrides.md)
29080
30214
  - [Bound Guard in UI Layer](./examples/bound-guard-in-ui-layer.md)
29081
30215
  - [Rule Specificity](./examples/disabling-wildcard-fallback.md)
29082
- - [Logger for Auditing](./examples/logger-for-auditing.md)
30216
+ - [Auditing Decisions](./examples/logger-for-auditing.md)
29083
30217
  - [Fresh Ward Per Test](./examples/snapshot-restore-for-test-isolation.md)
29084
30218
  - [Conflict Detection](./examples/conflict-detection.md)
29085
30219
  - [Trace a Decision](./examples/trace-decision.md)
@@ -29213,7 +30347,7 @@ router.dispose();
29213
30347
  - `match()` / `load()` — Inspect routes synchronously or load route data without navigation.
29214
30348
  - `preload()` — Warms route data for a later matching navigation.
29215
30349
  - `createMemoryHistory()` — Runs routers in tests and non-browser environments.
29216
- - `debugRouter()` — Logs navigation state from `@vielzeug/wayfinder/devtools`.
30350
+ - `subscribe()` — Reactive subscription to navigation state changes.
29217
30351
 
29218
30352
  ## Documentation
29219
30353
 
@@ -29253,10 +30387,9 @@ router.dispose();
29253
30387
 
29254
30388
  ## Package Entry Points
29255
30389
 
29256
- | Import | Purpose |
29257
- | ------------------------------ | -------------------------------------------- |
29258
- | `@vielzeug/wayfinder` | Main exports and types |
29259
- | `@vielzeug/wayfinder/devtools` | `debugRouter` — navigation logger (dev only) |
30390
+ | Import | Purpose |
30391
+ | --------------------- | ---------------------- |
30392
+ | `@vielzeug/wayfinder` | Main exports and types |
29260
30393
 
29261
30394
  ## `createRouter(options)`
29262
30395
 
@@ -30062,44 +31195,6 @@ Thrown on middleware misuse — currently only when a middleware function calls
30062
31195
  | `/files/:rest*` | `/files/a/b/c` | Wildcard suffix captured as one named param |
30063
31196
  | `*` | anything | Global catch-all |
30064
31197
 
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
31198
  ## Design Notes
30104
31199
 
30105
31200
  - Wayfinder no longer exposes imperative registration methods like `on()`, `group()`, or `use()`.
@@ -30741,43 +31836,50 @@ export function useRouter() {
30741
31836
 
30742
31837
  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
31838
 
30744
- ## Debug Mode
31839
+ ## Debug Logging
30745
31840
 
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.
31841
+ `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
31842
 
30748
31843
  ```ts
30749
- import { debugRouter } from '@vielzeug/wayfinder/devtools';
31844
+ import { createRouter } from '@vielzeug/wayfinder';
30750
31845
 
30751
- const router = debugRouter({
30752
- routes: {
30753
- home: { path: '/' },
30754
- dashboard: { path: '/dashboard', data: () => fetchDashboard() },
30755
- },
31846
+ const router = createRouter({ routes });
31847
+ const stop = router.subscribe((state) => {
31848
+ console.debug(`[wayfinder] ${state.status} ${state.location.pathname}`);
30756
31849
  });
30757
31850
 
30758
31851
  // Logged once the initial navigation completes:
30759
- // [wayfinder:nav] idle / [home]
31852
+ // [wayfinder] idle /
30760
31853
 
30761
31854
  // On navigate({ name: 'dashboard' }):
30762
- // [wayfinder:nav] loading /dashboard
30763
- // [wayfinder:nav] idle /dashboard [dashboard]
31855
+ // [wayfinder] loading /dashboard
31856
+ // [wayfinder] idle /dashboard
30764
31857
  ```
30765
31858
 
30766
- The router returned is identical to `createRouter()`all methods (`navigate`, `subscribe`, `waitFor`, etc.) work the same way.
31859
+ The returned function unsubscribes the listenercall it when the logger is no longer needed (e.g. on teardown):
30767
31860
 
30768
- Errors are logged with the error object appended:
31861
+ ```ts
31862
+ stop();
31863
+ ```
31864
+
31865
+ Errors are surfaced on the state object, so you can log them explicitly:
30769
31866
 
30770
31867
  ```ts
30771
- // [wayfinder:nav] error /dashboard [dashboard] Error: fetch failed
31868
+ router.subscribe((state) => {
31869
+ if (state.status === 'error') {
31870
+ console.error(`[wayfinder] ${state.location.pathname}`, state.error);
31871
+ }
31872
+ });
30772
31873
  ```
30773
31874
 
30774
- Use the `label` option when running multiple routers to distinguish their log output:
31875
+ Use a label when running multiple routers to distinguish their log output:
30775
31876
 
30776
31877
  ```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
31878
+ const main = createRouter({ routes });
31879
+ main.subscribe((state) => console.debug(`[wayfinder:main] ${state.status} ${state.location.pathname}`));
31880
+
31881
+ const modal = createRouter({ routes: modalRoutes });
31882
+ modal.subscribe((state) => console.debug(`[wayfinder:modal] ${state.status} ${state.location.pathname}`));
30781
31883
  ```
30782
31884
 
30783
31885
  Debug logging has no effect on behavior and should not be enabled in production.
@@ -30861,7 +31963,7 @@ router.subscribe((state) => {
30861
31963
  ### REPL Examples
30862
31964
 
30863
31965
  - Basic Routing — Route State and Navigation (id: `basic-routing`)
30864
- - Debug Router — Navigation Logging (id: `debug-router`)
31966
+ - Navigation Logging (id: `debug-router`)
30865
31967
  - Guards and Redirects — Auth Flows (id: `middleware-auth`)
30866
31968
  - Middleware Chain — Execution Flow (id: `middleware-chain`)
30867
31969
  - Named Routes — Type-Safe Navigation (id: `named-routes`)