@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.0.6-100",
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-100",
50
- "@syncular/transport-http": "0.0.6-100"
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
  });
@@ -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
  });