@ultimat3/testing 4.1.0 → 5.0.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/CLAUDE.md CHANGED
@@ -26,7 +26,11 @@ is its own entry point and not part of the barrel.
26
26
  | Injection | `SqlRunner` and `connect` are parameters, so unit tests need no server |
27
27
  | Fixtures | the preload registers the whole framework bag — an app registers only what the framework cannot know (`seed`, `actorFor`) |
28
28
  | e2e without a driver | `e2eTest` becomes `test.skip`, and the gate reports the step GREEN over it — `bun test` exits 0 on a skip and the exit code is the only channel between the step and the child that registers the driver. `hasE2eDriver()` is what a harness asks instead of reading an all-skipped run as a pass. Zero drivers are registered `As of 2026-08` |
29
- | Built vs declared | `clock` `mail` `network` `runJobs` `statements` are built in-process; `page` `budget` `signIn` `deploy` `subscribe` are declared and wait for a driver (`X_TEST_FIXTURE_UNAVAILABLE`) |
29
+ | Built vs declared | `clock` `mail` `network` `runJobs` `statements` `subscribe` are built in-process; `page` `budget` `signIn` `deploy` are declared and wait for a driver (`X_TEST_FIXTURE_UNAVAILABLE`). The four left all need a browser or a second build — things the framework genuinely cannot bundle |
30
+ | `subscribe` is a whole `sync` node | `live-node.ts` assembles what `x dev --role sync` assembles minus the listener — real `LiveQueryRegistry`, real `liveQueryDefinition` bridge, real per-subscriber gate, real cursor — over a socket that is two objects handing each other the JSON a WebSocket would. `live-replicator.ts` feeds it from `@ultimat3/entity`'s `setRowObserver`, which is the change SOURCE a test process never had: PGlite has no walsender and the memory driver no log, so `InMemoryChangeFeed` had nothing upstream of it. The WAL decoder is the only thing substituted; everything downstream of it is production code |
31
+ | What `subscribe` does NOT hold | a client store, an offline queue or a rebase log — so `feed.local()` answers `undefined` rather than the server row. A twin reported as applied whether or not a mutator ran is coverage that reads as proof, which is worse than none. That half is `useMutation` / `useMutationQueue` and an e2e |
32
+ | Draining is a macrotask yield | the node dispatches `message` into a floating async task, so `settled()` yields with `setImmediate` until the frame count stops moving. Counting microtask turns is a number that is right until someone adds an `await` — the first version drained 32 and read `examples/dummy`'s deeper feed read as "the node answered nothing" |
33
+ | A lone subscriber cannot resume | the retained window is the registry's ENTRY and the entry is dropped when its last subscriber goes, so a reconnect with nobody else holding it re-snapshots — correctly. `fixture-subscribe.test.ts` asserts both halves |
30
34
  | Strict is opt-in by destructuring | `statements` installs the N+1 detector in throw mode for one test. A fixture nobody names is a fixture nobody built, so there is no `strict: true` and no suite-wide switch — and no way to leave it on for the next file |
31
35
  | One threshold, one error | `N_PLUS_ONE_THRESHOLD` and `nPlusOne()` are `@ultimat3/entity`'s. A number or a message written here would make a loop that fails a test a different loop from the one `x dev` warns about |
32
36
  | The unit of work is the test | `x dev`'s ledger tallies per `Ctx` and ignores a statement issued outside a request; this counts every statement from build to disposal, because `posts.findById(id)` in a unit test has no request and is exactly the loop worth catching |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "4.1.0",
3
+ "version": "5.0.0",
4
4
  "description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,14 +33,16 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/cache": "4.1.0",
37
- "@ultimat3/core": "4.1.0",
38
- "@ultimat3/db": "4.1.0",
39
- "@ultimat3/entity": "4.1.0",
40
- "@ultimat3/i18n": "4.1.0",
41
- "@ultimat3/jobs": "4.1.0",
42
- "@ultimat3/mail": "4.1.0",
43
- "@ultimat3/policy": "4.1.0",
44
- "@ultimat3/time": "4.1.0"
36
+ "@ultimat3/cache": "5.0.0",
37
+ "@ultimat3/core": "5.0.0",
38
+ "@ultimat3/db": "5.0.0",
39
+ "@ultimat3/entity": "5.0.0",
40
+ "@ultimat3/i18n": "5.0.0",
41
+ "@ultimat3/jobs": "5.0.0",
42
+ "@ultimat3/mail": "5.0.0",
43
+ "@ultimat3/policy": "5.0.0",
44
+ "@ultimat3/query": "5.0.0",
45
+ "@ultimat3/realtime": "5.0.0",
46
+ "@ultimat3/time": "5.0.0"
45
47
  }
46
48
  }
package/src/errors.ts CHANGED
@@ -25,6 +25,8 @@ export const TESTING_ERROR_CODES = [
25
25
  'X_TEST_FACTORY_TRAIT_UNKNOWN',
26
26
  'X_TEST_FACTORY_NOT_PERSISTED',
27
27
  'X_TEST_REGISTRY_LEAK',
28
+ 'X_TEST_LIVE_NODE_EMPTY',
29
+ 'X_TEST_LIVE_NODE_UPGRADE_REFUSED',
28
30
  ] as const;
29
31
 
30
32
  export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
@@ -43,6 +45,8 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
43
45
  X_TEST_FACTORY_TRAIT_UNKNOWN: 'a factory was asked for a trait it does not declare',
44
46
  X_TEST_FACTORY_NOT_PERSISTED: 'a factory create() had nowhere to write the row',
45
47
  X_TEST_REGISTRY_LEAK: 'a test file left a process-global registry dirty',
