@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.
- package/README.md +10 -1
- package/dist/client.d.ts +12 -20
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +20 -5
- package/dist/client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +8 -3
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +165 -9
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/types.d.ts +86 -3
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/pull-engine.d.ts +17 -2
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +672 -284
- package/dist/pull-engine.js.map +1 -1
- package/dist/sync-loop.d.ts +5 -3
- package/dist/sync-loop.d.ts.map +1 -1
- package/dist/sync-loop.js +30 -0
- package/dist/sync-loop.js.map +1 -1
- package/dist/sync.d.ts +4 -3
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +1 -0
- package/dist/sync.js.map +1 -1
- package/package.json +3 -3
- package/src/client.ts +35 -25
- package/src/engine/SyncEngine.test.ts +64 -0
- package/src/engine/SyncEngine.ts +225 -18
- package/src/engine/types.ts +109 -1
- package/src/pull-engine.test.ts +365 -0
- package/src/pull-engine.ts +863 -340
- package/src/sync-loop.ts +52 -3
- package/src/sync.ts +6 -9
package/src/pull-engine.test.ts
CHANGED
|
@@ -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
|
+
});
|