@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.
Files changed (92) hide show
  1. package/README.md +210 -0
  2. package/dist/client.d.ts +68 -0
  3. package/dist/client.js +145 -0
  4. package/dist/clock.d.ts +24 -0
  5. package/dist/clock.js +32 -0
  6. package/dist/create-test-sync.d.ts +67 -0
  7. package/dist/create-test-sync.js +90 -0
  8. package/dist/faults.d.ts +52 -50
  9. package/dist/faults.js +74 -192
  10. package/dist/index.d.ts +17 -15
  11. package/dist/index.js +18 -15
  12. package/dist/react.d.ts +10 -0
  13. package/dist/react.js +41 -0
  14. package/dist/server.d.ts +44 -0
  15. package/dist/server.js +68 -0
  16. package/package.json +52 -29
  17. package/src/client.ts +218 -0
  18. package/src/clock.ts +45 -0
  19. package/src/create-test-sync.ts +158 -0
  20. package/src/faults.ts +77 -338
  21. package/src/index.ts +44 -14
  22. package/src/react.ts +46 -0
  23. package/src/server.ts +102 -0
  24. package/dist/audit.d.ts +0 -22
  25. package/dist/audit.d.ts.map +0 -1
  26. package/dist/audit.js +0 -40
  27. package/dist/audit.js.map +0 -1
  28. package/dist/client-bridge.d.ts +0 -77
  29. package/dist/client-bridge.d.ts.map +0 -1
  30. package/dist/client-bridge.js +0 -540
  31. package/dist/client-bridge.js.map +0 -1
  32. package/dist/deterministic.d.ts +0 -31
  33. package/dist/deterministic.d.ts.map +0 -1
  34. package/dist/deterministic.js +0 -69
  35. package/dist/deterministic.js.map +0 -1
  36. package/dist/disposable.d.ts +0 -10
  37. package/dist/disposable.d.ts.map +0 -1
  38. package/dist/disposable.js +0 -28
  39. package/dist/disposable.js.map +0 -1
  40. package/dist/faults.d.ts.map +0 -1
  41. package/dist/faults.js.map +0 -1
  42. package/dist/hono-node-server.d.ts +0 -20
  43. package/dist/hono-node-server.d.ts.map +0 -1
  44. package/dist/hono-node-server.js +0 -90
  45. package/dist/hono-node-server.js.map +0 -1
  46. package/dist/http-fixtures.d.ts +0 -32
  47. package/dist/http-fixtures.d.ts.map +0 -1
  48. package/dist/http-fixtures.js +0 -117
  49. package/dist/http-fixtures.js.map +0 -1
  50. package/dist/index.d.ts.map +0 -1
  51. package/dist/index.js.map +0 -1
  52. package/dist/project-scoped-tasks.d.ts +0 -40
  53. package/dist/project-scoped-tasks.d.ts.map +0 -1
  54. package/dist/project-scoped-tasks.js +0 -247
  55. package/dist/project-scoped-tasks.js.map +0 -1
  56. package/dist/realtime-ws.d.ts +0 -48
  57. package/dist/realtime-ws.d.ts.map +0 -1
  58. package/dist/realtime-ws.js +0 -203
  59. package/dist/realtime-ws.js.map +0 -1
  60. package/dist/runtime-process.d.ts +0 -14
  61. package/dist/runtime-process.d.ts.map +0 -1
  62. package/dist/runtime-process.js +0 -99
  63. package/dist/runtime-process.js.map +0 -1
  64. package/dist/sync-builders.d.ts +0 -48
  65. package/dist/sync-builders.d.ts.map +0 -1
  66. package/dist/sync-builders.js +0 -66
  67. package/dist/sync-builders.js.map +0 -1
  68. package/dist/sync-http.d.ts +0 -48
  69. package/dist/sync-http.d.ts.map +0 -1
  70. package/dist/sync-http.js +0 -43
  71. package/dist/sync-http.js.map +0 -1
  72. package/dist/sync-parse.d.ts +0 -8
  73. package/dist/sync-parse.d.ts.map +0 -1
  74. package/dist/sync-parse.js +0 -38
  75. package/dist/sync-parse.js.map +0 -1
  76. package/dist/sync-response.d.ts +0 -6
  77. package/dist/sync-response.d.ts.map +0 -1
  78. package/dist/sync-response.js +0 -19
  79. package/dist/sync-response.js.map +0 -1
  80. package/src/audit.ts +0 -74
  81. package/src/client-bridge.ts +0 -841
  82. package/src/deterministic.ts +0 -121
  83. package/src/disposable.ts +0 -50
  84. package/src/hono-node-server.ts +0 -144
  85. package/src/http-fixtures.ts +0 -187
  86. package/src/project-scoped-tasks.ts +0 -351
  87. package/src/realtime-ws.ts +0 -354
  88. package/src/runtime-process.ts +0 -159
  89. package/src/sync-builders.ts +0 -136
  90. package/src/sync-http.ts +0 -117
  91. package/src/sync-parse.ts +0 -86
  92. 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.
@@ -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
+ }
@@ -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
+ }