48
+ X_TEST_LIVE_NODE_EMPTY: 'the in-process sync node has no live query to serve',
49
+ X_TEST_LIVE_NODE_UPGRADE_REFUSED: 'the in-process sync node refused the connection',
46
50
  };
47
51
 
48
52
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -145,6 +149,50 @@ export class FixtureUnavailableError extends UltimateError {
145
149
  export const fixtureUnavailable = (name: string, needs: string): UltimateError =>
146
150
  new FixtureUnavailableError({ name, needs });
147
151
 
152
+ /**
153
+ * The `subscribe` fixture built a node and found nothing to register. A registry with no definition
154
+ * answers every subscribe with "no live query registered" — a working socket serving no reads,
155
+ * which looks the same as a harness that is broken and an app that declared nothing. Said at the
156
+ * one moment the count is known, rather than three awaits later at the first subscribe.
157
+ *
158
+ * Almost always the same cause: the app's api module was never imported, so `registerQueries()`
159
+ * never ran and `listQueries()` is empty. The preload is where an app imports it — see
160
+ * `examples/dummy/scripts/test-setup.ts`.
161
+ */
162
+ export class LiveNodeEmptyError extends UltimateError {
163
+ constructor() {
164
+ super({
165
+ code: 'X_TEST_LIVE_NODE_EMPTY',
166
+ cause:
167
+ 'no query declared live: true is registered in this process, so the node would serve none',
168
+ fix: "import the app's api module in the test preload — import './apps/web/api' — then: x queries list --json",
169
+ docs: docsFor('X_TEST_LIVE_NODE_EMPTY'),
170
+ });
171
+ }
172
+ }
173
+
174
+ export const liveNodeUnavailable = (): UltimateError => new LiveNodeEmptyError();
175
+
176
+ /**
177
+ * The node answered the upgrade with a response instead of taking it. A REAL refusal — the accept
178
+ * budget, the connection ceiling, or a node that is not ready — and a different failure from
179
+ * "nothing to serve", which is what this path reported until 2026-08-20 and sent a reader looking
180
+ * for a missing query rather than at a node that never started.
181
+ */
182
+ export class LiveNodeUpgradeRefusedError extends UltimateError {
183
+ constructor(status: number | undefined) {
184
+ super({
185
+ code: 'X_TEST_LIVE_NODE_UPGRADE_REFUSED',
186
+ cause: `the sync node answered the upgrade with ${status === undefined ? 'no response' : `HTTP ${String(status)}`} instead of taking it`,
187
+ fix: 'await node.start() before connect(), and keep the request path at /_x/sync',
188
+ docs: docsFor('X_TEST_LIVE_NODE_UPGRADE_REFUSED'),
189
+ });
190
+ }
191
+ }
192
+
193
+ export const upgradeRefused = (status: number | undefined): UltimateError =>
194
+ new LiveNodeUpgradeRefusedError(status);
195
+
148
196
  /**
149
197
  * A request made while `network.offline()` (or `network.drop()`) is in force. Coded rather than a
150
198
  * bare `TypeError` because a test that lands here uncaught needs to know which of the two it was:
@@ -1,7 +1,8 @@
1
1
  // The fixtures the framework DECLARES but cannot build in this process: a browser for `page`,
2
- // `budget`, `signIn` and `deploy`; a replicator feeding a live-query registry for `subscribe`.
2
+ // `budget`, `signIn` and `deploy` — all four wait for a browser or a second build.
3
3
  // Each is a type a driver implements, plus a factory that says what is missing until one does.
4
4
 
5
+ import type { Actor } from '@ultimat3/core';
5
6
  import { fixtureUnavailable } from './errors';
6
7
  import type { FixtureFactory, Fixtures } from './fixtures';
7
8
 
@@ -19,20 +20,20 @@ export interface TestDeploy {
19
20
  }
20
21
 
21
22
  /**
22
- * What `query.live(input, { actor })` resolves to, named structurally so `@ultimat3/testing` does
23
- * not take a dependency on `@ultimat3/query` for one type. The real `LiveQuery` satisfies it.
23
+ * The declared query itself — `liveFeed`, not a call to it. Named structurally so this package takes
24
+ * no static dependency on `@ultimat3/query` for one type; every `query()` satisfies it, because
25
+ * `registerQueries()` stamps the name a subscription is keyed by.
24
26
  *
25
- * NOT what `query.as(actor, input)` resolves to, which is a ROW ARRAY — and the difference is
26
- * invisible until a driver exists. `examples/dummy`'s five `subscribe` tests all call
27
- * `subscribe(liveFeed.as(actor, input))`, which is `TS2345` against `Subscribe` below and has
28
- * never been read, because that app's `typecheck` is pinned red in `scripts/lib/gated-apps.ts`.
29
- * A driver built to satisfy those call sites would have to accept a row array — and a `subscribe`
30
- * loose enough to do that proves nothing, which is strictly worse than the fixture being
31
- * unavailable, because it then reads as coverage.
27
+ * It was `{ name, queryHash }` — "what `query.live(input, { actor })` resolves to" — and that shape
28
+ * cannot be subscribed to: the node keys a subscription by `(name, input)`, and a hash is the input
29
+ * already thrown away. The five `subscribe` tests in `examples/dummy` wrote
30
+ * `subscribe(liveFeed.as(actor, input))`, which resolves to a ROW ARRAY and was `TS2345` against
31
+ * either shape — never read, because that app's `typecheck` is pinned red in
32
+ * `scripts/lib/gated-apps.ts`. The call is now `subscribe(liveFeed, input, actor)`: the query, its
33
+ * input, and who is asking — the three things a subscribe frame carries.
32
34
  */
33
35
  export interface LiveTarget {
34
36
  readonly name: string;
35
- readonly queryHash: string;
36
37
  }
37
38
 
38
39
  export interface LiveFeedPatch<R extends object> {
@@ -50,17 +51,32 @@ export interface LiveFeed<R extends object> {
50
51
  /** Resolves when every patch in flight has been applied — never a sleep. */
51
52
  settled(): Promise<void>;
52
53
  lsn(): string;
54
+ /**
55
+ * Drop the connection and subscribe again with the cursor this feed holds — what a client does
56
+ * by itself on a reconnect, spelled out so a test can put a write between the two halves. It
57
+ * resolves once the node has answered, with either a delta or a fresh snapshot; which one is
58
+ * `snapshots()` and `resubscribedFrom()` below.
59
+ */
60
+ reconnect(): Promise<void>;
53
61
  /** Set when a reconnect resumed from a cursor; undefined when it resnapshotted. */
54
62
  resubscribedFrom(): string | undefined;
55
63
  /** How many snapshots this subscriber received. A resume that refetched shows up here. */
56
64
  snapshots(): number;
57
65
  }
58
66
 
67
+ /**
68
+ * `actor` is the third argument rather than something baked into the target, because that is where
69
+ * the framework itself puts it: `liveQueryDefinition` builds the shared window with NO subject
70
+ * (`ToLiveOptions.enforce: false`) and decides per subscriber at subscribe time. A `subscribe` that
71
+ * took the actor inside the target would be describing a design the node does not have.
72
+ */
59
73
  export type Subscribe = <R extends object>(
60
- target: LiveTarget | Promise<LiveTarget>,
74
+ target: LiveTarget,
75
+ input: Readonly<Record<string, unknown>>,
76
+ actor?: Actor | null,
61
77
  ) => Promise<LiveFeed<R>>;
62
78
 
63
- export const DRIVER_FIXTURE_NAMES = ['budget', 'deploy', 'page', 'signIn', 'subscribe'] as const;
79
+ export const DRIVER_FIXTURE_NAMES = ['budget', 'deploy', 'page', 'signIn'] as const;
64
80
 
65
81
  export type DriverFixtureName = (typeof DRIVER_FIXTURE_NAMES)[number];
66
82
 
@@ -70,9 +86,6 @@ export const DRIVER_FIXTURE_NEEDS: Readonly<Record<DriverFixtureName, string>> =
70
86
  deploy: 'a second build to switch the running app to',
71
87
  page: 'a browser driving the built app',
72
88
  signIn: 'a browser session against the app’s own sign-in route',
73
- subscribe:
74
- 'an in-process replicator feeding the live-query registry, and a caller that hands it ' +
75
- 'query.live(input, { actor }) — query.as() resolves to ROWS, which is not a LiveTarget',
76
89
  };
77
90
 
78
91
  /**
@@ -103,5 +116,4 @@ export const driverFixtures = (): DriverFixtures => ({
103
116
  deploy: unavailableFixture('deploy'),
104
117
  page: unavailableFixture('page'),
105
118
  signIn: unavailableFixture('signIn'),
106
- subscribe: unavailableFixture('subscribe'),
107
119
  });
@@ -0,0 +1,214 @@
1
+ // The `subscribe` fixture's driver: one in-process `sync` node, one replicator, and a `LiveFeed`
2
+ // per subscriber built from the frames that subscriber actually received.
3
+ //
4
+ // Rows are never read out of the registry. They are accumulated from the `snapshot` and `patch`
5
+ // frames the node wrote to this socket, through `@ultimat3/realtime`'s own `applyPatches` — so what
6
+ // a test asserts is what a browser would hold, and a row the per-subscriber gate dropped is absent
7
+ // here for the same reason it would be absent there. A feed that reached into the window would
8
+ // prove the query works and say nothing about delivery, which is the half these tests are about.
9
+
10
+ import type { Actor } from '@ultimat3/core';
11
+ import { UltimateError } from '@ultimat3/core';
12
+ import type { LiveFeed, LiveFeedPatch, LiveTarget } from './fixture-drivers';
13
+ import type { LiveConnection, LiveNodeHandle } from './live-node';
14
+ import { createLiveNode } from './live-node';
15
+ import type { LiveReplicator } from './live-replicator';
16
+ import { startLiveReplicator } from './live-replicator';
17
+
18
+ interface Row {
19
+ readonly id: string;
20
+ readonly [column: string]: unknown;
21
+ }
22
+
23
+ interface SnapshotFrameLike {
24
+ readonly type: 'snapshot';
25
+ readonly sid: string;
26
+ readonly rows: readonly Row[];
27
+ /** `@ultimat3/realtime`'s `LiveCursor`, carried back verbatim on a resume. */
28
+ readonly cursor: { readonly qid: string; readonly lsn: string };
29
+ }
30
+
31
+ interface PatchFrameLike {
32
+ readonly type: 'patch';
33
+ readonly sid: string;
34
+ readonly patches: readonly {
35
+ readonly op: 'insert' | 'update' | 'delete';
36
+ readonly id: string;
37
+ readonly row: Record<string, unknown> | null;
38
+ readonly lsn: string;
39
+ }[];
40
+ readonly lsn: string;
41
+ }
42
+
43
+ export interface SubscribeDriver {
44
+ /** What `defineFixtures({ subscribe })` registers. */
45
+ subscribe<R extends object>(
46
+ target: LiveTarget,
47
+ input: Readonly<Record<string, unknown>>,
48
+ actor?: Actor | null,
49
+ ): Promise<LiveFeed<R>>;
50
+ readonly node: LiveNodeHandle;
51
+ readonly replicator: LiveReplicator;
52
+ stop(): Promise<void>;
53
+ }
54
+
55
+ /**
56
+ * One node and one replicator per FIXTURE, which is one per test: a node shared across tests would
57
+ * carry the previous test's subscriptions, and the row observer is process-global so two live at
58
+ * once would each see the other's writes. `bun test` builds a fixture on first use and disposes it
59
+ * with the test, which is exactly the lifetime this needs.
60
+ */
61
+ export async function createSubscribeDriver(): Promise<SubscribeDriver> {
62
+ const realtime = await import('@ultimat3/realtime');
63
+ const node = await createLiveNode();
64
+ const replicator = await startLiveReplicator({ registry: node.registry });
65
+ const connections: LiveConnection[] = [];
66
+ let sid = 0;
67
+
68
+ const subscribe = async <R extends object>(
69
+ target: LiveTarget,
70
+ input: Readonly<Record<string, unknown>>,
71
+ actor: Actor | null = null,
72
+ ): Promise<LiveFeed<R>> => {
73
+ const connection = await node.connect(actor);
74
+ connections.push(connection);
75
+ sid += 1;
76
+ const id = `s${String(sid)}`;
77
+
78
+ connection.send({
79
+ type: 'hello',
80
+ v: realtime.PROTOCOL_VERSION,
81
+ buildId: 'test-build',
82
+ sessionId: null,
83
+ actorId: actor?.id ?? null,
84
+ });
85
+ await connection.settled();
86
+
87
+ connection.send({
88
+ type: 'subscribe',
89
+ v: realtime.PROTOCOL_VERSION,
90
+ op: 'add',
91
+ sid: id,
92
+ // `SubscribeTarget`'s query form. `qid` carries the query NAME on the way in — the node
93
+ // derives the real qid from `(name, input)`, so a client can never pick its own fanout key —
94
+ // and `cursor: null` is what makes this a fresh subscribe rather than a resume.
95
+ target: { kind: 'query', qid: target.name, input, cursor: null },
96
+ });
97
+ await connection.settled();
98
+
99
+ const mine = (): readonly Record<string, unknown>[] =>
100
+ connection.frames().filter((frame) => frame['sid'] === id);
101
+
102
+ // A refusal arrives as an `ack` carrying an error, never as a thrown call — the socket stayed
103
+ // up and the node answered. A test asserting `X_FORBIDDEN` wants the error, so it is rethrown
104
+ // here rather than left for `feed.rows()` to report as an empty result.
105
+ const refusal = connection
106
+ .frames()
107
+ .find((frame) => frame['type'] === 'ack' && frame['ref'] === id && frame['error'] != null);
108
+ if (refusal !== undefined) {
109
+ const wire = refusal['error'] as { code: string; cause: string; fix: string };
110
+ throw new UltimateError({ code: wire.code, cause: wire.cause, fix: wire.fix });
111
+ }
112
+
113
+ /**
114
+ * Where each reconnect started, and the lsn it asked to resume from. A resume is decided by
115
+ * what the node answered AFTER that point — a `patch` means it replayed from the cursor, a
116
+ * `snapshot` means `resumeFrom` refused it and re-read. Reading the frames is the only honest
117
+ * way to tell: the decision is the node's, and a harness that recorded its own intention would
118
+ * report a resume it never got.
119
+ */
120
+ const marks: { readonly at: number; readonly askedLsn: string }[] = [];
121
+
122
+ const state = () => {
123
+ let rows: readonly Row[] = [];
124
+ let snapshots = 0;
125
+ let lsn = '';
126
+ let cursor: unknown = null;
127
+ const patches: LiveFeedPatch<R>[] = [];
128
+ const frames = mine();
129
+ for (const frame of frames) {
130
+ if (frame['type'] === 'snapshot') {
131
+ const snapshot = frame as unknown as SnapshotFrameLike;
132
+ rows = snapshot.rows;
133
+ cursor = snapshot.cursor;
134
+ lsn = snapshot.cursor.lsn;
135
+ snapshots += 1;
136
+ continue;
137
+ }
138
+ if (frame['type'] !== 'patch') continue;
139
+ const patch = frame as unknown as PatchFrameLike;
140
+ rows = realtime.applyPatches(rows as never, patch.patches as never) as unknown as Row[];
141
+ for (const one of patch.patches) {
142
+ patches.push({ op: one.op, row: { id: one.id, ...(one.row ?? {}) } as unknown as R });
143
+ }
144
+ lsn = patch.lsn;
145
+ }
146
+ const last = marks[marks.length - 1];
147
+ const resumedFrom =
148
+ last === undefined || frames.slice(last.at).some((frame) => frame['type'] === 'snapshot')
149
+ ? undefined
150
+ : last.askedLsn;
151
+ return { rows, snapshots, lsn, cursor, resumedFrom, patches, frames };
152
+ };
153
+
154
+ return {
155
+ rows: () => state().rows as unknown as readonly R[],
156
+ row: (rowId: string) => state().rows.find((one) => one.id === rowId) as R | undefined,
157
+ // No optimistic twin on this feed: `local()` is the client store's answer, and this driver
158
+ // holds no store. A feed that returned the server row here would report the twin as applied
159
+ // whether or not a mutator ever ran, which is the "reads as coverage" failure the fixture was
160
+ // left unavailable to avoid. `useMutation` + `useMutationQueue` are the surface for that half.
161
+ local: () => undefined,
162
+ patches: () => state().patches,
163
+ settled: async () => {
164
+ await replicator.settled();
165
+ await connection.settled();
166
+ },
167
+ lsn: () => state().lsn,
168
+
169
+ reconnect: async () => {
170
+ const held = state();
171
+ if (held.cursor === null) return;
172
+ // The cursor the client holds: the snapshot's, advanced to the last patch it applied. That
173
+ // is what a real client carries, and what `resumeFrom` compares against the retained window.
174
+ const askedLsn = held.lsn;
175
+ marks.push({ at: held.frames.length, askedLsn });
176
+ connection.send({
177
+ type: 'subscribe',
178
+ v: realtime.PROTOCOL_VERSION,
179
+ op: 'drop',
180
+ sid: id,
181
+ target: { kind: 'query', qid: target.name, input, cursor: null },
182
+ });
183
+ await connection.settled();
184
+ connection.send({
185
+ type: 'subscribe',
186
+ v: realtime.PROTOCOL_VERSION,
187
+ op: 'add',
188
+ sid: id,
189
+ target: {
190
+ kind: 'query',
191
+ qid: target.name,
192
+ input,
193
+ cursor: { ...(held.cursor as Record<string, unknown>), lsn: askedLsn },
194
+ },
195
+ });
196
+ await replicator.settled();
197
+ await connection.settled();
198
+ },
199
+
200
+ resubscribedFrom: () => state().resumedFrom,
201
+ snapshots: () => state().snapshots,
202
+ };
203
+ };
204
+
205
+ return {
206
+ subscribe,
207
+ node,
208
+ replicator,
209
+ stop: async () => {
210
+ replicator.stop();
211
+ await node.stop();
212
+ },
213
+ };
214
+ }
@@ -8,6 +8,7 @@ import { createRunJobs } from './fixture-jobs';
8
8
  import { createTestMail } from './fixture-mail';
