@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.
- package/dist/createSyncularReact.d.ts +221 -0
- package/dist/createSyncularReact.d.ts.map +1 -0
- package/dist/createSyncularReact.js +773 -0
- package/dist/createSyncularReact.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
- package/src/__tests__/SyncEngine.test.ts +653 -0
- package/src/__tests__/SyncProvider.strictmode.test.tsx +117 -0
- package/src/__tests__/fingerprint.test.ts +181 -0
- package/src/__tests__/hooks/useMutation.test.tsx +468 -0
- package/src/__tests__/hooks.test.tsx +384 -0
- package/src/__tests__/integration/conflict-resolution.test.ts +439 -0
- package/src/__tests__/integration/provider-reconfig.test.ts +291 -0
- package/src/__tests__/integration/push-flow.test.ts +320 -0
- package/src/__tests__/integration/realtime-sync.test.ts +222 -0
- package/src/__tests__/integration/self-conflict.test.ts +91 -0
- package/src/__tests__/integration/test-setup.ts +538 -0
- package/src/__tests__/integration/two-client-sync.test.ts +373 -0
- package/src/__tests__/setup.ts +7 -0
- package/src/__tests__/test-utils.ts +187 -0
- package/src/__tests__/useMutations.test.tsx +198 -0
- package/src/createSyncularReact.tsx +1340 -0
- package/src/index.ts +9 -0
|
@@ -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
|
+
});
|