@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.
- package/README.md +10 -1
- package/dist/client.d.ts +26 -20
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +42 -7
- package/dist/client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +4 -3
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +199 -26
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/types.d.ts +61 -3
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +31 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pull-engine.d.ts +6 -2
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +732 -234
- 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 +79 -29
- package/src/engine/SyncEngine.test.ts +238 -0
- package/src/engine/SyncEngine.ts +257 -40
- package/src/engine/types.ts +81 -1
- package/src/errors.ts +59 -0
- package/src/index.ts +1 -0
- package/src/pull-engine.test.ts +422 -7
- package/src/pull-engine.ts +906 -276
- package/src/sync-loop.ts +52 -3
- package/src/sync.ts +6 -9
package/src/pull-engine.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
659
|
-
applyPullResponse(
|
|
660
|
-
|
|
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
|
+
});
|