9
9
  import { createTestNetwork } from './fixture-network';
10
10
  import { createTestStatements } from './fixture-statements';
11
+ import { createSubscribeDriver } from './fixture-subscribe';
11
12
  import { defineFixtures } from './fixtures';
12
13
 
13
14
  /**
@@ -22,6 +23,12 @@ export const FRAMEWORK_FIXTURE_NAMES = [
22
23
  'network',
23
24
  'runJobs',
24
25
  'statements',
26
+ // Moved here from `DRIVER_FIXTURE_NAMES` on 2026-08-20: the driver it was waiting for is
27
+ // `createSubscribeDriver()`, and the framework can build one — a whole `sync` node in this
28
+ // process, over the change source `@ultimat3/entity`'s `setRowObserver` gives it. The four left
29
+ // in that list all need something the framework genuinely cannot bundle: a browser, or a
30
+ // second build.
31
+ 'subscribe',
25
32
  ] as const;
26
33
 
27
34
  export { DRIVER_FIXTURE_NAMES };
@@ -46,5 +53,9 @@ export function registerFrameworkFixtures(): void {
46
53
  network: createTestNetwork,
47
54
  runJobs: createRunJobs,
48
55
  statements: createTestStatements,
56
+ // After the spread, so it REPLACES the `unavailableFixture('subscribe')` declaration above it —
57
+ // `defineFixtures` merges and the last registration wins, which is the same seam an app's own
58
+ // driver uses.
59
+ subscribe: async () => (await createSubscribeDriver()).subscribe,
49
60
  });
50
61
  }
package/src/index.ts CHANGED
@@ -91,6 +91,8 @@ export type { TestNetwork } from './fixture-network';
91
91
  export { createTestNetwork } from './fixture-network';
92
92
  export type { ObservedStatement, StatementShape, TestStatements } from './fixture-statements';
93
93
  export { createTestStatements } from './fixture-statements';
94
+ export type { SubscribeDriver } from './fixture-subscribe';
95
+ export { createSubscribeDriver } from './fixture-subscribe';
94
96
  export { fixtureTest as test } from './fixtures';
95
97
  export {
96
98
  ALL_FIXTURE_NAMES,
@@ -100,6 +102,10 @@ export {
100
102
  } from './framework-fixtures';
101
103
  export type { AppHandle, AppOptions, BootedApp } from './harness';
102
104
  export { describeApp, testApp } from './harness';
105
+ export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node';
106
+ export { createLiveNode } from './live-node';
107
+ export type { LiveReplicator, LiveReplicatorOptions } from './live-replicator';
108
+ export { startLiveReplicator } from './live-replicator';
103
109
  export type { MatcherResult } from './matchers';
104
110
  export { matchersInstalled, recordSteps } from './matchers';
105
111
  // `isolateEntityRegistry` is deliberately NOT here — it is `@ultimat3/testing/registry-isolation`.
@@ -0,0 +1,249 @@
1
+ // A whole `sync` node and one client socket, in this process, with no port and no network. What
2
+ // `x dev --role sync` assembles, minus the listener: the real `LiveQueryRegistry`, the real bridge
3
+ // from every `query({ live: true })` the app declared, the real per-subscriber authz, the real
4
+ // cursor. The only thing replaced is the socket, and it is replaced by a pair of objects that hand
5
+ // each other the same JSON strings a WebSocket would.
6
+ //
7
+ // Dynamically imported by the `subscribe` fixture, never at module load: a test that never
8
+ // subscribes must not pull `@ultimat3/realtime` and `@ultimat3/query` into its process, which is
9
+ // the rule every fixture in this package already follows.
10
+
11
+ import type { Actor } from '@ultimat3/core';
12
+ import type {
13
+ ChangeEvent,
14
+ LiveQueryRegistry,
15
+ SyncNode,
16
+ SyncWs,
17
+ UpgradeTarget,
18
+ WsData,
19
+ WsLike,
20
+ } from '@ultimat3/realtime';
21
+ import { liveNodeUnavailable, upgradeRefused } from './errors';
22
+
23
+ /** Every frame this end received, in order, already parsed. */
24
+ export interface FramePipe {
25
+ readonly received: readonly unknown[];
26
+ send(data: string): void;
27
+ }
28
+
29
+ /**
30
+ * The server half of the pair. `SyncSocket` writes through `WsLike`, so a fake that records what
31
+ * it was handed is the whole of what a node needs to talk to a client that is in the same process.
32
+ * `getBufferedAmount` answers zero: backpressure is a real socket's, and a harness that invented
33
+ * one would fail a test for a reason no production node would.
34
+ */
35
+ export class PipeWs implements WsLike {
36
+ readonly sent: string[] = [];
37
+ #deliver: (data: string) => void;
38
+ #closed = false;
39
+
40
+ constructor(
41
+ readonly data: WsData,
42
+ deliver: (data: string) => void,
43
+ ) {
44
+ this.#deliver = deliver;
45
+ }
46
+
47
+ get closed(): boolean {
48
+ return this.#closed;
49
+ }
50
+
51
+ send(data: string): number {
52
+ this.sent.push(data);
53
+ // A dropped connection is a socket that still exists and delivers nothing, which is exactly
54
+ // what a client observes: the frames the node believes it sent are counted, and none arrive.
55
+ if (!this.#closed) this.#deliver(data);
56
+ return data.length;
57
+ }
58
+
59
+ close(): void {
60
+ this.#closed = true;
61
+ }
62
+
63
+ subscribe(): void {}
64
+ unsubscribe(): void {}
65
+
66
+ getBufferedAmount(): number {
67
+ return 0;
68
+ }
69
+
70
+ /** Cut the wire without closing the socket — `network.drop()`'s server end. */
71
+ cut(): void {
72
+ this.#closed = true;
73
+ }
74
+ }
75
+
76
+ export interface LiveNodeOptions {
77
+ readonly buildId?: string;
78
+ /** Pins the reconnect epoch, so a test can force a refetch by changing it. */
79
+ readonly epoch?: string;
80
+ // No `onMutate`. `SyncNodeOptions` takes one — `{ socket, name, key, seq, input }`, the actor
81
+ // read off the socket — and nothing here needs it: the mutation half of a live subscription is
82
+ // the CLIENT's local store and offline queue, which this driver deliberately does not hold. A
83
+ // forwarded option no test passes is a declaration nothing reads, which is what 4.0.0 spent a
84
+ // major deleting. It arrives with its first caller, in that caller's shape.
85
+ }
86
+
87
+ export interface LiveNodeHandle {
88
+ readonly node: SyncNode;
89
+ readonly registry: LiveQueryRegistry;
90
+ /** Open one client connection as this actor. Resolves once the node has the socket. */
91
+ connect(actor: Actor | null): Promise<LiveConnection>;
92
+ /** Fan one committed change out to every subscriber. Returns the frames the node sent. */
93
+ deliver(change: ChangeEvent): Promise<number>;
94
+ stop(): Promise<void>;
95
+ }
96
+
97
+ export interface LiveConnection {
98
+ readonly ws: PipeWs;
99
+ readonly socketId: string;
100
+ /** Frames this client received, newest last, already decoded. */
101
+ frames(): readonly Record<string, unknown>[];
102
+ /** Hand one frame to the node, exactly as a WebSocket message would arrive. */
103
+ send(frame: Record<string, unknown>): void;
104
+ /** Resolves when every frame the node has scheduled for this socket has been written. */
105
+ settled(): Promise<void>;
106
+ cut(): void;
107
+ close(): void;
108
+ }
109
+
110
+ /**
111
+ * The actor for a connection travels in the upgrade URL rather than in a token, because both ends
112
+ * of this pair are the harness: minting a real credential would test `@ultimat3/auth`'s signer, and
113
+ * a test that has to sign in to assert a row filter is a test about the wrong thing. The node still
114
+ * runs its real `authenticate` seam, its accept budget and its connection ceiling — what is faked
115
+ * is the credential, never the path that reads one.
116
+ */
117
+ const ACTORS = new Map<string, Actor | null>();
118
+
119
+ let sequence = 0;
120
+
121
+ export async function createLiveNode(options: LiveNodeOptions = {}): Promise<LiveNodeHandle> {
122
+ const core = await import('@ultimat3/core');
123
+ const query = await import('@ultimat3/query');
124
+ const realtime = await import('@ultimat3/realtime');
125
+
126
+ const buildId = options.buildId ?? 'test-build';
127
+ const transport = new realtime.InProcessTransport();
128
+ const sockets = new realtime.SocketRegistry();
129
+ const hub = new realtime.ChannelHub({ transport, sockets });
130
+ const registry = new realtime.LiveQueryRegistry({ source: new realtime.RingChangeBuffer() });
131
+
132
+ const ctx = core.createContext({ role: 'sync', buildId });
133
+ let live = 0;
134
+ for (const target of query.listQueries()) {
135
+ if (!target.isLive) continue;
136
+ registry.register(
137
+ realtime.liveQueryDefinition(target, {
138
+ ctx,
139
+ ...(options.epoch === undefined ? {} : { epoch: options.epoch }),
140
+ }),
141
+ );
142
+ live += 1;
143
+ }
144
+ // A registry with nothing in it answers every subscribe with "no live query registered" — a
145
+ // working socket serving no reads, which is indistinguishable from a harness that works and an
146
+ // app that declared nothing. Said here, where the count is known.
147
+ if (live === 0) throw liveNodeUnavailable();
148
+
149
+ const node = realtime.createSyncNode({
150
+ hub,
151
+ registry,
152
+ transport,
153
+ buildId,
154
+ sockets,
155
+ // A grant always, never `null`: `handleUpgrade` answers 401 to a `null` grant, so returning one
156
+ // for an anonymous connection would refuse the very socket a test asking about anonymous access
157
+ // needs. `anonymousActor()` is how core spells nobody — policy models it as `null`, core models
158
+ // it as an actor, and this is core's side of that seam.
159
+ authenticate: (request: Request) =>
160
+ Promise.resolve({
161
+ actor:
162
+ ACTORS.get(new URL(request.url).searchParams.get('c') ?? '') ?? core.anonymousActor(),
163
+ }),
164
+ });
165
+ await node.start();
166
+
167
+ const connections: LiveConnection[] = [];
168
+
169
+ return {
170
+ node,
171
+ registry,
172
+ deliver: (change) => registry.deliver(change),
173
+
174
+ connect: async (actor) => {
175
+ sequence += 1;
176
+ const key = `c${String(sequence)}`;
177
+ ACTORS.set(key, actor);
178
+ let data: WsData | undefined;
179
+ const target: UpgradeTarget = {
180
+ upgrade: (_request: Request, upgradeOptions: { data: WsData }) => {
181
+ data = upgradeOptions.data;
182
+ return true;
183
+ },
184
+ };
185
+ // `/_x/sync` is `SyncNodeOptions.path`'s default and `handleUpgrade` answers 404 to
186
+ // anything else. The `c` parameter is this connection's actor key — see `ACTORS` above.
187
+ const refusal = await node.fetch(new Request(`http://sync.test/_x/sync?c=${key}`), target);
188
+ // A refusal is a REAL one: the accept budget, the connection ceiling or `ready()`. Reported
189
+ // with what the node answered rather than as "no live query", which is a different failure
190
+ // and was this line's error until 2026-08-20. Thrown rather than asserted through a namespace
191
+ // import, which cannot narrow (TS2775).
192
+ if (data === undefined) throw upgradeRefused(refusal?.status);
193
+ const received: Record<string, unknown>[] = [];
194
+ const ws = new PipeWs(data, (raw) => {
195
+ received.push(JSON.parse(raw) as Record<string, unknown>);
196
+ });
197
+ node.websocket.open(ws as unknown as SyncWs);
198
+ const connection: LiveConnection = {
199
+ ws,
200
+ socketId: data.socketId,
201
+ frames: () => received,
202
+ send: (frame) => {
203
+ node.websocket.message(ws as unknown as SyncWs, JSON.stringify(frame));
204
+ },
205
+ /**
206
+ * `message` dispatches into a floating async task — `void (async () => …)()` — so a caller
207
+ * asserting straight after `send` would be reading frames that have not been routed.
208
+ *
209
+ * A MACROTASK yield, not a count of microtask turns. Counting turns is a number that is
210
+ * right until someone adds an `await`, and it fails as a flake: the first version drained 32
211
+ * turns and worked against a two-line query while `examples/dummy`'s feed — a repository, a
212
+ * cache lookup and a policy pass deeper — resolved nothing inside it, so the harness read an
213
+ * empty frame list as "the node answered nothing". One `setImmediate` drains the entire
214
+ * microtask queue however deep it is; the loop then repeats until the frame count has
215
+ * stopped moving for two yields, which is what covers a chain that goes quiet and then
216
+ * produces again.
217
+ *
218
+ * Bounded, because a harness that spins forever on a node that will never answer is worse
219
+ * than one that gives up and lets the assertion say what is missing. No timer: the preload
220
+ * freezes the clock this suite runs on, and a sleep would be a race dressed as a wait.
221
+ */
222
+ settled: async () => {
223
+ let quiet = 0;
224
+ for (let yields = 0; yields < 64 && quiet < 2; yields += 1) {
225
+ const before = received.length;
226
+ await new Promise<void>((resolve) => {
227
+ setImmediate(resolve);
228
+ });
229
+ quiet = received.length === before ? quiet + 1 : 0;
230
+ }
231
+ },
232
+ cut: () => {
233
+ ws.cut();
234
+ },
235
+ close: () => {
236
+ node.websocket.close(ws as unknown as SyncWs);
237
+ ACTORS.delete(key);
238
+ },
239
+ };
240
+ connections.push(connection);
241
+ return connection;
242
+ },
243
+
244
+ stop: async () => {
245
+ for (const connection of connections) connection.close();
246
+ await node.stop();
247
+ },
248
+ };
249
+ }
@@ -0,0 +1,141 @@
1
+ // The in-process replicator: committed row changes in, `ChangeEvent`s out, fanned into the node.
2
+ //
3
+ // Production decodes the write-ahead log. PGlite has no walsender and the memory driver has no log,
4
+ // so a test process had no change source at all — which is what left the `subscribe` fixture with
5
+ // no driver, and its five tests in `examples/dummy` asserting against a snapshot that never moved.
6
+ //
7
+ // WHAT IS REAL HERE, and it is everything downstream of the decoder: the matcher, the shared window,
8
+ // the per-subscriber `visible` gate, the cursor, the frames. This substitutes for the WAL DECODER
9
+ // and for nothing else — `@ultimat3/entity`'s `setRowObserver` reports what a repository wrote, in
10
+ // this process, and the events are shaped exactly as `PgLogicalReplicationFeed` shapes them.
11
+ //
12
+ // WHAT IS NOT: a write another process made is invisible, because nothing here reads a log. That is
13
+ // the honest bound, and it is why this is a fixture and not a `ChangeFeed` — `selectChangeFeed`
14
+ // still decides what a real node reads, and this is never in that decision.
15
+
16
+ import type { RowBulkChange, RowChange, RowObserver } from '@ultimat3/entity';
17
+ import type { ChangeEvent, ChangeOp, LiveQueryRegistry, Row } from '@ultimat3/realtime';
18
+
19
+ /** What a caller does with a change nobody could deliver. */
20
+ export interface LiveReplicatorOptions {
21
+ readonly registry: LiveQueryRegistry;
22
+ /** Tenant column, hoisted out of the row so fanout filters without parsing it. */
23
+ readonly tenantColumn?: string;
24
+ readonly onError?: (error: unknown) => void;
25
+ }
26
+
27
+ export interface LiveReplicator {
28
+ /** Resolves when every change observed so far has been fanned out. Never a sleep. */
29
+ settled(): Promise<void>;
30
+ /** Changes this replicator has delivered — the number a test asserts a patch count against. */
31
+ readonly delivered: number;
32
+ stop(): void;
33
+ }
34
+
35
+ const OPS: Readonly<Record<RowChange['op'], ChangeOp>> = {
36
+ insert: 'insert',
37
+ update: 'update',
38
+ delete: 'delete',
39
+ };
40
+
41
+ /**
42
+ * A `ChangeEvent` row is `Row` — a JSON object carrying an `id`. Every row a repository stores has
43
+ * one; the cast is what says so to a compiler that only sees `Record<string, unknown>`, and a row
44
+ * that genuinely has none fails downstream in `idOf`, with the entity named, exactly as a row off
45
+ * the wire would.
46
+ */
47
+ const asRow = (value: Readonly<Record<string, unknown>> | null): Row | null =>
48
+ value === null ? null : (value as unknown as Row);
49
+
50
+ /**
51
+ * Lexicographically comparable, which is the whole contract of an lsn — `formatLsn` in
52
+ * `@ultimat3/realtime` produces the same shape from a real WAL position. A counter is enough here
53
+ * because one process observes its own writes in the order it made them.
54
+ */
55
+ const lsnOf = (position: number): string => position.toString(16).padStart(16, '0');
56
+
57
+ /**
58
+ * Install the replicator for the length of one test. It takes over the process row observer and
59
+ * hands back whatever was installed before, because `bun test` shares one process across files and
60
+ * an unconditional clear would take an outer harness's observer with it.
61
+ */
62
+ export async function startLiveReplicator(options: LiveReplicatorOptions): Promise<LiveReplicator> {
63
+ // Awaited BEFORE the observer exists, so installation is the last thing this function does and
64
+ // no write between the call and the install can slip past unobserved.
65
+ const entity = await import('@ultimat3/entity');
66
+ const { registry } = options;
67
+ const tenant = options.tenantColumn ?? 'orgId';
68
+ let position = 0;
69
+ let delivered = 0;
70
+ // One promise chain, because ORDERING is the guarantee the whole pipeline is built on — the same
71
+ // reason `InMemoryChangeFeed` serializes its deliveries rather than firing them concurrently.
72
+ let tail: Promise<void> = Promise.resolve();
73
+ let stopped = false;
74
+
75
+ const enqueue = (work: () => Promise<void>): void => {
76
+ tail = tail.then(work).catch((error: unknown) => {
77
+ // Never rethrown into the chain: one failed fanout must not silence every change behind it,
78
+ // and a rejection with nobody to hand it to ends the Bun process.
79
+ options.onError?.(error);
80
+ });
81
+ };
82
+
83
+ const observer: RowObserver = {
84
+ onChange(change: RowChange): void {
85
+ if (stopped) return;
86
+ position += 1;
87
+ const at = position;
88
+ const row = change.after ?? change.before;
89
+ const orgId = typeof row?.[tenant] === 'string' ? (row[tenant] as string) : null;
90
+ const event: ChangeEvent = {
91
+ entity: change.entity,
92
+ op: OPS[change.op],
93
+ before: asRow(change.before),
94
+ after: asRow(change.after),
95
+ lsn: lsnOf(at),
96
+ txid: String(at),
97
+ orgId,
98
+ // Deliberately not a clock read: the preload freezes `Date.now()`, and a change's commit
99
+ // time is not something any assertion in this repo reads. `at` keeps it monotonic anyway.
100
+ at,
101
+ };
102
+ enqueue(async () => {
103
+ delivered += await registry.deliver(event);
104
+ });
105
+ },
106
+
107
+ /**
108
+ * A filtered write names rows this seam never saw, so there is no event to shape. Every window
109
+ * on the node is marked stale instead and re-read on the next change — `invalidate()` is the
110
+ * node's own answer to "the change stream skipped something", used here for the one write that
111
+ * genuinely does. Silence would be the alternative, and a subscriber told nothing happened
112
+ * diverges with nobody ever asking again.
113
+ */
114
+ onBulk(_change: RowBulkChange): void {
115
+ if (stopped) return;
116
+ registry.invalidate();
117
+ },
118
+ };
119
+
120
+ const previous = entity.setRowObserver(observer);
121
+
122
+ return {
123
+ get delivered() {
124
+ return delivered;
125
+ },
126
+ settled: async () => {
127
+ // Twice: a fanout can enqueue nothing, but the writes that produced these changes may still
128
+ // be resolving their own promises when a test asks. Awaiting the chain, letting the
129
+ // microtask queue drain, then awaiting it again covers a change observed in between.
130
+ await tail;
131
+ for (let turn = 0; turn < 8; turn += 1) await Promise.resolve();
132
+ await tail;
133
+ },
134
+ stop: () => {
135
+ stopped = true;
136
+ // Restored, never cleared: one process runs every test file, and an outer harness's observer
137
+ // must survive an inner fixture finishing.
138
+ entity.setRowObserver(previous);
139
+ },
140
+ };
141
+ }