@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.ts
CHANGED
|
@@ -11,15 +11,17 @@ import {
|
|
|
11
11
|
type SyncBootstrapApplyMode,
|
|
12
12
|
type SyncBootstrapState,
|
|
13
13
|
type SyncChange,
|
|
14
|
+
type SyncCombinedResponse,
|
|
14
15
|
type SyncPullRequest,
|
|
15
16
|
type SyncPullResponse,
|
|
16
17
|
type SyncPullSubscriptionResponse,
|
|
17
18
|
type SyncSnapshot,
|
|
18
|
-
type SyncSubscriptionRequest,
|
|
19
19
|
type SyncTransport,
|
|
20
20
|
type SyncTransportCapabilities,
|
|
21
21
|
} from '@syncular/core';
|
|
22
22
|
import { type Kysely, sql, type Transaction } from 'kysely';
|
|
23
|
+
import type { SyncClientSubscription, SyncTraceEvent } from './engine/types';
|
|
24
|
+
import { SyncClientStageError, wrapSyncClientStageError } from './errors';
|
|
23
25
|
import {
|
|
24
26
|
type ClientHandlerCollection,
|
|
25
27
|
getClientHandlerOrThrow,
|
|
@@ -93,7 +95,13 @@ function toOwnedUint8Array(chunk: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
async function maybeGunzipStream(
|
|
96
|
-
stream: ReadableStream<Uint8Array
|
|
98
|
+
stream: ReadableStream<Uint8Array>,
|
|
99
|
+
context?: {
|
|
100
|
+
stateId?: string;
|
|
101
|
+
subscriptionId?: string;
|
|
102
|
+
table?: string;
|
|
103
|
+
chunkId?: string;
|
|
104
|
+
}
|
|
97
105
|
): Promise<ReadableStream<Uint8Array>> {
|
|
98
106
|
const reader = stream.getReader();
|
|
99
107
|
const prefetched: Uint8Array[] = [];
|
|
@@ -139,7 +147,20 @@ async function maybeGunzipStream(
|
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
const compressedBytes = await readAllBytesFromCoreStream(replayStream);
|
|
142
|
-
|
|
150
|
+
try {
|
|
151
|
+
return bytesToReadableStream(await gunzipBytes(compressedBytes));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw wrapSyncClientStageError(
|
|
154
|
+
error,
|
|
155
|
+
{
|
|
156
|
+
stage: 'snapshot-gzip-decode',
|
|
157
|
+
...context,
|
|
158
|
+
},
|
|
159
|
+
`Failed to gunzip snapshot chunk${
|
|
160
|
+
context?.chunkId ? ` "${context.chunkId}"` : ''
|
|
161
|
+
}`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
143
164
|
}
|
|
144
165
|
|
|
145
166
|
async function* decodeSnapshotRowStreamBatches(
|
|
@@ -280,13 +301,30 @@ async function fetchSnapshotChunkStream(
|
|
|
280
301
|
request: {
|
|
281
302
|
chunkId: string;
|
|
282
303
|
scopeValues?: ScopeValues;
|
|
304
|
+
},
|
|
305
|
+
context?: {
|
|
306
|
+
stateId?: string;
|
|
307
|
+
subscriptionId?: string;
|
|
308
|
+
table?: string;
|
|
283
309
|
}
|
|
284
310
|
): Promise<ReadableStream<Uint8Array>> {
|
|
285
|
-
|
|
286
|
-
|
|
311
|
+
try {
|
|
312
|
+
if (transport.fetchSnapshotChunkStream) {
|
|
313
|
+
return await transport.fetchSnapshotChunkStream(request);
|
|
314
|
+
}
|
|
315
|
+
const bytes = await transport.fetchSnapshotChunk(request);
|
|
316
|
+
return bytesToReadableStream(bytes);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
throw wrapSyncClientStageError(
|
|
319
|
+
error,
|
|
320
|
+
{
|
|
321
|
+
stage: 'snapshot-chunk-fetch',
|
|
322
|
+
chunkId: request.chunkId,
|
|
323
|
+
...context,
|
|
324
|
+
},
|
|
325
|
+
`Failed to fetch snapshot chunk "${request.chunkId}"`
|
|
326
|
+
);
|
|
287
327
|
}
|
|
288
|
-
const bytes = await transport.fetchSnapshotChunk(request);
|
|
289
|
-
return bytesToReadableStream(bytes);
|
|
290
328
|
}
|
|
291
329
|
|
|
292
330
|
async function readAllBytesFromStream(
|
|
@@ -332,80 +370,212 @@ async function materializeSnapshotChunkRows(
|
|
|
332
370
|
scopeValues?: ScopeValues;
|
|
333
371
|
},
|
|
334
372
|
expectedHash: string | undefined,
|
|
335
|
-
sha256Override?: (bytes: Uint8Array) => Promise<string
|
|
373
|
+
sha256Override?: (bytes: Uint8Array) => Promise<string>,
|
|
374
|
+
trace?: {
|
|
375
|
+
stateId: string;
|
|
376
|
+
subscriptionId: string;
|
|
377
|
+
table: string;
|
|
378
|
+
chunkIndex: number;
|
|
379
|
+
onTrace?: SyncPullOnceOptions['onTrace'];
|
|
380
|
+
}
|
|
336
381
|
): Promise<unknown[]> {
|
|
382
|
+
emitTrace(trace?.onTrace, {
|
|
383
|
+
stage: 'apply:chunk-materialize:start',
|
|
384
|
+
stateId: trace?.stateId,
|
|
385
|
+
subscriptionId: trace?.subscriptionId,
|
|
386
|
+
table: trace?.table,
|
|
387
|
+
chunkId: request.chunkId,
|
|
388
|
+
chunkIndex: trace?.chunkIndex,
|
|
389
|
+
});
|
|
390
|
+
const startedAt = Date.now();
|
|
391
|
+
|
|
337
392
|
if (
|
|
338
393
|
transport.capabilities?.snapshotChunkReadMode === 'bytes' &&
|
|
339
394
|
transport.fetchSnapshotChunk
|
|
340
395
|
) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
396
|
+
try {
|
|
397
|
+
let bytes = await transport.fetchSnapshotChunk(request);
|
|
398
|
+
if (isGzipBytes(bytes)) {
|
|
399
|
+
try {
|
|
400
|
+
bytes = await gunzipBytes(bytes);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw wrapSyncClientStageError(
|
|
403
|
+
error,
|
|
404
|
+
{
|
|
405
|
+
stage: 'snapshot-gzip-decode',
|
|
406
|
+
stateId: trace?.stateId,
|
|
407
|
+
subscriptionId: trace?.subscriptionId,
|
|
408
|
+
table: trace?.table,
|
|
409
|
+
chunkId: request.chunkId,
|
|
410
|
+
},
|
|
411
|
+
`Failed to gunzip snapshot chunk "${request.chunkId}"`
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (expectedHash) {
|
|
416
|
+
const actualHash = await computeSha256Hex(bytes, sha256Override);
|
|
417
|
+
if (actualHash !== expectedHash) {
|
|
418
|
+
throw new SyncClientStageError(
|
|
419
|
+
`Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`,
|
|
420
|
+
{
|
|
421
|
+
stage: 'snapshot-integrity',
|
|
422
|
+
stateId: trace?.stateId,
|
|
423
|
+
subscriptionId: trace?.subscriptionId,
|
|
424
|
+
table: trace?.table,
|
|
425
|
+
chunkId: request.chunkId,
|
|
426
|
+
}
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
let rows: unknown[];
|
|
431
|
+
try {
|
|
432
|
+
rows = decodeSnapshotRows(bytes);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
throw wrapSyncClientStageError(
|
|
435
|
+
error,
|
|
436
|
+
{
|
|
437
|
+
stage: 'snapshot-chunk-decode',
|
|
438
|
+
stateId: trace?.stateId,
|
|
439
|
+
subscriptionId: trace?.subscriptionId,
|
|
440
|
+
table: trace?.table,
|
|
441
|
+
chunkId: request.chunkId,
|
|
442
|
+
},
|
|
443
|
+
`Failed to decode snapshot chunk "${request.chunkId}"`
|
|
350
444
|
);
|
|
351
445
|
}
|
|
446
|
+
emitTrace(trace?.onTrace, {
|
|
447
|
+
stage: 'apply:chunk-materialize:complete',
|
|
448
|
+
stateId: trace?.stateId,
|
|
449
|
+
subscriptionId: trace?.subscriptionId,
|
|
450
|
+
table: trace?.table,
|
|
451
|
+
chunkId: request.chunkId,
|
|
452
|
+
chunkIndex: trace?.chunkIndex,
|
|
453
|
+
rowCount: rows.length,
|
|
454
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
455
|
+
});
|
|
456
|
+
return rows;
|
|
457
|
+
} catch (error) {
|
|
458
|
+
emitTrace(trace?.onTrace, {
|
|
459
|
+
stage: 'apply:chunk-materialize:error',
|
|
460
|
+
stateId: trace?.stateId,
|
|
461
|
+
subscriptionId: trace?.subscriptionId,
|
|
462
|
+
table: trace?.table,
|
|
463
|
+
chunkId: request.chunkId,
|
|
464
|
+
chunkIndex: trace?.chunkIndex,
|
|
465
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
466
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
467
|
+
});
|
|
468
|
+
throw error;
|
|
352
469
|
}
|
|
353
|
-
return decodeSnapshotRows(bytes);
|
|
354
470
|
}
|
|
355
471
|
|
|
356
|
-
const rawStream = await fetchSnapshotChunkStream(transport, request);
|
|
357
|
-
const decodedStream = await maybeGunzipStream(rawStream);
|
|
358
|
-
let streamForDecode = decodedStream;
|
|
359
|
-
let chunkHashPromise: Promise<string> | null = null;
|
|
360
|
-
|
|
361
|
-
if (expectedHash) {
|
|
362
|
-
const [hashStream, decodeStream] = decodedStream.tee();
|
|
363
|
-
streamForDecode = decodeStream;
|
|
364
|
-
chunkHashPromise = readAllBytesFromStream(hashStream).then((bytes) =>
|
|
365
|
-
computeSha256Hex(bytes, sha256Override)
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const rows: unknown[] = [];
|
|
370
|
-
let materializeError: unknown = null;
|
|
371
|
-
|
|
372
472
|
try {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
473
|
+
const rawStream = await fetchSnapshotChunkStream(transport, request, {
|
|
474
|
+
stateId: trace?.stateId,
|
|
475
|
+
subscriptionId: trace?.subscriptionId,
|
|
476
|
+
table: trace?.table,
|
|
477
|
+
});
|
|
478
|
+
const decodedStream = await maybeGunzipStream(rawStream, {
|
|
479
|
+
stateId: trace?.stateId,
|
|
480
|
+
subscriptionId: trace?.subscriptionId,
|
|
481
|
+
table: trace?.table,
|
|
482
|
+
chunkId: request.chunkId,
|
|
483
|
+
});
|
|
484
|
+
let streamForDecode = decodedStream;
|
|
485
|
+
let chunkHashPromise: Promise<string> | null = null;
|
|
486
|
+
|
|
487
|
+
if (expectedHash) {
|
|
488
|
+
const [hashStream, decodeStream] = decodedStream.tee();
|
|
489
|
+
streamForDecode = decodeStream;
|
|
490
|
+
chunkHashPromise = readAllBytesFromStream(hashStream).then((bytes) =>
|
|
491
|
+
computeSha256Hex(bytes, sha256Override)
|
|
492
|
+
);
|
|
378
493
|
}
|
|
379
|
-
} catch (error) {
|
|
380
|
-
materializeError = error;
|
|
381
|
-
}
|
|
382
494
|
|
|
383
|
-
|
|
495
|
+
const rows: unknown[] = [];
|
|
496
|
+
let materializeError: unknown = null;
|
|
497
|
+
|
|
384
498
|
try {
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
);
|
|
499
|
+
for await (const batch of decodeSnapshotRowStreamBatches(
|
|
500
|
+
streamForDecode,
|
|
501
|
+
SNAPSHOT_APPLY_BATCH_ROWS
|
|
502
|
+
)) {
|
|
503
|
+
rows.push(...batch);
|
|
390
504
|
}
|
|
391
|
-
} catch (
|
|
392
|
-
|
|
393
|
-
|
|
505
|
+
} catch (error) {
|
|
506
|
+
materializeError = wrapSyncClientStageError(
|
|
507
|
+
error,
|
|
508
|
+
{
|
|
509
|
+
stage: 'snapshot-chunk-decode',
|
|
510
|
+
stateId: trace?.stateId,
|
|
511
|
+
subscriptionId: trace?.subscriptionId,
|
|
512
|
+
table: trace?.table,
|
|
513
|
+
chunkId: request.chunkId,
|
|
514
|
+
},
|
|
515
|
+
`Failed to decode snapshot chunk "${request.chunkId}"`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (chunkHashPromise) {
|
|
520
|
+
try {
|
|
521
|
+
const actualHash = await chunkHashPromise;
|
|
522
|
+
if (!materializeError && actualHash !== expectedHash) {
|
|
523
|
+
materializeError = new SyncClientStageError(
|
|
524
|
+
`Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`,
|
|
525
|
+
{
|
|
526
|
+
stage: 'snapshot-integrity',
|
|
527
|
+
stateId: trace?.stateId,
|
|
528
|
+
subscriptionId: trace?.subscriptionId,
|
|
529
|
+
table: trace?.table,
|
|
530
|
+
chunkId: request.chunkId,
|
|
531
|
+
}
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
} catch (hashError) {
|
|
535
|
+
if (!materializeError) {
|
|
536
|
+
materializeError = hashError;
|
|
537
|
+
}
|
|
394
538
|
}
|
|
395
539
|
}
|
|
396
|
-
}
|
|
397
540
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
541
|
+
if (materializeError) {
|
|
542
|
+
throw materializeError;
|
|
543
|
+
}
|
|
401
544
|
|
|
402
|
-
|
|
545
|
+
emitTrace(trace?.onTrace, {
|
|
546
|
+
stage: 'apply:chunk-materialize:complete',
|
|
547
|
+
stateId: trace?.stateId,
|
|
548
|
+
subscriptionId: trace?.subscriptionId,
|
|
549
|
+
table: trace?.table,
|
|
550
|
+
chunkId: request.chunkId,
|
|
551
|
+
chunkIndex: trace?.chunkIndex,
|
|
552
|
+
rowCount: rows.length,
|
|
553
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
554
|
+
});
|
|
555
|
+
return rows;
|
|
556
|
+
} catch (error) {
|
|
557
|
+
emitTrace(trace?.onTrace, {
|
|
558
|
+
stage: 'apply:chunk-materialize:error',
|
|
559
|
+
stateId: trace?.stateId,
|
|
560
|
+
subscriptionId: trace?.subscriptionId,
|
|
561
|
+
table: trace?.table,
|
|
562
|
+
chunkId: request.chunkId,
|
|
563
|
+
chunkIndex: trace?.chunkIndex,
|
|
564
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
565
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
566
|
+
});
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
403
569
|
}
|
|
404
570
|
|
|
405
571
|
async function materializeChunkedSnapshots(
|
|
406
572
|
transport: SyncTransport,
|
|
407
573
|
response: SyncPullResponse,
|
|
408
|
-
sha256Override?: (bytes: Uint8Array) => Promise<string
|
|
574
|
+
sha256Override?: (bytes: Uint8Array) => Promise<string>,
|
|
575
|
+
trace?: {
|
|
576
|
+
stateId: string;
|
|
577
|
+
onTrace?: SyncPullOnceOptions['onTrace'];
|
|
578
|
+
}
|
|
409
579
|
): Promise<SyncPullResponse> {
|
|
410
580
|
const subscriptions: SyncPullResponse['subscriptions'] = [];
|
|
411
581
|
|
|
@@ -424,7 +594,9 @@ async function materializeChunkedSnapshots(
|
|
|
424
594
|
}
|
|
425
595
|
|
|
426
596
|
const rows: unknown[] = [];
|
|
427
|
-
for (
|
|
597
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
|
|
598
|
+
const chunk = chunks[chunkIndex];
|
|
599
|
+
if (!chunk) continue;
|
|
428
600
|
const chunkRows = await materializeSnapshotChunkRows(
|
|
429
601
|
transport,
|
|
430
602
|
{
|
|
@@ -432,7 +604,14 @@ async function materializeChunkedSnapshots(
|
|
|
432
604
|
scopeValues: sub.scopes,
|
|
433
605
|
},
|
|
434
606
|
chunk.sha256,
|
|
435
|
-
sha256Override
|
|
607
|
+
sha256Override,
|
|
608
|
+
{
|
|
609
|
+
stateId: trace?.stateId ?? 'default',
|
|
610
|
+
subscriptionId: sub.id,
|
|
611
|
+
table: snapshot.table,
|
|
612
|
+
chunkIndex,
|
|
613
|
+
onTrace: trace?.onTrace,
|
|
614
|
+
}
|
|
436
615
|
);
|
|
437
616
|
rows.push(...chunkRows);
|
|
438
617
|
}
|
|
@@ -459,7 +638,12 @@ async function applyChunkedSnapshot<DB extends SyncClientDb>(
|
|
|
459
638
|
trx: Transaction<DB>,
|
|
460
639
|
snapshot: SyncSnapshot,
|
|
461
640
|
scopeValues: ScopeValues,
|
|
462
|
-
sha256Override?: (bytes: Uint8Array) => Promise<string
|
|
641
|
+
sha256Override?: (bytes: Uint8Array) => Promise<string>,
|
|
642
|
+
trace?: {
|
|
643
|
+
stateId: string;
|
|
644
|
+
subscriptionId: string;
|
|
645
|
+
onTrace?: SyncPullOnceOptions['onTrace'];
|
|
646
|
+
}
|
|
463
647
|
): Promise<void> {
|
|
464
648
|
const chunks = snapshot.chunks ?? [];
|
|
465
649
|
if (chunks.length === 0) {
|
|
@@ -472,88 +656,185 @@ async function applyChunkedSnapshot<DB extends SyncClientDb>(
|
|
|
472
656
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
|
|
473
657
|
const chunk = chunks[chunkIndex];
|
|
474
658
|
if (!chunk) continue;
|
|
475
|
-
|
|
476
|
-
|
|
659
|
+
emitTrace(trace?.onTrace, {
|
|
660
|
+
stage: 'apply:chunk-materialize:start',
|
|
661
|
+
stateId: trace?.stateId,
|
|
662
|
+
subscriptionId: trace?.subscriptionId,
|
|
663
|
+
table: snapshot.table,
|
|
477
664
|
chunkId: chunk.id,
|
|
478
|
-
|
|
665
|
+
chunkIndex,
|
|
479
666
|
});
|
|
480
|
-
const
|
|
481
|
-
let streamForDecode = decodedStream;
|
|
482
|
-
let chunkHashPromise: Promise<string> | null = null;
|
|
667
|
+
const chunkStartedAt = Date.now();
|
|
483
668
|
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
669
|
+
try {
|
|
670
|
+
const rawStream = await fetchSnapshotChunkStream(
|
|
671
|
+
transport,
|
|
672
|
+
{
|
|
673
|
+
chunkId: chunk.id,
|
|
674
|
+
scopeValues,
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
stateId: trace?.stateId,
|
|
678
|
+
subscriptionId: trace?.subscriptionId,
|
|
679
|
+
table: snapshot.table,
|
|
680
|
+
}
|
|
489
681
|
);
|
|
490
|
-
|
|
682
|
+
const decodedStream = await maybeGunzipStream(rawStream, {
|
|
683
|
+
stateId: trace?.stateId,
|
|
684
|
+
subscriptionId: trace?.subscriptionId,
|
|
685
|
+
table: snapshot.table,
|
|
686
|
+
chunkId: chunk.id,
|
|
687
|
+
});
|
|
688
|
+
let streamForDecode = decodedStream;
|
|
689
|
+
let chunkHashPromise: Promise<string> | null = null;
|
|
690
|
+
|
|
691
|
+
if (chunk.sha256) {
|
|
692
|
+
const [hashStream, decodeStream] = decodedStream.tee();
|
|
693
|
+
streamForDecode = decodeStream;
|
|
694
|
+
chunkHashPromise = readAllBytesFromStream(hashStream).then((bytes) =>
|
|
695
|
+
computeSha256Hex(bytes, sha256Override)
|
|
696
|
+
);
|
|
697
|
+
}
|
|
491
698
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
699
|
+
const rowBatchIterator = decodeSnapshotRowStreamBatches(
|
|
700
|
+
streamForDecode,
|
|
701
|
+
SNAPSHOT_APPLY_BATCH_ROWS
|
|
702
|
+
);
|
|
496
703
|
|
|
497
|
-
|
|
498
|
-
|
|
704
|
+
let pendingBatch: unknown[] | null = null;
|
|
705
|
+
let applyError: unknown = null;
|
|
706
|
+
let chunkRowCount = 0;
|
|
707
|
+
|
|
708
|
+
try {
|
|
709
|
+
// eslint-disable-next-line no-await-in-loop
|
|
710
|
+
for await (const batch of rowBatchIterator) {
|
|
711
|
+
chunkRowCount += batch.length;
|
|
712
|
+
if (pendingBatch) {
|
|
713
|
+
// eslint-disable-next-line no-await-in-loop
|
|
714
|
+
try {
|
|
715
|
+
await handler.applySnapshot(
|
|
716
|
+
{ trx },
|
|
717
|
+
{
|
|
718
|
+
...snapshot,
|
|
719
|
+
rows: pendingBatch,
|
|
720
|
+
chunks: undefined,
|
|
721
|
+
isFirstPage: nextIsFirstPage,
|
|
722
|
+
isLastPage: false,
|
|
723
|
+
}
|
|
724
|
+
);
|
|
725
|
+
} catch (error) {
|
|
726
|
+
throw wrapSyncClientStageError(
|
|
727
|
+
error,
|
|
728
|
+
{
|
|
729
|
+
stage: 'snapshot-apply',
|
|
730
|
+
stateId: trace?.stateId,
|
|
731
|
+
subscriptionId: trace?.subscriptionId,
|
|
732
|
+
table: snapshot.table,
|
|
733
|
+
chunkId: chunk.id,
|
|
734
|
+
},
|
|
735
|
+
`Failed to apply snapshot chunk "${chunk.id}"`
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
nextIsFirstPage = false;
|
|
739
|
+
}
|
|
740
|
+
pendingBatch = batch;
|
|
741
|
+
}
|
|
499
742
|
|
|
500
|
-
try {
|
|
501
|
-
// eslint-disable-next-line no-await-in-loop
|
|
502
|
-
for await (const batch of rowBatchIterator) {
|
|
503
743
|
if (pendingBatch) {
|
|
744
|
+
const isLastChunk = chunkIndex === chunks.length - 1;
|
|
504
745
|
// eslint-disable-next-line no-await-in-loop
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
746
|
+
try {
|
|
747
|
+
await handler.applySnapshot(
|
|
748
|
+
{ trx },
|
|
749
|
+
{
|
|
750
|
+
...snapshot,
|
|
751
|
+
rows: pendingBatch,
|
|
752
|
+
chunks: undefined,
|
|
753
|
+
isFirstPage: nextIsFirstPage,
|
|
754
|
+
isLastPage: isLastChunk ? snapshot.isLastPage : false,
|
|
755
|
+
}
|
|
756
|
+
);
|
|
757
|
+
} catch (error) {
|
|
758
|
+
throw wrapSyncClientStageError(
|
|
759
|
+
error,
|
|
760
|
+
{
|
|
761
|
+
stage: 'snapshot-apply',
|
|
762
|
+
stateId: trace?.stateId,
|
|
763
|
+
subscriptionId: trace?.subscriptionId,
|
|
764
|
+
table: snapshot.table,
|
|
765
|
+
chunkId: chunk.id,
|
|
766
|
+
},
|
|
767
|
+
`Failed to apply snapshot chunk "${chunk.id}"`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
515
770
|
nextIsFirstPage = false;
|
|
516
771
|
}
|
|
517
|
-
|
|
772
|
+
} catch (error) {
|
|
773
|
+
applyError =
|
|
774
|
+
error instanceof SyncClientStageError
|
|
775
|
+
? error
|
|
776
|
+
: wrapSyncClientStageError(
|
|
777
|
+
error,
|
|
778
|
+
{
|
|
779
|
+
stage: 'snapshot-chunk-decode',
|
|
780
|
+
stateId: trace?.stateId,
|
|
781
|
+
subscriptionId: trace?.subscriptionId,
|
|
782
|
+
table: snapshot.table,
|
|
783
|
+
chunkId: chunk.id,
|
|
784
|
+
},
|
|
785
|
+
`Failed to decode snapshot chunk "${chunk.id}"`
|
|
786
|
+
);
|
|
518
787
|
}
|
|
519
788
|
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
789
|
+
if (chunkHashPromise) {
|
|
790
|
+
try {
|
|
791
|
+
// eslint-disable-next-line no-await-in-loop
|
|
792
|
+
const actualHash = await chunkHashPromise;
|
|
793
|
+
if (!applyError && actualHash !== chunk.sha256) {
|
|
794
|
+
applyError = new SyncClientStageError(
|
|
795
|
+
`Snapshot chunk integrity check failed: expected sha256 ${chunk.sha256}, got ${actualHash}`,
|
|
796
|
+
{
|
|
797
|
+
stage: 'snapshot-integrity',
|
|
798
|
+
stateId: trace?.stateId,
|
|
799
|
+
subscriptionId: trace?.subscriptionId,
|
|
800
|
+
table: snapshot.table,
|
|
801
|
+
chunkId: chunk.id,
|
|
802
|
+
}
|
|
803
|
+
);
|
|
531
804
|
}
|
|
532
|
-
)
|
|
533
|
-
|
|
805
|
+
} catch (hashError) {
|
|
806
|
+
if (!applyError) {
|
|
807
|
+
applyError = hashError;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
534
810
|
}
|
|
535
|
-
} catch (error) {
|
|
536
|
-
applyError = error;
|
|
537
|
-
}
|
|
538
811
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
// eslint-disable-next-line no-await-in-loop
|
|
542
|
-
const actualHash = await chunkHashPromise;
|
|
543
|
-
if (!applyError && actualHash !== chunk.sha256) {
|
|
544
|
-
applyError = new Error(
|
|
545
|
-
`Snapshot chunk integrity check failed: expected sha256 ${chunk.sha256}, got ${actualHash}`
|
|
546
|
-
);
|
|
547
|
-
}
|
|
548
|
-
} catch (hashError) {
|
|
549
|
-
if (!applyError) {
|
|
550
|
-
applyError = hashError;
|
|
551
|
-
}
|
|
812
|
+
if (applyError) {
|
|
813
|
+
throw applyError;
|
|
552
814
|
}
|
|
553
|
-
}
|
|
554
815
|
|
|
555
|
-
|
|
556
|
-
|
|
816
|
+
emitTrace(trace?.onTrace, {
|
|
817
|
+
stage: 'apply:chunk-materialize:complete',
|
|
818
|
+
stateId: trace?.stateId,
|
|
819
|
+
subscriptionId: trace?.subscriptionId,
|
|
820
|
+
table: snapshot.table,
|
|
821
|
+
chunkId: chunk.id,
|
|
822
|
+
chunkIndex,
|
|
823
|
+
rowCount: chunkRowCount,
|
|
824
|
+
durationMs: Math.max(0, Date.now() - chunkStartedAt),
|
|
825
|
+
});
|
|
826
|
+
} catch (error) {
|
|
827
|
+
emitTrace(trace?.onTrace, {
|
|
828
|
+
stage: 'apply:chunk-materialize:error',
|
|
829
|
+
stateId: trace?.stateId,
|
|
830
|
+
subscriptionId: trace?.subscriptionId,
|
|
831
|
+
table: snapshot.table,
|
|
832
|
+
chunkId: chunk.id,
|
|
833
|
+
chunkIndex,
|
|
834
|
+
durationMs: Math.max(0, Date.now() - chunkStartedAt),
|
|
835
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
836
|
+
});
|
|
837
|
+
throw error;
|
|
557
838
|
}
|
|
558
839
|
}
|
|
559
840
|
}
|
|
@@ -580,6 +861,69 @@ function parseBootstrapState(
|
|
|
580
861
|
}
|
|
581
862
|
}
|
|
582
863
|
|
|
864
|
+
function normalizeBootstrapPhase(value: number | undefined): number {
|
|
865
|
+
if (value === undefined) return 0;
|
|
866
|
+
return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function isSubscriptionReady(
|
|
870
|
+
row: SyncSubscriptionStateTable | undefined
|
|
871
|
+
): boolean {
|
|
872
|
+
return (
|
|
873
|
+
row?.status === 'active' &&
|
|
874
|
+
parseBootstrapState(row.bootstrap_state_json) === null &&
|
|
875
|
+
row.cursor >= 0
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function isSubscriptionBootstrapping(
|
|
880
|
+
row: SyncSubscriptionStateTable | undefined
|
|
881
|
+
): boolean {
|
|
882
|
+
return (
|
|
883
|
+
row?.status === 'active' &&
|
|
884
|
+
parseBootstrapState(row.bootstrap_state_json) !== null
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function resolveActiveBootstrapPhase(
|
|
889
|
+
subscriptions: readonly SyncClientSubscription[],
|
|
890
|
+
existingById: ReadonlyMap<string, SyncSubscriptionStateTable>
|
|
891
|
+
): number | null {
|
|
892
|
+
let lowestPendingPhase: number | null = null;
|
|
893
|
+
|
|
894
|
+
for (const subscription of subscriptions) {
|
|
895
|
+
const phase = normalizeBootstrapPhase(subscription.bootstrapPhase);
|
|
896
|
+
if (isSubscriptionReady(existingById.get(subscription.id))) {
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
if (lowestPendingPhase === null || phase < lowestPendingPhase) {
|
|
900
|
+
lowestPendingPhase = phase;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return lowestPendingPhase;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function selectPullSubscriptions(
|
|
908
|
+
subscriptions: readonly SyncClientSubscription[],
|
|
909
|
+
existingById: ReadonlyMap<string, SyncSubscriptionStateTable>
|
|
910
|
+
): SyncClientSubscription[] {
|
|
911
|
+
const activePhase = resolveActiveBootstrapPhase(subscriptions, existingById);
|
|
912
|
+
if (activePhase === null) {
|
|
913
|
+
return [...subscriptions];
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return subscriptions.filter((subscription) => {
|
|
917
|
+
const phase = normalizeBootstrapPhase(subscription.bootstrapPhase);
|
|
918
|
+
const existing = existingById.get(subscription.id);
|
|
919
|
+
|
|
920
|
+
if (phase <= activePhase) return true;
|
|
921
|
+
if (isSubscriptionReady(existing)) return true;
|
|
922
|
+
if (isSubscriptionBootstrapping(existing)) return true;
|
|
923
|
+
return false;
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
583
927
|
function parseScopeValuesJson(
|
|
584
928
|
value: string | object | null | undefined
|
|
585
929
|
): ScopeValues {
|
|
@@ -698,7 +1042,7 @@ export interface SyncPullOnceOptions {
|
|
|
698
1042
|
* Desired subscriptions (client-chosen ids).
|
|
699
1043
|
* Cursors are persisted in `sync_subscription_state`.
|
|
700
1044
|
*/
|
|
701
|
-
subscriptions:
|
|
1045
|
+
subscriptions: SyncClientSubscription[];
|
|
702
1046
|
limitCommits?: number;
|
|
703
1047
|
limitSnapshotRows?: number;
|
|
704
1048
|
maxSnapshotPages?: number;
|
|
@@ -721,6 +1065,8 @@ export interface SyncPullOnceOptions {
|
|
|
721
1065
|
* Must return the hex-encoded hash string.
|
|
722
1066
|
*/
|
|
723
1067
|
sha256?: (bytes: Uint8Array) => Promise<string>;
|
|
1068
|
+
/** Optional structured tracing hook for pull/apply lifecycle diagnostics. */
|
|
1069
|
+
onTrace?: (event: SyncTraceEvent) => void;
|
|
724
1070
|
}
|
|
725
1071
|
|
|
726
1072
|
export interface SyncPullRequestState {
|
|
@@ -728,6 +1074,40 @@ export interface SyncPullRequestState {
|
|
|
728
1074
|
existing: SyncSubscriptionStateTable[];
|
|
729
1075
|
existingById: Map<string, SyncSubscriptionStateTable>;
|
|
730
1076
|
stateId: string;
|
|
1077
|
+
configuredSubscriptions: SyncClientSubscription[];
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function emitTrace(
|
|
1081
|
+
onTrace: SyncPullOnceOptions['onTrace'],
|
|
1082
|
+
event: Omit<SyncTraceEvent, 'timestamp'>
|
|
1083
|
+
): void {
|
|
1084
|
+
onTrace?.({
|
|
1085
|
+
timestamp: Date.now(),
|
|
1086
|
+
...event,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function countSubscriptionRows(
|
|
1091
|
+
subscription: SyncPullSubscriptionResponse
|
|
1092
|
+
): number | undefined {
|
|
1093
|
+
if (!subscription.bootstrap) return undefined;
|
|
1094
|
+
const snapshots = subscription.snapshots ?? [];
|
|
1095
|
+
if (snapshots.length === 0) return 0;
|
|
1096
|
+
return snapshots.reduce(
|
|
1097
|
+
(sum, snapshot) => sum + (snapshot.rows?.length ?? 0),
|
|
1098
|
+
0
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function countSubscriptionChunks(
|
|
1103
|
+
subscription: SyncPullSubscriptionResponse
|
|
1104
|
+
): number | undefined {
|
|
1105
|
+
if (!subscription.bootstrap) return undefined;
|
|
1106
|
+
const snapshots = subscription.snapshots ?? [];
|
|
1107
|
+
return snapshots.reduce(
|
|
1108
|
+
(sum, snapshot) => sum + (snapshot.chunks?.length ?? 0),
|
|
1109
|
+
0
|
|
1110
|
+
);
|
|
731
1111
|
}
|
|
732
1112
|
|
|
733
1113
|
/**
|
|
@@ -760,13 +1140,19 @@ export async function buildPullRequest<DB extends SyncClientDb>(
|
|
|
760
1140
|
const existingById = new Map<string, SyncSubscriptionStateTable>();
|
|
761
1141
|
for (const row of existing) existingById.set(row.subscription_id, row);
|
|
762
1142
|
|
|
1143
|
+
const configuredSubscriptions = options.subscriptions ?? [];
|
|
1144
|
+
const selectedSubscriptions = selectPullSubscriptions(
|
|
1145
|
+
configuredSubscriptions,
|
|
1146
|
+
existingById
|
|
1147
|
+
);
|
|
1148
|
+
|
|
763
1149
|
const request: SyncPullRequest = {
|
|
764
1150
|
clientId: options.clientId,
|
|
765
1151
|
limitCommits: options.limitCommits ?? 50,
|
|
766
1152
|
limitSnapshotRows: options.limitSnapshotRows ?? 1000,
|
|
767
1153
|
maxSnapshotPages: options.maxSnapshotPages ?? 4,
|
|
768
1154
|
dedupeRows: options.dedupeRows,
|
|
769
|
-
subscriptions:
|
|
1155
|
+
subscriptions: selectedSubscriptions.map((sub) => ({
|
|
770
1156
|
...sub,
|
|
771
1157
|
cursor: Math.max(-1, existingById.get(sub.id)?.cursor ?? -1),
|
|
772
1158
|
bootstrapState: parseBootstrapState(
|
|
@@ -775,7 +1161,13 @@ export async function buildPullRequest<DB extends SyncClientDb>(
|
|
|
775
1161
|
})),
|
|
776
1162
|
};
|
|
777
1163
|
|
|
778
|
-
return {
|
|
1164
|
+
return {
|
|
1165
|
+
request,
|
|
1166
|
+
existing,
|
|
1167
|
+
existingById,
|
|
1168
|
+
stateId,
|
|
1169
|
+
configuredSubscriptions,
|
|
1170
|
+
};
|
|
779
1171
|
}
|
|
780
1172
|
|
|
781
1173
|
export function createFollowupPullState(
|
|
@@ -822,9 +1214,13 @@ export function createFollowupPullState(
|
|
|
822
1214
|
nextExistingById.set(nextRow.subscription_id, nextRow);
|
|
823
1215
|
}
|
|
824
1216
|
|
|
1217
|
+
const nextSelectedSubscriptions = selectPullSubscriptions(
|
|
1218
|
+
pullState.configuredSubscriptions,
|
|
1219
|
+
nextExistingById
|
|
1220
|
+
);
|
|
825
1221
|
const nextRequest: SyncPullRequest = {
|
|
826
1222
|
...pullState.request,
|
|
827
|
-
subscriptions:
|
|
1223
|
+
subscriptions: nextSelectedSubscriptions.map((sub) => {
|
|
828
1224
|
const row = nextExistingById.get(sub.id);
|
|
829
1225
|
return {
|
|
830
1226
|
...sub,
|
|
@@ -839,6 +1235,7 @@ export function createFollowupPullState(
|
|
|
839
1235
|
existing: nextExisting,
|
|
840
1236
|
existingById: nextExistingById,
|
|
841
1237
|
stateId: pullState.stateId,
|
|
1238
|
+
configuredSubscriptions: pullState.configuredSubscriptions,
|
|
842
1239
|
};
|
|
843
1240
|
}
|
|
844
1241
|
|
|
@@ -916,7 +1313,15 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
916
1313
|
);
|
|
917
1314
|
|
|
918
1315
|
let responseToApply = requiresMaterializedSnapshots
|
|
919
|
-
? await materializeChunkedSnapshots(
|
|
1316
|
+
? await materializeChunkedSnapshots(
|
|
1317
|
+
transport,
|
|
1318
|
+
rawResponse,
|
|
1319
|
+
options.sha256,
|
|
1320
|
+
{
|
|
1321
|
+
stateId,
|
|
1322
|
+
onTrace: options.onTrace,
|
|
1323
|
+
}
|
|
1324
|
+
)
|
|
920
1325
|
: rawResponse;
|
|
921
1326
|
for (const plugin of plugins) {
|
|
922
1327
|
if (!plugin.afterPull) continue;
|
|
@@ -941,34 +1346,92 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
941
1346
|
|
|
942
1347
|
if (bootstrapApplyMode === 'per-subscription') {
|
|
943
1348
|
for (const sub of responseToApply.subscriptions) {
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1349
|
+
emitTrace(options.onTrace, {
|
|
1350
|
+
stage: 'apply:transaction:start',
|
|
1351
|
+
stateId,
|
|
1352
|
+
transactionMode: bootstrapApplyMode,
|
|
1353
|
+
subscriptionIds: [sub.id],
|
|
1354
|
+
subscriptionCount: 1,
|
|
1355
|
+
});
|
|
1356
|
+
const transactionStartedAt = Date.now();
|
|
1357
|
+
try {
|
|
1358
|
+
await db.transaction().execute(async (trx) => {
|
|
1359
|
+
await applySubscriptionResponse({
|
|
1360
|
+
trx,
|
|
1361
|
+
handlers,
|
|
1362
|
+
transport,
|
|
1363
|
+
options,
|
|
1364
|
+
stateId,
|
|
1365
|
+
existingById,
|
|
1366
|
+
subsById,
|
|
1367
|
+
sub,
|
|
1368
|
+
});
|
|
1369
|
+
});
|
|
1370
|
+
emitTrace(options.onTrace, {
|
|
1371
|
+
stage: 'apply:transaction:complete',
|
|
950
1372
|
stateId,
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1373
|
+
transactionMode: bootstrapApplyMode,
|
|
1374
|
+
subscriptionIds: [sub.id],
|
|
1375
|
+
subscriptionCount: 1,
|
|
1376
|
+
durationMs: Math.max(0, Date.now() - transactionStartedAt),
|
|
954
1377
|
});
|
|
955
|
-
})
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
await db.transaction().execute(async (trx) => {
|
|
959
|
-
for (const sub of responseToApply.subscriptions) {
|
|
960
|
-
await applySubscriptionResponse({
|
|
961
|
-
trx,
|
|
962
|
-
handlers,
|
|
963
|
-
transport,
|
|
964
|
-
options,
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
emitTrace(options.onTrace, {
|
|
1380
|
+
stage: 'apply:transaction:error',
|
|
965
1381
|
stateId,
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1382
|
+
transactionMode: bootstrapApplyMode,
|
|
1383
|
+
subscriptionIds: [sub.id],
|
|
1384
|
+
subscriptionCount: 1,
|
|
1385
|
+
durationMs: Math.max(0, Date.now() - transactionStartedAt),
|
|
1386
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
969
1387
|
});
|
|
1388
|
+
throw error;
|
|
970
1389
|
}
|
|
1390
|
+
}
|
|
1391
|
+
} else {
|
|
1392
|
+
emitTrace(options.onTrace, {
|
|
1393
|
+
stage: 'apply:transaction:start',
|
|
1394
|
+
stateId,
|
|
1395
|
+
transactionMode: bootstrapApplyMode,
|
|
1396
|
+
subscriptionIds: responseToApply.subscriptions.map((sub) => sub.id),
|
|
1397
|
+
subscriptionCount: responseToApply.subscriptions.length,
|
|
971
1398
|
});
|
|
1399
|
+
const transactionStartedAt = Date.now();
|
|
1400
|
+
try {
|
|
1401
|
+
await db.transaction().execute(async (trx) => {
|
|
1402
|
+
for (const sub of responseToApply.subscriptions) {
|
|
1403
|
+
await applySubscriptionResponse({
|
|
1404
|
+
trx,
|
|
1405
|
+
handlers,
|
|
1406
|
+
transport,
|
|
1407
|
+
options,
|
|
1408
|
+
stateId,
|
|
1409
|
+
existingById,
|
|
1410
|
+
subsById,
|
|
1411
|
+
sub,
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
});
|
|
1415
|
+
emitTrace(options.onTrace, {
|
|
1416
|
+
stage: 'apply:transaction:complete',
|
|
1417
|
+
stateId,
|
|
1418
|
+
transactionMode: bootstrapApplyMode,
|
|
1419
|
+
subscriptionIds: responseToApply.subscriptions.map((sub) => sub.id),
|
|
1420
|
+
subscriptionCount: responseToApply.subscriptions.length,
|
|
1421
|
+
durationMs: Math.max(0, Date.now() - transactionStartedAt),
|
|
1422
|
+
});
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
emitTrace(options.onTrace, {
|
|
1425
|
+
stage: 'apply:transaction:error',
|
|
1426
|
+
stateId,
|
|
1427
|
+
transactionMode: bootstrapApplyMode,
|
|
1428
|
+
subscriptionIds: responseToApply.subscriptions.map((sub) => sub.id),
|
|
1429
|
+
subscriptionCount: responseToApply.subscriptions.length,
|
|
1430
|
+
durationMs: Math.max(0, Date.now() - transactionStartedAt),
|
|
1431
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
1432
|
+
});
|
|
1433
|
+
throw error;
|
|
1434
|
+
}
|
|
972
1435
|
}
|
|
973
1436
|
|
|
974
1437
|
return responseToApply;
|
|
@@ -1002,7 +1465,7 @@ async function removeUndesiredSubscriptions<DB extends SyncClientDb>(
|
|
|
1002
1465
|
trx: Transaction<DB>,
|
|
1003
1466
|
handlers: ClientHandlerCollection<DB>,
|
|
1004
1467
|
existing: SyncSubscriptionStateTable[],
|
|
1005
|
-
desiredSubscriptions:
|
|
1468
|
+
desiredSubscriptions: SyncClientSubscription[],
|
|
1006
1469
|
stateId: string
|
|
1007
1470
|
): Promise<void> {
|
|
1008
1471
|
const desiredIds = new Set(
|
|
@@ -1063,7 +1526,7 @@ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
|
|
|
1063
1526
|
options: SyncPullOnceOptions;
|
|
1064
1527
|
stateId: string;
|
|
1065
1528
|
existingById: Map<string, SyncSubscriptionStateTable>;
|
|
1066
|
-
subsById: Map<string,
|
|
1529
|
+
subsById: Map<string, SyncClientSubscription | undefined>;
|
|
1067
1530
|
sub: SyncPullSubscriptionResponse;
|
|
1068
1531
|
}): Promise<void> {
|
|
1069
1532
|
const {
|
|
@@ -1102,146 +1565,275 @@ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
|
|
|
1102
1565
|
effectiveCursor !== null &&
|
|
1103
1566
|
sub.nextCursor < effectiveCursor;
|
|
1104
1567
|
|
|
1568
|
+
const applyStartedAt = Date.now();
|
|
1569
|
+
emitTrace(options.onTrace, {
|
|
1570
|
+
stage: 'apply:subscription:start',
|
|
1571
|
+
stateId,
|
|
1572
|
+
subscriptionId: sub.id,
|
|
1573
|
+
table: def?.table ?? prev?.table,
|
|
1574
|
+
bootstrap: sub.bootstrap,
|
|
1575
|
+
snapshotCount: sub.snapshots?.length ?? 0,
|
|
1576
|
+
commitCount: sub.commits?.length ?? 0,
|
|
1577
|
+
chunkCount: countSubscriptionChunks(sub),
|
|
1578
|
+
rowCount: countSubscriptionRows(sub),
|
|
1579
|
+
nextCursor: sub.nextCursor,
|
|
1580
|
+
});
|
|
1581
|
+
|
|
1105
1582
|
if (staleIncrementalResponse) {
|
|
1583
|
+
emitTrace(options.onTrace, {
|
|
1584
|
+
stage: 'apply:subscription:complete',
|
|
1585
|
+
stateId,
|
|
1586
|
+
subscriptionId: sub.id,
|
|
1587
|
+
table: def?.table ?? prev?.table,
|
|
1588
|
+
bootstrap: sub.bootstrap,
|
|
1589
|
+
snapshotCount: sub.snapshots?.length ?? 0,
|
|
1590
|
+
commitCount: sub.commits?.length ?? 0,
|
|
1591
|
+
chunkCount: countSubscriptionChunks(sub),
|
|
1592
|
+
rowCount: countSubscriptionRows(sub),
|
|
1593
|
+
nextCursor: sub.nextCursor,
|
|
1594
|
+
durationMs: Math.max(0, Date.now() - applyStartedAt),
|
|
1595
|
+
});
|
|
1106
1596
|
return;
|
|
1107
1597
|
}
|
|
1108
1598
|
|
|
1109
|
-
|
|
1110
|
-
if (
|
|
1599
|
+
try {
|
|
1600
|
+
if (sub.status === 'revoked') {
|
|
1601
|
+
if (prev?.table) {
|
|
1602
|
+
try {
|
|
1603
|
+
const scopes = parseScopeValuesJson(prev.scopes_json);
|
|
1604
|
+
await getClientHandlerOrThrow(handlers, prev.table).clearAll({
|
|
1605
|
+
trx,
|
|
1606
|
+
scopes,
|
|
1607
|
+
});
|
|
1608
|
+
} catch {
|
|
1609
|
+
// ignore missing handler
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
await sql`
|
|
1614
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
1615
|
+
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
1616
|
+
and ${sql.ref('subscription_id')} = ${sql.val(sub.id)}
|
|
1617
|
+
`.execute(trx);
|
|
1618
|
+
emitTrace(options.onTrace, {
|
|
1619
|
+
stage: 'apply:subscription:complete',
|
|
1620
|
+
stateId,
|
|
1621
|
+
subscriptionId: sub.id,
|
|
1622
|
+
table: def?.table ?? prev?.table,
|
|
1623
|
+
bootstrap: sub.bootstrap,
|
|
1624
|
+
snapshotCount: sub.snapshots?.length ?? 0,
|
|
1625
|
+
commitCount: sub.commits?.length ?? 0,
|
|
1626
|
+
chunkCount: countSubscriptionChunks(sub),
|
|
1627
|
+
rowCount: countSubscriptionRows(sub),
|
|
1628
|
+
nextCursor: null,
|
|
1629
|
+
durationMs: Math.max(0, Date.now() - applyStartedAt),
|
|
1630
|
+
});
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const nextScopes = sub.scopes ?? def?.scopes ?? {};
|
|
1635
|
+
const previousScopes = parseScopeValuesJson(prev?.scopes_json);
|
|
1636
|
+
const scopesChanged = !scopeValuesEqual(previousScopes, nextScopes);
|
|
1637
|
+
|
|
1638
|
+
if (sub.bootstrap && prev?.table && scopesChanged) {
|
|
1111
1639
|
try {
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1640
|
+
const clearScopes = resolveBootstrapClearScopes(
|
|
1641
|
+
previousScopes,
|
|
1642
|
+
nextScopes
|
|
1643
|
+
);
|
|
1644
|
+
if (clearScopes !== 'none') {
|
|
1645
|
+
await getClientHandlerOrThrow(handlers, prev.table).clearAll({
|
|
1646
|
+
trx,
|
|
1647
|
+
scopes: clearScopes ?? previousScopes,
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1117
1650
|
} catch {
|
|
1118
1651
|
// ignore missing handler
|
|
1119
1652
|
}
|
|
1120
1653
|
}
|
|
1121
1654
|
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1655
|
+
if (sub.bootstrap) {
|
|
1656
|
+
for (const snapshot of sub.snapshots ?? []) {
|
|
1657
|
+
const handler = getClientHandlerOrThrow(handlers, snapshot.table);
|
|
1658
|
+
const hasChunkRefs =
|
|
1659
|
+
Array.isArray(snapshot.chunks) && snapshot.chunks.length > 0;
|
|
1660
|
+
|
|
1661
|
+
if (snapshot.isFirstPage && handler.onSnapshotStart) {
|
|
1662
|
+
try {
|
|
1663
|
+
await handler.onSnapshotStart({
|
|
1664
|
+
trx,
|
|
1665
|
+
table: snapshot.table,
|
|
1666
|
+
scopes: sub.scopes,
|
|
1667
|
+
});
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
throw wrapSyncClientStageError(
|
|
1670
|
+
error,
|
|
1671
|
+
{
|
|
1672
|
+
stage: 'snapshot-apply',
|
|
1673
|
+
stateId,
|
|
1674
|
+
subscriptionId: sub.id,
|
|
1675
|
+
table: snapshot.table,
|
|
1676
|
+
},
|
|
1677
|
+
`Failed to start snapshot apply for subscription "${sub.id}"`
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1129
1681
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1682
|
+
if (hasChunkRefs) {
|
|
1683
|
+
await applyChunkedSnapshot(
|
|
1684
|
+
transport,
|
|
1685
|
+
handler,
|
|
1686
|
+
trx,
|
|
1687
|
+
snapshot,
|
|
1688
|
+
sub.scopes,
|
|
1689
|
+
options.sha256,
|
|
1690
|
+
{
|
|
1691
|
+
stateId,
|
|
1692
|
+
subscriptionId: sub.id,
|
|
1693
|
+
onTrace: options.onTrace,
|
|
1694
|
+
}
|
|
1695
|
+
);
|
|
1696
|
+
} else {
|
|
1697
|
+
try {
|
|
1698
|
+
await handler.applySnapshot({ trx }, snapshot);
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
throw wrapSyncClientStageError(
|
|
1701
|
+
error,
|
|
1702
|
+
{
|
|
1703
|
+
stage: 'snapshot-apply',
|
|
1704
|
+
stateId,
|
|
1705
|
+
subscriptionId: sub.id,
|
|
1706
|
+
table: snapshot.table,
|
|
1707
|
+
},
|
|
1708
|
+
`Failed to apply snapshot for subscription "${sub.id}"`
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1133
1712
|
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1713
|
+
if (snapshot.isLastPage && handler.onSnapshotEnd) {
|
|
1714
|
+
try {
|
|
1715
|
+
await handler.onSnapshotEnd({
|
|
1716
|
+
trx,
|
|
1717
|
+
table: snapshot.table,
|
|
1718
|
+
scopes: sub.scopes,
|
|
1719
|
+
});
|
|
1720
|
+
} catch (error) {
|
|
1721
|
+
throw wrapSyncClientStageError(
|
|
1722
|
+
error,
|
|
1723
|
+
{
|
|
1724
|
+
stage: 'snapshot-apply',
|
|
1725
|
+
stateId,
|
|
1726
|
+
subscriptionId: sub.id,
|
|
1727
|
+
table: snapshot.table,
|
|
1728
|
+
},
|
|
1729
|
+
`Failed to finalize snapshot apply for subscription "${sub.id}"`
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
} else {
|
|
1735
|
+
for (const commit of sub.commits) {
|
|
1736
|
+
try {
|
|
1737
|
+
await applyIncrementalCommitChanges(handlers, trx, {
|
|
1738
|
+
changes: commit.changes,
|
|
1739
|
+
commitSeq: commit.commitSeq ?? null,
|
|
1740
|
+
actorId: commit.actorId ?? null,
|
|
1741
|
+
createdAt: commit.createdAt ?? null,
|
|
1742
|
+
});
|
|
1743
|
+
} catch (error) {
|
|
1744
|
+
throw wrapSyncClientStageError(
|
|
1745
|
+
error,
|
|
1746
|
+
{
|
|
1747
|
+
stage: 'snapshot-apply',
|
|
1748
|
+
stateId,
|
|
1749
|
+
subscriptionId: sub.id,
|
|
1750
|
+
table: def?.table ?? prev?.table,
|
|
1751
|
+
},
|
|
1752
|
+
`Failed to apply incremental changes for subscription "${sub.id}"`
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1145
1755
|
}
|
|
1146
|
-
} catch {
|
|
1147
|
-
// ignore missing handler
|
|
1148
1756
|
}
|
|
1149
|
-
}
|
|
1150
1757
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
table: snapshot.table,
|
|
1161
|
-
scopes: sub.scopes,
|
|
1162
|
-
});
|
|
1163
|
-
}
|
|
1758
|
+
const now = Date.now();
|
|
1759
|
+
const paramsJson = serializeJsonCached(def?.params ?? {});
|
|
1760
|
+
const scopesJson = serializeJsonCached(nextScopes);
|
|
1761
|
+
const bootstrapStateJson = sub.bootstrap
|
|
1762
|
+
? sub.bootstrapState
|
|
1763
|
+
? serializeJsonCached(sub.bootstrapState)
|
|
1764
|
+
: null
|
|
1765
|
+
: null;
|
|
1766
|
+
const table = def?.table ?? 'unknown';
|
|
1164
1767
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1768
|
+
await sql`
|
|
1769
|
+
insert into ${sql.table('sync_subscription_state')} (
|
|
1770
|
+
${sql.join([
|
|
1771
|
+
sql.ref('state_id'),
|
|
1772
|
+
sql.ref('subscription_id'),
|
|
1773
|
+
sql.ref('table'),
|
|
1774
|
+
sql.ref('scopes_json'),
|
|
1775
|
+
sql.ref('params_json'),
|
|
1776
|
+
sql.ref('cursor'),
|
|
1777
|
+
sql.ref('bootstrap_state_json'),
|
|
1778
|
+
sql.ref('status'),
|
|
1779
|
+
sql.ref('created_at'),
|
|
1780
|
+
sql.ref('updated_at'),
|
|
1781
|
+
])}
|
|
1782
|
+
) values (
|
|
1783
|
+
${sql.join([
|
|
1784
|
+
sql.val(stateId),
|
|
1785
|
+
sql.val(sub.id),
|
|
1786
|
+
sql.val(table),
|
|
1787
|
+
sql.val(scopesJson),
|
|
1788
|
+
sql.val(paramsJson),
|
|
1789
|
+
sql.val(sub.nextCursor),
|
|
1790
|
+
sql.val(bootstrapStateJson),
|
|
1791
|
+
sql.val('active'),
|
|
1792
|
+
sql.val(now),
|
|
1793
|
+
sql.val(now),
|
|
1794
|
+
])}
|
|
1795
|
+
)
|
|
1796
|
+
on conflict (${sql.join([sql.ref('state_id'), sql.ref('subscription_id')])})
|
|
1797
|
+
do update set
|
|
1798
|
+
${sql.ref('table')} = ${sql.val(table)},
|
|
1799
|
+
${sql.ref('scopes_json')} = ${sql.val(scopesJson)},
|
|
1800
|
+
${sql.ref('params_json')} = ${sql.val(paramsJson)},
|
|
1801
|
+
${sql.ref('cursor')} = ${sql.val(sub.nextCursor)},
|
|
1802
|
+
${sql.ref('bootstrap_state_json')} = ${sql.val(bootstrapStateJson)},
|
|
1803
|
+
${sql.ref('status')} = ${sql.val('active')},
|
|
1804
|
+
${sql.ref('updated_at')} = ${sql.val(now)}
|
|
1805
|
+
`.execute(trx);
|
|
1177
1806
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1807
|
+
emitTrace(options.onTrace, {
|
|
1808
|
+
stage: 'apply:subscription:complete',
|
|
1809
|
+
stateId,
|
|
1810
|
+
subscriptionId: sub.id,
|
|
1811
|
+
table,
|
|
1812
|
+
bootstrap: sub.bootstrap,
|
|
1813
|
+
snapshotCount: sub.snapshots?.length ?? 0,
|
|
1814
|
+
commitCount: sub.commits?.length ?? 0,
|
|
1815
|
+
chunkCount: countSubscriptionChunks(sub),
|
|
1816
|
+
rowCount: countSubscriptionRows(sub),
|
|
1817
|
+
nextCursor: sub.nextCursor,
|
|
1818
|
+
durationMs: Math.max(0, Date.now() - applyStartedAt),
|
|
1819
|
+
});
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
emitTrace(options.onTrace, {
|
|
1822
|
+
stage: 'apply:subscription:error',
|
|
1823
|
+
stateId,
|
|
1824
|
+
subscriptionId: sub.id,
|
|
1825
|
+
table: def?.table ?? prev?.table,
|
|
1826
|
+
bootstrap: sub.bootstrap,
|
|
1827
|
+
snapshotCount: sub.snapshots?.length ?? 0,
|
|
1828
|
+
commitCount: sub.commits?.length ?? 0,
|
|
1829
|
+
chunkCount: countSubscriptionChunks(sub),
|
|
1830
|
+
rowCount: countSubscriptionRows(sub),
|
|
1831
|
+
nextCursor: sub.nextCursor,
|
|
1832
|
+
durationMs: Math.max(0, Date.now() - applyStartedAt),
|
|
1833
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
1834
|
+
});
|
|
1835
|
+
throw error;
|
|
1195
1836
|
}
|
|
1196
|
-
|
|
1197
|
-
const now = Date.now();
|
|
1198
|
-
const paramsJson = serializeJsonCached(def?.params ?? {});
|
|
1199
|
-
const scopesJson = serializeJsonCached(nextScopes);
|
|
1200
|
-
const bootstrapStateJson = sub.bootstrap
|
|
1201
|
-
? sub.bootstrapState
|
|
1202
|
-
? serializeJsonCached(sub.bootstrapState)
|
|
1203
|
-
: null
|
|
1204
|
-
: null;
|
|
1205
|
-
const table = def?.table ?? 'unknown';
|
|
1206
|
-
|
|
1207
|
-
await sql`
|
|
1208
|
-
insert into ${sql.table('sync_subscription_state')} (
|
|
1209
|
-
${sql.join([
|
|
1210
|
-
sql.ref('state_id'),
|
|
1211
|
-
sql.ref('subscription_id'),
|
|
1212
|
-
sql.ref('table'),
|
|
1213
|
-
sql.ref('scopes_json'),
|
|
1214
|
-
sql.ref('params_json'),
|
|
1215
|
-
sql.ref('cursor'),
|
|
1216
|
-
sql.ref('bootstrap_state_json'),
|
|
1217
|
-
sql.ref('status'),
|
|
1218
|
-
sql.ref('created_at'),
|
|
1219
|
-
sql.ref('updated_at'),
|
|
1220
|
-
])}
|
|
1221
|
-
) values (
|
|
1222
|
-
${sql.join([
|
|
1223
|
-
sql.val(stateId),
|
|
1224
|
-
sql.val(sub.id),
|
|
1225
|
-
sql.val(table),
|
|
1226
|
-
sql.val(scopesJson),
|
|
1227
|
-
sql.val(paramsJson),
|
|
1228
|
-
sql.val(sub.nextCursor),
|
|
1229
|
-
sql.val(bootstrapStateJson),
|
|
1230
|
-
sql.val('active'),
|
|
1231
|
-
sql.val(now),
|
|
1232
|
-
sql.val(now),
|
|
1233
|
-
])}
|
|
1234
|
-
)
|
|
1235
|
-
on conflict (${sql.join([sql.ref('state_id'), sql.ref('subscription_id')])})
|
|
1236
|
-
do update set
|
|
1237
|
-
${sql.ref('table')} = ${sql.val(table)},
|
|
1238
|
-
${sql.ref('scopes_json')} = ${sql.val(scopesJson)},
|
|
1239
|
-
${sql.ref('params_json')} = ${sql.val(paramsJson)},
|
|
1240
|
-
${sql.ref('cursor')} = ${sql.val(sub.nextCursor)},
|
|
1241
|
-
${sql.ref('bootstrap_state_json')} = ${sql.val(bootstrapStateJson)},
|
|
1242
|
-
${sql.ref('status')} = ${sql.val('active')},
|
|
1243
|
-
${sql.ref('updated_at')} = ${sql.val(now)}
|
|
1244
|
-
`.execute(trx);
|
|
1245
1837
|
}
|
|
1246
1838
|
|
|
1247
1839
|
export async function syncPullOnce<DB extends SyncClientDb>(
|
|
@@ -1253,10 +1845,48 @@ export async function syncPullOnce<DB extends SyncClientDb>(
|
|
|
1253
1845
|
): Promise<SyncPullResponse> {
|
|
1254
1846
|
const pullState = pullStateOverride ?? (await buildPullRequest(db, options));
|
|
1255
1847
|
const { clientId, ...pullBody } = pullState.request;
|
|
1256
|
-
|
|
1848
|
+
emitTrace(options.onTrace, {
|
|
1849
|
+
stage: 'pull:start',
|
|
1850
|
+
stateId: pullState.stateId,
|
|
1851
|
+
subscriptionIds: pullState.request.subscriptions.map(
|
|
1852
|
+
(subscription) => subscription.id
|
|
1853
|
+
),
|
|
1854
|
+
subscriptionCount: pullState.request.subscriptions.length,
|
|
1855
|
+
});
|
|
1856
|
+
let combined: SyncCombinedResponse;
|
|
1857
|
+
try {
|
|
1858
|
+
combined = await transport.sync({ clientId, pull: pullBody });
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
emitTrace(options.onTrace, {
|
|
1861
|
+
stage: 'pull:error',
|
|
1862
|
+
stateId: pullState.stateId,
|
|
1863
|
+
subscriptionIds: pullState.request.subscriptions.map(
|
|
1864
|
+
(subscription) => subscription.id
|
|
1865
|
+
),
|
|
1866
|
+
subscriptionCount: pullState.request.subscriptions.length,
|
|
1867
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
1868
|
+
});
|
|
1869
|
+
throw error;
|
|
1870
|
+
}
|
|
1257
1871
|
if (!combined.pull) {
|
|
1258
1872
|
return { ok: true, subscriptions: [] };
|
|
1259
1873
|
}
|
|
1874
|
+
emitTrace(options.onTrace, {
|
|
1875
|
+
stage: 'pull:response',
|
|
1876
|
+
stateId: pullState.stateId,
|
|
1877
|
+
subscriptionIds: combined.pull.subscriptions.map(
|
|
1878
|
+
(subscription) => subscription.id
|
|
1879
|
+
),
|
|
1880
|
+
subscriptionCount: combined.pull.subscriptions.length,
|
|
1881
|
+
commitCount: combined.pull.subscriptions.reduce(
|
|
1882
|
+
(sum, subscription) => sum + (subscription.commits?.length ?? 0),
|
|
1883
|
+
0
|
|
1884
|
+
),
|
|
1885
|
+
snapshotCount: combined.pull.subscriptions.reduce(
|
|
1886
|
+
(sum, subscription) => sum + (subscription.snapshots?.length ?? 0),
|
|
1887
|
+
0
|
|
1888
|
+
),
|
|
1889
|
+
});
|
|
1260
1890
|
return applyPullResponse(
|
|
1261
1891
|
db,
|
|
1262
1892
|
transport,
|