stitchkit 0.89.0 → 0.90.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.
package/llms-full.txt CHANGED
@@ -48,7 +48,7 @@ own, recorded as an ADR.
48
48
  |--------|--------|----------|-------|
49
49
  | `stitchkit` | browser **and** server | stable | `defineContract`, `createClient`, `createHttpClient`, `createSocketIOClient`, `parseSSE`, the error model |
50
50
  | `stitchkit/contract` | browser **and** server | stable | the contract layer alone — `defineContract`, errors, pagination |
51
- | `stitchkit/live` | browser **and** server | evolving | `defineEvents` — topic declarations beside the operation contract, projected onto the realtime contract |
51
+ | `stitchkit/live` | browser **and** server | evolving | `defineEvents` — topic declarations beside the operation contract and watched reads: `createWatchClient`, the value/difference frame and its reassembly |
52
52
  | `stitchkit/primitives` | browser **and** server | evolving | generic values and declarations for lifecycle, access, audit, delivery and exports |
53
53
  | `stitchkit/server` | server (Bun) | stable | `createServer`, `implement`, hooks, auth, Socket.IO server, server primitives |
54
54
  | `stitchkit/node` | server (Node ≥ 22) | stable | `serveNode` + the runtime-agnostic core — the Node mirror of `/server` |
@@ -64,11 +64,11 @@ own, recorded as an ADR.
64
64
  | `stitchkit/tracking/server` | server (Bun or Node) | evolving | the decisions a tracking backend makes — dispositions, visit lease over an application-owned store, active intervals, presence; no database |
65
65
  | `stitchkit/release` | browser **and** server | evolving | a page follows the release it was built for — `createReleaseMarker` on the server, `createReleaseWatcher` in the browser, the `X-Build-Id` header and a socket event between them |
66
66
  | `stitchkit/geo` | server (Bun or Node) | evolving | managed GeoIP reader generations, last-known-good reload and the optional MaxMind adapter |
67
- | `stitchkit/observability` | server | stable<br>_redefined in 1 of the 34 minors since 0.56.2, most recently 0.83.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
67
+ | `stitchkit/observability` | server | stable<br>_redefined in 1 of the 35 minors since 0.56.2, most recently 0.83.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
68
68
  | `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the store and managed-resource conformance kits |
69
69
  | `stitchkit/declaration` | browser + build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
70
70
  | `stitchkit/react` | browser + server rendering | stable | `createCursorQuery`, `createCacheBridge`, QueryClient and `ApiError` retry policy |
71
- | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 20 of the 34 minors since 0.56.2, most recently 0.89.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
71
+ | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 20 of the 35 minors since 0.56.2, most recently 0.89.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
72
72
  | `stitchkit/agent-runtime/sandbox` | server | evolving | optional Linux Bubblewrap sessions, durable workspaces and a host HTTP credential gateway; see [sandbox guide](sandbox.md) |
73
73
  | `stitchkit/agent-runtime/testing` | tests on Bun or Node | evolving | credential-free replay, scripted provider faults and deterministic race controls |
74
74
  | `stitchkit/agent-runtime/harness` | server | evolving | resource-aware process-local facade over the canonical Agent runtime; supervision stays outside |
@@ -78,7 +78,7 @@ own, recorded as an ADR.
78
78
  | `stitchkit/agent-runtime/sqlite/bun` | server (Bun) | evolving | durable built-in SQLite store for the agent runtime |
79
79
  | `stitchkit/agent-runtime/sqlite/node` | server (Node ≥ 22.5) | evolving | durable built-in SQLite store for the agent runtime |
80
80
  | `stitchkit-tui` | terminal (Bun) | evolving | optional official OpenTUI host over a caller-composed headless runtime |
81
- | `stitchkit/application` | browser + server | evolving<br>_redefined in 7 of the 34 minors since 0.56.2, most recently 0.83.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
81
+ | `stitchkit/application` | browser + server | evolving<br>_redefined in 7 of the 35 minors since 0.56.2, most recently 0.83.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
82
82
  | `stitchkit/application/grammy` | server | evolving | isolated grammY polling and webhook lifecycle adapters |
83
83
  | `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
84
84
  | `stitchkit/application/schemas` | browser + server | evolving | the application's snapshot, health and shutdown schemas alone, without the kernel |
@@ -8810,13 +8810,49 @@ Nothing an open can fail with escapes as a rejected promise: a disconnected
8810
8810
  socket, a timeout, a refusal all arrive as `unavailable` carrying the error's own
8811
8811
  code and message, and the next connection retries.
8812
8812
 
