@ultimat3/testing 4.0.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 +5 -1
- package/package.json +12 -10
- package/src/errors.ts +48 -0
- package/src/fixture-drivers.ts +29 -17
- package/src/fixture-subscribe.ts +214 -0
- package/src/framework-fixtures.ts +11 -0
- package/src/index.ts +6 -0
- package/src/live-node.ts +249 -0
- package/src/live-replicator.ts +141 -0
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`
|
|
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": "
|
|
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": "
|
|
37
|
-
"@ultimat3/core": "
|
|
38
|
-
"@ultimat3/db": "
|
|
39
|
-
"@ultimat3/entity": "
|
|
40
|
-
"@ultimat3/i18n": "
|
|
41
|
-
"@ultimat3/jobs": "
|
|
42
|
-
"@ultimat3/mail": "
|
|
43
|
-
"@ultimat3/policy": "
|
|
44
|
-
"@ultimat3/
|
|
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:
|
package/src/fixture-drivers.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
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'
|
|
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`.
|
package/src/live-node.ts
ADDED
|
@@ -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
|
+
}
|