@syncular/client 0.0.6-100 → 0.0.6-101
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/package.json +3 -3
- package/src/engine/SyncEngine.test.ts +151 -0
- package/src/pull-engine.test.ts +141 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.0.6-
|
|
3
|
+
"version": "0.0.6-101",
|
|
4
4
|
"description": "Client-side sync engine with offline-first support, outbox, and conflict resolution",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"release": "bunx syncular-publish"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@syncular/core": "0.0.6-
|
|
50
|
-
"@syncular/transport-http": "0.0.6-
|
|
49
|
+
"@syncular/core": "0.0.6-101",
|
|
50
|
+
"@syncular/transport-http": "0.0.6-101"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"kysely": "*"
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createDatabase,
|
|
4
4
|
type SyncChange,
|
|
5
5
|
type SyncTransport,
|
|
6
|
+
SyncTransportError,
|
|
6
7
|
} from '@syncular/core';
|
|
7
8
|
import type { Kysely } from 'kysely';
|
|
8
9
|
import { sql } from 'kysely';
|
|
@@ -251,4 +252,154 @@ describe('SyncEngine WS inline apply', () => {
|
|
|
251
252
|
await coldDb.destroy();
|
|
252
253
|
}
|
|
253
254
|
});
|
|
255
|
+
|
|
256
|
+
it('classifies missing snapshot chunk pull failures as non-retryable', async () => {
|
|
257
|
+
const missingChunkTransport: SyncTransport = {
|
|
258
|
+
async sync() {
|
|
259
|
+
throw new SyncTransportError('snapshot chunk not found', 404);
|
|
260
|
+
},
|
|
261
|
+
async fetchSnapshotChunk() {
|
|
262
|
+
return new Uint8Array();
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
267
|
+
{
|
|
268
|
+
table: 'tasks',
|
|
269
|
+
async applySnapshot() {},
|
|
270
|
+
async clearAll() {},
|
|
271
|
+
async applyChange() {},
|
|
272
|
+
},
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
const engine = new SyncEngine<TestDb>({
|
|
276
|
+
db,
|
|
277
|
+
transport: missingChunkTransport,
|
|
278
|
+
handlers,
|
|
279
|
+
actorId: 'u1',
|
|
280
|
+
clientId: 'client-missing-chunk',
|
|
281
|
+
subscriptions: [
|
|
282
|
+
{
|
|
283
|
+
id: 'sub-1',
|
|
284
|
+
table: 'tasks',
|
|
285
|
+
scopes: {},
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
stateId: 'default',
|
|
289
|
+
pollIntervalMs: 60_000,
|
|
290
|
+
maxRetries: 3,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await engine.start();
|
|
294
|
+
engine.stop();
|
|
295
|
+
|
|
296
|
+
const state = engine.getState();
|
|
297
|
+
expect(state.error?.code).toBe('SNAPSHOT_CHUNK_NOT_FOUND');
|
|
298
|
+
expect(state.error?.retryable).toBe(false);
|
|
299
|
+
expect(state.retryCount).toBe(1);
|
|
300
|
+
expect(state.isRetrying).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('repairs rebootstrap-missing-chunks by clearing synced state and data', async () => {
|
|
304
|
+
const outboxId = 'outbox-1';
|
|
305
|
+
const now = Date.now();
|
|
306
|
+
|
|
307
|
+
await db
|
|
308
|
+
.insertInto('tasks')
|
|
309
|
+
.values({
|
|
310
|
+
id: 't2',
|
|
311
|
+
title: 'to-clear',
|
|
312
|
+
server_version: 2,
|
|
313
|
+
})
|
|
314
|
+
.execute();
|
|
315
|
+
|
|
316
|
+
await db
|
|
317
|
+
.insertInto('sync_outbox_commits')
|
|
318
|
+
.values({
|
|
319
|
+
id: outboxId,
|
|
320
|
+
client_commit_id: 'client-commit-1',
|
|
321
|
+
status: 'pending',
|
|
322
|
+
operations_json: '[]',
|
|
323
|
+
last_response_json: null,
|
|
324
|
+
error: null,
|
|
325
|
+
created_at: now,
|
|
326
|
+
updated_at: now,
|
|
327
|
+
acked_commit_seq: null,
|
|
328
|
+
})
|
|
329
|
+
.execute();
|
|
330
|
+
|
|
331
|
+
await db
|
|
332
|
+
.insertInto('sync_conflicts')
|
|
333
|
+
.values({
|
|
334
|
+
id: 'conflict-1',
|
|
335
|
+
outbox_commit_id: outboxId,
|
|
336
|
+
client_commit_id: 'client-commit-1',
|
|
337
|
+
op_index: 0,
|
|
338
|
+
result_status: 'conflict',
|
|
339
|
+
message: 'forced conflict',
|
|
340
|
+
code: 'TEST_CONFLICT',
|
|
341
|
+
server_version: 1,
|
|
342
|
+
server_row_json: '{}',
|
|
343
|
+
created_at: now,
|
|
344
|
+
resolved_at: null,
|
|
345
|
+
resolution: null,
|
|
346
|
+
})
|
|
347
|
+
.execute();
|
|
348
|
+
|
|
349
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
350
|
+
{
|
|
351
|
+
table: 'tasks',
|
|
352
|
+
async applySnapshot() {},
|
|
353
|
+
async clearAll(ctx) {
|
|
354
|
+
await sql`delete from ${sql.table('tasks')}`.execute(ctx.trx);
|
|
355
|
+
},
|
|
356
|
+
async applyChange() {},
|
|
357
|
+
},
|
|
358
|
+
];
|
|
359
|
+
|
|
360
|
+
const engine = new SyncEngine<TestDb>({
|
|
361
|
+
db,
|
|
362
|
+
transport: noopTransport,
|
|
363
|
+
handlers,
|
|
364
|
+
actorId: 'u1',
|
|
365
|
+
clientId: 'client-repair',
|
|
366
|
+
subscriptions: [],
|
|
367
|
+
stateId: 'default',
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const result = await engine.repair({
|
|
371
|
+
mode: 'rebootstrap-missing-chunks',
|
|
372
|
+
clearOutbox: true,
|
|
373
|
+
clearConflicts: true,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(result.deletedSubscriptionStates).toBe(1);
|
|
377
|
+
expect(result.deletedOutboxCommits).toBe(1);
|
|
378
|
+
expect(result.deletedConflicts).toBe(1);
|
|
379
|
+
expect(result.clearedTables).toEqual(['tasks']);
|
|
380
|
+
|
|
381
|
+
const tasksCount = await db
|
|
382
|
+
.selectFrom('tasks')
|
|
383
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
384
|
+
.executeTakeFirst();
|
|
385
|
+
expect(Number(tasksCount?.total ?? 0)).toBe(0);
|
|
386
|
+
|
|
387
|
+
const subscriptionsCount = await db
|
|
388
|
+
.selectFrom('sync_subscription_state')
|
|
389
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
390
|
+
.executeTakeFirst();
|
|
391
|
+
expect(Number(subscriptionsCount?.total ?? 0)).toBe(0);
|
|
392
|
+
|
|
393
|
+
const outboxCount = await db
|
|
394
|
+
.selectFrom('sync_outbox_commits')
|
|
395
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
396
|
+
.executeTakeFirst();
|
|
397
|
+
expect(Number(outboxCount?.total ?? 0)).toBe(0);
|
|
398
|
+
|
|
399
|
+
const conflictsCount = await db
|
|
400
|
+
.selectFrom('sync_conflicts')
|
|
401
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
402
|
+
.executeTakeFirst();
|
|
403
|
+
expect(Number(conflictsCount?.total ?? 0)).toBe(0);
|
|
404
|
+
});
|
|
254
405
|
});
|
package/src/pull-engine.test.ts
CHANGED
|
@@ -148,4 +148,145 @@ describe('applyPullResponse chunk streaming', () => {
|
|
|
148
148
|
expect(Number(countResult.rows[0]?.count ?? 0)).toBe(rows.length);
|
|
149
149
|
expect(streamFetchCount).toBe(1);
|
|
150
150
|
});
|
|
151
|
+
|
|
152
|
+
it('rolls back partial chunked bootstrap when a later chunk fails', async () => {
|
|
153
|
+
const firstRows = Array.from({ length: 1500 }, (_, index) => ({
|
|
154
|
+
id: `${index + 1}`,
|
|
155
|
+
name: `Item ${index + 1}`,
|
|
156
|
+
}));
|
|
157
|
+
const secondRows = Array.from({ length: 1500 }, (_, index) => ({
|
|
158
|
+
id: `${index + 1501}`,
|
|
159
|
+
name: `Item ${index + 1501}`,
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
const firstChunk = new Uint8Array(gzipSync(encodeSnapshotRows(firstRows)));
|
|
163
|
+
const secondChunk = new Uint8Array(
|
|
164
|
+
gzipSync(encodeSnapshotRows(secondRows))
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
let failSecondChunk = true;
|
|
168
|
+
const transport: SyncTransport = {
|
|
169
|
+
async sync() {
|
|
170
|
+
return {};
|
|
171
|
+
},
|
|
172
|
+
async fetchSnapshotChunk() {
|
|
173
|
+
throw new Error('fetchSnapshotChunk should not be used');
|
|
174
|
+
},
|
|
175
|
+
async fetchSnapshotChunkStream({ chunkId }) {
|
|
176
|
+
if (chunkId === 'chunk-2' && failSecondChunk) {
|
|
177
|
+
throw new Error('chunk-2 missing');
|
|
178
|
+
}
|
|
179
|
+
if (chunkId === 'chunk-1') {
|
|
180
|
+
return createStreamFromBytes(firstChunk, 317);
|
|
181
|
+
}
|
|
182
|
+
if (chunkId === 'chunk-2') {
|
|
183
|
+
return createStreamFromBytes(secondChunk, 503);
|
|
184
|
+
}
|
|
185
|
+
throw new Error(`Unexpected chunk id: ${chunkId}`);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
190
|
+
createClientHandler({
|
|
191
|
+
table: 'items',
|
|
192
|
+
scopes: ['items:{id}'],
|
|
193
|
+
}),
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
const options = {
|
|
197
|
+
clientId: 'client-1',
|
|
198
|
+
subscriptions: [
|
|
199
|
+
{
|
|
200
|
+
id: 'items-sub',
|
|
201
|
+
table: 'items',
|
|
202
|
+
scopes: {},
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
stateId: 'default',
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const response: SyncPullResponse = {
|
|
209
|
+
ok: true,
|
|
210
|
+
subscriptions: [
|
|
211
|
+
{
|
|
212
|
+
id: 'items-sub',
|
|
213
|
+
status: 'active',
|
|
214
|
+
scopes: {},
|
|
215
|
+
bootstrap: true,
|
|
216
|
+
bootstrapState: null,
|
|
217
|
+
nextCursor: 12,
|
|
218
|
+
commits: [],
|
|
219
|
+
snapshots: [
|
|
220
|
+
{
|
|
221
|
+
table: 'items',
|
|
222
|
+
rows: [],
|
|
223
|
+
chunks: [
|
|
224
|
+
{
|
|
225
|
+
id: 'chunk-1',
|
|
226
|
+
byteLength: firstChunk.length,
|
|
227
|
+
sha256: '',
|
|
228
|
+
encoding: 'json-row-frame-v1',
|
|
229
|
+
compression: 'gzip',
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
id: 'chunk-2',
|
|
233
|
+
byteLength: secondChunk.length,
|
|
234
|
+
sha256: '',
|
|
235
|
+
encoding: 'json-row-frame-v1',
|
|
236
|
+
compression: 'gzip',
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
isFirstPage: true,
|
|
240
|
+
isLastPage: true,
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const firstPullState = await buildPullRequest(db, options);
|
|
248
|
+
await expect(
|
|
249
|
+
applyPullResponse(
|
|
250
|
+
db,
|
|
251
|
+
transport,
|
|
252
|
+
handlers,
|
|
253
|
+
options,
|
|
254
|
+
firstPullState,
|
|
255
|
+
response
|
|
256
|
+
)
|
|
257
|
+
).rejects.toThrow('chunk-2 missing');
|
|
258
|
+
|
|
259
|
+
const countAfterFailure = await sql<{ count: number }>`
|
|
260
|
+
select count(*) as count
|
|
261
|
+
from ${sql.table('items')}
|
|
262
|
+
`.execute(db);
|
|
263
|
+
expect(Number(countAfterFailure.rows[0]?.count ?? 0)).toBe(0);
|
|
264
|
+
|
|
265
|
+
const stateAfterFailure = await db
|
|
266
|
+
.selectFrom('sync_subscription_state')
|
|
267
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
268
|
+
.where('state_id', '=', 'default')
|
|
269
|
+
.where('subscription_id', '=', 'items-sub')
|
|
270
|
+
.executeTakeFirst();
|
|
271
|
+
expect(Number(stateAfterFailure?.total ?? 0)).toBe(0);
|
|
272
|
+
|
|
273
|
+
failSecondChunk = false;
|
|
274
|
+
const retryPullState = await buildPullRequest(db, options);
|
|
275
|
+
await applyPullResponse(
|
|
276
|
+
db,
|
|
277
|
+
transport,
|
|
278
|
+
handlers,
|
|
279
|
+
options,
|
|
280
|
+
retryPullState,
|
|
281
|
+
response
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const countAfterRetry = await sql<{ count: number }>`
|
|
285
|
+
select count(*) as count
|
|
286
|
+
from ${sql.table('items')}
|
|
287
|
+
`.execute(db);
|
|
288
|
+
expect(Number(countAfterRetry.rows[0]?.count ?? 0)).toBe(
|
|
289
|
+
firstRows.length + secondRows.length
|
|
290
|
+
);
|
|
291
|
+
});
|
|
151
292
|
});
|