8813
+ The re-open carries what the client still holds, so a reconnection is usually
8814
+ cheap: the hub answers with a difference, or with nothing at all when the answer
8815
+ has not moved. That needs the key to still exist on the server — set `holdMs`
8816
+ past your reconnect delay, or the last detach releases the source and the page
8817
+ pays the whole value once.
8818
+
8819
+ ### Large answers cross as differences
8820
+
8821
+ A frame carries the value, a **difference** to a revision this subscriber already
8822
+ holds, or `unchanged`. The hub chooses per subscriber and sends a difference only
8823
+ when it is genuinely smaller; your `value` listener sees the rebuilt value either
8824
+ way and needs no code for this.
8825
+
8826
+ It matters when an answer is large and moves a little: a ~75 KB list in which two
8827
+ timestamps change, republished every fifteen seconds, was a megabyte per
8828
+ subscriber per minute before and is a frame under a kilobyte now.
8829
+
8830
+ ```ts
8831
+ createWatchHub({
8832
+ // Superseded values kept per key, so a difference has something to be taken
8833
+ // against. Default 262144. Set 0 to send whole values only.
8834
+ deltaMemoryBytes: 256 * 1024,
8835
+ holdMs: 30_000,
8836
+ // …
8837
+ });
8838
+ ```
8839
+
8840
+ Reassembly is checked against the server's fingerprint on every frame. If a
8841
+ difference will not apply — a client that missed a revision, a hub that restarted
8842
+ — that **one key** resynchronises: your `state` listener sees `resync-required`
8843
+ and the whole value follows. Other keys on the same socket are untouched.
8844
+
8845
+ Both ends must come from the same major release: a client older than 0.90 reads a
8846
+ difference frame as a value of `undefined`.
8847
+
8813
8848
  ### `watch` or `createLiveStateController`?
8814
8849
 
8815
8850
  > **If you would have written `applyEvent` as `(_, next) => next`, you want
8816
- > `watch`: the server sends the value whole.** `createLiveStateController` is for
8817
- > a server that sends *deltas* you have to fold. `watch` is that controller with
8818
- > the fold fixed to replacement, plus the key sharing and the retention so
8819
- > applying both to one value is always a mistake.
8851
+ > `watch`: whatever crosses the wire, what your `value` listener receives is the
8852
+ > whole answer.** `createLiveStateController` is for a server whose *protocol* is
8853
+ > deltas that your code folds. `watch` may send a difference too, but it folds it
8854
+ > for you and hands you the value — so applying both to one value is always a
8855
+ > mistake.
8820
8856
 
8821
8857
  ### What it cannot promise
8822
8858
 
@@ -9746,9 +9782,10 @@ stitchkit answers this at two levels.
9746
9782
  sanitisation and `createObservability` with independent request/tool sinks.
