@syncular/client 0.0.6-212 → 0.0.6-219

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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @syncular/client - Sync pull engine
3
3
  */
4
- import type { SyncChange, SyncPullRequest, SyncPullResponse, SyncSubscriptionRequest, SyncTransport } from '@syncular/core';
4
+ import { type SyncBootstrapApplyMode, type SyncChange, type SyncPullRequest, type SyncPullResponse, type SyncSubscriptionRequest, type SyncTransport } from '@syncular/core';
5
5
  import { type Kysely, type Transaction } from 'kysely';
6
6
  import { type ClientHandlerCollection } from './handlers/collection';
7
7
  import type { SyncClientPlugin } from './plugins/types';
@@ -20,6 +20,17 @@ export interface SyncPullOnceOptions {
20
20
  maxSnapshotPages?: number;
21
21
  dedupeRows?: boolean;
22
22
  stateId?: string;
23
+ /**
24
+ * Controls how bootstrap snapshot results are committed to the local DB.
25
+ * - `single-transaction`: all subscriptions in one DB transaction
26
+ * - `per-subscription`: each subscription in its own DB transaction
27
+ * - `auto`: choose a sensible runtime-specific default
28
+ *
29
+ * `per-subscription` makes early subscriptions visible sooner and prevents a
30
+ * later large bootstrap table from hiding already-applied tables behind one
31
+ * long-running transaction.
32
+ */
33
+ bootstrapApplyMode?: 'auto' | SyncBootstrapApplyMode;
23
34
  /**
24
35
  * Custom SHA-256 hash function for snapshot chunk integrity verification.
25
36
  * Provide this on platforms where `crypto.subtle` is unavailable (e.g. React Native).
@@ -1 +1 @@
1
- {"version":3,"file":"pull-engine.d.ts","sourceRoot":"","sources":["../src/pull-engine.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAGV,UAAU,EACV,eAAe,EACf,gBAAgB,EAGhB,uBAAuB,EACvB,aAAa,EACd,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,KAAK,MAAM,EAAO,KAAK,WAAW,EAAE,MAAM,QAAQ,CAAC;AAC5D,OAAO,EACL,KAAK,uBAAuB,EAE7B,MAAM,uBAAuB,CAAC;AAE/B,OAAO,KAAK,EACV,gBAAgB,EAEjB,MAAM,iBAAiB,CAAC;AACzB,OAAO,KAAK,EAAE,YAAY,EAAE,0BAA0B,EAAE,MAAM,UAAU,CAAC;AA4oBzE,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC7B;;;OAGG;IACH,aAAa,EAAE,KAAK,CAAC,IAAI,CAAC,uBAAuB,EAAE,QAAQ,CAAC,CAAC,CAAC;IAC9D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACjD;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,eAAe,CAAC;IACzB,QAAQ,EAAE,0BAA0B,EAAE,CAAC;IACvC,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,0BAA0B,CAAC,CAAC;IACtD,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CAAC,EAAE,SAAS,YAAY,EAC5D,EAAE,EAAE,MAAM,CAAC,EAAE,CAAC,EACd,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,oBAAoB,CAAC,CAuC/B;AAED,wBAAgB,uBAAuB,CACrC,SAAS,EAAE,oBAAoB,EAC/B,QAAQ,EAAE,gBAAgB,GACzB,oBAAoB,CA2DtB;AAED,wBAAsB,6BAA6B,CAAC,EAAE,SAAS,YAAY,EACzE,QAAQ,EAAE,uBAAuB,CAAC,EAAE,CAAC,EACrC,GAAG,EAAE,WAAW,CAAC,EAAE,CAAC,EACpB,IAAI,EAAE;IACJ,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,GACA,OAAO,CAAC,IAAI,CAAC,CAkCf;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,EAAE,SAAS,YAAY,EAC7D,EAAE,EAAE,MAAM,CAAC,EAAE,CAAC,EACd,SAAS,EAAE,aAAa,EACxB,QAAQ,EAAE,uBAAuB,CAAC,EAAE,CAAC,EACrC,OAAO,EAAE,mBAAmB,EAC5B,SAAS,EAAE,oBAAoB,EAC/B,WAAW,EAAE,gBAAgB,GAC5B,OAAO,CAAC,gBAAgB,CAAC,CAyQ3B;AAED,wBAAsB,YAAY,CAAC,EAAE,SAAS,YAAY,EACxD,EAAE,EAAE,MAAM,CAAC,EAAE,CAAC,EACd,SAAS,EAAE,aAAa,EACxB,QAAQ,EAAE,uBAAuB,CAAC,EAAE,CAAC,EACrC,OAAO,EAAE,mBAAmB,EAC5B,iBAAiB,CAAC,EAAE,oBAAoB,GACvC,OAAO,CAAC,gBAAgB,CAAC,CAe3B"}
1
+ {"version":3,"file":"pull-engine.d.ts","sourceRoot":"","sources":["../src/pull-engine.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @syncular/client - Sync pull engine
3
3
  */
4
+ import { bytesToReadableStream, decodeSnapshotRows, gunzipBytes, readAllBytesFromStream as readAllBytesFromCoreStream, } from '@syncular/core';
4
5
  import { sql } from 'kysely';
5
6
  import { getClientHandlerOrThrow, } from './handlers/collection.js';
6
7
  // Simple JSON serialization cache to avoid repeated stringification
@@ -30,14 +31,6 @@ function serializeJsonCached(obj) {
30
31
  function isGzipBytes(bytes) {
31
32
  return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
32
33
  }
33
- function bytesToReadableStream(bytes) {
34
- return new ReadableStream({
35
- start(controller) {
36
- controller.enqueue(bytes);
37
- controller.close();
38
- },
39
- });
40
- }
41
34
  function concatBytes(chunks) {
42
35
  if (chunks.length === 1) {
43
36
  return chunks[0] ?? new Uint8Array();
@@ -111,7 +104,8 @@ async function maybeGunzipStream(stream) {
111
104
  if (typeof DecompressionStream !== 'undefined') {
112
105
  return replayStream.pipeThrough(new DecompressionStream('gzip'));
113
106
  }
114
- throw new Error('Snapshot chunk appears gzip-compressed but gzip decompression is not available in this runtime');
107
+ const compressedBytes = await readAllBytesFromCoreStream(replayStream);
108
+ return bytesToReadableStream(await gunzipBytes(compressedBytes));
115
109
  }
116
110
  async function* decodeSnapshotRowStreamBatches(stream, batchSize) {
117
111
  const reader = stream.getReader();
@@ -250,6 +244,20 @@ async function readAllBytesFromStream(stream) {
250
244
  return bytes;
251
245
  }
252
246
  async function materializeSnapshotChunkRows(transport, request, expectedHash, sha256Override) {
247
+ if (transport.capabilities?.snapshotChunkReadMode === 'bytes' &&
248
+ 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}`);
257
+ }
258
+ }
259
+ return decodeSnapshotRows(bytes);
260
+ }
253
261
  const rawStream = await fetchSnapshotChunkStream(transport, request);
254
262
  const decodedStream = await maybeGunzipStream(rawStream);
255
263
  let streamForDecode = decodedStream;
@@ -645,7 +653,9 @@ export async function applyPullResponse(db, transport, handlers, options, pullSt
645
653
  clientId: options.clientId,
646
654
  };
647
655
  const plugins = options.plugins ?? [];
648
- const requiresMaterializedSnapshots = plugins.some((plugin) => !!plugin.afterPull);
656
+ const requiresMaterializedSnapshots = plugins.some((plugin) => !!plugin.afterPull) ||
657
+ transport.capabilities?.preferMaterializedSnapshots === true;
658
+ const bootstrapApplyMode = resolveBootstrapApplyMode(options, rawResponse, transport.capabilities);
649
659
  let responseToApply = requiresMaterializedSnapshots
650
660
  ? await materializeChunkedSnapshots(transport, rawResponse, options.sha256)
651
661
  : rawResponse;
@@ -657,219 +667,253 @@ export async function applyPullResponse(db, transport, handlers, options, pullSt
657
667
  response: responseToApply,
658
668
  });
659
669
  }
670
+ const subsById = new Map();
671
+ for (const s of options.subscriptions ?? [])
672
+ subsById.set(s.id, s);
660
673
  await db.transaction().execute(async (trx) => {
661
- const desiredIds = new Set((options.subscriptions ?? []).map((s) => s.id));
662
- // Remove local data for subscriptions that are no longer desired.
663
- for (const row of existing) {
664
- if (desiredIds.has(row.subscription_id))
665
- continue;
666
- // Clear data for this table matching the subscription's scopes
667
- if (row.table) {
668
- try {
669
- const scopes = row.scopes_json
670
- ? typeof row.scopes_json === 'string'
671
- ? JSON.parse(row.scopes_json)
672
- : row.scopes_json
673
- : {};
674
- await getClientHandlerOrThrow(handlers, row.table).clearAll({
675
- trx,
676
- scopes,
677
- });
678
- }
679
- catch {
680
- // ignore missing table handler
681
- }
674
+ await removeUndesiredSubscriptions(trx, handlers, existing, options.subscriptions ?? [], stateId);
675
+ });
676
+ if (bootstrapApplyMode === 'per-subscription') {
677
+ for (const sub of responseToApply.subscriptions) {
678
+ await db.transaction().execute(async (trx) => {
679
+ await applySubscriptionResponse({
680
+ trx,
681
+ handlers,
682
+ transport,
683
+ options,
684
+ stateId,
685
+ existingById,
686
+ subsById,
687
+ sub,
688
+ });
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,
700
+ stateId,
701
+ existingById,
702
+ subsById,
703
+ sub,
704
+ });
705
+ }
706
+ });
707
+ }
708
+ return responseToApply;
709
+ }
710
+ function resolveBootstrapApplyMode(options, response, capabilities) {
711
+ const mode = options.bootstrapApplyMode ?? 'auto';
712
+ if (mode === 'single-transaction' || mode === 'per-subscription') {
713
+ return mode;
714
+ }
715
+ if (!response.subscriptions.some((sub) => sub.bootstrap)) {
716
+ return 'single-transaction';
717
+ }
718
+ if (capabilities?.preferredBootstrapApplyMode) {
719
+ return capabilities.preferredBootstrapApplyMode;
720
+ }
721
+ if (capabilities?.snapshotChunkReadMode === 'bytes' ||
722
+ capabilities?.gzipDecompressionMode === 'buffered') {
723
+ return 'per-subscription';
724
+ }
725
+ return 'single-transaction';
726
+ }
727
+ async function removeUndesiredSubscriptions(trx, handlers, existing, desiredSubscriptions, stateId) {
728
+ const desiredIds = new Set(desiredSubscriptions.map((subscription) => subscription.id));
729
+ for (const row of existing) {
730
+ if (desiredIds.has(row.subscription_id))
731
+ continue;
732
+ if (row.table) {
733
+ try {
734
+ const scopes = row.scopes_json
735
+ ? typeof row.scopes_json === 'string'
736
+ ? JSON.parse(row.scopes_json)
737
+ : row.scopes_json
738
+ : {};
739
+ await getClientHandlerOrThrow(handlers, row.table).clearAll({
740
+ trx,
741
+ scopes,
742
+ });
682
743
  }
683
- await sql `
684
- delete from ${sql.table('sync_subscription_state')}
685
- where ${sql.ref('state_id')} = ${sql.val(stateId)}
686
- and ${sql.ref('subscription_id')} = ${sql.val(row.subscription_id)}
687
- `.execute(trx);
688
- }
689
- const subsById = new Map();
690
- for (const s of options.subscriptions ?? [])
691
- subsById.set(s.id, s);
692
- const latestStateRows = await sql `
693
- select
694
- ${sql.ref('subscription_id')} as subscription_id,
695
- ${sql.ref('cursor')} as cursor
696
- from ${sql.table('sync_subscription_state')}
744
+ catch {
745
+ // ignore missing table handler
746
+ }
747
+ }
748
+ await sql `
749
+ delete from ${sql.table('sync_subscription_state')}
697
750
  where ${sql.ref('state_id')} = ${sql.val(stateId)}
751
+ and ${sql.ref('subscription_id')} = ${sql.val(row.subscription_id)}
698
752
  `.execute(trx);
699
- const latestCursorBySubscriptionId = new Map();
700
- for (const row of latestStateRows.rows) {
701
- const raw = row.cursor;
702
- const cursor = typeof raw === 'number'
703
- ? raw
704
- : raw === null || raw === undefined
705
- ? null
706
- : Number(raw);
707
- latestCursorBySubscriptionId.set(row.subscription_id, cursor);
708
- }
709
- for (const sub of responseToApply.subscriptions) {
710
- const def = subsById.get(sub.id);
711
- const prev = existingById.get(sub.id);
712
- const prevCursorRaw = prev?.cursor;
713
- const prevCursor = typeof prevCursorRaw === 'number'
714
- ? prevCursorRaw
715
- : prevCursorRaw === null || prevCursorRaw === undefined
716
- ? null
717
- : Number(prevCursorRaw);
718
- const latestCursorRaw = latestCursorBySubscriptionId.get(sub.id);
719
- const latestCursor = typeof latestCursorRaw === 'number'
720
- ? latestCursorRaw
721
- : latestCursorRaw === null || latestCursorRaw === undefined
722
- ? null
723
- : Number(latestCursorRaw);
724
- const effectiveCursor = prevCursor !== null &&
725
- Number.isFinite(prevCursor) &&
726
- latestCursor !== null &&
727
- Number.isFinite(latestCursor)
728
- ? Math.max(prevCursor, latestCursor)
729
- : prevCursor !== null && Number.isFinite(prevCursor)
730
- ? prevCursor
731
- : latestCursor !== null && Number.isFinite(latestCursor)
732
- ? latestCursor
733
- : null;
734
- const staleIncrementalResponse = !sub.bootstrap &&
735
- effectiveCursor !== null &&
736
- sub.nextCursor < effectiveCursor;
737
- // Guard against out-of-order duplicate pull responses from older requests.
738
- if (staleIncrementalResponse) {
739
- continue;
753
+ }
754
+ }
755
+ async function readLatestSubscriptionCursor(trx, stateId, subscriptionId) {
756
+ const result = await sql `
757
+ select ${sql.ref('cursor')} as cursor
758
+ from ${sql.table('sync_subscription_state')}
759
+ where ${sql.ref('state_id')} = ${sql.val(stateId)}
760
+ and ${sql.ref('subscription_id')} = ${sql.val(subscriptionId)}
761
+ limit 1
762
+ `.execute(trx);
763
+ const raw = result.rows[0]?.cursor;
764
+ return typeof raw === 'number'
765
+ ? raw
766
+ : raw === null || raw === undefined
767
+ ? null
768
+ : Number(raw);
769
+ }
770
+ async function applySubscriptionResponse(args) {
771
+ const { trx, handlers, transport, options, stateId, existingById, subsById, sub, } = args;
772
+ const def = subsById.get(sub.id);
773
+ const prev = existingById.get(sub.id);
774
+ const prevCursorRaw = prev?.cursor;
775
+ const prevCursor = typeof prevCursorRaw === 'number'
776
+ ? prevCursorRaw
777
+ : prevCursorRaw === null || prevCursorRaw === undefined
778
+ ? null
779
+ : Number(prevCursorRaw);
780
+ const latestCursor = await readLatestSubscriptionCursor(trx, stateId, sub.id);
781
+ const effectiveCursor = prevCursor !== null &&
782
+ Number.isFinite(prevCursor) &&
783
+ latestCursor !== null &&
784
+ Number.isFinite(latestCursor)
785
+ ? Math.max(prevCursor, latestCursor)
786
+ : prevCursor !== null && Number.isFinite(prevCursor)
787
+ ? prevCursor
788
+ : latestCursor !== null && Number.isFinite(latestCursor)
789
+ ? latestCursor
790
+ : null;
791
+ const staleIncrementalResponse = !sub.bootstrap &&
792
+ effectiveCursor !== null &&
793
+ sub.nextCursor < effectiveCursor;
794
+ if (staleIncrementalResponse) {
795
+ return;
796
+ }
797
+ if (sub.status === 'revoked') {
798
+ if (prev?.table) {
799
+ try {
800
+ const scopes = parseScopeValuesJson(prev.scopes_json);
801
+ await getClientHandlerOrThrow(handlers, prev.table).clearAll({
802
+ trx,
803
+ scopes,
804
+ });
740
805
  }
741
- // Revoked: clear data and drop the subscription row.
742
- if (sub.status === 'revoked') {
743
- if (prev?.table) {
744
- try {
745
- const scopes = parseScopeValuesJson(prev.scopes_json);
746
- await getClientHandlerOrThrow(handlers, prev.table).clearAll({
747
- trx,
748
- scopes,
749
- });
750
- }
751
- catch {
752
- // ignore missing handler
753
- }
754
- }
755
- await sql `
756
- delete from ${sql.table('sync_subscription_state')}
757
- where ${sql.ref('state_id')} = ${sql.val(stateId)}
758
- and ${sql.ref('subscription_id')} = ${sql.val(sub.id)}
759
- `.execute(trx);
760
- latestCursorBySubscriptionId.delete(sub.id);
761
- continue;
806
+ catch {
807
+ // ignore missing handler
762
808
  }
763
- const nextScopes = sub.scopes ?? def?.scopes ?? {};
764
- const previousScopes = parseScopeValuesJson(prev?.scopes_json);
765
- const scopesChanged = !scopeValuesEqual(previousScopes, nextScopes);
766
- if (sub.bootstrap && prev?.table && scopesChanged) {
767
- try {
768
- const clearScopes = resolveBootstrapClearScopes(previousScopes, nextScopes);
769
- if (clearScopes !== 'none') {
770
- await getClientHandlerOrThrow(handlers, prev.table).clearAll({
771
- trx,
772
- scopes: clearScopes ?? previousScopes,
773
- });
774
- }
775
- }
776
- catch {
777
- // ignore missing handler
778
- }
809
+ }
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
+ });
779
828
  }
780
- // Apply snapshots (bootstrap mode)
781
- if (sub.bootstrap) {
782
- for (const snapshot of sub.snapshots ?? []) {
783
- const handler = getClientHandlerOrThrow(handlers, snapshot.table);
784
- const hasChunkRefs = Array.isArray(snapshot.chunks) && snapshot.chunks.length > 0;
785
- // Call onSnapshotStart hook when starting a new snapshot
786
- if (snapshot.isFirstPage && handler.onSnapshotStart) {
787
- await handler.onSnapshotStart({
788
- trx,
789
- table: snapshot.table,
790
- scopes: sub.scopes,
791
- });
792
- }
793
- if (hasChunkRefs) {
794
- await applyChunkedSnapshot(transport, handler, trx, snapshot, sub.scopes, options.sha256);
795
- }
796
- else {
797
- await handler.applySnapshot({ trx }, snapshot);
798
- }
799
- // Call onSnapshotEnd hook when snapshot is complete
800
- if (snapshot.isLastPage && handler.onSnapshotEnd) {
801
- await handler.onSnapshotEnd({
802
- trx,
803
- table: snapshot.table,
804
- scopes: sub.scopes,
805
- });
806
- }
807
- }
829
+ }
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);
808
847
  }
809
848
  else {
810
- // Apply incremental changes
811
- for (const commit of sub.commits) {
812
- await applyIncrementalCommitChanges(handlers, trx, {
813
- changes: commit.changes,
814
- commitSeq: commit.commitSeq ?? null,
815
- actorId: commit.actorId ?? null,
816
- createdAt: commit.createdAt ?? null,
817
- });
818
- }
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,
856
+ });
819
857
  }
820
- // Persist subscription cursor + metadata.
821
- // Use cached JSON serialization to avoid repeated stringification
822
- const now = Date.now();
823
- const paramsJson = serializeJsonCached(def?.params ?? {});
824
- const scopesJson = serializeJsonCached(nextScopes);
825
- const bootstrapStateJson = sub.bootstrap
826
- ? sub.bootstrapState
827
- ? serializeJsonCached(sub.bootstrapState)
828
- : null
829
- : null;
830
- const table = def?.table ?? 'unknown';
831
- await sql `
832
- insert into ${sql.table('sync_subscription_state')} (
833
- ${sql.join([
834
- sql.ref('state_id'),
835
- sql.ref('subscription_id'),
836
- sql.ref('table'),
837
- sql.ref('scopes_json'),
838
- sql.ref('params_json'),
839
- sql.ref('cursor'),
840
- sql.ref('bootstrap_state_json'),
841
- sql.ref('status'),
842
- sql.ref('created_at'),
843
- sql.ref('updated_at'),
844
- ])}
845
- ) values (
846
- ${sql.join([
847
- sql.val(stateId),
848
- sql.val(sub.id),
849
- sql.val(table),
850
- sql.val(scopesJson),
851
- sql.val(paramsJson),
852
- sql.val(sub.nextCursor),
853
- sql.val(bootstrapStateJson),
854
- sql.val('active'),
855
- sql.val(now),
856
- sql.val(now),
857
- ])}
858
- )
859
- on conflict (${sql.join([sql.ref('state_id'), sql.ref('subscription_id')])})
860
- do update set
861
- ${sql.ref('table')} = ${sql.val(table)},
862
- ${sql.ref('scopes_json')} = ${sql.val(scopesJson)},
863
- ${sql.ref('params_json')} = ${sql.val(paramsJson)},
864
- ${sql.ref('cursor')} = ${sql.val(sub.nextCursor)},
865
- ${sql.ref('bootstrap_state_json')} = ${sql.val(bootstrapStateJson)},
866
- ${sql.ref('status')} = ${sql.val('active')},
867
- ${sql.ref('updated_at')} = ${sql.val(now)}
868
- `.execute(trx);
869
- latestCursorBySubscriptionId.set(sub.id, sub.nextCursor);
870
858
  }
871
- });
872
- return responseToApply;
859
+ }
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
+ }
869
+ }
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);
873
917
  }
874
918
  export async function syncPullOnce(db, transport, handlers, options, pullStateOverride) {
875
919
  const pullState = pullStateOverride ?? (await buildPullRequest(db, options));