@syncular/client 0.0.6-230 → 0.0.6-237

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.0.6-230",
3
+ "version": "0.0.6-237",
4
4
  "description": "Client-side sync engine with offline-first support, outbox, and conflict resolution",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -119,8 +119,8 @@
119
119
  "release": "bunx syncular-publish"
120
120
  },
121
121
  "dependencies": {
122
- "@syncular/core": "0.0.6-230",
123
- "@syncular/transport-http": "0.0.6-230"
122
+ "@syncular/core": "0.0.6-237",
123
+ "@syncular/transport-http": "0.0.6-237"
124
124
  },
125
125
  "peerDependencies": {
126
126
  "kysely": "*"
@@ -924,6 +924,150 @@ describe('SyncEngine WS inline apply', () => {
924
924
  expect(state.error?.retryable).toBe(false);
925
925
  });
926
926
 
927
+ it('pulls newly eligible bootstrap phases in the same sync cycle', async () => {
928
+ const handlers: ClientHandlerCollection<TestDb> = [
929
+ {
930
+ table: 'tasks',
931
+ async applySnapshot() {},
932
+ async clearAll() {},
933
+ async applyChange() {},
934
+ },
935
+ ];
936
+
937
+ const syncRequests: string[][] = [];
938
+ const transport: SyncTransport = {
939
+ async sync(request) {
940
+ const ids = request.pull?.subscriptions.map((subscription) => {
941
+ return subscription.id;
942
+ }) ?? ['unexpected'];
943
+ syncRequests.push(ids);
944
+
945
+ if (syncRequests.length === 1) {
946
+ expect(ids).toEqual(['catalog-meta', 'catalog-codes']);
947
+ return {
948
+ pull: {
949
+ ok: true,
950
+ subscriptions: [
951
+ {
952
+ id: 'catalog-meta',
953
+ status: 'active',
954
+ scopes: {},
955
+ bootstrap: false,
956
+ nextCursor: 1,
957
+ commits: [],
958
+ snapshots: [],
959
+ },
960
+ {
961
+ id: 'catalog-codes',
962
+ status: 'active',
963
+ scopes: {},
964
+ bootstrap: false,
965
+ nextCursor: 1,
966
+ commits: [],
967
+ snapshots: [],
968
+ },
969
+ ],
970
+ },
971
+ };
972
+ }
973
+
974
+ expect(ids).toEqual([
975
+ 'catalog-meta',
976
+ 'catalog-codes',
977
+ 'catalog-relations',
978
+ ]);
979
+ return {
980
+ pull: {
981
+ ok: true,
982
+ subscriptions: [
983
+ {
984
+ id: 'catalog-meta',
985
+ status: 'active',
986
+ scopes: {},
987
+ bootstrap: false,
988
+ nextCursor: 1,
989
+ commits: [],
990
+ snapshots: [],
991
+ },
992
+ {
993
+ id: 'catalog-codes',
994
+ status: 'active',
995
+ scopes: {},
996
+ bootstrap: false,
997
+ nextCursor: 1,
998
+ commits: [],
999
+ snapshots: [],
1000
+ },
1001
+ {
1002
+ id: 'catalog-relations',
1003
+ status: 'active',
1004
+ scopes: {},
1005
+ bootstrap: false,
1006
+ nextCursor: 1,
1007
+ commits: [],
1008
+ snapshots: [],
1009
+ },
1010
+ ],
1011
+ },
1012
+ };
1013
+ },
1014
+ async fetchSnapshotChunk() {
1015
+ return new Uint8Array();
1016
+ },
1017
+ };
1018
+
1019
+ const engine = new SyncEngine<TestDb>({
1020
+ db,
1021
+ transport,
1022
+ handlers,
1023
+ actorId: 'catalog-public',
1024
+ clientId: 'client-bootstrap-phases',
1025
+ subscriptions: [
1026
+ {
1027
+ id: 'catalog-meta',
1028
+ table: 'tasks',
1029
+ scopes: {},
1030
+ bootstrapPhase: 0,
1031
+ },
1032
+ {
1033
+ id: 'catalog-codes',
1034
+ table: 'tasks',
1035
+ scopes: {},
1036
+ bootstrapPhase: 0,
1037
+ },
1038
+ {
1039
+ id: 'catalog-relations',
1040
+ table: 'tasks',
1041
+ scopes: {},
1042
+ bootstrapPhase: 1,
1043
+ },
1044
+ ],
1045
+ stateId: 'default',
1046
+ pollIntervalMs: 60_000,
1047
+ });
1048
+
1049
+ await engine.start();
1050
+ engine.stop();
1051
+
1052
+ expect(syncRequests).toEqual([
1053
+ ['catalog-meta', 'catalog-codes'],
1054
+ ['catalog-meta', 'catalog-codes', 'catalog-relations'],
1055
+ ]);
1056
+
1057
+ const relationState = await db
1058
+ .selectFrom('sync_subscription_state')
1059
+ .select(['subscription_id', 'status', 'cursor'])
1060
+ .where('state_id', '=', 'default')
1061
+ .where('subscription_id', '=', 'catalog-relations')
1062
+ .executeTakeFirst();
1063
+
1064
+ expect(relationState).toEqual({
1065
+ subscription_id: 'catalog-relations',
1066
+ status: 'active',
1067
+ cursor: 1,
1068
+ });
1069
+ });
1070
+
927
1071
  it('skips outbox and conflict refresh after a read-only successful sync', async () => {
928
1072
  const handlers: ClientHandlerCollection<TestDb> = [
929
1073
  {
@@ -2172,6 +2172,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
2172
2172
  limitCommits: this.config.limitCommits,
2173
2173
  limitSnapshotRows: this.config.limitSnapshotRows,
2174
2174
  maxSnapshotPages: this.config.maxSnapshotPages,
2175
+ snapshotApplyYieldMs: this.config.snapshotApplyYieldMs,
2175
2176
  dedupeRows: this.config.dedupeRows,
2176
2177
  stateId: this.config.stateId,
2177
2178
  sha256: this.config.sha256,
@@ -371,6 +371,13 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
371
371
  limitSnapshotRows?: number;
372
372
  /** Bootstrap snapshot pages per pull */
373
373
  maxSnapshotPages?: number;
374
+ /**
375
+ * Yield delay between large bootstrap apply batches.
376
+ * - `0`: yield on the next macrotask
377
+ * - `false`: disable yielding
378
+ * - omitted: use transport/runtime defaults
379
+ */
380
+ snapshotApplyYieldMs?: number | false;
374
381
  /** Deduplicate rows in pull responses on the server */
375
382
  dedupeRows?: boolean;
376
383
  /** Optional state row id (multi-profile support) */
@@ -278,6 +278,7 @@ export function createClientHandler<
278
278
  )}
279
279
  on conflict (${sql.ref(primaryKey)}) ${onConflict}
280
280
  `.execute(ctx.trx);
281
+ await ctx.yieldToMainThread?.();
281
282
  }
282
283
  };
283
284
 
@@ -359,6 +360,7 @@ export function createClientHandler<
359
360
  delete from ${sql.table(table)}
360
361
  where ${sql.ref(primaryKey)} in ${sql`(${sql.join(batchIds.map((rowId) => sql.val(rowId)))})`}
361
362
  `.execute(ctx.trx);
363
+ await ctx.yieldToMainThread?.();
362
364
  }
363
365
  };
364
366
 
@@ -404,6 +406,7 @@ export function createClientHandler<
404
406
  )}
