@syncular/client-react 0.0.1

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.
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Integration tests for SyncProvider reconfiguration
3
+ *
4
+ * Tests that verify the SyncProvider correctly handles changes to critical props.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
8
+ import {
9
+ ClientTableRegistry,
10
+ ensureClientSyncSchema,
11
+ type SyncClientDb,
12
+ SyncEngine,
13
+ } from '@syncular/client';
14
+ import { createBunSqliteDb } from '@syncular/dialect-bun-sqlite';
15
+ import { cleanup, render } from '@testing-library/react';
16
+ import type { Kysely } from 'kysely';
17
+ import { createElement } from 'react';
18
+ import { createSyncularReact } from '../../index';
19
+ import {
20
+ createTestServer,
21
+ destroyTestServer,
22
+ type TestServer,
23
+ } from './test-setup';
24
+ import '../setup'; // Ensure happy-dom is registered
25
+
26
+ /**
27
+ * Client database schema for tests
28
+ */
29
+ interface ClientDb extends SyncClientDb {
30
+ tasks: {
31
+ id: string;
32
+ title: string;
33
+ completed: number;
34
+ user_id: string;
35
+ server_version: number;
36
+ };
37
+ }
38
+
39
+ const { SyncProvider } = createSyncularReact<ClientDb>();
40
+
41
+ // Create a mock ClientTableRegistry for tests
42
+ function createMockClientTableRegistry(): ClientTableRegistry<ClientDb> {
43
+ return new ClientTableRegistry<ClientDb>();
44
+ }
45
+
46
+ describe('SyncProvider Reconfiguration', () => {
47
+ let server: TestServer;
48
+ let db: Kysely<ClientDb>;
49
+ let mockShapes: ClientTableRegistry<ClientDb>;
50
+
51
+ beforeEach(async () => {
52
+ server = await createTestServer();
53
+ db = createBunSqliteDb<ClientDb>({ path: ':memory:' });
54
+ mockShapes = createMockClientTableRegistry();
55
+
56
+ await ensureClientSyncSchema(db);
57
+
58
+ // Create tasks table
59
+ await db.schema
60
+ .createTable('tasks')
61
+ .ifNotExists()
62
+ .addColumn('id', 'text', (col) => col.primaryKey())
63
+ .addColumn('title', 'text', (col) => col.notNull())
64
+ .addColumn('completed', 'integer', (col) => col.notNull().defaultTo(0))
65
+ .addColumn('user_id', 'text', (col) => col.notNull())
66
+ .addColumn('server_version', 'integer', (col) =>
67
+ col.notNull().defaultTo(0)
68
+ )
69
+ .execute();
70
+ });
71
+
72
+ afterEach(async () => {
73
+ await db.destroy();
74
+ await destroyTestServer(server);
75
+ });
76
+
77
+ it('SyncEngine recreates when actorId changes', async () => {
78
+ // Create a simple transport for testing
79
+ const transport = {
80
+ async pull() {
81
+ return {
82
+ ok: true as const,
83
+ subscriptions: [],
84
+ };
85
+ },
86
+ async push() {
87
+ return { ok: true as const, status: 'applied' as const, results: [] };
88
+ },
89
+ async fetchSnapshotChunk() {
90
+ return new Uint8Array();
91
+ },
92
+ };
93
+
94
+ // Create first engine with actorId 'user-1'
95
+ const engine1 = new SyncEngine({
96
+ db,
97
+ transport,
98
+ shapes: mockShapes,
99
+ actorId: 'user-1',
100
+ clientId: 'client-1',
101
+ subscriptions: [],
102
+ pollIntervalMs: 999999,
103
+ realtimeEnabled: false,
104
+ });
105
+
106
+ // Verify engine has correct actorId
107
+ expect(engine1.getActorId()).toBe('user-1');
108
+
109
+ // Create second engine with different actorId
110
+ const engine2 = new SyncEngine({
111
+ db,
112
+ transport,
113
+ shapes: mockShapes,
114
+ actorId: 'user-2',
115
+ clientId: 'client-1',
116
+ subscriptions: [],
117
+ pollIntervalMs: 999999,
118
+ realtimeEnabled: false,
119
+ });
120
+
121
+ // Verify new engine has different actorId
122
+ expect(engine2.getActorId()).toBe('user-2');
123
+
124
+ // Engines should be different instances
125
+ expect(engine1).not.toBe(engine2);
126
+
127
+ // Cleanup
128
+ engine1.destroy();
129
+ engine2.destroy();
130
+ });
131
+
132
+ it('error message format for prop changes is correct', () => {
133
+ // Tests that the error message format is correct for prop changes
134
+ // In dev mode, SyncProvider throws an error when critical props change
135
+ // In production, it logs an error
136
+
137
+ const initialProps = {
138
+ actorId: 'user-1',
139
+ clientId: 'client-1',
140
+ };
141
+
142
+ const newActorId = 'user-2';
143
+ const changedProps: string[] = [];
144
+
145
+ if (newActorId !== initialProps.actorId) changedProps.push('actorId');
146
+
147
+ // Verify message construction
148
+ expect(changedProps.length).toBe(1);
149
+ expect(changedProps[0]).toBe('actorId');
150
+
151
+ const message =
152
+ `[SyncProvider] Critical props changed after mount: ${changedProps.join(', ')}. ` +
153
+ 'This is not supported. Use a React key prop to force remount, e.g., ' +
154
+ '<SyncProvider key={actorId} ...>';
155
+
156
+ expect(message).toContain(
157
+ '[SyncProvider] Critical props changed after mount'
158
+ );
159
+ expect(message).toContain('actorId');
160
+ expect(message).toContain('This is not supported');
161
+ expect(message).toContain('<SyncProvider key={actorId} ...>');
162
+ });
163
+
164
+ it('engine config is immutable after creation', () => {
165
+ const transport = {
166
+ async pull() {
167
+ return { ok: true as const, subscriptions: [] };
168
+ },
169
+ async push() {
170
+ return { ok: true as const, status: 'applied' as const, results: [] };
171
+ },
172
+ async fetchSnapshotChunk() {
173
+ return new Uint8Array();
174
+ },
175
+ };
176
+
177
+ const engine = new SyncEngine({
178
+ db,
179
+ transport,
180
+ shapes: mockShapes,
181
+ actorId: 'user-1',
182
+ clientId: 'client-1',
183
+ subscriptions: [],
184
+ pollIntervalMs: 999999,
185
+ realtimeEnabled: false,
186
+ });
187
+
188
+ const originalActorId = engine.getActorId();
189
+ expect(originalActorId).toBe('user-1');
190
+
191
+ // Engine should maintain its original config
192
+ expect(engine.getActorId()).toBe('user-1');
193
+
194
+ engine.destroy();
195
+ });
196
+ });
197
+
198
+ describe('SyncProvider React render tests', () => {
199
+ let db: Kysely<ClientDb>;
200
+ let mockShapes: ClientTableRegistry<ClientDb>;
201
+ const mockTransport = {
202
+ async pull() {
203
+ return { ok: true as const, subscriptions: [] };
204
+ },
205
+ async push() {
206
+ return { ok: true as const, status: 'applied' as const, results: [] };
207
+ },
208
+ async fetchSnapshotChunk() {
209
+ return new Uint8Array();
210
+ },
211
+ };
212
+
213
+ beforeEach(async () => {
214
+ db = createBunSqliteDb<ClientDb>({ path: ':memory:' });
215
+ mockShapes = new ClientTableRegistry<ClientDb>();
216
+ await ensureClientSyncSchema(db);
217
+ });
218
+
219
+ afterEach(async () => {
220
+ cleanup();
221
+ await db.destroy();
222
+ });
223
+
224
+ it('warns when critical props change after mount', () => {
225
+ const child = createElement('div', null, 'Test Child');
226
+
227
+ // Render with initial props
228
+ const { rerender } = render(
229
+ createElement(SyncProvider, {
230
+ db,
231
+ transport: mockTransport,
232
+ shapes: mockShapes,
233
+ actorId: 'user-1',
234
+ clientId: 'client-1',
235
+ autoStart: false, // Disable auto-start for faster test
236
+ // biome-ignore lint/correctness/noChildrenProp: createElement requires children prop
237
+ children: child,
238
+ })
239
+ );
240
+
241
+ // Re-render with changed actorId should warn but not throw
242
+ expect(() => {
243
+ rerender(
244
+ createElement(SyncProvider, {
245
+ db,
246
+ transport: mockTransport,
247
+ shapes: mockShapes,
248
+ actorId: 'user-2', // Changed!
249
+ clientId: 'client-1',
250
+ autoStart: false,
251
+ // biome-ignore lint/correctness/noChildrenProp: createElement requires children prop
252
+ children: child,
253
+ })
254
+ );
255
+ }).not.toThrow();
256
+ });
257
+
258
+ it('does not throw when non-critical props change', () => {
259
+ const child = createElement('div', null, 'Test Child');
260
+ const { rerender } = render(
261
+ createElement(SyncProvider, {
262
+ db,
263
+ transport: mockTransport,
264
+ shapes: mockShapes,
265
+ actorId: 'user-1',
266
+ clientId: 'client-1',
267
+ autoStart: false,
268
+ pollIntervalMs: 1000,
269
+ // biome-ignore lint/correctness/noChildrenProp: createElement requires children prop
270
+ children: child,
271
+ })
272
+ );
273
+
274
+ // Re-render with changed pollIntervalMs should not throw
275
+ expect(() => {
276
+ rerender(
277
+ createElement(SyncProvider, {
278
+ db,
279
+ transport: mockTransport,
280
+ shapes: mockShapes,
281
+ actorId: 'user-1',
282
+ clientId: 'client-1',
283
+ autoStart: false,
284
+ pollIntervalMs: 5000, // Changed non-critical prop
285
+ // biome-ignore lint/correctness/noChildrenProp: createElement requires children prop
286
+ children: child,
287
+ })
288
+ );
289
+ }).not.toThrow();
290
+ });
291
+ });
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Integration tests for push flow
3
+ *
4
+ * Tests that verify the full push flow from mutation to server.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
8
+ import {
9
+ enqueueOutboxCommit,
10
+ getNextSendableOutboxCommit,
11
+ } from '@syncular/client';
12
+ import type { SyncPushRequest, SyncPushResponse } from '@syncular/core';
13
+ import {
14
+ createTestClient,
15
+ createTestServer,
16
+ destroyTestClient,
17
+ destroyTestServer,
18
+ type TestClient,
19
+ type TestServer,
20
+ } from './test-setup';
21
+
22
+ describe('Push Flow', () => {
23
+ let server: TestServer;
24
+ let client: TestClient;
25
+
26
+ const userId = 'test-user';
27
+
28
+ beforeEach(async () => {
29
+ server = await createTestServer();
30
+ client = await createTestClient(server, {
31
+ actorId: userId,
32
+ clientId: 'test-client',
33
+ });
34
+ await client.engine.start();
35
+ });
36
+
37
+ afterEach(async () => {
38
+ await destroyTestClient(client);
39
+ await destroyTestServer(server);
40
+ });
41
+
42
+ it('enqueue creates outbox commit with pending status', async () => {
43
+ // Enqueue a commit
44
+ const result = await enqueueOutboxCommit(client.db, {
45
+ operations: [
46
+ {
47
+ table: 'tasks',
48
+ row_id: 'task-1',
49
+ op: 'upsert',
50
+ payload: { title: 'Test Task', completed: 0, user_id: userId },
51
+ base_version: null,
52
+ },
53
+ ],
54
+ });
55
+
56
+ expect(result.id).toBeTruthy();
57
+ expect(result.clientCommitId).toBeTruthy();
58
+
59
+ // Verify the commit is in the outbox
60
+ // Note: getNextSendableOutboxCommit atomically claims the commit, so it returns with 'sending' status
61
+ const nextCommit = await getNextSendableOutboxCommit(client.db);
62
+ expect(nextCommit).not.toBeNull();
63
+ expect(nextCommit!.status).toBe('sending');
64
+ expect(nextCommit!.operations.length).toBe(1);
65
+ });
66
+
67
+ it('sync pushes outbox commits to server', async () => {
68
+ // Enqueue a commit
69
+ await enqueueOutboxCommit(client.db, {
70
+ operations: [
71
+ {
72
+ table: 'tasks',
73
+ row_id: 'push-task-1',
74
+ op: 'upsert',
75
+ payload: { title: 'Pushed Task', completed: 0, user_id: userId },
76
+ base_version: null,
77
+ },
78
+ ],
79
+ });
80
+
81
+ // Before sync, verify commit exists with pending status (using direct query to avoid claiming)
82
+ const pendingCommit = await client.db
83
+ .selectFrom('sync_outbox_commits')
84
+ .selectAll()
85
+ .where('status', '=', 'pending')
86
+ .executeTakeFirst();
87
+ expect(pendingCommit).not.toBeNull();
88
+
89
+ // Sync
90
+ const result = await client.engine.sync();
91
+ expect(result.success).toBe(true);
92
+ expect(result.pushedCommits).toBe(1);
93
+
94
+ // After sync, outbox should be empty (commit acked)
95
+ const remainingCommit = await getNextSendableOutboxCommit(client.db);
96
+ expect(remainingCommit).toBeNull();
97
+
98
+ // Verify task is on server
99
+ const serverTasks = await server.db
100
+ .selectFrom('tasks')
101
+ .where('id', '=', 'push-task-1')
102
+ .selectAll()
103
+ .execute();
104
+
105
+ expect(serverTasks.length).toBe(1);
106
+ expect(serverTasks[0]!.title).toBe('Pushed Task');
107
+ expect(serverTasks[0]!.server_version).toBe(1);
108
+ });
109
+
110
+ it('sync updates local row with server version after push', async () => {
111
+ // First, apply the mutation locally (this is what useMutation does)
112
+ const shapes = client.shapes;
113
+ const handler = shapes.get('tasks');
114
+
115
+ await client.db.transaction().execute(async (trx) => {
116
+ await handler!.applyChange(
117
+ { trx },
118
+ {
119
+ table: 'tasks',
120
+ row_id: 'version-test',
121
+ op: 'upsert',
122
+ row_json: { title: 'Local Task', completed: 0, user_id: userId },
123
+ row_version: null, // Local optimistic - no server version yet
124
+ scopes: { user_id: userId },
125
+ }
126
+ );
127
+ });
128
+
129
+ // Verify local row has version 0
130
+ let localRow = await client.db
131
+ .selectFrom('tasks')
132
+ .where('id', '=', 'version-test')
133
+ .selectAll()
134
+ .executeTakeFirst();
135
+ expect(localRow).toBeTruthy();
136
+ expect(localRow!.server_version).toBe(0);
137
+
138
+ // Enqueue the commit
139
+ await enqueueOutboxCommit(client.db, {
140
+ operations: [
141
+ {
142
+ table: 'tasks',
143
+ row_id: 'version-test',
144
+ op: 'upsert',
145
+ payload: { title: 'Local Task', completed: 0, user_id: userId },
146
+ base_version: null,
147
+ },
148
+ ],
149
+ });
150
+
151
+ // Sync (push + pull)
152
+ await client.engine.sync();
153
+
154
+ // After sync, local row should have server version
155
+ localRow = await client.db
156
+ .selectFrom('tasks')
157
+ .where('id', '=', 'version-test')
158
+ .selectAll()
159
+ .executeTakeFirst();
160
+ expect(localRow).toBeTruthy();
161
+ expect(localRow!.server_version).toBe(1);
162
+ });
163
+
164
+ it('multiple syncs push multiple commits', async () => {
165
+ // Enqueue first commit
166
+ await enqueueOutboxCommit(client.db, {
167
+ operations: [
168
+ {
169
+ table: 'tasks',
170
+ row_id: 'multi-1',
171
+ op: 'upsert',
172
+ payload: { title: 'Task 1', completed: 0, user_id: userId },
173
+ base_version: null,
174
+ },
175
+ ],
176
+ });
177
+
178
+ // First sync
179
+ let result = await client.engine.sync();
180
+ expect(result.pushedCommits).toBe(1);
181
+
182
+ // Enqueue second commit
183
+ await enqueueOutboxCommit(client.db, {
184
+ operations: [
185
+ {
186
+ table: 'tasks',
187
+ row_id: 'multi-2',
188
+ op: 'upsert',
189
+ payload: { title: 'Task 2', completed: 0, user_id: userId },
190
+ base_version: null,
191
+ },
192
+ ],
193
+ });
194
+
195
+ // Second sync
196
+ result = await client.engine.sync();
197
+ expect(result.pushedCommits).toBe(1);
198
+
199
+ // Verify both on server
200
+ const serverTasks = await server.db
201
+ .selectFrom('tasks')
202
+ .where('user_id', '=', userId)
203
+ .selectAll()
204
+ .execute();
205
+
206
+ expect(serverTasks.length).toBe(2);
207
+ });
208
+
209
+ it('outbox stats reflect pending commits', async () => {
210
+ // Initially no pending
211
+ let stats = await client.engine.refreshOutboxStats();
212
+ expect(stats.pending).toBe(0);
213
+
214
+ // Enqueue a commit
215
+ await enqueueOutboxCommit(client.db, {
216
+ operations: [
217
+ {
218
+ table: 'tasks',
219
+ row_id: 'stats-test',
220
+ op: 'upsert',
221
+ payload: { title: 'Stats Task', completed: 0, user_id: userId },
222
+ base_version: null,
223
+ },
224
+ ],
225
+ });
226
+
227
+ // Should show 1 pending
228
+ stats = await client.engine.refreshOutboxStats();
229
+ expect(stats.pending).toBe(1);
230
+
231
+ // Sync
232
+ await client.engine.sync();
233
+
234
+ // Should show 0 pending
235
+ stats = await client.engine.refreshOutboxStats();
236
+ expect(stats.pending).toBe(0);
237
+ });
238
+
239
+ it('keeps commit pending when server returns retriable error', async () => {
240
+ // Create a client with a transport that returns retriable errors
241
+ const retriableClient = await createTestClient(server, {
242
+ actorId: userId,
243
+ clientId: 'retriable-client',
244
+ });
245
+
246
+ // Override the transport to return retriable errors
247
+ const originalPush = retriableClient.transport.push.bind(
248
+ retriableClient.transport
249
+ );
250
+ let retriableErrorCount = 0;
251
+ retriableClient.transport.push = async (
252
+ request: SyncPushRequest
253
+ ): Promise<SyncPushResponse> => {
254
+ if (retriableErrorCount < 2) {
255
+ // Return retriable error for first two attempts
256
+ retriableErrorCount++;
257
+ return {
258
+ ok: true as const,
259
+ status: 'rejected' as const,
260
+ results: request.operations.map((_, i) => ({
261
+ opIndex: i,
262
+ status: 'error' as const,
263
+ error: 'TEMPORARY_FAILURE',
264
+ code: 'TEMPORARY',
265
+ retriable: true,
266
+ })),
267
+ };
268
+ }
269
+ // After that, use the real transport
270
+ return originalPush(request);
271
+ };
272
+
273
+ await retriableClient.engine.start();
274
+
275
+ try {
276
+ // Enqueue a commit
277
+ await enqueueOutboxCommit(retriableClient.db, {
278
+ operations: [
279
+ {
280
+ table: 'tasks',
281
+ row_id: 'retriable-task',
282
+ op: 'upsert',
283
+ payload: { title: 'Retriable Task', completed: 0, user_id: userId },
284
+ base_version: null,
285
+ },
286
+ ],
287
+ });
288
+
289
+ // Verify commit is pending before sync
290
+ let stats = await retriableClient.engine.refreshOutboxStats();
291
+ expect(stats.pending).toBe(1);
292
+ expect(stats.failed).toBe(0);
293
+
294
+ // First sync - will hit retriable error but commit stays pending (not failed)
295
+ await retriableClient.engine.sync();
296
+
297
+ // Commit should still be pending (not failed) after retriable error
298
+ stats = await retriableClient.engine.refreshOutboxStats();
299
+ expect(stats.failed).toBe(0);
300
+ // Commit may still be pending since it wasn't terminal failure
301
+ // Note: pushedCommits counts attempts, which may be > 0
302
+
303
+ // After enough retries, the commit should eventually succeed
304
+ // Keep syncing until successful or we've tried enough times
305
+ for (let i = 0; i < 5 && stats.pending > 0; i++) {
306
+ await retriableClient.engine.sync();
307
+ stats = await retriableClient.engine.refreshOutboxStats();
308
+ }
309
+
310
+ // Now should be empty (commit succeeded) and no failures
311
+ expect(stats.pending).toBe(0);
312
+ expect(stats.failed).toBe(0);
313
+
314
+ // Verify the retriable error was actually returned (at least twice before success)
315
+ expect(retriableErrorCount).toBe(2);
316
+ } finally {
317
+ await destroyTestClient(retriableClient);
318
+ }
319
+ });
320
+ });