@syncular/testkit 0.1.3 → 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 -11
- package/dist/index.js +18 -11
- package/dist/react.d.ts +10 -0
- package/dist/react.js +41 -0
- package/dist/server.d.ts +44 -4
- package/dist/server.js +68 -4
- package/package.json +41 -57
- 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 -10
- package/src/react.ts +46 -0
- package/src/server.ts +102 -3
- 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/server.d.ts.map +0 -1
- package/dist/server.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/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/testkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Syncular test kit: in-process loopback server + clients for integration scenarios",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
7
7
|
"homepage": "https://syncular.dev",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "https://github.com/syncular/syncular.git",
|
|
11
|
-
"directory": "packages/
|
|
11
|
+
"directory": "packages/testing"
|
|
12
12
|
},
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/syncular/syncular/issues"
|
|
@@ -16,88 +16,72 @@
|
|
|
16
16
|
"keywords": [
|
|
17
17
|
"sync",
|
|
18
18
|
"offline-first",
|
|
19
|
-
"
|
|
20
|
-
"
|
|
19
|
+
"realtime",
|
|
20
|
+
"database",
|
|
21
21
|
"typescript"
|
|
22
22
|
],
|
|
23
|
-
"
|
|
23
|
+
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
24
25
|
"publishConfig": {
|
|
25
26
|
"access": "public"
|
|
26
27
|
},
|
|
27
|
-
"type": "module",
|
|
28
28
|
"exports": {
|
|
29
29
|
".": {
|
|
30
30
|
"bun": "./src/index.ts",
|
|
31
|
+
"browser": "./src/index.ts",
|
|
31
32
|
"import": {
|
|
32
33
|
"types": "./dist/index.d.ts",
|
|
33
34
|
"default": "./dist/index.js"
|
|
34
35
|
}
|
|
35
36
|
},
|
|
36
|
-
"./
|
|
37
|
-
"bun": "./src/
|
|
37
|
+
"./react": {
|
|
38
|
+
"bun": "./src/react.ts",
|
|
39
|
+
"browser": "./src/react.ts",
|
|
38
40
|
"import": {
|
|
39
|
-
"types": "./dist/
|
|
40
|
-
"default": "./dist/
|
|
41
|
+
"types": "./dist/react.d.ts",
|
|
42
|
+
"default": "./dist/react.js"
|
|
41
43
|
}
|
|
42
44
|
},
|
|
43
|
-
"./
|
|
44
|
-
"bun": "./src/
|
|
45
|
+
"./faults": {
|
|
46
|
+
"bun": "./src/faults.ts",
|
|
47
|
+
"browser": "./src/faults.ts",
|
|
45
48
|
"import": {
|
|
46
|
-
"types": "./dist/
|
|
47
|
-
"default": "./dist/
|
|
49
|
+
"types": "./dist/faults.d.ts",
|
|
50
|
+
"default": "./dist/faults.js"
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
},
|
|
51
|
-
"scripts": {
|
|
52
|
-
"test": "bun test --pass-with-no-tests",
|
|
53
|
-
"tsgo": "tsgo --noEmit",
|
|
54
|
-
"build": "tsgo",
|
|
55
|
-
"release": "syncular-publish"
|
|
56
|
-
},
|
|
57
|
-
"dependencies": {
|
|
58
|
-
"@syncular/core": "0.1.3"
|
|
59
|
-
},
|
|
60
|
-
"peerDependencies": {
|
|
61
|
-
"@electric-sql/pglite": "^0.3.15",
|
|
62
|
-
"@syncular/client": "0.1.3",
|
|
63
|
-
"@syncular/server": "0.1.3",
|
|
64
|
-
"hono": "^4.12.15",
|
|
65
|
-
"kysely": "^0.28.0"
|
|
66
|
-
},
|
|
67
|
-
"devDependencies": {
|
|
68
|
-
"@electric-sql/pglite": "^0.3.15",
|
|
69
|
-
"@syncular/client": "0.1.3",
|
|
70
|
-
"@syncular/config": "0.0.0",
|
|
71
|
-
"@syncular/server": "0.1.3",
|
|
72
|
-
"hono": "^4.12.15",
|
|
73
|
-
"kysely": "*"
|
|
74
|
-
},
|
|
75
54
|
"files": [
|
|
76
55
|
"dist",
|
|
77
56
|
"src",
|
|
57
|
+
"README.md",
|
|
78
58
|
"!src/**/*.test.ts",
|
|
79
59
|
"!src/**/*.test.tsx",
|
|
80
|
-
"!
|
|
81
|
-
"!dist/**/*.test
|
|
82
|
-
"!dist/**/__tests__/**",
|
|
83
|
-
"!test/**",
|
|
84
|
-
"!tests/**"
|
|
60
|
+
"!dist/**/*.test.js",
|
|
61
|
+
"!dist/**/*.test.d.ts"
|
|
85
62
|
],
|
|
63
|
+
"scripts": {
|
|
64
|
+
"test": "bun test --preload ./test/setup.ts"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"@syncular/server": "0.2.0",
|
|
68
|
+
"@syncular/client": "0.2.0"
|
|
69
|
+
},
|
|
70
|
+
"peerDependencies": {
|
|
71
|
+
"react": ">=18.0.0"
|
|
72
|
+
},
|
|
86
73
|
"peerDependenciesMeta": {
|
|
87
|
-
"
|
|
88
|
-
"optional": true
|
|
89
|
-
},
|
|
90
|
-
"@syncular/client": {
|
|
91
|
-
"optional": true
|
|
92
|
-
},
|
|
93
|
-
"@syncular/server": {
|
|
94
|
-
"optional": true
|
|
95
|
-
},
|
|
96
|
-
"hono": {
|
|
97
|
-
"optional": true
|
|
98
|
-
},
|
|
99
|
-
"kysely": {
|
|
74
|
+
"react": {
|
|
100
75
|
"optional": true
|
|
101
76
|
}
|
|
77
|
+
},
|
|
78
|
+
"devDependencies": {
|
|
79
|
+
"@happy-dom/global-registrator": "^15.11.0",
|
|
80
|
+
"@syncular/core": "0.2.0",
|
|
81
|
+
"@syncular/react": "0.2.0",
|
|
82
|
+
"@testing-library/react": "^16.1.0",
|
|
83
|
+
"@types/react": "^18.3.0",
|
|
84
|
+
"react": "^18.3.1",
|
|
85
|
+
"react-dom": "^18.3.1"
|
|
102
86
|
}
|
|
103
87
|
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
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
|
+
|
|
15
|
+
import {
|
|
16
|
+
type ClientSchema,
|
|
17
|
+
type RealtimeConnector,
|
|
18
|
+
type SegmentDownloader,
|
|
19
|
+
type SegmentFetchRequest,
|
|
20
|
+
SyncClient,
|
|
21
|
+
type SyncClientConfig,
|
|
22
|
+
type SyncTransport,
|
|
23
|
+
} from '@syncular/client';
|
|
24
|
+
import { BunClientDatabase } from '@syncular/client/bun';
|
|
25
|
+
import { handleSegmentDownload, handleSyncRequest } from '@syncular/server';
|
|
26
|
+
import type { VirtualClock } from './clock';
|
|
27
|
+
import { seededRandom, TransportFault, TransportFaults } from './faults';
|
|
28
|
+
import type { TestServer } from './server';
|
|
29
|
+
|
|
30
|
+
/** A client-facing offline error: rejects a hop the way a dead network does. */
|
|
31
|
+
class OfflineError extends Error {
|
|
32
|
+
override readonly name = 'OfflineError';
|
|
33
|
+
readonly code = 'sync.transport_failed';
|
|
34
|
+
readonly retryable = true;
|
|
35
|
+
constructor() {
|
|
36
|
+
super('injected: client is offline');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A test client. Everything an app test needs to script a scenario:
|
|
42
|
+
* the real `SyncClient` (`api`, or spread it — see the getters), the fault
|
|
43
|
+
* controller, and the connectivity toggle.
|
|
44
|
+
*/
|
|
45
|
+
export interface TestClient {
|
|
46
|
+
/** The stable client id (§1.5). */
|
|
47
|
+
readonly id: string;
|
|
48
|
+
/** The actor this client authenticates as (§1.1). */
|
|
49
|
+
readonly actorId: string;
|
|
50
|
+
/** The underlying `SyncClient` — the full shipped client API. */
|
|
51
|
+
readonly api: SyncClient;
|
|
52
|
+
/** Transport-seam faults: drop / duplicate / corrupt the next exchange. */
|
|
53
|
+
readonly faults: TransportFaults;
|
|
54
|
+
/** True while the client is offline (every hop rejects). */
|
|
55
|
+
readonly offline: boolean;
|
|
56
|
+
/** Cut the network: sync / segment / realtime hops reject until online. */
|
|
57
|
+
goOffline(): void;
|
|
58
|
+
/** Restore the network. Does NOT auto-sync — call `sync()` yourself. */
|
|
59
|
+
goOnline(): void;
|
|
60
|
+
/** Push the outbox and pull to quiescence (`SyncClient.syncUntilIdle`). */
|
|
61
|
+
sync(): Promise<void>;
|
|
62
|
+
/** Attach a realtime socket to the hub (deltas, presence, socket rounds). */
|
|
63
|
+
connectRealtime(): Promise<void>;
|
|
64
|
+
/** Detach the realtime socket. */
|
|
65
|
+
disconnectRealtime(): void;
|
|
66
|
+
/** Close the client and its database. */
|
|
67
|
+
close(): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TestClientOptions {
|
|
71
|
+
readonly server: TestServer;
|
|
72
|
+
readonly schema: ClientSchema;
|
|
73
|
+
readonly clock: VirtualClock;
|
|
74
|
+
readonly id: string;
|
|
75
|
+
readonly actorId: string;
|
|
76
|
+
/**
|
|
77
|
+
* Extra `SyncClient` config merged over the loopback defaults — e.g.
|
|
78
|
+
* `onConflict`, `limits`. `database`, `schema`, `clientId`, `now`,
|
|
79
|
+
* `transport`, `segments`, and `realtime` are owned by the kit.
|
|
80
|
+
*/
|
|
81
|
+
readonly clientConfig?: Partial<
|
|
82
|
+
Omit<
|
|
83
|
+
SyncClientConfig,
|
|
84
|
+
| 'database'
|
|
85
|
+
| 'schema'
|
|
86
|
+
| 'clientId'
|
|
87
|
+
| 'now'
|
|
88
|
+
| 'transport'
|
|
89
|
+
| 'segments'
|
|
90
|
+
| 'realtime'
|
|
91
|
+
>
|
|
92
|
+
>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build a `TestClient`. The client is NOT started — the caller (or
|
|
97
|
+
* `createTestSync`) awaits `api.start()`. Split out so `createTestSync` can
|
|
98
|
+
* construct and start in one place.
|
|
99
|
+
*/
|
|
100
|
+
export function buildTestClient(options: TestClientOptions): {
|
|
101
|
+
client: TestClient;
|
|
102
|
+
start: () => Promise<void>;
|
|
103
|
+
} {
|
|
104
|
+
const { server, schema, clock, id, actorId } = options;
|
|
105
|
+
const db = new BunClientDatabase();
|
|
106
|
+
const faults = new TransportFaults(seededRandom(hashSeed(id)));
|
|
107
|
+
const state = { offline: false };
|
|
108
|
+
const ctx = () => server.ctxFor(actorId);
|
|
109
|
+
|
|
110
|
+
const transport: SyncTransport = async (request) => {
|
|
111
|
+
if (state.offline) throw new OfflineError();
|
|
112
|
+
if (faults.dropNextRequests > 0) {
|
|
113
|
+
faults.dropNextRequests -= 1;
|
|
114
|
+
throw new TransportFault('injected: request lost');
|
|
115
|
+
}
|
|
116
|
+
if (faults.duplicateNextRequest) {
|
|
117
|
+
faults.duplicateNextRequest = false;
|
|
118
|
+
// First delivery is processed by the server; its response is discarded
|
|
119
|
+
// (the §6 idempotency cache absorbs the replay on the second delivery).
|
|
120
|
+
await handleSyncRequest(request, ctx());
|
|
121
|
+
}
|
|
122
|
+
const bytes = await handleSyncRequest(request, ctx());
|
|
123
|
+
if (faults.dropNextResponses > 0) {
|
|
124
|
+
faults.dropNextResponses -= 1;
|
|
125
|
+
throw new TransportFault('injected: response lost');
|
|
126
|
+
}
|
|
127
|
+
if (faults.truncateNextResponse) {
|
|
128
|
+
faults.truncateNextResponse = false;
|
|
129
|
+
return faults.truncate(bytes);
|
|
130
|
+
}
|
|
131
|
+
return bytes;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const segments: SegmentDownloader = async (request: SegmentFetchRequest) => {
|
|
135
|
+
if (state.offline) throw new OfflineError();
|
|
136
|
+
if (faults.dropNextSegmentRequests > 0) {
|
|
137
|
+
faults.dropNextSegmentRequests -= 1;
|
|
138
|
+
throw new TransportFault('injected: segment request lost');
|
|
139
|
+
}
|
|
140
|
+
const result = await handleSegmentDownload(ctx(), {
|
|
141
|
+
segmentId: request.segmentId,
|
|
142
|
+
scopesHeader: request.requestedScopesJson,
|
|
143
|
+
});
|
|
144
|
+
if (faults.truncateNextSegmentDownload) {
|
|
145
|
+
faults.truncateNextSegmentDownload = false;
|
|
146
|
+
return faults.truncate(result.bytes);
|
|
147
|
+
}
|
|
148
|
+
return result.bytes;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const realtime: RealtimeConnector = async (handlers) => {
|
|
152
|
+
if (state.offline) throw new OfflineError();
|
|
153
|
+
const session = await server.hub.connect({
|
|
154
|
+
partition: server.partition,
|
|
155
|
+
actorId,
|
|
156
|
+
clientId: id,
|
|
157
|
+
send: (data) => {
|
|
158
|
+
if (typeof data === 'string') handlers.onText(data);
|
|
159
|
+
else handlers.onBinary(data);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
send: (text) => session.handleMessage(text),
|
|
164
|
+
sendBytes: (bytes) => session.handleBinary(bytes),
|
|
165
|
+
close: () => {
|
|
166
|
+
session.close();
|
|
167
|
+
handlers.onClose?.();
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const api = new SyncClient({
|
|
173
|
+
database: db,
|
|
174
|
+
schema,
|
|
175
|
+
clientId: id,
|
|
176
|
+
now: () => clock.now(),
|
|
177
|
+
transport,
|
|
178
|
+
segments,
|
|
179
|
+
realtime,
|
|
180
|
+
...options.clientConfig,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const client: TestClient = {
|
|
184
|
+
id,
|
|
185
|
+
actorId,
|
|
186
|
+
api,
|
|
187
|
+
faults,
|
|
188
|
+
get offline() {
|
|
189
|
+
return state.offline;
|
|
190
|
+
},
|
|
191
|
+
goOffline: () => {
|
|
192
|
+
state.offline = true;
|
|
193
|
+
api.disconnectRealtime();
|
|
194
|
+
},
|
|
195
|
+
goOnline: () => {
|
|
196
|
+
state.offline = false;
|
|
197
|
+
},
|
|
198
|
+
sync: async () => {
|
|
199
|
+
await api.syncUntilIdle();
|
|
200
|
+
},
|
|
201
|
+
connectRealtime: () => api.connectRealtime(),
|
|
202
|
+
disconnectRealtime: () => api.disconnectRealtime(),
|
|
203
|
+
close: () => api.close(),
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return { client, start: () => api.start() };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Deterministic per-client fault seed from the client id (mirrors the
|
|
210
|
+
* conformance harness's per-scenario seeding). */
|
|
211
|
+
function hashSeed(name: string): number {
|
|
212
|
+
let hash = 2166136261;
|
|
213
|
+
for (let i = 0; i < name.length; i++) {
|
|
214
|
+
hash ^= name.charCodeAt(i);
|
|
215
|
+
hash = Math.imul(hash, 16777619);
|
|
216
|
+
}
|
|
217
|
+
return hash >>> 0;
|
|
218
|
+
}
|
package/src/clock.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
|
|
14
|
+
/** The default epoch (ms) a fresh clock starts at — a fixed, readable point
|
|
15
|
+
* so golden values and TTL maths are stable across runs. */
|
|
16
|
+
export const DEFAULT_EPOCH_MS = 1_750_000_000_000;
|
|
17
|
+
|
|
18
|
+
export interface VirtualClock {
|
|
19
|
+
/** Current time in epoch ms — the value the clock seam returns. */
|
|
20
|
+
now(): number;
|
|
21
|
+
/** Move time forward by `ms` (must be ≥ 0). Returns the new `now()`. */
|
|
22
|
+
advance(ms: number): number;
|
|
23
|
+
/** Jump to an absolute epoch-ms instant. Returns the new `now()`. */
|
|
24
|
+
set(ms: number): number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createVirtualClock(startMs = DEFAULT_EPOCH_MS): VirtualClock {
|
|
28
|
+
let ms = startMs;
|
|
29
|
+
return {
|
|
30
|
+
now: () => ms,
|
|
31
|
+
advance: (delta) => {
|
|
32
|
+
if (delta < 0) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`clock.advance: time cannot move backwards (${delta}ms)`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
ms += delta;
|
|
38
|
+
return ms;
|
|
39
|
+
},
|
|
40
|
+
set: (next) => {
|
|
41
|
+
ms = next;
|
|
42
|
+
return ms;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
|
|
18
|
+
import type { ClientSchema, SyncClientConfig } from '@syncular/client';
|
|
19
|
+
import type { ResolveScopes } from '@syncular/server';
|
|
20
|
+
import { buildTestClient, type TestClient } from './client';
|
|
21
|
+
import { createVirtualClock, type VirtualClock } from './clock';
|
|
22
|
+
import {
|
|
23
|
+
createTestServer,
|
|
24
|
+
DEFAULT_ACTOR,
|
|
25
|
+
DEFAULT_PARTITION,
|
|
26
|
+
type TestServer,
|
|
27
|
+
} from './server';
|
|
28
|
+
|
|
29
|
+
export interface CreateTestSyncOptions {
|
|
30
|
+
/**
|
|
31
|
+
* The generated schema (`syncular.generated.ts`'s `schema`, or any
|
|
32
|
+
* `ClientSchema`). The same object feeds the server and every client — a
|
|
33
|
+
* single-version, matched-schema world, which is what an app test wants.
|
|
34
|
+
*/
|
|
35
|
+
readonly schema: ClientSchema;
|
|
36
|
+
/** Partition every client lives in (§1.1); defaults to `"test"`. */
|
|
37
|
+
readonly partition?: string;
|
|
38
|
+
/** Default actor a client authenticates as; defaults to `"test-actor"`. */
|
|
39
|
+
readonly actorId?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Host authorization (§3.2 step 3). Omit for "grant everything" — the
|
|
42
|
+
* default resolver returns `'*'` for every scope variable the schema
|
|
43
|
+
* declares. Provide your own to test scope-scoping / revocation; it runs
|
|
44
|
+
* in the server exactly as it would in production.
|
|
45
|
+
*/
|
|
46
|
+
readonly resolveScopes?: ResolveScopes;
|
|
47
|
+
/** Epoch ms the shared virtual clock starts at (default 1_750_000_000_000). */
|
|
48
|
+
readonly startMs?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TestClientOverrides {
|
|
52
|
+
/** Actor id for this client (defaults to the sync-wide actor). */
|
|
53
|
+
readonly actorId?: string;
|
|
54
|
+
/** Extra `SyncClient` config (e.g. `onConflict`, `limits`). */
|
|
55
|
+
readonly clientConfig?: Partial<
|
|
56
|
+
Omit<
|
|
57
|
+
SyncClientConfig,
|
|
58
|
+
| 'database'
|
|
59
|
+
| 'schema'
|
|
60
|
+
| 'clientId'
|
|
61
|
+
| 'now'
|
|
62
|
+
| 'transport'
|
|
63
|
+
| 'segments'
|
|
64
|
+
| 'realtime'
|
|
65
|
+
>
|
|
66
|
+
>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface TestSync {
|
|
70
|
+
/** The shared virtual clock — server + every client read it. */
|
|
71
|
+
readonly clock: VirtualClock;
|
|
72
|
+
/** The in-memory server (storage / segments / realtime hub). */
|
|
73
|
+
readonly server: TestServer;
|
|
74
|
+
/** Every client created so far, in creation order. */
|
|
75
|
+
readonly clients: readonly TestClient[];
|
|
76
|
+
/**
|
|
77
|
+
* Create and start one client. `id` is its stable client id (§1.5);
|
|
78
|
+
* omit for an auto-generated one. Returns once `start()` has run, so the
|
|
79
|
+
* client is ready to `subscribe` / `mutate` immediately.
|
|
80
|
+
*/
|
|
81
|
+
client(id?: string, overrides?: TestClientOverrides): Promise<TestClient>;
|
|
82
|
+
/** `syncUntilIdle` on every online client — the "let them converge" step. */
|
|
83
|
+
syncAll(): Promise<void>;
|
|
84
|
+
/** Close every client and the server. Idempotent. */
|
|
85
|
+
dispose(): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function createTestSync(
|
|
89
|
+
options: CreateTestSyncOptions,
|
|
90
|
+
): Promise<TestSync> {
|
|
91
|
+
const partition = options.partition ?? DEFAULT_PARTITION;
|
|
92
|
+
const defaultActor = options.actorId ?? DEFAULT_ACTOR;
|
|
93
|
+
const clock = createVirtualClock(options.startMs);
|
|
94
|
+
const server = createTestServer({
|
|
95
|
+
schema: options.schema,
|
|
96
|
+
clock,
|
|
97
|
+
partition,
|
|
98
|
+
...(options.resolveScopes !== undefined
|
|
99
|
+
? { resolveScopes: options.resolveScopes }
|
|
100
|
+
: {}),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const clients: TestClient[] = [];
|
|
104
|
+
let autoId = 0;
|
|
105
|
+
let disposed = false;
|
|
106
|
+
|
|
107
|
+
const sync: TestSync = {
|
|
108
|
+
clock,
|
|
109
|
+
server,
|
|
110
|
+
clients,
|
|
111
|
+
client: async (id, overrides) => {
|
|
112
|
+
const clientId = id ?? `client-${++autoId}`;
|
|
113
|
+
const { client, start } = buildTestClient({
|
|
114
|
+
server,
|
|
115
|
+
schema: options.schema,
|
|
116
|
+
clock,
|
|
117
|
+
id: clientId,
|
|
118
|
+
actorId: overrides?.actorId ?? defaultActor,
|
|
119
|
+
...(overrides?.clientConfig !== undefined
|
|
120
|
+
? { clientConfig: overrides.clientConfig }
|
|
121
|
+
: {}),
|
|
122
|
+
});
|
|
123
|
+
await start();
|
|
124
|
+
clients.push(client);
|
|
125
|
+
return client;
|
|
126
|
+
},
|
|
127
|
+
syncAll: async () => {
|
|
128
|
+
// Sequential, not parallel: the reference server serializes anyway, and
|
|
129
|
+
// sequential rounds make cross-client convergence order deterministic.
|
|
130
|
+
// Repeat until no online client still has pending commits — one pass of
|
|
131
|
+
// A's push may be another's pull.
|
|
132
|
+
for (let pass = 0; pass < 20; pass++) {
|
|
133
|
+
let progressed = false;
|
|
134
|
+
for (const client of clients) {
|
|
135
|
+
if (client.offline) continue;
|
|
136
|
+
const before = client.api.pendingCommits().length;
|
|
137
|
+
await client.sync();
|
|
138
|
+
if (before > 0) progressed = true;
|
|
139
|
+
}
|
|
140
|
+
if (!progressed) return;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
dispose: async () => {
|
|
144
|
+
if (disposed) return;
|
|
145
|
+
disposed = true;
|
|
146
|
+
for (const client of clients) {
|
|
147
|
+
try {
|
|
148
|
+
await client.close();
|
|
149
|
+
} catch {
|
|
150
|
+
// best-effort teardown; keep closing the rest
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
server.close();
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return sync;
|
|
158
|
+
}
|