405
407
  on conflict (${sql.ref(primaryKey)}) ${onConflict}
406
408
  `.execute(ctx.trx);
409
+ await ctx.yieldToMainThread?.();
407
410
  }
408
411
  };
409
412
 
@@ -16,6 +16,12 @@ import type { Transaction } from 'kysely';
16
16
  export interface ClientHandlerContext<DB> {
17
17
  /** Database transaction */
18
18
  trx: Transaction<DB>;
19
+ /**
20
+ * Yields control back to the runtime scheduler between large apply batches.
21
+ * Useful on React Native / Hermes where long bootstrap writes can otherwise
22
+ * monopolize the JS thread.
23
+ */
24
+ yieldToMainThread?: () => Promise<void>;
19
25
  /**
20
26
  * Commit metadata for server-delivered changes.
21
27
  * Undefined for local optimistic changes.
@@ -639,6 +639,7 @@ async function applyChunkedSnapshot<DB extends SyncClientDb>(
639
639
  snapshot: SyncSnapshot,
640
640
  scopeValues: ScopeValues,
641
641
  sha256Override?: (bytes: Uint8Array) => Promise<string>,
642
+ yieldToMainThread?: () => Promise<void>,
642
643
  trace?: {
643
644
  stateId: string;
644
645
  subscriptionId: string;
@@ -647,7 +648,7 @@ async function applyChunkedSnapshot<DB extends SyncClientDb>(
647
648
  ): Promise<void> {
648
649
  const chunks = snapshot.chunks ?? [];
649
650
  if (chunks.length === 0) {
650
- await handler.applySnapshot({ trx }, snapshot);
651
+ await handler.applySnapshot({ trx, yieldToMainThread }, snapshot);
651
652
  return;
652
653
  }
653
654
 
@@ -713,7 +714,7 @@ async function applyChunkedSnapshot<DB extends SyncClientDb>(
713
714
  // eslint-disable-next-line no-await-in-loop
714
715
  try {
715
716
  await handler.applySnapshot(
716
- { trx },
717
+ { trx, yieldToMainThread },
717
718
  {
718
719
  ...snapshot,
719
720
  rows: pendingBatch,
@@ -745,7 +746,7 @@ async function applyChunkedSnapshot<DB extends SyncClientDb>(
745
746
  // eslint-disable-next-line no-await-in-loop
746
747
  try {
747
748
  await handler.applySnapshot(
748
- { trx },
749
+ { trx, yieldToMainThread },
749
750
  {
750
751
  ...snapshot,
751
752
  rows: pendingBatch,
@@ -1046,6 +1047,13 @@ export interface SyncPullOnceOptions {
1046
1047
  limitCommits?: number;
1047
1048
  limitSnapshotRows?: number;
1048
1049
  maxSnapshotPages?: number;
1050
+ /**
1051
+ * Yield delay between heavy bootstrap apply batches.
1052
+ * - `0`: yield on the next macrotask
1053
+ * - `false`: disable yielding
1054
+ * - omitted: use transport/runtime defaults
1055
+ */
1056
+ snapshotApplyYieldMs?: number | false;
1049
1057
  dedupeRows?: boolean;
1050
1058
  stateId?: string;
1051
1059
  /**
@@ -1087,6 +1095,26 @@ function emitTrace(
1087
1095
  });
1088
1096
  }
1089
1097
 
1098
+ function resolveSnapshotApplyYieldToMainThread(
1099
+ options: SyncPullOnceOptions,
1100
+ capabilities?: SyncTransportCapabilities
1101
+ ): (() => Promise<void>) | undefined {
1102
+ const configuredDelay =
1103
+ options.snapshotApplyYieldMs ??
1104
+ capabilities?.preferredSnapshotApplyYieldMs ??
1105
+ false;
1106
+
1107
+ if (configuredDelay === false) {
1108
+ return undefined;
1109
+ }
1110
+
1111
+ const delay = Math.max(0, Math.trunc(configuredDelay));
1112
+ return () =>
1113
+ new Promise<void>((resolve) => {
1114
+ setTimeout(resolve, delay);
1115
+ });
1116
+ }
1117
+
1090
1118
  function countSubscriptionRows(
1091
1119
  subscription: SyncPullSubscriptionResponse
1092
1120
  ): number | undefined {
@@ -1247,10 +1275,12 @@ export async function applyIncrementalCommitChanges<DB extends SyncClientDb>(
1247
1275
  commitSeq?: number | null;
1248
1276
  actorId?: string | null;
1249
1277
  createdAt?: string | null;
1278
+ yieldToMainThread?: () => Promise<void>;
1250
1279
  }
1251
1280
  ): Promise<void> {
1252
1281
  const ctx = {
1253
1282
  trx,
1283
+ yieldToMainThread: args.yieldToMainThread,
1254
1284
  commitSeq: args.commitSeq ?? null,
1255
1285
  actorId: args.actorId ?? null,
1256
1286
  createdAt: args.createdAt ?? null,
@@ -1311,6 +1341,10 @@ export async function applyPullResponse<DB extends SyncClientDb>(
1311
1341
  rawResponse,
1312
1342
  transport.capabilities
1313
1343
  );
1344
+ const yieldToMainThread = resolveSnapshotApplyYieldToMainThread(
1345
+ options,
1346
+ transport.capabilities
1347
+ );
1314
1348
 
1315
1349
  let responseToApply = requiresMaterializedSnapshots
1316
1350
  ? await materializeChunkedSnapshots(
@@ -1365,6 +1399,7 @@ export async function applyPullResponse<DB extends SyncClientDb>(
1365
1399
  existingById,
1366
1400
  subsById,
1367
1401
  sub,
1402
+ yieldToMainThread,
1368
1403
  });
1369
1404
  });
1370
1405
  emitTrace(options.onTrace, {
@@ -1409,6 +1444,7 @@ export async function applyPullResponse<DB extends SyncClientDb>(
1409
1444
  existingById,
1410
1445
  subsById,
1411
1446
  sub,
1447
+ yieldToMainThread,
1412
1448
  });
1413
1449
  }
1414
1450
  });
@@ -1528,6 +1564,7 @@ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
1528
1564
  existingById: Map<string, SyncSubscriptionStateTable>;
1529
1565
  subsById: Map<string, SyncClientSubscription | undefined>;
1530
1566
  sub: SyncPullSubscriptionResponse;
1567
+ yieldToMainThread?: () => Promise<void>;
1531
1568
  }): Promise<void> {
1532
1569
  const {
1533
1570
  trx,
@@ -1538,6 +1575,7 @@ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
1538
1575
  existingById,
1539
1576
  subsById,
1540
1577
  sub,
1578
+ yieldToMainThread,
1541
1579
  } = args;
1542
1580
  const def = subsById.get(sub.id);
1543
1581
  const prev = existingById.get(sub.id);
@@ -1603,6 +1641,7 @@ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
1603
1641
  const scopes = parseScopeValuesJson(prev.scopes_json);
1604
1642
  await getClientHandlerOrThrow(handlers, prev.table).clearAll({
1605
1643
  trx,
1644
+ yieldToMainThread,
1606
1645
  scopes,
1607
1646
  });
1608
1647
  } catch {
@@ -1644,6 +1683,7 @@ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
1644
1683
  if (clearScopes !== 'none') {
1645
1684
  await getClientHandlerOrThrow(handlers, prev.table).clearAll({
1646
1685
  trx,
1686
+ yieldToMainThread,
1647
1687
  scopes: clearScopes ?? previousScopes,
1648
1688
  });
1649
1689
  }
@@ -1662,6 +1702,7 @@ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
1662
1702
  try {
1663
1703
  await handler.onSnapshotStart({
1664
1704
  trx,
1705
+ yieldToMainThread,
1665
1706
  table: snapshot.table,
1666
1707
  scopes: sub.scopes,
1667
1708
  });
@@ -1687,6 +1728,7 @@ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
1687
1728
  snapshot,
1688
1729
  sub.scopes,
1689
1730
  options.sha256,
1731
+ yieldToMainThread,
1690
1732
  {
1691
1733
  stateId,
1692
1734
  subscriptionId: sub.id,
@@ -1695,7 +1737,7 @@ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
1695
1737
  );
1696
1738
  } else {
1697
1739
  try {
1698
- await handler.applySnapshot({ trx }, snapshot);
1740
+ await handler.applySnapshot({ trx, yieldToMainThread }, snapshot);
1699
1741
  } catch (error) {
1700
1742
  throw wrapSyncClientStageError(
1701
1743
  error,
@@ -1714,6 +1756,7 @@ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
1714
1756
  try {
1715
1757
  await handler.onSnapshotEnd({
1716
1758
  trx,
1759
+ yieldToMainThread,
1717
1760
  table: snapshot.table,
1718
1761
  scopes: sub.scopes,
1719
1762
  });
@@ -1739,6 +1782,7 @@ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
1739
1782
  commitSeq: commit.commitSeq ?? null,
1740
1783
  actorId: commit.actorId ?? null,
1741
1784
  createdAt: commit.createdAt ?? null,
1785
+ yieldToMainThread,
1742
1786
  });
1743
1787
  } catch (error) {
1744
1788
  throw wrapSyncClientStageError(
package/src/sync-loop.ts CHANGED
@@ -459,6 +459,21 @@ function needsAnotherPull(
459
459
  return totalCommits >= limitCommits;
460
460
  }
461
461
 
462
+ function hasNewlyEligibleBootstrapSubscriptions(
463
+ currentPullState: SyncPullRequestState,
464
+ nextPullState: SyncPullRequestState
465
+ ): boolean {
466
+ const currentIds = new Set(
467
+ (currentPullState.request.subscriptions ?? []).map((subscription) => {
468
+ return subscription.id;
469
+ })
470
+ );
471
+
472
+ return (nextPullState.request.subscriptions ?? []).some((subscription) => {
473
+ return !currentIds.has(subscription.id);
474
+ });
475
+ }
476
+
462
477
  function mergePullResponse(
463
478
  targetBySubId: Map<string, SyncPullSubscriptionResponse>,
464
479
  res: SyncPullResponse
@@ -545,8 +560,14 @@ async function syncPullUntilSettled<DB extends SyncClientDb>(
545
560
  const res = await syncPullOnce(db, transport, handlers, options, pullState);
546
561
  mergePullResponse(aggregatedBySubId, res);
547
562
 
548
- if (!needsAnotherPull(res, pullState.request.limitCommits)) break;
549
- pullState = createFollowupPullState(pullState, res);
563
+ const nextPullState = createFollowupPullState(pullState, res);
564
+ if (
565
+ !needsAnotherPull(res, pullState.request.limitCommits) &&
566
+ !hasNewlyEligibleBootstrapSubscriptions(pullState, nextPullState)
567
+ ) {
568
+ break;
569
+ }
570
+ pullState = nextPullState;
550
571
  }
551
572
 
552
573
  return {
@@ -568,6 +589,7 @@ export interface SyncOnceOptions {
568
589
  limitCommits?: number;
569
590
  limitSnapshotRows?: number;
570
591
  maxSnapshotPages?: number;
592
+ snapshotApplyYieldMs?: number | false;
571
593
  dedupeRows?: boolean;
572
594
  stateId?: string;
573
595
  maxPushCommits?: number;
@@ -612,6 +634,7 @@ async function syncOnceCombined<DB extends SyncClientDb>(
612
634
  limitCommits: options.limitCommits,
613
635
  limitSnapshotRows: options.limitSnapshotRows,
614
636
  maxSnapshotPages: options.maxSnapshotPages,
637
+ snapshotApplyYieldMs: options.snapshotApplyYieldMs,
615
638
  dedupeRows: options.dedupeRows,
616
639
  stateId: options.stateId,
617
640
  sha256: options.sha256,
@@ -868,14 +891,18 @@ async function syncOnceCombined<DB extends SyncClientDb>(
868
891
  pullRounds = 1;
869
892
 
870
893
  // Continue pulling if more data
871
- if (needsAnotherPull(pullResponse, pullState.request.limitCommits)) {
894
+ const nextPullState = createFollowupPullState(pullState, pullResponse);
895
+ if (
896
+ needsAnotherPull(pullResponse, pullState.request.limitCommits) ||
897
+ hasNewlyEligibleBootstrapSubscriptions(pullState, nextPullState)
898
+ ) {
872
899
  const aggregatedBySubId = new Map<string, SyncPullSubscriptionResponse>();
873
900
  mergePullResponse(aggregatedBySubId, pullResponse);
874
901
 
875
902
  const more = await syncPullUntilSettled(db, transport, handlers, {
876
903
  ...pullOpts,
877
904
  maxRounds: (options.maxPullRounds ?? 20) - 1,
878
- initialPullState: createFollowupPullState(pullState, pullResponse),
905
+ initialPullState: nextPullState,
879
906
  });
880
907
  pullRounds += more.rounds;
881
908
  mergePullResponse(aggregatedBySubId, more.response);