9747
9783
  [Start here ↓](#the-observability-module)
9748
9784
 
9749
- stitchkit still ships no logger and no audit store those are the app's choice.
9750
- What it ships is the machinery that turns a completed call into a clean,
9751
- normalised record.
9785
+ stitchkit still ships no logger and no audit store, and auditing stays opt-in
9786
+ those are the app's choice (→ ADR 0012, reaffirmed in ADR 0184). What it ships is
9787
+ the machinery that turns a completed call into a clean, normalised record, keeps
9788
+ the ones worth keeping, and does not lose them when the store is down.
9752
9789
 
9753
9790
  ## The observability module
9754
9791
 
@@ -9985,6 +10022,69 @@ queryable across all three:
9985
10022
  | `resultSize` / `responseBytes` | result item count + serialised size |
9986
10023
  | `userId` / `ipAddress` / `userAgent` | identity |
9987
10024
 
10025
+ ### Write the calls that changed something
10026
+
10027
+ `auditChanges` is the filter most projects end up writing, shipped so they do not
10028
+ have to write it six ways:
10029
+
10030
+ ```ts
10031
+ import { auditChanges, createObservability } from 'stitchkit/observability';
10032
+
10033
+ createObservability({
10034
+ request: { write: saveAuditRow, filter: auditChanges },
10035
+ tools: { write: saveAuditRow, filter: auditChanges },
10036
+ });
10037
+ ```
10038
+
10039
+ It drops `GET`, `HEAD` and `OPTIONS`, keeps everything else, and keeps `401` and
10040
+ `403` **whatever the verb was** — a refused read is the row an audit exists to
10041
+ hold, and a filter that drops every `GET` drops exactly that. An unrecognised
10042
+ verb is kept: an extra row costs bytes, a missing one costs the answer to "who
10043
+ changed this", silently and only later.
10044
+
10045
+ One filter across HTTP, MCP and agent calls, because a tool call carries its
10046
+ contract verb in `httpMethod` while its `method` is the literal `TOOL`.
10047
+
10048
+ Narrower policy stays yours — compose it:
10049
+
10050
+ ```ts
10051
+ filter: (event) => auditChanges(event) && event.serviceName !== 'health',
10052
+ ```
10053
+
10054
+ ### Keeping the row when the store is down
10055
+
10056
+ A sink is fire-and-forget, so an unreachable store means the event is simply
10057
+ gone — and a store is most likely to be unreachable during an incident, which is
10058
+ the window whose rows someone will later want most.
10059
+
10060
+ `createSpooledSink` writes the row to a local append-only file first, offers it
10061
+ to the store second, and marks it delivered third. What a previous process left
10062
+ unmarked is replayed:
10063
+
10064
+ ```ts
10065
+ import { createSpooledSink } from 'stitchkit/observability';
10066
+
10067
+ const audit = createSpooledSink({
10068
+ path: '/var/lib/app/audit.ndjson',
10069
+ write: saveAuditRow,
10070
+ });
10071
+
10072
+ // Once, at startup, before the sink is wired — and await it.
10073
+ const { replayed, failed } = await audit.recover();
10074
+
10075
+ createObservability({ request: { write: audit.write, filter: auditChanges } });
10076
+ ```
10077
+
10078
+ The guarantee is **at least once**. A crash between the store accepting a row and
10079
+ the file recording that it did replays the row, so **the store must be idempotent
10080
+ on the record key** — `event.spanId` by default, unique per call. A unique index
10081
+ on it turns the duplicate into a no-op; without one, replay writes the row twice.
10082
+ Exactly-once would need the file and the database to share a transaction. They do
10083
+ not.
10084
+
10085
+ One process, one path. Two processes pointed at the same file replay each other's
10086
+ records: harmless against an idempotent store, wasteful always.
10087
+
9988
10088
  ### Request context
9989
10089
 
9990
10090
  When request observability is configured, `createHandler` establishes the
@@ -11382,6 +11482,32 @@ analytics policy belong to the application.
11382
11482
 
11383
11483
  # Upgrading stitchkit
11384
11484
 
11485
+ ## Released migration: 0.90.0
11486
+
11487
+ 1. `stitchkit.watch.value` is a discriminated union on `kind` — `full`, `delta`,
11488
+ `unchanged` — and every frame carries `fingerprint`. **Both ends must come from
11489
+ the same major.** A client older than this release reads a `delta` frame as a
11490
+ value of `undefined`, silently; upgrade the hub and the pages that talk to it
11491
+ together, or set `deltaMemoryBytes: 0` on the hub until they are.
11492
+
11493
+ Applications using `createWatchClient` need no code change: it rebuilds the
11494
+ value and hands the listener the whole answer as before. Code that reads
11495
+ `WatchValueFrame.value` directly — a hand-written subscriber, a test double —
11496
+ narrows on `kind` first.
11497
+
11498
+ ```ts
11499
+ // before: frame.value
11500
+ // after:
11501
+ if (frame.kind === 'full') hold(frame.value);
11502
+ else if (frame.kind === 'delta') hold(applyWatchDelta(held, frame.delta));
11503
+ // 'unchanged' leaves what you hold standing
11504
+ ```
11505
+
11506
+ 2. `AttachedWatcher.open` takes an optional third argument, `have`. A custom
11507
+ server binding that forwards `stitchkit.watch.open` should pass the payload's
11508
+ `have` through; omitting it costs a whole value on every reconnection and is
11509
+ otherwise harmless.
11510
+
11385
11511
  ## Released migration: 0.89.0
11386
11512
 
11387
11513
  1. Custom `AgentRuntimeStore` adapters implement `seedConversationInput`; custom
@@ -15121,7 +15247,10 @@ realtime contract from `stitchkit`, and the server halves live in `stitchkit/app
15121
15247
  | `watchContract` | const | the four-event realtime contract a watched read travels on |
15122
15248
  | `WATCH_OPEN` / `WATCH_CLOSE` / `WATCH_VALUE` / `WATCH_STATE` | const | the event names of that contract |
15123
15249
  | `WatchKey` / `WatchKeySchema` / `watchKeyString` | type / schema / function | `(service, action, arguments digest)` — the identity both ends compute the same way |
15124
- | `WatchValueFrame` / `WatchValueSchema` / `WatchStateFrame` / `WatchStateSchema` | _types_ | a value with its monotonic revision, and a phase from `LiveStatePhase` with the read's own code and message |
15250
+ | `WatchValueFrame` / `WatchValueSchema` / `WatchStateFrame` / `WatchStateSchema` | _types_ | one answer — `full`, `delta` or `unchanged`, with its monotonic revision and fingerprint — and a phase from `LiveStatePhase` with the read's own code and message |
15251
+ | `WatchHave` / `WatchHaveSchema` | type / schema | what a reconnecting subscriber already holds, offered on `open`: a revision and the fingerprint that proves it |
15252
+ | `WatchDelta` / `WatchDeltaSchema` / `WatchArrayOp` / `WatchArrayOpSchema` | _types_ / schemas | a structural difference over parsed JSON — objects by changed and dropped key, arrays by runs copied from the previous value |
15253
+ | `watchDiff` / `applyWatchDelta` / `watchDeltaWins` | functions | build a difference (`undefined` when equal), rebuild the value from one (throws rather than inventing), and whether it is actually smaller than the value |
15125
15254
 
15126
15255
  ---
15127
15256
 
@@ -16142,6 +16271,8 @@ audit event. See the [Observability guide](../guide/observability.md).
16142
16271
  | Export | Kind | Summary |
16143
16272
  |--------|------|---------|
16144
16273
  | `createObservability` | function | configure framework-owned request completion and canonical tool event sinks — [guide](../guide/observability.md#createobservability) |
16274
+ | `auditChanges` | function | the sink `filter` most projects write themselves — drops `GET`/`HEAD`/`OPTIONS`, keeps `401`/`403` whatever the verb, keeps an unrecognised verb |
16275
+ | `createSpooledSink` / `SpooledSink` / `SpooledSinkConfig` / `SpoolRecovery` | function / _types_ | spool the row to an append-only file before the store and replay what a previous process left undelivered — at least once, keyed on `spanId` |
16145
16276
  | `createDimensionsProjector` / `DimensionsProjector` / `DimensionsProjectorConfig` / `ProjectedDimensions` | function / _type_ | typed request/result/error attribution projected into the existing request context |
16146
16277
  | `DimensionCollision` / `SetRequestDimensionsOptions` | _type_ | explicit overwrite, preserve or error policy for dimension keys |
16147
16278
  | `createBoundedLogger` | function | decorate a `StitchLogger` with request context, shared sanitisation, redaction and total bounds |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.89.0",
3
+ "version": "0.90.0",
4
4
  "description": "Contract-first backend framework — one defineContract() into an HTTP API, MCP tools, AI-agent tools and a typed client. Bun and Node.",
5
5
  "keywords": [
6
6
  "bun",
@@ -1,48 +0,0 @@
1
- import {
2
- LiveStatePhaseSchema,
3
- LiveStateStopReasonSchema
4
- } from "./index-ywd0y6m1.js";
5
-
6
- // src/live/watch-contract.ts
7
- import { z } from "zod";
8
- var WatchKeySchema = z.object({
9
- service: z.string().min(1),
10
- action: z.string().min(1),
11
- digest: z.string().min(1)
12
- }).strict().readonly();
13
- var WatchOpenSchema = z.object({ key: WatchKeySchema, args: z.unknown() }).readonly();
14
- var WatchAcceptedSchema = z.object({
15
- accepted: z.boolean(),
16
- reason: z.string().optional()
17
- }).strict().readonly();
18
- var WatchValueSchema = z.object({
19
- key: WatchKeySchema,
20
- revision: z.number().int().nonnegative(),
21
- value: z.unknown()
22
- }).readonly();
23
- var WatchStateSchema = z.object({
24
- key: WatchKeySchema,
25
- phase: LiveStatePhaseSchema,
26
- reason: LiveStateStopReasonSchema.optional(),
27
- code: z.string().optional(),
28
- message: z.string().optional()
29
- }).readonly();
30
- var WATCH_OPEN = "stitchkit.watch.open";
31
- var WATCH_CLOSE = "stitchkit.watch.close";
32
- var WATCH_VALUE = "stitchkit.watch.value";
33
- var WATCH_STATE = "stitchkit.watch.state";
34
- var watchContract = {
35
- serverToClient: {
36
- [WATCH_VALUE]: { args: z.tuple([WatchValueSchema]) },
37
- [WATCH_STATE]: { args: z.tuple([WatchStateSchema]) }
38
- },
39
- clientToServer: {
40
- [WATCH_OPEN]: { args: z.tuple([WatchOpenSchema]), ack: WatchAcceptedSchema },
41
- [WATCH_CLOSE]: { args: z.tuple([z.object({ key: WatchKeySchema }).readonly()]) }
42
- }
43
- };
44
- function watchKeyString(key) {
45
- return `${key.service}/${key.action}/${key.digest}`;
46
- }
47
-
48
- export { WatchKeySchema, WatchValueSchema, WatchStateSchema, WATCH_OPEN, WATCH_CLOSE, WATCH_VALUE, WATCH_STATE, watchContract, watchKeyString };