@syncular/client 0.0.6-108 → 0.0.6-109

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-108",
3
+ "version": "0.0.6-109",
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-108",
50
- "@syncular/transport-http": "0.0.6-108"
49
+ "@syncular/core": "0.0.6-109",
50
+ "@syncular/transport-http": "0.0.6-109"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "kysely": "*"
@@ -1,6 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { gzipSync } from 'node:zlib';
2
3
  import {
3
4
  createDatabase,
5
+ encodeSnapshotRows,
4
6
  type SyncChange,
5
7
  type SyncTransport,
6
8
  SyncTransportError,
@@ -10,6 +12,7 @@ import { sql } from 'kysely';
10
12
  import { createBunSqliteDialect } from '../../../dialect-bun-sqlite/src';
11
13
  import type { ClientHandlerCollection } from '../handlers/collection';
12
14
  import { ensureClientSyncSchema } from '../migrate';
15
+ import { enqueueOutboxCommit } from '../outbox';
13
16
  import type { SyncClientDb } from '../schema';
14
17
  import { SyncEngine } from './SyncEngine';
15
18
 
@@ -23,6 +26,21 @@ interface TestDb extends SyncClientDb {
23
26
  tasks: TasksTable;
24
27
  }
25
28
 
29
+ async function waitFor(
30
+ condition: () => boolean,
31
+ timeoutMs = 2000,
32
+ stepMs = 10
33
+ ): Promise<void> {
34
+ const deadline = Date.now() + timeoutMs;
35
+ while (Date.now() < deadline) {
36
+ if (condition()) {
37
+ return;
38
+ }
39
+ await new Promise<void>((resolve) => setTimeout(resolve, stepMs));
40
+ }
41
+ throw new Error(`Timed out waiting for condition after ${timeoutMs}ms`);
42
+ }
43
+
26
44
  const noopTransport: SyncTransport = {
27
45
  async sync() {
28
46
  return {};
@@ -300,6 +318,337 @@ describe('SyncEngine WS inline apply', () => {
300
318
  expect(state.isRetrying).toBe(false);
301
319
  });
302
320
 
321
+ it('classifies 429 sync failures as retryable and schedules exponential backoff', async () => {
322
+ let syncAttempts = 0;
323
+ const rateLimitedTransport: SyncTransport = {
324
+ async sync() {
325
+ syncAttempts += 1;
326
+ throw new SyncTransportError('rate limited', 429);
327
+ },
328
+ async fetchSnapshotChunk() {
329
+ return new Uint8Array();
330
+ },
331
+ };
332
+
333
+ const handlers: ClientHandlerCollection<TestDb> = [
334
+ {
335
+ table: 'tasks',
336
+ async applySnapshot() {},
337
+ async clearAll() {},
338
+ async applyChange() {},
339
+ },
340
+ ];
341
+
342
+ const delays: number[] = [];
343
+ const timeoutHandles: Array<ReturnType<typeof setTimeout>> = [];
344
+ const originalSetTimeout = globalThis.setTimeout;
345
+ const patchedSetTimeout: typeof globalThis.setTimeout = (
346
+ _handler,
347
+ timeout,
348
+ ..._args
349
+ ) => {
350
+ const delayMs = typeof timeout === 'number' ? timeout : 0;
351
+ delays.push(delayMs);
352
+ const handle = originalSetTimeout(() => {}, 60_000);
353
+ timeoutHandles.push(handle);
354
+ return handle;
355
+ };
356
+ globalThis.setTimeout = patchedSetTimeout;
357
+
358
+ try {
359
+ const engine = new SyncEngine<TestDb>({
360
+ db,
361
+ transport: rateLimitedTransport,
362
+ handlers,
363
+ actorId: 'u1',
364
+ clientId: 'client-rate-limit',
365
+ subscriptions: [
366
+ {
367
+ id: 'sub-1',
368
+ table: 'tasks',
369
+ scopes: {},
370
+ },
371
+ ],
372
+ stateId: 'default',
373
+ pollIntervalMs: 60_000,
374
+ maxRetries: 4,
375
+ });
376
+
377
+ await engine.start();
378
+
379
+ let state = engine.getState();
380
+ expect(state.error?.code).toBe('NETWORK_ERROR');
381
+ expect(state.error?.retryable).toBe(true);
382
+ expect(state.error?.httpStatus).toBe(429);
383
+ expect(state.retryCount).toBe(1);
384
+ expect(state.isRetrying).toBe(true);
385
+ expect(delays).toEqual([2000]);
386
+
387
+ await engine.sync();
388
+ state = engine.getState();
389
+ expect(state.retryCount).toBe(2);
390
+ expect(state.isRetrying).toBe(true);
391
+ expect(delays).toEqual([2000, 4000]);
392
+
393
+ await engine.sync();
394
+ state = engine.getState();
395
+ expect(state.retryCount).toBe(3);
396
+ expect(state.isRetrying).toBe(true);
397
+ expect(delays).toEqual([2000, 4000, 8000]);
398
+ expect(syncAttempts).toBe(3);
399
+
400
+ engine.destroy();
401
+ } finally {
402
+ globalThis.setTimeout = originalSetTimeout;
403
+ for (const handle of timeoutHandles) {
404
+ clearTimeout(handle);
405
+ }
406
+ }
407
+ });
408
+
409
+ it('keeps push failures retryable on 503 and preserves pending outbox state', async () => {
410
+ let sawPushRequest = false;
411
+ const unavailablePushTransport: SyncTransport = {
412
+ async sync(request) {
413
+ sawPushRequest = sawPushRequest || Boolean(request.push);
414
+ throw new SyncTransportError('service unavailable', 503);
415
+ },
416
+ async fetchSnapshotChunk() {
417
+ return new Uint8Array();
418
+ },
419
+ };
420
+
421
+ const handlers: ClientHandlerCollection<TestDb> = [
422
+ {
423
+ table: 'tasks',
424
+ async applySnapshot() {},
425
+ async clearAll() {},
426
+ async applyChange() {},
427
+ },
428
+ ];
429
+
430
+ const originalSetTimeout = globalThis.setTimeout;
431
+ const timeoutHandles: Array<ReturnType<typeof setTimeout>> = [];
432
+ const patchedSetTimeout: typeof globalThis.setTimeout = (
433
+ _handler,
434
+ _timeout,
435
+ ..._args
436
+ ) => {
437
+ const handle = originalSetTimeout(() => {}, 60_000);
438
+ timeoutHandles.push(handle);
439
+ return handle;
440
+ };
441
+ globalThis.setTimeout = patchedSetTimeout;
442
+
443
+ try {
444
+ await enqueueOutboxCommit(db, {
445
+ operations: [
446
+ {
447
+ table: 'tasks',
448
+ row_id: 'push-failure-task',
449
+ op: 'upsert',
450
+ payload: {
451
+ title: 'Push Failure Task',
452
+ },
453
+ base_version: null,
454
+ },
455
+ ],
456
+ });
457
+
458
+ const engine = new SyncEngine<TestDb>({
459
+ db,
460
+ transport: unavailablePushTransport,
461
+ handlers,
462
+ actorId: 'u1',
463
+ clientId: 'client-push-503',
464
+ subscriptions: [
465
+ {
466
+ id: 'sub-1',
467
+ table: 'tasks',
468
+ scopes: {},
469
+ },
470
+ ],
471
+ stateId: 'default',
472
+ pollIntervalMs: 60_000,
473
+ });
474
+
475
+ await engine.start();
476
+
477
+ const state = engine.getState();
478
+ expect(state.error?.code).toBe('NETWORK_ERROR');
479
+ expect(state.error?.retryable).toBe(true);
480
+ expect(state.error?.httpStatus).toBe(503);
481
+ expect(sawPushRequest).toBe(true);
482
+
483
+ const row = await db
484
+ .selectFrom('sync_outbox_commits')
485
+ .select(['status'])
486
+ .executeTakeFirst();
487
+ const failedCount = await db
488
+ .selectFrom('sync_outbox_commits')
489
+ .select(({ fn }) => fn.countAll().as('total'))
490
+ .where('status', '=', 'failed')
491
+ .executeTakeFirst();
492
+ expect(row?.status === 'pending' || row?.status === 'sending').toBe(true);
493
+ expect(Number(failedCount?.total ?? 0)).toBe(0);
494
+
495
+ engine.destroy();
496
+ } finally {
497
+ globalThis.setTimeout = originalSetTimeout;
498
+ for (const handle of timeoutHandles) {
499
+ clearTimeout(handle);
500
+ }
501
+ }
502
+ });
503
+
504
+ it('classifies 503 snapshot chunk fetch failures as retryable and recovers on retry', async () => {
505
+ let syncCalls = 0;
506
+ let chunkFetchCalls = 0;
507
+ const payload = new Uint8Array(
508
+ gzipSync(
509
+ encodeSnapshotRows([
510
+ {
511
+ id: 'chunk-retry-task',
512
+ title: 'Chunk Retry',
513
+ server_version: 1,
514
+ },
515
+ ])
516
+ )
517
+ );
518
+ const chunkFailThenRecoverTransport: SyncTransport = {
519
+ async sync() {
520
+ syncCalls += 1;
521
+ return {
522
+ ok: true,
523
+ pull: {
524
+ ok: true,
525
+ subscriptions: [
526
+ {
527
+ id: 'sub-1',
528
+ status: 'active',
529
+ scopes: {},
530
+ bootstrap: true,
531
+ bootstrapState: null,
532
+ nextCursor: 1,
533
+ commits: [],
534
+ snapshots: [
535
+ {
536
+ table: 'tasks',
537
+ rows: [],
538
+ chunks: [
539
+ {
540
+ id: 'chunk-retry-1',
541
+ byteLength: payload.length,
542
+ sha256: '',
543
+ encoding: 'json-row-frame-v1',
544
+ compression: 'gzip',
545
+ },
546
+ ],
547
+ isFirstPage: true,
548
+ isLastPage: true,
549
+ },
550
+ ],
551
+ },
552
+ ],
553
+ },
554
+ };
555
+ },
556
+ async fetchSnapshotChunk() {
557
+ chunkFetchCalls += 1;
558
+ if (chunkFetchCalls === 1) {
559
+ throw new SyncTransportError('temporary chunk outage', 503);
560
+ }
561
+ return payload;
562
+ },
563
+ };
564
+
565
+ const handlers: ClientHandlerCollection<TestDb> = [
566
+ {
567
+ table: 'tasks',
568
+ async applySnapshot(ctx, snapshot) {
569
+ for (const row of snapshot.rows as TasksTable[]) {
570
+ await sql`
571
+ insert into ${sql.table('tasks')} (
572
+ ${sql.ref('id')},
573
+ ${sql.ref('title')},
574
+ ${sql.ref('server_version')}
575
+ ) values (
576
+ ${sql.val(row.id)},
577
+ ${sql.val(row.title)},
578
+ ${sql.val(row.server_version)}
579
+ )
580
+ on conflict (${sql.ref('id')})
581
+ do update set
582
+ ${sql.ref('title')} = excluded.${sql.ref('title')},
583
+ ${sql.ref('server_version')} = excluded.${sql.ref('server_version')}
584
+ `.execute(ctx.trx);
585
+ }
586
+ },
587
+ async clearAll(ctx) {
588
+ await sql`delete from ${sql.table('tasks')}`.execute(ctx.trx);
589
+ },
590
+ async applyChange() {},
591
+ },
592
+ ];
593
+
594
+ const originalSetTimeout = globalThis.setTimeout;
595
+ const delays: number[] = [];
596
+ const patchedSetTimeout: typeof globalThis.setTimeout = (
597
+ handler,
598
+ timeout,
599
+ ...args
600
+ ) => {
601
+ const delayMs = typeof timeout === 'number' ? timeout : 0;
602
+ delays.push(delayMs);
603
+ return originalSetTimeout(() => {
604
+ if (typeof handler === 'function') {
605
+ handler(...args);
606
+ }
607
+ }, 0);
608
+ };
609
+ globalThis.setTimeout = patchedSetTimeout;
610
+
611
+ try {
612
+ const engine = new SyncEngine<TestDb>({
613
+ db,
614
+ transport: chunkFailThenRecoverTransport,
615
+ handlers,
616
+ actorId: 'u1',
617
+ clientId: 'client-chunk-503',
618
+ subscriptions: [
619
+ {
620
+ id: 'sub-1',
621
+ table: 'tasks',
622
+ scopes: {},
623
+ },
624
+ ],
625
+ stateId: 'default',
626
+ pollIntervalMs: 60_000,
627
+ });
628
+
629
+ await engine.start();
630
+ await waitFor(() => engine.getState().error === null, 2000, 10);
631
+
632
+ const state = engine.getState();
633
+ expect(state.retryCount).toBe(0);
634
+ expect(state.isRetrying).toBe(false);
635
+ expect(delays[0]).toBe(2000);
636
+ expect(chunkFetchCalls).toBeGreaterThanOrEqual(2);
637
+ expect(syncCalls).toBeGreaterThanOrEqual(2);
638
+
639
+ const row = await db
640
+ .selectFrom('tasks')
641
+ .select(['id', 'title'])
642
+ .where('id', '=', 'chunk-retry-task')
643
+ .executeTakeFirst();
644
+ expect(row?.title).toBe('Chunk Retry');
645
+
646
+ engine.destroy();
647
+ } finally {
648
+ globalThis.setTimeout = originalSetTimeout;
649
+ }
650
+ });
651
+
303
652
  it('repairs rebootstrap-missing-chunks by clearing synced state and data', async () => {
304
653
  const outboxId = 'outbox-1';
305
654
  const now = Date.now();