@syncular/client 0.0.6-219 → 0.0.6-223

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.
@@ -9,6 +9,7 @@ import {
9
9
  } from '@syncular/core';
10
10
  import { type Kysely, sql } from 'kysely';
11
11
  import { createBunSqliteDialect } from '../../dialect-bun-sqlite/src';
12
+ import { SyncClientStageError } from './errors';
12
13
  import type { ClientHandlerCollection } from './handlers/collection';
13
14
  import { createClientHandler } from './handlers/create-handler';
14
15
  import { ensureClientSyncSchema } from './migrate';
@@ -269,6 +270,277 @@ describe('applyPullResponse chunk streaming', () => {
269
270
  }
270
271
  });
271
272
 
273
+ it('surfaces gzip decode failures with stage metadata', async () => {
274
+ const invalidCompressed = new Uint8Array(
275
+ gzipSync(new TextEncoder().encode('truncated-gzip')).subarray(0, 8)
276
+ );
277
+
278
+ const transport: SyncTransport = {
279
+ capabilities: {
280
+ snapshotChunkReadMode: 'bytes',
281
+ preferMaterializedSnapshots: true,
282
+ },
283
+ async sync() {
284
+ return {};
285
+ },
286
+ async fetchSnapshotChunk() {
287
+ return invalidCompressed;
288
+ },
289
+ };
290
+
291
+ const handlers: ClientHandlerCollection<TestDb> = [
292
+ createClientHandler({
293
+ table: 'items',
294
+ scopes: ['items:{id}'],
295
+ }),
296
+ ];
297
+
298
+ const options = {
299
+ clientId: 'client-1',
300
+ subscriptions: [
301
+ {
302
+ id: 'items-sub',
303
+ table: 'items',
304
+ scopes: {},
305
+ },
306
+ ],
307
+ stateId: 'default',
308
+ };
309
+
310
+ const pullState = await buildPullRequest(db, options);
311
+
312
+ const response: SyncPullResponse = {
313
+ ok: true,
314
+ subscriptions: [
315
+ {
316
+ id: 'items-sub',
317
+ status: 'active',
318
+ scopes: {},
319
+ bootstrap: true,
320
+ bootstrapState: null,
321
+ nextCursor: 1,
322
+ commits: [],
323
+ snapshots: [
324
+ {
325
+ table: 'items',
326
+ rows: [],
327
+ chunks: [
328
+ {
329
+ id: 'chunk-1',
330
+ byteLength: invalidCompressed.length,
331
+ sha256: '',
332
+ encoding: 'json-row-frame-v1',
333
+ compression: 'gzip',
334
+ },
335
+ ],
336
+ isFirstPage: true,
337
+ isLastPage: true,
338
+ },
339
+ ],
340
+ },
341
+ ],
342
+ };
343
+
344
+ try {
345
+ await applyPullResponse(
346
+ db,
347
+ transport,
348
+ handlers,
349
+ options,
350
+ pullState,
351
+ response
352
+ );
353
+ throw new Error('Expected applyPullResponse to fail');
354
+ } catch (error) {
355
+ expect(error).toBeInstanceOf(SyncClientStageError);
356
+ expect((error as SyncClientStageError).stage).toBe(
357
+ 'snapshot-gzip-decode'
358
+ );
359
+ expect((error as SyncClientStageError).chunkId).toBe('chunk-1');
360
+ expect((error as SyncClientStageError).subscriptionId).toBe('items-sub');
361
+ }
362
+ });
363
+
364
+ it('surfaces snapshot decode failures with stage metadata', async () => {
365
+ const invalidPayload = new TextEncoder().encode('not-a-row-frame');
366
+ const compressed = new Uint8Array(gzipSync(invalidPayload));
367
+
368
+ const transport: SyncTransport = {
369
+ async sync() {
370
+ return {};
371
+ },
372
+ async fetchSnapshotChunk() {
373
+ throw new Error('fetchSnapshotChunk should not be used');
374
+ },
375
+ async fetchSnapshotChunkStream() {
376
+ return createStreamFromBytes(compressed, 13);
377
+ },
378
+ };
379
+
380
+ const handlers: ClientHandlerCollection<TestDb> = [
381
+ createClientHandler({
382
+ table: 'items',
383
+ scopes: ['items:{id}'],
384
+ }),
385
+ ];
386
+
387
+ const options = {
388
+ clientId: 'client-1',
389
+ subscriptions: [
390
+ {
391
+ id: 'items-sub',
392
+ table: 'items',
393
+ scopes: {},
394
+ },
395
+ ],
396
+ stateId: 'default',
397
+ };
398
+
399
+ const pullState = await buildPullRequest(db, options);
400
+
401
+ const response: SyncPullResponse = {
402
+ ok: true,
403
+ subscriptions: [
404
+ {
405
+ id: 'items-sub',
406
+ status: 'active',
407
+ scopes: {},
408
+ bootstrap: true,
409
+ bootstrapState: null,
410
+ nextCursor: 1,
411
+ commits: [],
412
+ snapshots: [
413
+ {
414
+ table: 'items',
415
+ rows: [],
416
+ chunks: [
417
+ {
418
+ id: 'chunk-1',
419
+ byteLength: compressed.length,
420
+ sha256: '',
421
+ encoding: 'json-row-frame-v1',
422
+ compression: 'gzip',
423
+ },
424
+ ],
425
+ isFirstPage: true,
426
+ isLastPage: true,
427
+ },
428
+ ],
429
+ },
430
+ ],
431
+ };
432
+
433
+ try {
434
+ await applyPullResponse(
435
+ db,
436
+ transport,
437
+ handlers,
438
+ options,
439
+ pullState,
440
+ response
441
+ );
442
+ throw new Error('Expected applyPullResponse to fail');
443
+ } catch (error) {
444
+ expect(error).toBeInstanceOf(SyncClientStageError);
445
+ expect((error as SyncClientStageError).stage).toBe(
446
+ 'snapshot-chunk-decode'
447
+ );
448
+ expect((error as SyncClientStageError).chunkId).toBe('chunk-1');
449
+ expect((error as SyncClientStageError).table).toBe('items');
450
+ }
451
+ });
452
+
453
+ it('surfaces snapshot apply failures with stage metadata', async () => {
454
+ const rows = [{ id: '1', name: 'Item 1' }];
455
+ const encoded = encodeSnapshotRows(rows);
456
+ const compressed = new Uint8Array(gzipSync(encoded));
457
+
458
+ const transport: SyncTransport = {
459
+ async sync() {
460
+ return {};
461
+ },
462
+ async fetchSnapshotChunk() {
463
+ throw new Error('fetchSnapshotChunk should not be used');
464
+ },
465
+ async fetchSnapshotChunkStream() {
466
+ return createStreamFromBytes(compressed, 17);
467
+ },
468
+ };
469
+
470
+ const handlers: ClientHandlerCollection<TestDb> = [
471
+ {
472
+ table: 'items',
473
+ async applySnapshot() {
474
+ throw new Error('apply failed');
475
+ },
476
+ async clearAll() {},
477
+ },
478
+ ];
479
+
480
+ const options = {
481
+ clientId: 'client-1',
482
+ subscriptions: [
483
+ {
484
+ id: 'items-sub',
485
+ table: 'items',
486
+ scopes: {},
487
+ },
488
+ ],
489
+ stateId: 'default',
490
+ };
491
+
492
+ const pullState = await buildPullRequest(db, options);
493
+
494
+ const response: SyncPullResponse = {
495
+ ok: true,
496
+ subscriptions: [
497
+ {
498
+ id: 'items-sub',
499
+ status: 'active',
500
+ scopes: {},
501
+ bootstrap: true,
502
+ bootstrapState: null,
503
+ nextCursor: 1,
504
+ commits: [],
505
+ snapshots: [
506
+ {
507
+ table: 'items',
508
+ rows: [],
509
+ chunks: [
510
+ {
511
+ id: 'chunk-1',
512
+ byteLength: compressed.length,
513
+ sha256: '',
514
+ encoding: 'json-row-frame-v1',
515
+ compression: 'gzip',
516
+ },
517
+ ],
518
+ isFirstPage: true,
519
+ isLastPage: true,
520
+ },
521
+ ],
522
+ },
523
+ ],
524
+ };
525
+
526
+ try {
527
+ await applyPullResponse(
528
+ db,
529
+ transport,
530
+ handlers,
531
+ options,
532
+ pullState,
533
+ response
534
+ );
535
+ throw new Error('Expected applyPullResponse to fail');
536
+ } catch (error) {
537
+ expect(error).toBeInstanceOf(SyncClientStageError);
538
+ expect((error as SyncClientStageError).stage).toBe('snapshot-apply');
539
+ expect((error as SyncClientStageError).subscriptionId).toBe('items-sub');
540
+ expect((error as SyncClientStageError).table).toBe('items');
541
+ }
542
+ });
543
+
272
544
  it('materializes chunked bootstrap snapshots for afterPull plugins via streaming transport', async () => {
273
545
  const firstRows = Array.from({ length: 1200 }, (_, index) => ({
274
546
  id: `${index + 1}`,
@@ -501,16 +773,26 @@ describe('applyPullResponse chunk streaming', () => {
501
773
  };
502
774
 
503
775
  const firstPullState = await buildPullRequest(db, options);
504
- await expect(
505
- applyPullResponse(
776
+ try {
777
+ await applyPullResponse(
506
778
  db,
507
779
  transport,
508
780
  handlers,
509
781
  options,
510
782
  firstPullState,
511
783
  response
512
- )
513
- ).rejects.toThrow('chunk-2 missing');
784
+ );
785
+ throw new Error('Expected applyPullResponse to fail');
786
+ } catch (error) {
787
+ expect(error).toBeInstanceOf(SyncClientStageError);
788
+ expect((error as SyncClientStageError).stage).toBe(
789
+ 'snapshot-chunk-fetch'
790
+ );
791
+ expect((error as SyncClientStageError).chunkId).toBe('chunk-2');
792
+ expect((error as SyncClientStageError).cause?.message).toContain(
793
+ 'chunk-2 missing'
794
+ );
795
+ }
514
796
 
515
797
  const countAfterFailure = await sql<{ count: number }>`
516
798
  select count(*) as count
@@ -655,9 +937,24 @@ describe('applyPullResponse chunk streaming', () => {
655
937
  ],
656
938
  };
657
939
 
658
- await expect(
659
- applyPullResponse(db, transport, handlers, options, pullState, response)
660
- ).rejects.toThrow('scoped bootstrap failed');
940
+ try {
941
+ await applyPullResponse(
942
+ db,
943
+ transport,
944
+ handlers,
945
+ options,
946
+ pullState,
947
+ response
948
+ );
949
+ throw new Error('Expected applyPullResponse to fail');
950
+ } catch (error) {
951
+ expect(error).toBeInstanceOf(SyncClientStageError);
952
+ expect((error as SyncClientStageError).stage).toBe('snapshot-apply');
953
+ expect((error as SyncClientStageError).subscriptionId).toBe('scoped-sub');
954
+ expect((error as SyncClientStageError).cause?.message).toContain(
955
+ 'scoped bootstrap failed'
956
+ );
957
+ }
661
958
 
662
959
  const itemCount = await sql<{ count: number }>`
663
960
  select count(*) as count
@@ -1790,3 +2087,121 @@ describe('applyPullResponse chunk streaming', () => {
1790
2087
  expect(clearedScopes).toEqual([{ project_id: 'p1' }]);
1791
2088
  });
1792
2089
  });
2090
+
2091
+ describe('buildPullRequest phased bootstrap selection', () => {
2092
+ let db: Kysely<TestDb>;
2093
+
2094
+ beforeEach(async () => {
2095
+ db = createDatabase<TestDb>({
2096
+ dialect: createBunSqliteDialect({ path: ':memory:' }),
2097
+ family: 'sqlite',
2098
+ });
2099
+ await ensureClientSyncSchema(db);
2100
+ });
2101
+
2102
+ afterEach(async () => {
2103
+ await db.destroy();
2104
+ });
2105
+
2106
+ it('requests only the lowest pending bootstrap phase by default', async () => {
2107
+ const pullState = await buildPullRequest(db, {
2108
+ clientId: 'client-1',
2109
+ stateId: 'default',
2110
+ subscriptions: [
2111
+ { id: 'catalog-meta', table: 'items', bootstrapPhase: 0 },
2112
+ { id: 'catalog-relations', table: 'scoped_items', bootstrapPhase: 1 },
2113
+ ],
2114
+ });
2115
+
2116
+ expect(pullState.request.subscriptions.map((sub) => sub.id)).toEqual([
2117
+ 'catalog-meta',
2118
+ ]);
2119
+ });
2120
+
2121
+ it('unlocks later phases once earlier phases are ready', async () => {
2122
+ const now = Date.now();
2123
+
2124
+ await db
2125
+ .insertInto('sync_subscription_state')
2126
+ .values({
2127
+ state_id: 'default',
2128
+ subscription_id: 'catalog-meta',
2129
+ table: 'items',
2130
+ scopes_json: '{}',
2131
+ params_json: '{}',
2132
+ cursor: 12,
2133
+ bootstrap_state_json: null,
2134
+ status: 'active',
2135
+ created_at: now,
2136
+ updated_at: now,
2137
+ })
2138
+ .execute();
2139
+
2140
+ const pullState = await buildPullRequest(db, {
2141
+ clientId: 'client-1',
2142
+ stateId: 'default',
2143
+ subscriptions: [
2144
+ { id: 'catalog-meta', table: 'items', bootstrapPhase: 0 },
2145
+ { id: 'catalog-relations', table: 'scoped_items', bootstrapPhase: 1 },
2146
+ ],
2147
+ });
2148
+
2149
+ expect(pullState.request.subscriptions.map((sub) => sub.id)).toEqual([
2150
+ 'catalog-meta',
2151
+ 'catalog-relations',
2152
+ ]);
2153
+ });
2154
+
2155
+ it('keeps already-ready later phases live while earlier phases rebootstrap', async () => {
2156
+ const now = Date.now();
2157
+
2158
+ await db
2159
+ .insertInto('sync_subscription_state')
2160
+ .values([
2161
+ {
2162
+ state_id: 'default',
2163
+ subscription_id: 'catalog-meta',
2164
+ table: 'items',
2165
+ scopes_json: '{}',
2166
+ params_json: '{}',
2167
+ cursor: -1,
2168
+ bootstrap_state_json: JSON.stringify({
2169
+ asOfCommitSeq: 0,
2170
+ tables: ['items'],
2171
+ tableIndex: 0,
2172
+ rowCursor: null,
2173
+ }),
2174
+ status: 'active',
2175
+ created_at: now,
2176
+ updated_at: now,
2177
+ },
2178
+ {
2179
+ state_id: 'default',
2180
+ subscription_id: 'catalog-relations',
2181
+ table: 'scoped_items',
2182
+ scopes_json: '{}',
2183
+ params_json: '{}',
2184
+ cursor: 42,
2185
+ bootstrap_state_json: null,
2186
+ status: 'active',
2187
+ created_at: now,
2188
+ updated_at: now,
2189
+ },
2190
+ ])
2191
+ .execute();
2192
+
2193
+ const pullState = await buildPullRequest(db, {
2194
+ clientId: 'client-1',
2195
+ stateId: 'default',
2196
+ subscriptions: [
2197
+ { id: 'catalog-meta', table: 'items', bootstrapPhase: 0 },
2198
+ { id: 'catalog-relations', table: 'scoped_items', bootstrapPhase: 1 },
2199
+ ],
2200
+ });
2201
+
2202
+ expect(pullState.request.subscriptions.map((sub) => sub.id)).toEqual([
2203
+ 'catalog-meta',
2204
+ 'catalog-relations',
2205
+ ]);
2206
+ });
2207
+ });