@syncular/client 0.0.6-219 → 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.
@@ -243,59 +243,119 @@ async function readAllBytesFromStream(stream) {
243
243
  }
244
244
  return bytes;
245
245
  }
246
- async function materializeSnapshotChunkRows(transport, request, expectedHash, sha256Override) {
246
+ async function materializeSnapshotChunkRows(transport, request, expectedHash, sha256Override, trace) {
247
+ emitTrace(trace?.onTrace, {
248
+ stage: 'apply:chunk-materialize:start',
249
+ stateId: trace?.stateId,
250
+ subscriptionId: trace?.subscriptionId,
251
+ table: trace?.table,
252
+ chunkId: request.chunkId,
253
+ chunkIndex: trace?.chunkIndex,
254
+ });
255
+ const startedAt = Date.now();
247
256
  if (transport.capabilities?.snapshotChunkReadMode === 'bytes' &&
248
257
  transport.fetchSnapshotChunk) {
249
- let bytes = await transport.fetchSnapshotChunk(request);
250
- if (isGzipBytes(bytes)) {
251
- bytes = await gunzipBytes(bytes);
252
- }
253
- if (expectedHash) {
254
- const actualHash = await computeSha256Hex(bytes, sha256Override);
255
- if (actualHash !== expectedHash) {
256
- throw new Error(`Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`);
258
+ try {
259
+ let bytes = await transport.fetchSnapshotChunk(request);
260
+ if (isGzipBytes(bytes)) {
261
+ bytes = await gunzipBytes(bytes);
262
+ }
263
+ if (expectedHash) {
264
+ const actualHash = await computeSha256Hex(bytes, sha256Override);
265
+ if (actualHash !== expectedHash) {
266
+ throw new Error(`Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`);
267
+ }
257
268
  }
269
+ const rows = decodeSnapshotRows(bytes);
270
+ emitTrace(trace?.onTrace, {
271
+ stage: 'apply:chunk-materialize:complete',
272
+ stateId: trace?.stateId,
273
+ subscriptionId: trace?.subscriptionId,
274
+ table: trace?.table,
275
+ chunkId: request.chunkId,
276
+ chunkIndex: trace?.chunkIndex,
277
+ rowCount: rows.length,
278
+ durationMs: Math.max(0, Date.now() - startedAt),
279
+ });
280
+ return rows;
281
+ }
282
+ catch (error) {
283
+ emitTrace(trace?.onTrace, {
284
+ stage: 'apply:chunk-materialize:error',
285
+ stateId: trace?.stateId,
286
+ subscriptionId: trace?.subscriptionId,
287
+ table: trace?.table,
288
+ chunkId: request.chunkId,
289
+ chunkIndex: trace?.chunkIndex,
290
+ durationMs: Math.max(0, Date.now() - startedAt),
291
+ errorMessage: error instanceof Error ? error.message : String(error),
292
+ });
293
+ throw error;
258
294
  }
259
- return decodeSnapshotRows(bytes);
260
- }
261
- const rawStream = await fetchSnapshotChunkStream(transport, request);
262
- const decodedStream = await maybeGunzipStream(rawStream);
263
- let streamForDecode = decodedStream;
264
- let chunkHashPromise = null;
265
- if (expectedHash) {
266
- const [hashStream, decodeStream] = decodedStream.tee();
267
- streamForDecode = decodeStream;
268
- chunkHashPromise = readAllBytesFromStream(hashStream).then((bytes) => computeSha256Hex(bytes, sha256Override));
269
295
  }
270
- const rows = [];
271
- let materializeError = null;
272
296
  try {
273
- for await (const batch of decodeSnapshotRowStreamBatches(streamForDecode, SNAPSHOT_APPLY_BATCH_ROWS)) {
274
- rows.push(...batch);
297
+ const rawStream = await fetchSnapshotChunkStream(transport, request);
298
+ const decodedStream = await maybeGunzipStream(rawStream);
299
+ let streamForDecode = decodedStream;
300
+ let chunkHashPromise = null;
301
+ if (expectedHash) {
302
+ const [hashStream, decodeStream] = decodedStream.tee();
303
+ streamForDecode = decodeStream;
304
+ chunkHashPromise = readAllBytesFromStream(hashStream).then((bytes) => computeSha256Hex(bytes, sha256Override));
275
305
  }
276
- }
277
- catch (error) {
278
- materializeError = error;
279
- }
280
- if (chunkHashPromise) {
306
+ const rows = [];
307
+ let materializeError = null;
281
308
  try {
282
- const actualHash = await chunkHashPromise;
283
- if (!materializeError && actualHash !== expectedHash) {
284
- materializeError = new Error(`Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`);
309
+ for await (const batch of decodeSnapshotRowStreamBatches(streamForDecode, SNAPSHOT_APPLY_BATCH_ROWS)) {
310
+ rows.push(...batch);
285
311
  }
286
312
  }
287
- catch (hashError) {
288
- if (!materializeError) {
289
- materializeError = hashError;
313
+ catch (error) {
314
+ materializeError = error;
315
+ }
316
+ if (chunkHashPromise) {
317
+ try {
318
+ const actualHash = await chunkHashPromise;
319
+ if (!materializeError && actualHash !== expectedHash) {
320
+ materializeError = new Error(`Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`);
321
+ }
290
322
  }
323
+ catch (hashError) {
324
+ if (!materializeError) {
325
+ materializeError = hashError;
326
+ }
327
+ }
328
+ }
329
+ if (materializeError) {
330
+ throw materializeError;
291
331
  }
332
+ emitTrace(trace?.onTrace, {
333
+ stage: 'apply:chunk-materialize:complete',
334
+ stateId: trace?.stateId,
335
+ subscriptionId: trace?.subscriptionId,
336
+ table: trace?.table,
337
+ chunkId: request.chunkId,
338
+ chunkIndex: trace?.chunkIndex,
339
+ rowCount: rows.length,
340
+ durationMs: Math.max(0, Date.now() - startedAt),
341
+ });
342
+ return rows;
292
343
  }
293
- if (materializeError) {
294
- throw materializeError;
344
+ catch (error) {
345
+ emitTrace(trace?.onTrace, {
346
+ stage: 'apply:chunk-materialize:error',
347
+ stateId: trace?.stateId,
348
+ subscriptionId: trace?.subscriptionId,
349
+ table: trace?.table,
350
+ chunkId: request.chunkId,
351
+ chunkIndex: trace?.chunkIndex,
352
+ durationMs: Math.max(0, Date.now() - startedAt),
353
+ errorMessage: error instanceof Error ? error.message : String(error),
354
+ });
355
+ throw error;
295
356
  }
296
- return rows;
297
357
  }
298
- async function materializeChunkedSnapshots(transport, response, sha256Override) {
358
+ async function materializeChunkedSnapshots(transport, response, sha256Override, trace) {
299
359
  const subscriptions = [];
300
360
  for (const sub of response.subscriptions) {
301
361
  if (!sub.bootstrap || !sub.snapshots || sub.snapshots.length === 0) {
@@ -310,11 +370,20 @@ async function materializeChunkedSnapshots(transport, response, sha256Override)
310
370
  continue;
311
371
  }
312
372
  const rows = [];
313
- for (const chunk of chunks) {
373
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
374
+ const chunk = chunks[chunkIndex];
375
+ if (!chunk)
376
+ continue;
314
377
  const chunkRows = await materializeSnapshotChunkRows(transport, {
315
378
  chunkId: chunk.id,
316
379
  scopeValues: sub.scopes,
317
- }, chunk.sha256, sha256Override);
380
+ }, chunk.sha256, sha256Override, {
381
+ stateId: trace?.stateId ?? 'default',
382
+ subscriptionId: sub.id,
383
+ table: snapshot.table,
384
+ chunkIndex,
385
+ onTrace: trace?.onTrace,
386
+ });
318
387
  rows.push(...chunkRows);
319
388
  }
320
389
  snapshots.push({
@@ -330,7 +399,7 @@ async function materializeChunkedSnapshots(transport, response, sha256Override)
330
399
  }
331
400
  return { ...response, subscriptions };
332
401
  }
333
- async function applyChunkedSnapshot(transport, handler, trx, snapshot, scopeValues, sha256Override) {
402
+ async function applyChunkedSnapshot(transport, handler, trx, snapshot, scopeValues, sha256Override, trace) {
334
403
  const chunks = snapshot.chunks ?? [];
335
404
  if (chunks.length === 0) {
336
405
  await handler.applySnapshot({ trx }, snapshot);
@@ -341,69 +410,105 @@ async function applyChunkedSnapshot(transport, handler, trx, snapshot, scopeValu
341
410
  const chunk = chunks[chunkIndex];
342
411
  if (!chunk)
343
412
  continue;
344
- const rawStream = await fetchSnapshotChunkStream(transport, {
413
+ emitTrace(trace?.onTrace, {
414
+ stage: 'apply:chunk-materialize:start',
415
+ stateId: trace?.stateId,
416
+ subscriptionId: trace?.subscriptionId,
417
+ table: snapshot.table,
345
418
  chunkId: chunk.id,
346
- scopeValues,
419
+ chunkIndex,
347
420
  });
348
- const decodedStream = await maybeGunzipStream(rawStream);
349
- let streamForDecode = decodedStream;
350
- let chunkHashPromise = null;
351
- if (chunk.sha256) {
352
- const [hashStream, decodeStream] = decodedStream.tee();
353
- streamForDecode = decodeStream;
354
- chunkHashPromise = readAllBytesFromStream(hashStream).then((bytes) => computeSha256Hex(bytes, sha256Override));
355
- }
356
- const rowBatchIterator = decodeSnapshotRowStreamBatches(streamForDecode, SNAPSHOT_APPLY_BATCH_ROWS);
357
- let pendingBatch = null;
358
- let applyError = null;
421
+ const chunkStartedAt = Date.now();
359
422
  try {
360
- // eslint-disable-next-line no-await-in-loop
361
- for await (const batch of rowBatchIterator) {
423
+ const rawStream = await fetchSnapshotChunkStream(transport, {
424
+ chunkId: chunk.id,
425
+ scopeValues,
426
+ });
427
+ const decodedStream = await maybeGunzipStream(rawStream);
428
+ let streamForDecode = decodedStream;
429
+ let chunkHashPromise = null;
430
+ if (chunk.sha256) {
431
+ const [hashStream, decodeStream] = decodedStream.tee();
432
+ streamForDecode = decodeStream;
433
+ chunkHashPromise = readAllBytesFromStream(hashStream).then((bytes) => computeSha256Hex(bytes, sha256Override));
434
+ }
435
+ const rowBatchIterator = decodeSnapshotRowStreamBatches(streamForDecode, SNAPSHOT_APPLY_BATCH_ROWS);
436
+ let pendingBatch = null;
437
+ let applyError = null;
438
+ let chunkRowCount = 0;
439
+ try {
440
+ // eslint-disable-next-line no-await-in-loop
441
+ for await (const batch of rowBatchIterator) {
442
+ chunkRowCount += batch.length;
443
+ if (pendingBatch) {
444
+ // eslint-disable-next-line no-await-in-loop
445
+ await handler.applySnapshot({ trx }, {
446
+ ...snapshot,
447
+ rows: pendingBatch,
448
+ chunks: undefined,
449
+ isFirstPage: nextIsFirstPage,
450
+ isLastPage: false,
451
+ });
452
+ nextIsFirstPage = false;
453
+ }
454
+ pendingBatch = batch;
455
+ }
362
456
  if (pendingBatch) {
457
+ const isLastChunk = chunkIndex === chunks.length - 1;
363
458
  // eslint-disable-next-line no-await-in-loop
364
459
  await handler.applySnapshot({ trx }, {
365
460
  ...snapshot,
366
461
  rows: pendingBatch,
367
462
  chunks: undefined,
368
463
  isFirstPage: nextIsFirstPage,
369
- isLastPage: false,
464
+ isLastPage: isLastChunk ? snapshot.isLastPage : false,
370
465
  });
371
466
  nextIsFirstPage = false;
372
467
  }
373
- pendingBatch = batch;
374
468
  }
375
- if (pendingBatch) {
376
- const isLastChunk = chunkIndex === chunks.length - 1;
377
- // eslint-disable-next-line no-await-in-loop
378
- await handler.applySnapshot({ trx }, {
379
- ...snapshot,
380
- rows: pendingBatch,
381
- chunks: undefined,
382
- isFirstPage: nextIsFirstPage,
383
- isLastPage: isLastChunk ? snapshot.isLastPage : false,
384
- });
385
- nextIsFirstPage = false;
469
+ catch (error) {
470
+ applyError = error;
386
471
  }
387
- }
388
- catch (error) {
389
- applyError = error;
390
- }
391
- if (chunkHashPromise) {
392
- try {
393
- // eslint-disable-next-line no-await-in-loop
394
- const actualHash = await chunkHashPromise;
395
- if (!applyError && actualHash !== chunk.sha256) {
396
- applyError = new Error(`Snapshot chunk integrity check failed: expected sha256 ${chunk.sha256}, got ${actualHash}`);
472
+ if (chunkHashPromise) {
473
+ try {
474
+ // eslint-disable-next-line no-await-in-loop
475
+ const actualHash = await chunkHashPromise;
476
+ if (!applyError && actualHash !== chunk.sha256) {
477
+ applyError = new Error(`Snapshot chunk integrity check failed: expected sha256 ${chunk.sha256}, got ${actualHash}`);
478
+ }
397
479
  }
398
- }
399
- catch (hashError) {
400
- if (!applyError) {
401
- applyError = hashError;
480
+ catch (hashError) {
481
+ if (!applyError) {
482
+ applyError = hashError;
483
+ }
402
484
  }
403
485
  }
486
+ if (applyError) {
487
+ throw applyError;
488
+ }
489
+ emitTrace(trace?.onTrace, {
490
+ stage: 'apply:chunk-materialize:complete',
491
+ stateId: trace?.stateId,
492
+ subscriptionId: trace?.subscriptionId,
493
+ table: snapshot.table,
494
+ chunkId: chunk.id,
495
+ chunkIndex,
496
+ rowCount: chunkRowCount,
497
+ durationMs: Math.max(0, Date.now() - chunkStartedAt),
498
+ });
404
499
  }
405
- if (applyError) {
406
- throw applyError;
500
+ catch (error) {
501
+ emitTrace(trace?.onTrace, {
502
+ stage: 'apply:chunk-materialize:error',
503
+ stateId: trace?.stateId,
504
+ subscriptionId: trace?.subscriptionId,
505
+ table: snapshot.table,
506
+ chunkId: chunk.id,
507
+ chunkIndex,
508
+ durationMs: Math.max(0, Date.now() - chunkStartedAt),
509
+ errorMessage: error instanceof Error ? error.message : String(error),
510
+ });
511
+ throw error;
407
512
  }
408
513
  }
409
514
  }
@@ -431,6 +536,50 @@ function parseBootstrapState(value) {
431
536
  return null;
432
537
  }
433
538
  }
539
+ function normalizeBootstrapPhase(value) {
540
+ if (value === undefined)
541
+ return 0;
542
+ return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
543
+ }
544
+ function isSubscriptionReady(row) {
545
+ return (row?.status === 'active' &&
546
+ parseBootstrapState(row.bootstrap_state_json) === null &&
547
+ row.cursor >= 0);
548
+ }
549
+ function isSubscriptionBootstrapping(row) {
550
+ return (row?.status === 'active' &&
551
+ parseBootstrapState(row.bootstrap_state_json) !== null);
552
+ }
553
+ function resolveActiveBootstrapPhase(subscriptions, existingById) {
554
+ let lowestPendingPhase = null;
555
+ for (const subscription of subscriptions) {
556
+ const phase = normalizeBootstrapPhase(subscription.bootstrapPhase);
557
+ if (isSubscriptionReady(existingById.get(subscription.id))) {
558
+ continue;
559
+ }
560
+ if (lowestPendingPhase === null || phase < lowestPendingPhase) {
561
+ lowestPendingPhase = phase;
562
+ }
563
+ }
564
+ return lowestPendingPhase;
565
+ }
566
+ function selectPullSubscriptions(subscriptions, existingById) {
567
+ const activePhase = resolveActiveBootstrapPhase(subscriptions, existingById);
568
+ if (activePhase === null) {
569
+ return [...subscriptions];
570
+ }
571
+ return subscriptions.filter((subscription) => {
572
+ const phase = normalizeBootstrapPhase(subscription.bootstrapPhase);
573
+ const existing = existingById.get(subscription.id);
574
+ if (phase <= activePhase)
575
+ return true;
576
+ if (isSubscriptionReady(existing))
577
+ return true;
578
+ if (isSubscriptionBootstrapping(existing))
579
+ return true;
580
+ return false;
581
+ });
582
+ }
434
583
  function parseScopeValuesJson(value) {
435
584
  if (!value)
436
585
  return {};
@@ -517,6 +666,26 @@ function resolveBootstrapClearScopes(previous, next) {
517
666
  }
518
667
  return narrowed;
519
668
  }
669
+ function emitTrace(onTrace, event) {
670
+ onTrace?.({
671
+ timestamp: Date.now(),
672
+ ...event,
673
+ });
674
+ }
675
+ function countSubscriptionRows(subscription) {
676
+ if (!subscription.bootstrap)
677
+ return undefined;
678
+ const snapshots = subscription.snapshots ?? [];
679
+ if (snapshots.length === 0)
680
+ return 0;
681
+ return snapshots.reduce((sum, snapshot) => sum + (snapshot.rows?.length ?? 0), 0);
682
+ }
683
+ function countSubscriptionChunks(subscription) {
684
+ if (!subscription.bootstrap)
685
+ return undefined;
686
+ const snapshots = subscription.snapshots ?? [];
687
+ return snapshots.reduce((sum, snapshot) => sum + (snapshot.chunks?.length ?? 0), 0);
688
+ }
520
689
  /**
521
690
  * Build a pull request from subscription state. Exported for use
522
691
  * by the combined sync path in sync-loop.ts.
@@ -542,19 +711,27 @@ export async function buildPullRequest(db, options) {
542
711
  const existingById = new Map();
543
712
  for (const row of existing)
544
713
  existingById.set(row.subscription_id, row);
714
+ const configuredSubscriptions = options.subscriptions ?? [];
715
+ const selectedSubscriptions = selectPullSubscriptions(configuredSubscriptions, existingById);
545
716
  const request = {
546
717
  clientId: options.clientId,
547
718
  limitCommits: options.limitCommits ?? 50,
548
719
  limitSnapshotRows: options.limitSnapshotRows ?? 1000,
549
720
  maxSnapshotPages: options.maxSnapshotPages ?? 4,
550
721
  dedupeRows: options.dedupeRows,
551
- subscriptions: (options.subscriptions ?? []).map((sub) => ({
722
+ subscriptions: selectedSubscriptions.map((sub) => ({
552
723
  ...sub,
553
724
  cursor: Math.max(-1, existingById.get(sub.id)?.cursor ?? -1),
554
725
  bootstrapState: parseBootstrapState(existingById.get(sub.id)?.bootstrap_state_json),
555
726
  })),
556
727
  };
557
- return { request, existing, existingById, stateId };
728
+ return {
729
+ request,
730
+ existing,
731
+ existingById,
732
+ stateId,
733
+ configuredSubscriptions,
734
+ };
558
735
  }
559
736
  export function createFollowupPullState(pullState, response) {
560
737
  const responseById = new Map();
@@ -593,9 +770,10 @@ export function createFollowupPullState(pullState, response) {
593
770
  nextExisting.push(nextRow);
594
771
  nextExistingById.set(nextRow.subscription_id, nextRow);
595
772
  }
773
+ const nextSelectedSubscriptions = selectPullSubscriptions(pullState.configuredSubscriptions, nextExistingById);
596
774
  const nextRequest = {
597
775
  ...pullState.request,
598
- subscriptions: (pullState.request.subscriptions ?? []).map((sub) => {
776
+ subscriptions: nextSelectedSubscriptions.map((sub) => {
599
777
  const row = nextExistingById.get(sub.id);
600
778
  return {
601
779
  ...sub,
@@ -609,6 +787,7 @@ export function createFollowupPullState(pullState, response) {
609
787
  existing: nextExisting,
610
788
  existingById: nextExistingById,
611
789
  stateId: pullState.stateId,
790
+ configuredSubscriptions: pullState.configuredSubscriptions,
612
791
  };
613
792
  }
614
793
  export async function applyIncrementalCommitChanges(handlers, trx, args) {
@@ -657,7 +836,10 @@ export async function applyPullResponse(db, transport, handlers, options, pullSt
657
836
  transport.capabilities?.preferMaterializedSnapshots === true;
658
837
  const bootstrapApplyMode = resolveBootstrapApplyMode(options, rawResponse, transport.capabilities);
659
838
  let responseToApply = requiresMaterializedSnapshots
660
- ? await materializeChunkedSnapshots(transport, rawResponse, options.sha256)
839
+ ? await materializeChunkedSnapshots(transport, rawResponse, options.sha256, {
840
+ stateId,
841
+ onTrace: options.onTrace,
842
+ })
661
843
  : rawResponse;
662
844
  for (const plugin of plugins) {
663
845
  if (!plugin.afterPull)
@@ -675,35 +857,95 @@ export async function applyPullResponse(db, transport, handlers, options, pullSt
675
857
  });
676
858
  if (bootstrapApplyMode === 'per-subscription') {
677
859
  for (const sub of responseToApply.subscriptions) {
678
- await db.transaction().execute(async (trx) => {
679
- await applySubscriptionResponse({
680
- trx,
681
- handlers,
682
- transport,
683
- options,
860
+ emitTrace(options.onTrace, {
861
+ stage: 'apply:transaction:start',
862
+ stateId,
863
+ transactionMode: bootstrapApplyMode,
864
+ subscriptionIds: [sub.id],
865
+ subscriptionCount: 1,
866
+ });
867
+ const transactionStartedAt = Date.now();
868
+ try {
869
+ await db.transaction().execute(async (trx) => {
870
+ await applySubscriptionResponse({
871
+ trx,
872
+ handlers,
873
+ transport,
874
+ options,
875
+ stateId,
876
+ existingById,
877
+ subsById,
878
+ sub,
879
+ });
880
+ });
881
+ emitTrace(options.onTrace, {
882
+ stage: 'apply:transaction:complete',
684
883
  stateId,
685
- existingById,
686
- subsById,
687
- sub,
884
+ transactionMode: bootstrapApplyMode,
885
+ subscriptionIds: [sub.id],
886
+ subscriptionCount: 1,
887
+ durationMs: Math.max(0, Date.now() - transactionStartedAt),
688
888
  });
689
- });
690
- }
691
- }
692
- else {
693
- await db.transaction().execute(async (trx) => {
694
- for (const sub of responseToApply.subscriptions) {
695
- await applySubscriptionResponse({
696
- trx,
697
- handlers,
698
- transport,
699
- options,
889
+ }
890
+ catch (error) {
891
+ emitTrace(options.onTrace, {
892
+ stage: 'apply:transaction:error',
700
893
  stateId,
701
- existingById,
702
- subsById,
703
- sub,
894
+ transactionMode: bootstrapApplyMode,
895
+ subscriptionIds: [sub.id],
896
+ subscriptionCount: 1,
897
+ durationMs: Math.max(0, Date.now() - transactionStartedAt),
898
+ errorMessage: error instanceof Error ? error.message : String(error),
704
899
  });
900
+ throw error;
705
901
  }
902
+ }
903
+ }
904
+ else {
905
+ emitTrace(options.onTrace, {
906
+ stage: 'apply:transaction:start',
907
+ stateId,
908
+ transactionMode: bootstrapApplyMode,
909
+ subscriptionIds: responseToApply.subscriptions.map((sub) => sub.id),
910
+ subscriptionCount: responseToApply.subscriptions.length,
706
911
  });
912
+ const transactionStartedAt = Date.now();
913
+ try {
914
+ await db.transaction().execute(async (trx) => {
915
+ for (const sub of responseToApply.subscriptions) {
916
+ await applySubscriptionResponse({
917
+ trx,
918
+ handlers,
919
+ transport,
920
+ options,
921
+ stateId,
922
+ existingById,
923
+ subsById,
924
+ sub,
925
+ });
926
+ }
927
+ });
928
+ emitTrace(options.onTrace, {
929
+ stage: 'apply:transaction:complete',
930
+ stateId,
931
+ transactionMode: bootstrapApplyMode,
932
+ subscriptionIds: responseToApply.subscriptions.map((sub) => sub.id),
933
+ subscriptionCount: responseToApply.subscriptions.length,
934
+ durationMs: Math.max(0, Date.now() - transactionStartedAt),
935
+ });
936
+ }
937
+ catch (error) {
938
+ emitTrace(options.onTrace, {
939
+ stage: 'apply:transaction:error',
940
+ stateId,
941
+ transactionMode: bootstrapApplyMode,
942
+ subscriptionIds: responseToApply.subscriptions.map((sub) => sub.id),
943
+ subscriptionCount: responseToApply.subscriptions.length,
944
+ durationMs: Math.max(0, Date.now() - transactionStartedAt),
945
+ errorMessage: error instanceof Error ? error.message : String(error),
946
+ });
947
+ throw error;
948
+ }
707
949
  }
708
950
  return responseToApply;
709
951
  }
@@ -791,137 +1033,239 @@ async function applySubscriptionResponse(args) {
791
1033
  const staleIncrementalResponse = !sub.bootstrap &&
792
1034
  effectiveCursor !== null &&
793
1035
  sub.nextCursor < effectiveCursor;
1036
+ const applyStartedAt = Date.now();
1037
+ emitTrace(options.onTrace, {
1038
+ stage: 'apply:subscription:start',
1039
+ stateId,
1040
+ subscriptionId: sub.id,
1041
+ table: def?.table ?? prev?.table,
1042
+ bootstrap: sub.bootstrap,
1043
+ snapshotCount: sub.snapshots?.length ?? 0,
1044
+ commitCount: sub.commits?.length ?? 0,
1045
+ chunkCount: countSubscriptionChunks(sub),
1046
+ rowCount: countSubscriptionRows(sub),
1047
+ nextCursor: sub.nextCursor,
1048
+ });
794
1049
  if (staleIncrementalResponse) {
1050
+ emitTrace(options.onTrace, {
1051
+ stage: 'apply:subscription:complete',
1052
+ stateId,
1053
+ subscriptionId: sub.id,
1054
+ table: def?.table ?? prev?.table,
1055
+ bootstrap: sub.bootstrap,
1056
+ snapshotCount: sub.snapshots?.length ?? 0,
1057
+ commitCount: sub.commits?.length ?? 0,
1058
+ chunkCount: countSubscriptionChunks(sub),
1059
+ rowCount: countSubscriptionRows(sub),
1060
+ nextCursor: sub.nextCursor,
1061
+ durationMs: Math.max(0, Date.now() - applyStartedAt),
1062
+ });
795
1063
  return;
796
1064
  }
797
- if (sub.status === 'revoked') {
798
- if (prev?.table) {
1065
+ try {
1066
+ if (sub.status === 'revoked') {
1067
+ if (prev?.table) {
1068
+ try {
1069
+ const scopes = parseScopeValuesJson(prev.scopes_json);
1070
+ await getClientHandlerOrThrow(handlers, prev.table).clearAll({
1071
+ trx,
1072
+ scopes,
1073
+ });
1074
+ }
1075
+ catch {
1076
+ // ignore missing handler
1077
+ }
1078
+ }
1079
+ await sql `
1080
+ delete from ${sql.table('sync_subscription_state')}
1081
+ where ${sql.ref('state_id')} = ${sql.val(stateId)}
1082
+ and ${sql.ref('subscription_id')} = ${sql.val(sub.id)}
1083
+ `.execute(trx);
1084
+ emitTrace(options.onTrace, {
1085
+ stage: 'apply:subscription:complete',
1086
+ stateId,
1087
+ subscriptionId: sub.id,
1088
+ table: def?.table ?? prev?.table,
1089
+ bootstrap: sub.bootstrap,
1090
+ snapshotCount: sub.snapshots?.length ?? 0,
1091
+ commitCount: sub.commits?.length ?? 0,
1092
+ chunkCount: countSubscriptionChunks(sub),
1093
+ rowCount: countSubscriptionRows(sub),
1094
+ nextCursor: null,
1095
+ durationMs: Math.max(0, Date.now() - applyStartedAt),
1096
+ });
1097
+ return;
1098
+ }
1099
+ const nextScopes = sub.scopes ?? def?.scopes ?? {};
1100
+ const previousScopes = parseScopeValuesJson(prev?.scopes_json);
1101
+ const scopesChanged = !scopeValuesEqual(previousScopes, nextScopes);
1102
+ if (sub.bootstrap && prev?.table && scopesChanged) {
799
1103
  try {
800
- const scopes = parseScopeValuesJson(prev.scopes_json);
801
- await getClientHandlerOrThrow(handlers, prev.table).clearAll({
802
- trx,
803
- scopes,
804
- });
1104
+ const clearScopes = resolveBootstrapClearScopes(previousScopes, nextScopes);
1105
+ if (clearScopes !== 'none') {
1106
+ await getClientHandlerOrThrow(handlers, prev.table).clearAll({
1107
+ trx,
1108
+ scopes: clearScopes ?? previousScopes,
1109
+ });
1110
+ }
805
1111
  }
806
1112
  catch {
807
1113
  // ignore missing handler
808
1114
  }
809
1115
  }
810
- await sql `
811
- delete from ${sql.table('sync_subscription_state')}
812
- where ${sql.ref('state_id')} = ${sql.val(stateId)}
813
- and ${sql.ref('subscription_id')} = ${sql.val(sub.id)}
814
- `.execute(trx);
815
- return;
816
- }
817
- const nextScopes = sub.scopes ?? def?.scopes ?? {};
818
- const previousScopes = parseScopeValuesJson(prev?.scopes_json);
819
- const scopesChanged = !scopeValuesEqual(previousScopes, nextScopes);
820
- if (sub.bootstrap && prev?.table && scopesChanged) {
821
- try {
822
- const clearScopes = resolveBootstrapClearScopes(previousScopes, nextScopes);
823
- if (clearScopes !== 'none') {
824
- await getClientHandlerOrThrow(handlers, prev.table).clearAll({
825
- trx,
826
- scopes: clearScopes ?? previousScopes,
827
- });
1116
+ if (sub.bootstrap) {
1117
+ for (const snapshot of sub.snapshots ?? []) {
1118
+ const handler = getClientHandlerOrThrow(handlers, snapshot.table);
1119
+ const hasChunkRefs = Array.isArray(snapshot.chunks) && snapshot.chunks.length > 0;
1120
+ if (snapshot.isFirstPage && handler.onSnapshotStart) {
1121
+ await handler.onSnapshotStart({
1122
+ trx,
1123
+ table: snapshot.table,
1124
+ scopes: sub.scopes,
1125
+ });
1126
+ }
1127
+ if (hasChunkRefs) {
1128
+ await applyChunkedSnapshot(transport, handler, trx, snapshot, sub.scopes, options.sha256, {
1129
+ stateId,
1130
+ subscriptionId: sub.id,
1131
+ onTrace: options.onTrace,
1132
+ });
1133
+ }
1134
+ else {
1135
+ await handler.applySnapshot({ trx }, snapshot);
1136
+ }
1137
+ if (snapshot.isLastPage && handler.onSnapshotEnd) {
1138
+ await handler.onSnapshotEnd({
1139
+ trx,
1140
+ table: snapshot.table,
1141
+ scopes: sub.scopes,
1142
+ });
1143
+ }
828
1144
  }
829
1145
  }
830
- catch {
831
- // ignore missing handler
832
- }
833
- }
834
- if (sub.bootstrap) {
835
- for (const snapshot of sub.snapshots ?? []) {
836
- const handler = getClientHandlerOrThrow(handlers, snapshot.table);
837
- const hasChunkRefs = Array.isArray(snapshot.chunks) && snapshot.chunks.length > 0;
838
- if (snapshot.isFirstPage && handler.onSnapshotStart) {
839
- await handler.onSnapshotStart({
840
- trx,
841
- table: snapshot.table,
842
- scopes: sub.scopes,
843
- });
844
- }
845
- if (hasChunkRefs) {
846
- await applyChunkedSnapshot(transport, handler, trx, snapshot, sub.scopes, options.sha256);
847
- }
848
- else {
849
- await handler.applySnapshot({ trx }, snapshot);
850
- }
851
- if (snapshot.isLastPage && handler.onSnapshotEnd) {
852
- await handler.onSnapshotEnd({
853
- trx,
854
- table: snapshot.table,
855
- scopes: sub.scopes,
1146
+ else {
1147
+ for (const commit of sub.commits) {
1148
+ await applyIncrementalCommitChanges(handlers, trx, {
1149
+ changes: commit.changes,
1150
+ commitSeq: commit.commitSeq ?? null,
1151
+ actorId: commit.actorId ?? null,
1152
+ createdAt: commit.createdAt ?? null,
856
1153
  });
857
1154
  }
858
1155
  }
1156
+ const now = Date.now();
1157
+ const paramsJson = serializeJsonCached(def?.params ?? {});
1158
+ const scopesJson = serializeJsonCached(nextScopes);
1159
+ const bootstrapStateJson = sub.bootstrap
1160
+ ? sub.bootstrapState
1161
+ ? serializeJsonCached(sub.bootstrapState)
1162
+ : null
1163
+ : null;
1164
+ const table = def?.table ?? 'unknown';
1165
+ await sql `
1166
+ insert into ${sql.table('sync_subscription_state')} (
1167
+ ${sql.join([
1168
+ sql.ref('state_id'),
1169
+ sql.ref('subscription_id'),
1170
+ sql.ref('table'),
1171
+ sql.ref('scopes_json'),
1172
+ sql.ref('params_json'),
1173
+ sql.ref('cursor'),
1174
+ sql.ref('bootstrap_state_json'),
1175
+ sql.ref('status'),
1176
+ sql.ref('created_at'),
1177
+ sql.ref('updated_at'),
1178
+ ])}
1179
+ ) values (
1180
+ ${sql.join([
1181
+ sql.val(stateId),
1182
+ sql.val(sub.id),
1183
+ sql.val(table),
1184
+ sql.val(scopesJson),
1185
+ sql.val(paramsJson),
1186
+ sql.val(sub.nextCursor),
1187
+ sql.val(bootstrapStateJson),
1188
+ sql.val('active'),
1189
+ sql.val(now),
1190
+ sql.val(now),
1191
+ ])}
1192
+ )
1193
+ on conflict (${sql.join([sql.ref('state_id'), sql.ref('subscription_id')])})
1194
+ do update set
1195
+ ${sql.ref('table')} = ${sql.val(table)},
1196
+ ${sql.ref('scopes_json')} = ${sql.val(scopesJson)},
1197
+ ${sql.ref('params_json')} = ${sql.val(paramsJson)},
1198
+ ${sql.ref('cursor')} = ${sql.val(sub.nextCursor)},
1199
+ ${sql.ref('bootstrap_state_json')} = ${sql.val(bootstrapStateJson)},
1200
+ ${sql.ref('status')} = ${sql.val('active')},
1201
+ ${sql.ref('updated_at')} = ${sql.val(now)}
1202
+ `.execute(trx);
1203
+ emitTrace(options.onTrace, {
1204
+ stage: 'apply:subscription:complete',
1205
+ stateId,
1206
+ subscriptionId: sub.id,
1207
+ table,
1208
+ bootstrap: sub.bootstrap,
1209
+ snapshotCount: sub.snapshots?.length ?? 0,
1210
+ commitCount: sub.commits?.length ?? 0,
1211
+ chunkCount: countSubscriptionChunks(sub),
1212
+ rowCount: countSubscriptionRows(sub),
1213
+ nextCursor: sub.nextCursor,
1214
+ durationMs: Math.max(0, Date.now() - applyStartedAt),
1215
+ });
859
1216
  }
860
- else {
861
- for (const commit of sub.commits) {
862
- await applyIncrementalCommitChanges(handlers, trx, {
863
- changes: commit.changes,
864
- commitSeq: commit.commitSeq ?? null,
865
- actorId: commit.actorId ?? null,
866
- createdAt: commit.createdAt ?? null,
867
- });
868
- }
1217
+ catch (error) {
1218
+ emitTrace(options.onTrace, {
1219
+ stage: 'apply:subscription:error',
1220
+ stateId,
1221
+ subscriptionId: sub.id,
1222
+ table: def?.table ?? prev?.table,
1223
+ bootstrap: sub.bootstrap,
1224
+ snapshotCount: sub.snapshots?.length ?? 0,
1225
+ commitCount: sub.commits?.length ?? 0,
1226
+ chunkCount: countSubscriptionChunks(sub),
1227
+ rowCount: countSubscriptionRows(sub),
1228
+ nextCursor: sub.nextCursor,
1229
+ durationMs: Math.max(0, Date.now() - applyStartedAt),
1230
+ errorMessage: error instanceof Error ? error.message : String(error),
1231
+ });
1232
+ throw error;
869
1233
  }
870
- const now = Date.now();
871
- const paramsJson = serializeJsonCached(def?.params ?? {});
872
- const scopesJson = serializeJsonCached(nextScopes);
873
- const bootstrapStateJson = sub.bootstrap
874
- ? sub.bootstrapState
875
- ? serializeJsonCached(sub.bootstrapState)
876
- : null
877
- : null;
878
- const table = def?.table ?? 'unknown';
879
- await sql `
880
- insert into ${sql.table('sync_subscription_state')} (
881
- ${sql.join([
882
- sql.ref('state_id'),
883
- sql.ref('subscription_id'),
884
- sql.ref('table'),
885
- sql.ref('scopes_json'),
886
- sql.ref('params_json'),
887
- sql.ref('cursor'),
888
- sql.ref('bootstrap_state_json'),
889
- sql.ref('status'),
890
- sql.ref('created_at'),
891
- sql.ref('updated_at'),
892
- ])}
893
- ) values (
894
- ${sql.join([
895
- sql.val(stateId),
896
- sql.val(sub.id),
897
- sql.val(table),
898
- sql.val(scopesJson),
899
- sql.val(paramsJson),
900
- sql.val(sub.nextCursor),
901
- sql.val(bootstrapStateJson),
902
- sql.val('active'),
903
- sql.val(now),
904
- sql.val(now),
905
- ])}
906
- )
907
- on conflict (${sql.join([sql.ref('state_id'), sql.ref('subscription_id')])})
908
- do update set
909
- ${sql.ref('table')} = ${sql.val(table)},
910
- ${sql.ref('scopes_json')} = ${sql.val(scopesJson)},
911
- ${sql.ref('params_json')} = ${sql.val(paramsJson)},
912
- ${sql.ref('cursor')} = ${sql.val(sub.nextCursor)},
913
- ${sql.ref('bootstrap_state_json')} = ${sql.val(bootstrapStateJson)},
914
- ${sql.ref('status')} = ${sql.val('active')},
915
- ${sql.ref('updated_at')} = ${sql.val(now)}
916
- `.execute(trx);
917
1234
  }
918
1235
  export async function syncPullOnce(db, transport, handlers, options, pullStateOverride) {
919
1236
  const pullState = pullStateOverride ?? (await buildPullRequest(db, options));
920
1237
  const { clientId, ...pullBody } = pullState.request;
921
- const combined = await transport.sync({ clientId, pull: pullBody });
1238
+ emitTrace(options.onTrace, {
1239
+ stage: 'pull:start',
1240
+ stateId: pullState.stateId,
1241
+ subscriptionIds: pullState.request.subscriptions.map((subscription) => subscription.id),
1242
+ subscriptionCount: pullState.request.subscriptions.length,
1243
+ });
1244
+ let combined;
1245
+ try {
1246
+ combined = await transport.sync({ clientId, pull: pullBody });
1247
+ }
1248
+ catch (error) {
1249
+ emitTrace(options.onTrace, {
1250
+ stage: 'pull:error',
1251
+ stateId: pullState.stateId,
1252
+ subscriptionIds: pullState.request.subscriptions.map((subscription) => subscription.id),
1253
+ subscriptionCount: pullState.request.subscriptions.length,
1254
+ errorMessage: error instanceof Error ? error.message : String(error),
1255
+ });
1256
+ throw error;
1257
+ }
922
1258
  if (!combined.pull) {
923
1259
  return { ok: true, subscriptions: [] };
924
1260
  }
1261
+ emitTrace(options.onTrace, {
1262
+ stage: 'pull:response',
1263
+ stateId: pullState.stateId,
1264
+ subscriptionIds: combined.pull.subscriptions.map((subscription) => subscription.id),
1265
+ subscriptionCount: combined.pull.subscriptions.length,
1266
+ commitCount: combined.pull.subscriptions.reduce((sum, subscription) => sum + (subscription.commits?.length ?? 0), 0),
1267
+ snapshotCount: combined.pull.subscriptions.reduce((sum, subscription) => sum + (subscription.snapshots?.length ?? 0), 0),
1268
+ });
925
1269
  return applyPullResponse(db, transport, handlers, options, pullState, combined.pull);
926
1270
  }
927
1271
  //# sourceMappingURL=pull-engine.js.map