@syncular/testkit 0.1.2 → 0.2.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/README.md +210 -0
- package/dist/client.d.ts +68 -0
- package/dist/client.js +145 -0
- package/dist/clock.d.ts +24 -0
- package/dist/clock.js +32 -0
- package/dist/create-test-sync.d.ts +67 -0
- package/dist/create-test-sync.js +90 -0
- package/dist/faults.d.ts +52 -50
- package/dist/faults.js +74 -192
- package/dist/index.d.ts +17 -15
- package/dist/index.js +18 -15
- package/dist/react.d.ts +10 -0
- package/dist/react.js +41 -0
- package/dist/server.d.ts +44 -0
- package/dist/server.js +68 -0
- package/package.json +52 -29
- package/src/client.ts +218 -0
- package/src/clock.ts +45 -0
- package/src/create-test-sync.ts +158 -0
- package/src/faults.ts +77 -338
- package/src/index.ts +44 -14
- package/src/react.ts +46 -0
- package/src/server.ts +102 -0
- package/dist/audit.d.ts +0 -22
- package/dist/audit.d.ts.map +0 -1
- package/dist/audit.js +0 -40
- package/dist/audit.js.map +0 -1
- package/dist/client-bridge.d.ts +0 -77
- package/dist/client-bridge.d.ts.map +0 -1
- package/dist/client-bridge.js +0 -540
- package/dist/client-bridge.js.map +0 -1
- package/dist/deterministic.d.ts +0 -31
- package/dist/deterministic.d.ts.map +0 -1
- package/dist/deterministic.js +0 -69
- package/dist/deterministic.js.map +0 -1
- package/dist/disposable.d.ts +0 -10
- package/dist/disposable.d.ts.map +0 -1
- package/dist/disposable.js +0 -28
- package/dist/disposable.js.map +0 -1
- package/dist/faults.d.ts.map +0 -1
- package/dist/faults.js.map +0 -1
- package/dist/hono-node-server.d.ts +0 -20
- package/dist/hono-node-server.d.ts.map +0 -1
- package/dist/hono-node-server.js +0 -90
- package/dist/hono-node-server.js.map +0 -1
- package/dist/http-fixtures.d.ts +0 -32
- package/dist/http-fixtures.d.ts.map +0 -1
- package/dist/http-fixtures.js +0 -117
- package/dist/http-fixtures.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/project-scoped-tasks.d.ts +0 -40
- package/dist/project-scoped-tasks.d.ts.map +0 -1
- package/dist/project-scoped-tasks.js +0 -247
- package/dist/project-scoped-tasks.js.map +0 -1
- package/dist/realtime-ws.d.ts +0 -48
- package/dist/realtime-ws.d.ts.map +0 -1
- package/dist/realtime-ws.js +0 -203
- package/dist/realtime-ws.js.map +0 -1
- package/dist/runtime-process.d.ts +0 -14
- package/dist/runtime-process.d.ts.map +0 -1
- package/dist/runtime-process.js +0 -99
- package/dist/runtime-process.js.map +0 -1
- package/dist/sync-builders.d.ts +0 -48
- package/dist/sync-builders.d.ts.map +0 -1
- package/dist/sync-builders.js +0 -66
- package/dist/sync-builders.js.map +0 -1
- package/dist/sync-http.d.ts +0 -48
- package/dist/sync-http.d.ts.map +0 -1
- package/dist/sync-http.js +0 -43
- package/dist/sync-http.js.map +0 -1
- package/dist/sync-parse.d.ts +0 -8
- package/dist/sync-parse.d.ts.map +0 -1
- package/dist/sync-parse.js +0 -38
- package/dist/sync-parse.js.map +0 -1
- package/dist/sync-response.d.ts +0 -6
- package/dist/sync-response.d.ts.map +0 -1
- package/dist/sync-response.js +0 -19
- package/dist/sync-response.js.map +0 -1
- package/src/audit.ts +0 -74
- package/src/client-bridge.ts +0 -841
- package/src/deterministic.ts +0 -121
- package/src/disposable.ts +0 -50
- package/src/hono-node-server.ts +0 -144
- package/src/http-fixtures.ts +0 -187
- package/src/project-scoped-tasks.ts +0 -351
- package/src/realtime-ws.ts +0 -354
- package/src/runtime-process.ts +0 -159
- package/src/sync-builders.ts +0 -136
- package/src/sync-http.ts +0 -117
- package/src/sync-parse.ts +0 -86
- package/src/sync-response.ts +0 -43
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# @syncular/testkit
|
|
2
|
+
|
|
3
|
+
The app-developer test kit. Stand up a whole Syncular backend and N real
|
|
4
|
+
clients in memory, drive them from a `bun test` (or vitest/jest) file, and
|
|
5
|
+
assert what your app sees — convergence, offline queues, conflicts, live
|
|
6
|
+
queries — with **no HTTP server, no browser, no mocks**.
|
|
7
|
+
|
|
8
|
+
Everything here is the shipped core: the same `SyncClient`
|
|
9
|
+
(`@syncular/client`) and the same `@syncular/server` protocol
|
|
10
|
+
handler your production app runs, wired through an in-process loopback. A
|
|
11
|
+
green test here is real sync behaviour, not a fake.
|
|
12
|
+
|
|
13
|
+
> Designed for **app tests** — readable and minimal. If you are implementing
|
|
14
|
+
> a Syncular client or server and need the driver/pairing machinery, that is
|
|
15
|
+
> `@syncular/conformance`, not this.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
Workspace-internal only:
|
|
20
|
+
|
|
21
|
+
```jsonc
|
|
22
|
+
// package.json
|
|
23
|
+
{
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@syncular/testkit": "workspace:*"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires the Bun runtime (the in-memory client backend is `bun:sqlite`). The
|
|
31
|
+
React helper (`@syncular/testkit/react`) needs `react` — an optional peer.
|
|
32
|
+
|
|
33
|
+
## 60-second tour
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { expect, test } from 'bun:test';
|
|
37
|
+
import { createTestSync } from '@syncular/testkit';
|
|
38
|
+
import { schema } from '../src/syncular.generated'; // your generated schema
|
|
39
|
+
|
|
40
|
+
test('two clients converge', async () => {
|
|
41
|
+
const sync = await createTestSync({ schema });
|
|
42
|
+
const a = await sync.client('a');
|
|
43
|
+
const b = await sync.client('b');
|
|
44
|
+
|
|
45
|
+
const sub = { id: 's', table: 'notes', scopes: { list_id: ['welcome'] } };
|
|
46
|
+
a.api.subscribe(sub);
|
|
47
|
+
b.api.subscribe(sub);
|
|
48
|
+
|
|
49
|
+
a.api.mutate([
|
|
50
|
+
{ table: 'notes', op: 'upsert', values: { id: 'n1', list_id: 'welcome', body: 'hi' } },
|
|
51
|
+
]);
|
|
52
|
+
await sync.syncAll(); // push A's outbox, pull it into B
|
|
53
|
+
|
|
54
|
+
expect(b.api.query('SELECT body FROM notes')).toEqual([{ body: 'hi' }]);
|
|
55
|
+
await sync.dispose();
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`sync.client(id?)` returns a **`TestClient`**. Reach the full client API
|
|
60
|
+
through `.api` (it is a real `SyncClient`: `subscribe`, `mutate`, `query`,
|
|
61
|
+
`syncUntilIdle`, `conflicts`, `setWindow`, `uploadBlob`, …). The `TestClient`
|
|
62
|
+
adds the test-only controls below.
|
|
63
|
+
|
|
64
|
+
## The surface
|
|
65
|
+
|
|
66
|
+
### `createTestSync(options) → Promise<TestSync>`
|
|
67
|
+
|
|
68
|
+
| option | default | meaning |
|
|
69
|
+
| --------------- | ---------------------- | ------------------------------------------------------------- |
|
|
70
|
+
| `schema` | — | your generated `ClientSchema` — feeds server and clients |
|
|
71
|
+
| `partition` | `"test"` | the §1.1 partition every client lives in |
|
|
72
|
+
| `actorId` | `"test-actor"` | default actor a client authenticates as |
|
|
73
|
+
| `resolveScopes` | grant-all | host authorization (§3.2); omit to grant `'*'` for every var |
|
|
74
|
+
| `startMs` | `1_750_000_000_000` | epoch ms the shared virtual clock starts at |
|
|
75
|
+
|
|
76
|
+
`TestSync`:
|
|
77
|
+
|
|
78
|
+
- `clock` — the shared `VirtualClock` (below).
|
|
79
|
+
- `server` — the in-memory server (`storage`, `segments`, realtime `hub`).
|
|
80
|
+
- `clients` — every client created, in order.
|
|
81
|
+
- `client(id?, overrides?)` — create **and start** one client.
|
|
82
|
+
- `syncAll()` — `syncUntilIdle()` on every online client until quiescent.
|
|
83
|
+
- `dispose()` — close every client and the server (idempotent).
|
|
84
|
+
|
|
85
|
+
`overrides` lets a client use a different `actorId` or extra `SyncClient`
|
|
86
|
+
config (e.g. `{ clientConfig: { onConflict: fn } }`).
|
|
87
|
+
|
|
88
|
+
### `TestClient`
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
client.api // the real SyncClient
|
|
92
|
+
client.id // stable client id (§1.5)
|
|
93
|
+
client.actorId // the actor it authenticates as
|
|
94
|
+
client.faults // transport-fault controller (below)
|
|
95
|
+
client.offline // boolean
|
|
96
|
+
|
|
97
|
+
client.goOffline() // cut the network — every hop rejects
|
|
98
|
+
client.goOnline() // restore it (does NOT auto-sync; call sync())
|
|
99
|
+
await client.sync() // syncUntilIdle: push the outbox, pull to idle
|
|
100
|
+
await client.connectRealtime() // attach a live socket to the hub
|
|
101
|
+
client.disconnectRealtime()
|
|
102
|
+
await client.close()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Offline queue / drain
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
a.goOffline();
|
|
109
|
+
a.api.mutate([{ table: 'notes', op: 'upsert', values: { id: 'n1', list_id: 'l', body: 'draft' } }]);
|
|
110
|
+
|
|
111
|
+
// Visible locally (optimistic), but nothing leaves:
|
|
112
|
+
expect(a.api.query('SELECT body FROM notes')).toEqual([{ body: 'draft' }]);
|
|
113
|
+
expect(a.api.pendingCommits()).toHaveLength(1);
|
|
114
|
+
await expect(a.api.sync()).rejects.toThrow(); // offline
|
|
115
|
+
|
|
116
|
+
a.goOnline();
|
|
117
|
+
await sync.syncAll(); // the queue drains
|
|
118
|
+
expect(a.api.pendingCommits()).toHaveLength(0);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Fault injection
|
|
122
|
+
|
|
123
|
+
`client.faults` is the conformance harness's `TransportFaults` controller,
|
|
124
|
+
**re-exported, not re-implemented** — the same vocabulary the reference
|
|
125
|
+
pairing arms. Arm one flag; the next matching exchange misbehaves:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
a.faults.dropNextRequests = 1; // lose the next request (outbox survives)
|
|
129
|
+
a.faults.dropNextResponses = 1; // server applies it, the ack is lost
|
|
130
|
+
a.faults.duplicateNextRequest = true; // replay it — idempotency-cache test
|
|
131
|
+
a.faults.truncateNextResponse = true; // cut the response short (decode error)
|
|
132
|
+
a.faults.truncateNextSegmentDownload = true;
|
|
133
|
+
a.faults.corrupt(bytes); // flip a seeded byte (§5.1 tamper)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
a.api.mutate([/* … */]);
|
|
138
|
+
a.faults.dropNextRequests = 1;
|
|
139
|
+
await expect(a.api.sync()).rejects.toThrow(); // request lost
|
|
140
|
+
expect(a.api.pendingCommits()).toHaveLength(1); // still queued
|
|
141
|
+
await a.sync(); // retried, drains
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Virtual clock
|
|
145
|
+
|
|
146
|
+
The server, every client, and the realtime hub share one clock. Time only
|
|
147
|
+
moves when you move it — so §5.1 segment TTLs, §5.4 signed-URL expiry, and
|
|
148
|
+
§7.3 lease windows are deterministic with no wall-clock flake.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
sync.clock.now(); // current epoch ms
|
|
152
|
+
sync.clock.advance(60_000); // +60s → returns the new now()
|
|
153
|
+
sync.clock.set(1_800_000_000_000); // jump to an absolute instant
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
> The clock is the epoch-ms source Syncular's clock seam reads. It does **not**
|
|
157
|
+
> intercept `setTimeout`, so presence rate caps / heartbeats (which use real
|
|
158
|
+
> timers) are out of scope — the kit targets sync / offline / fault behaviour.
|
|
159
|
+
|
|
160
|
+
### Realtime deltas & presence
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
await b.connectRealtime(); // b now has a live socket on the hub
|
|
164
|
+
a.api.mutate([/* … */]);
|
|
165
|
+
await a.sync(); // the hub fans the commit to b as a delta
|
|
166
|
+
// b applies it without an explicit pull
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`goOffline()` also drops the socket; reconnect with `connectRealtime()`.
|
|
170
|
+
|
|
171
|
+
## React
|
|
172
|
+
|
|
173
|
+
`@syncular/react`'s `SyncProvider` already takes any `SyncClient`, so
|
|
174
|
+
mounting hooks against a test client is a one-liner. `syncWrapper` builds the
|
|
175
|
+
`wrapper` prop `@testing-library/react` wants:
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
import { renderHook, act, waitFor } from '@testing-library/react';
|
|
179
|
+
import { useSyncQuery } from '@syncular/react';
|
|
180
|
+
import { createTestSync } from '@syncular/testkit';
|
|
181
|
+
import { syncWrapper } from '@syncular/testkit/react';
|
|
182
|
+
|
|
183
|
+
const sync = await createTestSync({ schema });
|
|
184
|
+
const client = await sync.client('a');
|
|
185
|
+
client.api.subscribe({ id: 's', table: 'notes', scopes: { list_id: ['x'] } });
|
|
186
|
+
await client.sync();
|
|
187
|
+
|
|
188
|
+
const { result } = renderHook(
|
|
189
|
+
() => useSyncQuery('SELECT * FROM notes'),
|
|
190
|
+
{ wrapper: syncWrapper(client) },
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
await act(async () => {
|
|
194
|
+
client.api.mutate([{ table: 'notes', op: 'upsert', values: { id: 'n1', list_id: 'x', body: 'hi' } }]);
|
|
195
|
+
});
|
|
196
|
+
await waitFor(() => expect(result.current.rows).toHaveLength(1));
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The re-render is driven by the client's **real** invalidation choke point —
|
|
200
|
+
the same one production hooks fire on — so a passing test reflects real
|
|
201
|
+
live-query behaviour.
|
|
202
|
+
|
|
203
|
+
## What it is not
|
|
204
|
+
|
|
205
|
+
- Not an HTTP harness. The transport is an in-process loopback; if you need
|
|
206
|
+
to test your Hono/Workers adapter or real fetch/WebSocket wiring, boot the
|
|
207
|
+
server yourself (see `examples/quickstart`).
|
|
208
|
+
- Not the conformance runner. No driver/pairing abstraction, no catalog —
|
|
209
|
+
those live in `@syncular/conformance`.
|
|
210
|
+
- Not a `setTimeout` mock. See the clock note above.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client half of a test sync: a real `SyncClient` on bun:sqlite wired
|
|
3
|
+
* to the {@link TestServer} through an in-process loopback seam. No HTTP —
|
|
4
|
+
* the transport calls `handleSyncRequest` directly, the segment downloader
|
|
5
|
+
* calls `handleSegmentDownload`, and (when realtime is on) the connector
|
|
6
|
+
* attaches a `RealtimeSession` to the hub. Every hop passes through a fault
|
|
7
|
+
* gate so an app test can drop / duplicate / corrupt individual exchanges,
|
|
8
|
+
* and an offline flag that makes every hop reject like a lost network.
|
|
9
|
+
*
|
|
10
|
+
* The fault controller is the conformance harness's `TransportFaults`,
|
|
11
|
+
* re-exported (see `index.ts`) — the SAME vocabulary the reference pairing
|
|
12
|
+
* arms, not a parallel one.
|
|
13
|
+
*/
|
|
14
|
+
import { type ClientSchema, SyncClient, type SyncClientConfig } from '@syncular/client';
|
|
15
|
+
import type { VirtualClock } from './clock.js';
|
|
16
|
+
import { TransportFaults } from './faults.js';
|
|
17
|
+
import type { TestServer } from './server.js';
|
|
18
|
+
/**
|
|
19
|
+
* A test client. Everything an app test needs to script a scenario:
|
|
20
|
+
* the real `SyncClient` (`api`, or spread it — see the getters), the fault
|
|
21
|
+
* controller, and the connectivity toggle.
|
|
22
|
+
*/
|
|
23
|
+
export interface TestClient {
|
|
24
|
+
/** The stable client id (§1.5). */
|
|
25
|
+
readonly id: string;
|
|
26
|
+
/** The actor this client authenticates as (§1.1). */
|
|
27
|
+
readonly actorId: string;
|
|
28
|
+
/** The underlying `SyncClient` — the full shipped client API. */
|
|
29
|
+
readonly api: SyncClient;
|
|
30
|
+
/** Transport-seam faults: drop / duplicate / corrupt the next exchange. */
|
|
31
|
+
readonly faults: TransportFaults;
|
|
32
|
+
/** True while the client is offline (every hop rejects). */
|
|
33
|
+
readonly offline: boolean;
|
|
34
|
+
/** Cut the network: sync / segment / realtime hops reject until online. */
|
|
35
|
+
goOffline(): void;
|
|
36
|
+
/** Restore the network. Does NOT auto-sync — call `sync()` yourself. */
|
|
37
|
+
goOnline(): void;
|
|
38
|
+
/** Push the outbox and pull to quiescence (`SyncClient.syncUntilIdle`). */
|
|
39
|
+
sync(): Promise<void>;
|
|
40
|
+
/** Attach a realtime socket to the hub (deltas, presence, socket rounds). */
|
|
41
|
+
connectRealtime(): Promise<void>;
|
|
42
|
+
/** Detach the realtime socket. */
|
|
43
|
+
disconnectRealtime(): void;
|
|
44
|
+
/** Close the client and its database. */
|
|
45
|
+
close(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export interface TestClientOptions {
|
|
48
|
+
readonly server: TestServer;
|
|
49
|
+
readonly schema: ClientSchema;
|
|
50
|
+
readonly clock: VirtualClock;
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly actorId: string;
|
|
53
|
+
/**
|
|
54
|
+
* Extra `SyncClient` config merged over the loopback defaults — e.g.
|
|
55
|
+
* `onConflict`, `limits`. `database`, `schema`, `clientId`, `now`,
|
|
56
|
+
* `transport`, `segments`, and `realtime` are owned by the kit.
|
|
57
|
+
*/
|
|
58
|
+
readonly clientConfig?: Partial<Omit<SyncClientConfig, 'database' | 'schema' | 'clientId' | 'now' | 'transport' | 'segments' | 'realtime'>>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build a `TestClient`. The client is NOT started — the caller (or
|
|
62
|
+
* `createTestSync`) awaits `api.start()`. Split out so `createTestSync` can
|
|
63
|
+
* construct and start in one place.
|
|
64
|
+
*/
|
|
65
|
+
export declare function buildTestClient(options: TestClientOptions): {
|
|
66
|
+
client: TestClient;
|
|
67
|
+
start: () => Promise<void>;
|
|
68
|
+
};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client half of a test sync: a real `SyncClient` on bun:sqlite wired
|
|
3
|
+
* to the {@link TestServer} through an in-process loopback seam. No HTTP —
|
|
4
|
+
* the transport calls `handleSyncRequest` directly, the segment downloader
|
|
5
|
+
* calls `handleSegmentDownload`, and (when realtime is on) the connector
|
|
6
|
+
* attaches a `RealtimeSession` to the hub. Every hop passes through a fault
|
|
7
|
+
* gate so an app test can drop / duplicate / corrupt individual exchanges,
|
|
8
|
+
* and an offline flag that makes every hop reject like a lost network.
|
|
9
|
+
*
|
|
10
|
+
* The fault controller is the conformance harness's `TransportFaults`,
|
|
11
|
+
* re-exported (see `index.ts`) — the SAME vocabulary the reference pairing
|
|
12
|
+
* arms, not a parallel one.
|
|
13
|
+
*/
|
|
14
|
+
import { SyncClient, } from '@syncular/client';
|
|
15
|
+
import { BunClientDatabase } from '@syncular/client/bun';
|
|
16
|
+
import { handleSegmentDownload, handleSyncRequest } from '@syncular/server';
|
|
17
|
+
import { seededRandom, TransportFault, TransportFaults } from './faults.js';
|
|
18
|
+
/** A client-facing offline error: rejects a hop the way a dead network does. */
|
|
19
|
+
class OfflineError extends Error {
|
|
20
|
+
name = 'OfflineError';
|
|
21
|
+
code = 'sync.transport_failed';
|
|
22
|
+
retryable = true;
|
|
23
|
+
constructor() {
|
|
24
|
+
super('injected: client is offline');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build a `TestClient`. The client is NOT started — the caller (or
|
|
29
|
+
* `createTestSync`) awaits `api.start()`. Split out so `createTestSync` can
|
|
30
|
+
* construct and start in one place.
|
|
31
|
+
*/
|
|
32
|
+
export function buildTestClient(options) {
|
|
33
|
+
const { server, schema, clock, id, actorId } = options;
|
|
34
|
+
const db = new BunClientDatabase();
|
|
35
|
+
const faults = new TransportFaults(seededRandom(hashSeed(id)));
|
|
36
|
+
const state = { offline: false };
|
|
37
|
+
const ctx = () => server.ctxFor(actorId);
|
|
38
|
+
const transport = async (request) => {
|
|
39
|
+
if (state.offline)
|
|
40
|
+
throw new OfflineError();
|
|
41
|
+
if (faults.dropNextRequests > 0) {
|
|
42
|
+
faults.dropNextRequests -= 1;
|
|
43
|
+
throw new TransportFault('injected: request lost');
|
|
44
|
+
}
|
|
45
|
+
if (faults.duplicateNextRequest) {
|
|
46
|
+
faults.duplicateNextRequest = false;
|
|
47
|
+
// First delivery is processed by the server; its response is discarded
|
|
48
|
+
// (the §6 idempotency cache absorbs the replay on the second delivery).
|
|
49
|
+
await handleSyncRequest(request, ctx());
|
|
50
|
+
}
|
|
51
|
+
const bytes = await handleSyncRequest(request, ctx());
|
|
52
|
+
if (faults.dropNextResponses > 0) {
|
|
53
|
+
faults.dropNextResponses -= 1;
|
|
54
|
+
throw new TransportFault('injected: response lost');
|
|
55
|
+
}
|
|
56
|
+
if (faults.truncateNextResponse) {
|
|
57
|
+
faults.truncateNextResponse = false;
|
|
58
|
+
return faults.truncate(bytes);
|
|
59
|
+
}
|
|
60
|
+
return bytes;
|
|
61
|
+
};
|
|
62
|
+
const segments = async (request) => {
|
|
63
|
+
if (state.offline)
|
|
64
|
+
throw new OfflineError();
|
|
65
|
+
if (faults.dropNextSegmentRequests > 0) {
|
|
66
|
+
faults.dropNextSegmentRequests -= 1;
|
|
67
|
+
throw new TransportFault('injected: segment request lost');
|
|
68
|
+
}
|
|
69
|
+
const result = await handleSegmentDownload(ctx(), {
|
|
70
|
+
segmentId: request.segmentId,
|
|
71
|
+
scopesHeader: request.requestedScopesJson,
|
|
72
|
+
});
|
|
73
|
+
if (faults.truncateNextSegmentDownload) {
|
|
74
|
+
faults.truncateNextSegmentDownload = false;
|
|
75
|
+
return faults.truncate(result.bytes);
|
|
76
|
+
}
|
|
77
|
+
return result.bytes;
|
|
78
|
+
};
|
|
79
|
+
const realtime = async (handlers) => {
|
|
80
|
+
if (state.offline)
|
|
81
|
+
throw new OfflineError();
|
|
82
|
+
const session = await server.hub.connect({
|
|
83
|
+
partition: server.partition,
|
|
84
|
+
actorId,
|
|
85
|
+
clientId: id,
|
|
86
|
+
send: (data) => {
|
|
87
|
+
if (typeof data === 'string')
|
|
88
|
+
handlers.onText(data);
|
|
89
|
+
else
|
|
90
|
+
handlers.onBinary(data);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
send: (text) => session.handleMessage(text),
|
|
95
|
+
sendBytes: (bytes) => session.handleBinary(bytes),
|
|
96
|
+
close: () => {
|
|
97
|
+
session.close();
|
|
98
|
+
handlers.onClose?.();
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
const api = new SyncClient({
|
|
103
|
+
database: db,
|
|
104
|
+
schema,
|
|
105
|
+
clientId: id,
|
|
106
|
+
now: () => clock.now(),
|
|
107
|
+
transport,
|
|
108
|
+
segments,
|
|
109
|
+
realtime,
|
|
110
|
+
...options.clientConfig,
|
|
111
|
+
});
|
|
112
|
+
const client = {
|
|
113
|
+
id,
|
|
114
|
+
actorId,
|
|
115
|
+
api,
|
|
116
|
+
faults,
|
|
117
|
+
get offline() {
|
|
118
|
+
return state.offline;
|
|
119
|
+
},
|
|
120
|
+
goOffline: () => {
|
|
121
|
+
state.offline = true;
|
|
122
|
+
api.disconnectRealtime();
|
|
123
|
+
},
|
|
124
|
+
goOnline: () => {
|
|
125
|
+
state.offline = false;
|
|
126
|
+
},
|
|
127
|
+
sync: async () => {
|
|
128
|
+
await api.syncUntilIdle();
|
|
129
|
+
},
|
|
130
|
+
connectRealtime: () => api.connectRealtime(),
|
|
131
|
+
disconnectRealtime: () => api.disconnectRealtime(),
|
|
132
|
+
close: () => api.close(),
|
|
133
|
+
};
|
|
134
|
+
return { client, start: () => api.start() };
|
|
135
|
+
}
|
|
136
|
+
/** Deterministic per-client fault seed from the client id (mirrors the
|
|
137
|
+
* conformance harness's per-scenario seeding). */
|
|
138
|
+
function hashSeed(name) {
|
|
139
|
+
let hash = 2166136261;
|
|
140
|
+
for (let i = 0; i < name.length; i++) {
|
|
141
|
+
hash ^= name.charCodeAt(i);
|
|
142
|
+
hash = Math.imul(hash, 16777619);
|
|
143
|
+
}
|
|
144
|
+
return hash >>> 0;
|
|
145
|
+
}
|
package/dist/clock.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A virtual clock the whole test sync shares — server, every client, and
|
|
3
|
+
* the realtime hub read the same `now()`. Time only moves when the test
|
|
4
|
+
* moves it, so §5.1 TTLs, §5.4 signed-URL expiry, and §7.3 lease windows
|
|
5
|
+
* are exercised deterministically with no wall-clock flake.
|
|
6
|
+
*
|
|
7
|
+
* This is deliberately NOT a fake-timers shim: it does not intercept
|
|
8
|
+
* `setTimeout`. It is the single epoch-ms source that Syncular's clock seam
|
|
9
|
+
* (`SyncServerConfig.clock`, `SyncClientConfig.now`) reads. Presence rate
|
|
10
|
+
* caps and heartbeats, which use real `setTimeout`, are out of scope — the
|
|
11
|
+
* kit targets sync/offline/fault behaviour, not wall-clock scheduling.
|
|
12
|
+
*/
|
|
13
|
+
/** The default epoch (ms) a fresh clock starts at — a fixed, readable point
|
|
14
|
+
* so golden values and TTL maths are stable across runs. */
|
|
15
|
+
export declare const DEFAULT_EPOCH_MS = 1750000000000;
|
|
16
|
+
export interface VirtualClock {
|
|
17
|
+
/** Current time in epoch ms — the value the clock seam returns. */
|
|
18
|
+
now(): number;
|
|
19
|
+
/** Move time forward by `ms` (must be ≥ 0). Returns the new `now()`. */
|
|
20
|
+
advance(ms: number): number;
|
|
21
|
+
/** Jump to an absolute epoch-ms instant. Returns the new `now()`. */
|
|
22
|
+
set(ms: number): number;
|
|
23
|
+
}
|
|
24
|
+
export declare function createVirtualClock(startMs?: number): VirtualClock;
|
package/dist/clock.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A virtual clock the whole test sync shares — server, every client, and
|
|
3
|
+
* the realtime hub read the same `now()`. Time only moves when the test
|
|
4
|
+
* moves it, so §5.1 TTLs, §5.4 signed-URL expiry, and §7.3 lease windows
|
|
5
|
+
* are exercised deterministically with no wall-clock flake.
|
|
6
|
+
*
|
|
7
|
+
* This is deliberately NOT a fake-timers shim: it does not intercept
|
|
8
|
+
* `setTimeout`. It is the single epoch-ms source that Syncular's clock seam
|
|
9
|
+
* (`SyncServerConfig.clock`, `SyncClientConfig.now`) reads. Presence rate
|
|
10
|
+
* caps and heartbeats, which use real `setTimeout`, are out of scope — the
|
|
11
|
+
* kit targets sync/offline/fault behaviour, not wall-clock scheduling.
|
|
12
|
+
*/
|
|
13
|
+
/** The default epoch (ms) a fresh clock starts at — a fixed, readable point
|
|
14
|
+
* so golden values and TTL maths are stable across runs. */
|
|
15
|
+
export const DEFAULT_EPOCH_MS = 1_750_000_000_000;
|
|
16
|
+
export function createVirtualClock(startMs = DEFAULT_EPOCH_MS) {
|
|
17
|
+
let ms = startMs;
|
|
18
|
+
return {
|
|
19
|
+
now: () => ms,
|
|
20
|
+
advance: (delta) => {
|
|
21
|
+
if (delta < 0) {
|
|
22
|
+
throw new Error(`clock.advance: time cannot move backwards (${delta}ms)`);
|
|
23
|
+
}
|
|
24
|
+
ms += delta;
|
|
25
|
+
return ms;
|
|
26
|
+
},
|
|
27
|
+
set: (next) => {
|
|
28
|
+
ms = next;
|
|
29
|
+
return ms;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createTestSync` — the one call an app test makes. It stands up an
|
|
3
|
+
* in-memory Syncular backend and hands back a factory for N real clients,
|
|
4
|
+
* all sharing one virtual clock, all talking to the server through an
|
|
5
|
+
* in-process loopback (no HTTP unless you ask). Everything is the SHIPPED
|
|
6
|
+
* core; the kit is wiring, not a mock.
|
|
7
|
+
*
|
|
8
|
+
* const sync = await createTestSync({ schema });
|
|
9
|
+
* const a = await sync.client('a');
|
|
10
|
+
* const b = await sync.client('b');
|
|
11
|
+
* a.api.subscribe({ id: 's', table: 'notes', scopes: { list_id: ['x'] } });
|
|
12
|
+
* b.api.subscribe({ id: 's', table: 'notes', scopes: { list_id: ['x'] } });
|
|
13
|
+
* a.api.mutate([{ table: 'notes', op: 'upsert', values: … }]);
|
|
14
|
+
* await sync.syncAll(); // both converge
|
|
15
|
+
* await sync.dispose();
|
|
16
|
+
*/
|
|
17
|
+
import type { ClientSchema, SyncClientConfig } from '@syncular/client';
|
|
18
|
+
import type { ResolveScopes } from '@syncular/server';
|
|
19
|
+
import { type TestClient } from './client.js';
|
|
20
|
+
import { type VirtualClock } from './clock.js';
|
|
21
|
+
import { type TestServer } from './server.js';
|
|
22
|
+
export interface CreateTestSyncOptions {
|
|
23
|
+
/**
|
|
24
|
+
* The generated schema (`syncular.generated.ts`'s `schema`, or any
|
|
25
|
+
* `ClientSchema`). The same object feeds the server and every client — a
|
|
26
|
+
* single-version, matched-schema world, which is what an app test wants.
|
|
27
|
+
*/
|
|
28
|
+
readonly schema: ClientSchema;
|
|
29
|
+
/** Partition every client lives in (§1.1); defaults to `"test"`. */
|
|
30
|
+
readonly partition?: string;
|
|
31
|
+
/** Default actor a client authenticates as; defaults to `"test-actor"`. */
|
|
32
|
+
readonly actorId?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Host authorization (§3.2 step 3). Omit for "grant everything" — the
|
|
35
|
+
* default resolver returns `'*'` for every scope variable the schema
|
|
36
|
+
* declares. Provide your own to test scope-scoping / revocation; it runs
|
|
37
|
+
* in the server exactly as it would in production.
|
|
38
|
+
*/
|
|
39
|
+
readonly resolveScopes?: ResolveScopes;
|
|
40
|
+
/** Epoch ms the shared virtual clock starts at (default 1_750_000_000_000). */
|
|
41
|
+
readonly startMs?: number;
|
|
42
|
+
}
|
|
43
|
+
export interface TestClientOverrides {
|
|
44
|
+
/** Actor id for this client (defaults to the sync-wide actor). */
|
|
45
|
+
readonly actorId?: string;
|
|
46
|
+
/** Extra `SyncClient` config (e.g. `onConflict`, `limits`). */
|
|
47
|
+
readonly clientConfig?: Partial<Omit<SyncClientConfig, 'database' | 'schema' | 'clientId' | 'now' | 'transport' | 'segments' | 'realtime'>>;
|
|
48
|
+
}
|
|
49
|
+
export interface TestSync {
|
|
50
|
+
/** The shared virtual clock — server + every client read it. */
|
|
51
|
+
readonly clock: VirtualClock;
|
|
52
|
+
/** The in-memory server (storage / segments / realtime hub). */
|
|
53
|
+
readonly server: TestServer;
|
|
54
|
+
/** Every client created so far, in creation order. */
|
|
55
|
+
readonly clients: readonly TestClient[];
|
|
56
|
+
/**
|
|
57
|
+
* Create and start one client. `id` is its stable client id (§1.5);
|
|
58
|
+
* omit for an auto-generated one. Returns once `start()` has run, so the
|
|
59
|
+
* client is ready to `subscribe` / `mutate` immediately.
|
|
60
|
+
*/
|
|
61
|
+
client(id?: string, overrides?: TestClientOverrides): Promise<TestClient>;
|
|
62
|
+
/** `syncUntilIdle` on every online client — the "let them converge" step. */
|
|
63
|
+
syncAll(): Promise<void>;
|
|
64
|
+
/** Close every client and the server. Idempotent. */
|
|
65
|
+
dispose(): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
export declare function createTestSync(options: CreateTestSyncOptions): Promise<TestSync>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createTestSync` — the one call an app test makes. It stands up an
|
|
3
|
+
* in-memory Syncular backend and hands back a factory for N real clients,
|
|
4
|
+
* all sharing one virtual clock, all talking to the server through an
|
|
5
|
+
* in-process loopback (no HTTP unless you ask). Everything is the SHIPPED
|
|
6
|
+
* core; the kit is wiring, not a mock.
|
|
7
|
+
*
|
|
8
|
+
* const sync = await createTestSync({ schema });
|
|
9
|
+
* const a = await sync.client('a');
|
|
10
|
+
* const b = await sync.client('b');
|
|
11
|
+
* a.api.subscribe({ id: 's', table: 'notes', scopes: { list_id: ['x'] } });
|
|
12
|
+
* b.api.subscribe({ id: 's', table: 'notes', scopes: { list_id: ['x'] } });
|
|
13
|
+
* a.api.mutate([{ table: 'notes', op: 'upsert', values: … }]);
|
|
14
|
+
* await sync.syncAll(); // both converge
|
|
15
|
+
* await sync.dispose();
|
|
16
|
+
*/
|
|
17
|
+
import { buildTestClient } from './client.js';
|
|
18
|
+
import { createVirtualClock } from './clock.js';
|
|
19
|
+
import { createTestServer, DEFAULT_ACTOR, DEFAULT_PARTITION, } from './server.js';
|
|
20
|
+
export async function createTestSync(options) {
|
|
21
|
+
const partition = options.partition ?? DEFAULT_PARTITION;
|
|
22
|
+
const defaultActor = options.actorId ?? DEFAULT_ACTOR;
|
|
23
|
+
const clock = createVirtualClock(options.startMs);
|
|
24
|
+
const server = createTestServer({
|
|
25
|
+
schema: options.schema,
|
|
26
|
+
clock,
|
|
27
|
+
partition,
|
|
28
|
+
...(options.resolveScopes !== undefined
|
|
29
|
+
? { resolveScopes: options.resolveScopes }
|
|
30
|
+
: {}),
|
|
31
|
+
});
|
|
32
|
+
const clients = [];
|
|
33
|
+
let autoId = 0;
|
|
34
|
+
let disposed = false;
|
|
35
|
+
const sync = {
|
|
36
|
+
clock,
|
|
37
|
+
server,
|
|
38
|
+
clients,
|
|
39
|
+
client: async (id, overrides) => {
|
|
40
|
+
const clientId = id ?? `client-${++autoId}`;
|
|
41
|
+
const { client, start } = buildTestClient({
|
|
42
|
+
server,
|
|
43
|
+
schema: options.schema,
|
|
44
|
+
clock,
|
|
45
|
+
id: clientId,
|
|
46
|
+
actorId: overrides?.actorId ?? defaultActor,
|
|
47
|
+
...(overrides?.clientConfig !== undefined
|
|
48
|
+
? { clientConfig: overrides.clientConfig }
|
|
49
|
+
: {}),
|
|
50
|
+
});
|
|
51
|
+
await start();
|
|
52
|
+
clients.push(client);
|
|
53
|
+
return client;
|
|
54
|
+
},
|
|
55
|
+
syncAll: async () => {
|
|
56
|
+
// Sequential, not parallel: the reference server serializes anyway, and
|
|
57
|
+
// sequential rounds make cross-client convergence order deterministic.
|
|
58
|
+
// Repeat until no online client still has pending commits — one pass of
|
|
59
|
+
// A's push may be another's pull.
|
|
60
|
+
for (let pass = 0; pass < 20; pass++) {
|
|
61
|
+
let progressed = false;
|
|
62
|
+
for (const client of clients) {
|
|
63
|
+
if (client.offline)
|
|
64
|
+
continue;
|
|
65
|
+
const before = client.api.pendingCommits().length;
|
|
66
|
+
await client.sync();
|
|
67
|
+
if (before > 0)
|
|
68
|
+
progressed = true;
|
|
69
|
+
}
|
|
70
|
+
if (!progressed)
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
dispose: async () => {
|
|
75
|
+
if (disposed)
|
|
76
|
+
return;
|
|
77
|
+
disposed = true;
|
|
78
|
+
for (const client of clients) {
|
|
79
|
+
try {
|
|
80
|
+
await client.close();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// best-effort teardown; keep closing the rest
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
server.close();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
return sync;
|
|
90
|
+
}
|