@syncular/client 0.0.6-213 → 0.0.6-221

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.
@@ -162,6 +162,113 @@ describe('applyPullResponse chunk streaming', () => {
162
162
  expect(streamFetchCount).toBe(1);
163
163
  });
164
164
 
165
+ it('applies gzip-compressed chunk streams without DecompressionStream', async () => {
166
+ const rows = Array.from({ length: 128 }, (_, index) => ({
167
+ id: `${index + 1}`,
168
+ name: `Item ${index + 1}`,
169
+ }));
170
+ const encoded = encodeSnapshotRows(rows);
171
+ const compressed = new Uint8Array(gzipSync(encoded));
172
+
173
+ const originalDecompressionStream = globalThis.DecompressionStream;
174
+ Object.defineProperty(globalThis, 'DecompressionStream', {
175
+ value: undefined,
176
+ configurable: true,
177
+ writable: true,
178
+ });
179
+
180
+ try {
181
+ let streamFetchCount = 0;
182
+ const transport: SyncTransport = {
183
+ async sync() {
184
+ return {};
185
+ },
186
+ async fetchSnapshotChunk() {
187
+ throw new Error('fetchSnapshotChunk should not be used');
188
+ },
189
+ async fetchSnapshotChunkStream() {
190
+ streamFetchCount += 1;
191
+ return createStreamFromBytes(compressed, 73);
192
+ },
193
+ };
194
+
195
+ const handlers: ClientHandlerCollection<TestDb> = [
196
+ createClientHandler({
197
+ table: 'items',
198
+ scopes: ['items:{id}'],
199
+ }),
200
+ ];
201
+
202
+ const options = {
203
+ clientId: 'client-1',
204
+ subscriptions: [
205
+ {
206
+ id: 'items-sub',
207
+ table: 'items',
208
+ scopes: {},
209
+ },
210
+ ],
211
+ stateId: 'default',
212
+ };
213
+
214
+ const pullState = await buildPullRequest(db, options);
215
+
216
+ const response: SyncPullResponse = {
217
+ ok: true,
218
+ subscriptions: [
219
+ {
220
+ id: 'items-sub',
221
+ status: 'active',
222
+ scopes: {},
223
+ bootstrap: true,
224
+ bootstrapState: null,
225
+ nextCursor: 1,
226
+ commits: [],
227
+ snapshots: [
228
+ {
229
+ table: 'items',
230
+ rows: [],
231
+ chunks: [
232
+ {
233
+ id: 'chunk-1',
234
+ byteLength: compressed.length,
235
+ sha256: '',
236
+ encoding: 'json-row-frame-v1',
237
+ compression: 'gzip',
238
+ },
239
+ ],
240
+ isFirstPage: true,
241
+ isLastPage: true,
242
+ },
243
+ ],
244
+ },
245
+ ],
246
+ };
247
+
248
+ await applyPullResponse(
249
+ db,
250
+ transport,
251
+ handlers,
252
+ options,
253
+ pullState,
254
+ response
255
+ );
256
+
257
+ const countResult = await sql<{ count: number }>`
258
+ select count(*) as count
259
+ from ${sql.table('items')}
260
+ `.execute(db);
261
+ expect(Number(countResult.rows[0]?.count ?? 0)).toBe(rows.length);
262
+ expect(streamFetchCount).toBe(1);
263
+ } finally {
264
+ Object.defineProperty(globalThis, 'DecompressionStream', {
265
+ value: originalDecompressionStream,
266
+ configurable: true,
267
+ writable: true,
268
+ });
269
+ }
270
+ });
271
+
165
272
  it('materializes chunked bootstrap snapshots for afterPull plugins via streaming transport', async () => {
166
273
  const firstRows = Array.from({ length: 1200 }, (_, index) => ({
167
274
  id: `${index + 1}`,
@@ -439,6 +546,146 @@ describe('applyPullResponse chunk streaming', () => {
439
546
  );
440
547
  });
441
548
 
549
+ it('can commit bootstrap subscriptions independently via transport capabilities', async () => {
550
+ await db.schema
551
+ .createTable('scoped_items')
552
+ .addColumn('id', 'text', (col) => col.primaryKey())
553
+ .addColumn('project_id', 'text', (col) => col.notNull())
554
+ .addColumn('name', 'text', (col) => col.notNull())
555
+ .execute();
556
+
557
+ const itemsRows = Array.from({ length: 3 }, (_, index) => ({
558
+ id: `${index + 1}`,
559
+ name: `Item ${index + 1}`,
560
+ }));
561
+ const scopedRows = Array.from({ length: 2 }, (_, index) => ({
562
+ id: `scoped-${index + 1}`,
563
+ project_id: 'alpha',
564
+ name: `Scoped ${index + 1}`,
565
+ }));
566
+
567
+ const transport: SyncTransport = {
568
+ capabilities: {
569
+ preferredBootstrapApplyMode: 'per-subscription',
570
+ },
571
+ async sync() {
572
+ return {};
573
+ },
574
+ async fetchSnapshotChunk() {
575
+ throw new Error('fetchSnapshotChunk should not be used');
576
+ },
577
+ };
578
+
579
+ const handlers: ClientHandlerCollection<TestDb> = [
580
+ createClientHandler({
581
+ table: 'items',
582
+ scopes: ['items:{id}'],
583
+ }),
584
+ {
585
+ table: 'scoped_items',
586
+ scopePatterns: ['scoped_items:{project}'],
587
+ async applySnapshot() {
588
+ throw new Error('scoped bootstrap failed');
589
+ },
590
+ async clearAll() {
591
+ return;
592
+ },
593
+ async applyChange() {
594
+ return;
595
+ },
596
+ },
597
+ ];
598
+
599
+ const options = {
600
+ clientId: 'client-1',
601
+ subscriptions: [
602
+ {
603
+ id: 'items-sub',
604
+ table: 'items',
605
+ scopes: {},
606
+ },
607
+ {
608
+ id: 'scoped-sub',
609
+ table: 'scoped_items',
610
+ scopes: { project: 'alpha' },
611
+ },
612
+ ],
613
+ stateId: 'default',
614
+ };
615
+
616
+ const pullState = await buildPullRequest(db, options);
617
+
618
+ const response: SyncPullResponse = {
619
+ ok: true,
620
+ subscriptions: [
621
+ {
622
+ id: 'items-sub',
623
+ status: 'active',
624
+ scopes: {},
625
+ bootstrap: true,
626
+ bootstrapState: null,
627
+ nextCursor: 3,
628
+ commits: [],
629
+ snapshots: [
630
+ {
631
+ table: 'items',
632
+ rows: itemsRows,
633
+ isFirstPage: true,
634
+ isLastPage: true,
635
+ },
636
+ ],
637
+ },
638
+ {
639
+ id: 'scoped-sub',
640
+ status: 'active',
641
+ scopes: { project: 'alpha' },
642
+ bootstrap: true,
643
+ bootstrapState: null,
644
+ nextCursor: 3,
645
+ commits: [],
646
+ snapshots: [
647
+ {
648
+ table: 'scoped_items',
649
+ rows: scopedRows,
650
+ isFirstPage: true,
651
+ isLastPage: true,
652
+ },
653
+ ],
654
+ },
655
+ ],
656
+ };
657
+
658
+ await expect(
659
+ applyPullResponse(db, transport, handlers, options, pullState, response)
660
+ ).rejects.toThrow('scoped bootstrap failed');
661
+
662
+ const itemCount = await sql<{ count: number }>`
663
+ select count(*) as count
664
+ from ${sql.table('items')}
665
+ `.execute(db);
666
+ expect(Number(itemCount.rows[0]?.count ?? 0)).toBe(itemsRows.length);
667
+
668
+ const scopedCount = await sql<{ count: number }>`
669
+ select count(*) as count
670
+ from ${sql.table('scoped_items')}
671
+ `.execute(db);
672
+ expect(Number(scopedCount.rows[0]?.count ?? 0)).toBe(0);
673
+
674
+ const stateRows = await db
675
+ .selectFrom('sync_subscription_state')
676
+ .select(['subscription_id', 'cursor'])
677
+ .where('state_id', '=', 'default')
678
+ .orderBy('subscription_id')
679
+ .execute();
680
+
681
+ expect(stateRows).toEqual([
682
+ {
683
+ subscription_id: 'items-sub',
684
+ cursor: 3,
685
+ },
686
+ ]);
687
+ });
688
+
442
689
  it('verifies sha256 integrity for streamed chunk snapshots', async () => {
443
690
  const rows = Array.from({ length: 1000 }, (_, index) => ({
444
691
  id: `${index + 1}`,
@@ -1543,3 +1790,121 @@ describe('applyPullResponse chunk streaming', () => {
1543
1790
  expect(clearedScopes).toEqual([{ project_id: 'p1' }]);
1544
1791
  });
1545
1792
  });
1793
+
1794
+ describe('buildPullRequest phased bootstrap selection', () => {
1795
+ let db: Kysely<TestDb>;
1796
+
1797
+ beforeEach(async () => {
1798
+ db = createDatabase<TestDb>({
1799
+ dialect: createBunSqliteDialect({ path: ':memory:' }),
1800
+ family: 'sqlite',
1801
+ });
1802
+ await ensureClientSyncSchema(db);
1803
+ });
1804
+
1805
+ afterEach(async () => {
1806
+ await db.destroy();
1807
+ });
1808
+
1809
+ it('requests only the lowest pending bootstrap phase by default', async () => {
1810
+ const pullState = await buildPullRequest(db, {
1811
+ clientId: 'client-1',
1812
+ stateId: 'default',
1813
+ subscriptions: [
1814
+ { id: 'catalog-meta', table: 'items', bootstrapPhase: 0 },
1815
+ { id: 'catalog-relations', table: 'scoped_items', bootstrapPhase: 1 },
1816
+ ],
1817
+ });
1818
+
1819
+ expect(pullState.request.subscriptions.map((sub) => sub.id)).toEqual([
1820
+ 'catalog-meta',
1821
+ ]);
1822
+ });
1823
+
1824
+ it('unlocks later phases once earlier phases are ready', async () => {
1825
+ const now = Date.now();
1826
+
1827
+ await db
1828
+ .insertInto('sync_subscription_state')
1829
+ .values({
1830
+ state_id: 'default',
1831
+ subscription_id: 'catalog-meta',
1832
+ table: 'items',
1833
+ scopes_json: '{}',
1834
+ params_json: '{}',
1835
+ cursor: 12,
1836
+ bootstrap_state_json: null,
1837
+ status: 'active',
1838
+ created_at: now,
1839
+ updated_at: now,
1840
+ })
1841
+ .execute();
1842
+
1843
+ const pullState = await buildPullRequest(db, {
1844
+ clientId: 'client-1',
1845
+ stateId: 'default',
1846
+ subscriptions: [
1847
+ { id: 'catalog-meta', table: 'items', bootstrapPhase: 0 },
1848
+ { id: 'catalog-relations', table: 'scoped_items', bootstrapPhase: 1 },
1849
+ ],
1850
+ });
1851
+
1852
+ expect(pullState.request.subscriptions.map((sub) => sub.id)).toEqual([
1853
+ 'catalog-meta',
1854
+ 'catalog-relations',
1855
+ ]);
1856
+ });
1857
+
1858
+ it('keeps already-ready later phases live while earlier phases rebootstrap', async () => {
1859
+ const now = Date.now();
1860
+
1861
+ await db
1862
+ .insertInto('sync_subscription_state')
1863
+ .values([
1864
+ {
1865
+ state_id: 'default',
1866
+ subscription_id: 'catalog-meta',
1867
+ table: 'items',
1868
+ scopes_json: '{}',
1869
+ params_json: '{}',
1870
+ cursor: -1,
1871
+ bootstrap_state_json: JSON.stringify({
1872
+ asOfCommitSeq: 0,
1873
+ tables: ['items'],
1874
+ tableIndex: 0,
1875
+ rowCursor: null,
1876
+ }),
1877
+ status: 'active',
1878
+ created_at: now,
1879
+ updated_at: now,
1880
+ },
1881
+ {
1882
+ state_id: 'default',
1883
+ subscription_id: 'catalog-relations',
1884
+ table: 'scoped_items',
1885
+ scopes_json: '{}',
1886
+ params_json: '{}',
1887
+ cursor: 42,
1888
+ bootstrap_state_json: null,
1889
+ status: 'active',
1890
+ created_at: now,
1891
+ updated_at: now,
1892
+ },
1893
+ ])
1894
+ .execute();
1895
+
1896
+ const pullState = await buildPullRequest(db, {
1897
+ clientId: 'client-1',
1898
+ stateId: 'default',
1899
+ subscriptions: [
1900
+ { id: 'catalog-meta', table: 'items', bootstrapPhase: 0 },
1901
+ { id: 'catalog-relations', table: 'scoped_items', bootstrapPhase: 1 },
1902
+ ],
1903
+ });
1904
+
1905
+ expect(pullState.request.subscriptions.map((sub) => sub.id)).toEqual([
1906
+ 'catalog-meta',
1907
+ 'catalog-relations',
1908
+ ]);
1909
+ });
1910
+ });