@syncular/client 0.0.6-213 → 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.
@@ -2,16 +2,22 @@
2
2
  * @syncular/client - Sync pull engine
3
3
  */
4
4
 
5
- import type {
6
- ScopeValues,
7
- SyncBootstrapState,
8
- SyncChange,
9
- SyncPullRequest,
10
- SyncPullResponse,
11
- SyncPullSubscriptionResponse,
12
- SyncSnapshot,
13
- SyncSubscriptionRequest,
14
- SyncTransport,
5
+ import {
6
+ bytesToReadableStream,
7
+ decodeSnapshotRows,
8
+ gunzipBytes,
9
+ readAllBytesFromStream as readAllBytesFromCoreStream,
10
+ type ScopeValues,
11
+ type SyncBootstrapApplyMode,
12
+ type SyncBootstrapState,
13
+ type SyncChange,
14
+ type SyncPullRequest,
15
+ type SyncPullResponse,
16
+ type SyncPullSubscriptionResponse,
17
+ type SyncSnapshot,
18
+ type SyncSubscriptionRequest,
19
+ type SyncTransport,
20
+ type SyncTransportCapabilities,
15
21
  } from '@syncular/core';
16
22
  import { type Kysely, sql, type Transaction } from 'kysely';
17
23
  import {
@@ -55,15 +61,6 @@ function isGzipBytes(bytes: Uint8Array): boolean {
55
61
  return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
56
62
  }
57
63
 
58
- function bytesToReadableStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
59
- return new ReadableStream<Uint8Array>({
60
- start(controller) {
61
- controller.enqueue(bytes);
62
- controller.close();
63
- },
64
- });
65
- }
66
-
67
64
  function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
68
65
  if (chunks.length === 1) {
69
66
  return chunks[0] ?? new Uint8Array();
@@ -141,9 +138,8 @@ async function maybeGunzipStream(
141
138
  return replayStream.pipeThrough(new DecompressionStream('gzip'));
142
139
  }
143
140
 
144
- throw new Error(
145
- 'Snapshot chunk appears gzip-compressed but gzip decompression is not available in this runtime'
146
- );
141
+ const compressedBytes = await readAllBytesFromCoreStream(replayStream);
142
+ return bytesToReadableStream(await gunzipBytes(compressedBytes));
147
143
  }
148
144
 
149
145
  async function* decodeSnapshotRowStreamBatches(
@@ -338,6 +334,25 @@ async function materializeSnapshotChunkRows(
338
334
  expectedHash: string | undefined,
339
335
  sha256Override?: (bytes: Uint8Array) => Promise<string>
340
336
  ): Promise<unknown[]> {
337
+ if (
338
+ transport.capabilities?.snapshotChunkReadMode === 'bytes' &&
339
+ transport.fetchSnapshotChunk
340
+ ) {
341
+ let bytes = await transport.fetchSnapshotChunk(request);
342
+ if (isGzipBytes(bytes)) {
343
+ bytes = await gunzipBytes(bytes);
344
+ }
345
+ if (expectedHash) {
346
+ const actualHash = await computeSha256Hex(bytes, sha256Override);
347
+ if (actualHash !== expectedHash) {
348
+ throw new Error(
349
+ `Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`
350
+ );
351
+ }
352
+ }
353
+ return decodeSnapshotRows(bytes);
354
+ }
355
+
341
356
  const rawStream = await fetchSnapshotChunkStream(transport, request);
342
357
  const decodedStream = await maybeGunzipStream(rawStream);
343
358
  let streamForDecode = decodedStream;
@@ -689,6 +704,17 @@ export interface SyncPullOnceOptions {
689
704
  maxSnapshotPages?: number;
690
705
  dedupeRows?: boolean;
691
706
  stateId?: string;
707
+ /**
708
+ * Controls how bootstrap snapshot results are committed to the local DB.
709
+ * - `single-transaction`: all subscriptions in one DB transaction
710
+ * - `per-subscription`: each subscription in its own DB transaction
711
+ * - `auto`: choose a sensible runtime-specific default
712
+ *
713
+ * `per-subscription` makes early subscriptions visible sooner and prevents a
714
+ * later large bootstrap table from hiding already-applied tables behind one
715
+ * long-running transaction.
716
+ */
717
+ bootstrapApplyMode?: 'auto' | SyncBootstrapApplyMode;
692
718
  /**
693
719
  * Custom SHA-256 hash function for snapshot chunk integrity verification.
694
720
  * Provide this on platforms where `crypto.subtle` is unavailable (e.g. React Native).
@@ -880,8 +906,13 @@ export async function applyPullResponse<DB extends SyncClientDb>(
880
906
  clientId: options.clientId,
881
907
  };
882
908
  const plugins = options.plugins ?? [];
883
- const requiresMaterializedSnapshots = plugins.some(
884
- (plugin) => !!plugin.afterPull
909
+ const requiresMaterializedSnapshots =
910
+ plugins.some((plugin) => !!plugin.afterPull) ||
911
+ transport.capabilities?.preferMaterializedSnapshots === true;
912
+ const bootstrapApplyMode = resolveBootstrapApplyMode(
913
+ options,
914
+ rawResponse,
915
+ transport.capabilities
885
916
  );
886
917
 
887
918
  let responseToApply = requiresMaterializedSnapshots
@@ -895,248 +926,322 @@ export async function applyPullResponse<DB extends SyncClientDb>(
895
926
  });
896
927
  }
897
928
 
929
+ const subsById = new Map<string, (typeof options.subscriptions)[number]>();
930
+ for (const s of options.subscriptions ?? []) subsById.set(s.id, s);
931
+
898
932
  await db.transaction().execute(async (trx) => {
899
- const desiredIds = new Set((options.subscriptions ?? []).map((s) => s.id));
900
-
901
- // Remove local data for subscriptions that are no longer desired.
902
- for (const row of existing) {
903
- if (desiredIds.has(row.subscription_id)) continue;
904
-
905
- // Clear data for this table matching the subscription's scopes
906
- if (row.table) {
907
- try {
908
- const scopes = row.scopes_json
909
- ? typeof row.scopes_json === 'string'
910
- ? JSON.parse(row.scopes_json)
911
- : row.scopes_json
912
- : {};
913
- await getClientHandlerOrThrow(handlers, row.table).clearAll({
914
- trx,
915
- scopes,
916
- });
917
- } catch {
918
- // ignore missing table handler
919
- }
933
+ await removeUndesiredSubscriptions(
934
+ trx,
935
+ handlers,
936
+ existing,
937
+ options.subscriptions ?? [],
938
+ stateId
939
+ );
940
+ });
941
+
942
+ if (bootstrapApplyMode === 'per-subscription') {
943
+ for (const sub of responseToApply.subscriptions) {
944
+ await db.transaction().execute(async (trx) => {
945
+ await applySubscriptionResponse({
946
+ trx,
947
+ handlers,
948
+ transport,
949
+ options,
950
+ stateId,
951
+ existingById,
952
+ subsById,
953
+ sub,
954
+ });
955
+ });
956
+ }
957
+ } else {
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,
965
+ stateId,
966
+ existingById,
967
+ subsById,
968
+ sub,
969
+ });
920
970
  }
971
+ });
972
+ }
921
973
 
922
- await sql`
923
- delete from ${sql.table('sync_subscription_state')}
924
- where ${sql.ref('state_id')} = ${sql.val(stateId)}
925
- and ${sql.ref('subscription_id')} = ${sql.val(row.subscription_id)}
926
- `.execute(trx);
974
+ return responseToApply;
975
+ }
976
+
977
+ function resolveBootstrapApplyMode(
978
+ options: SyncPullOnceOptions,
979
+ response: SyncPullResponse,
980
+ capabilities?: SyncTransportCapabilities
981
+ ): SyncBootstrapApplyMode {
982
+ const mode = options.bootstrapApplyMode ?? 'auto';
983
+ if (mode === 'single-transaction' || mode === 'per-subscription') {
984
+ return mode;
985
+ }
986
+ if (!response.subscriptions.some((sub) => sub.bootstrap)) {
987
+ return 'single-transaction';
988
+ }
989
+ if (capabilities?.preferredBootstrapApplyMode) {
990
+ return capabilities.preferredBootstrapApplyMode;
991
+ }
992
+ if (
993
+ capabilities?.snapshotChunkReadMode === 'bytes' ||
994
+ capabilities?.gzipDecompressionMode === 'buffered'
995
+ ) {
996
+ return 'per-subscription';
997
+ }
998
+ return 'single-transaction';
999
+ }
1000
+
1001
+ async function removeUndesiredSubscriptions<DB extends SyncClientDb>(
1002
+ trx: Transaction<DB>,
1003
+ handlers: ClientHandlerCollection<DB>,
1004
+ existing: SyncSubscriptionStateTable[],
1005
+ desiredSubscriptions: Array<Omit<SyncSubscriptionRequest, 'cursor'>>,
1006
+ stateId: string
1007
+ ): Promise<void> {
1008
+ const desiredIds = new Set(
1009
+ desiredSubscriptions.map((subscription) => subscription.id)
1010
+ );
1011
+
1012
+ for (const row of existing) {
1013
+ if (desiredIds.has(row.subscription_id)) continue;
1014
+
1015
+ if (row.table) {
1016
+ try {
1017
+ const scopes = row.scopes_json
1018
+ ? typeof row.scopes_json === 'string'
1019
+ ? JSON.parse(row.scopes_json)
1020
+ : row.scopes_json
1021
+ : {};
1022
+ await getClientHandlerOrThrow(handlers, row.table).clearAll({
1023
+ trx,
1024
+ scopes,
1025
+ });
1026
+ } catch {
1027
+ // ignore missing table handler
1028
+ }
927
1029
  }
928
1030
 
929
- const subsById = new Map<string, (typeof options.subscriptions)[number]>();
930
- for (const s of options.subscriptions ?? []) subsById.set(s.id, s);
931
- const latestStateRows = await sql<{
932
- subscription_id: string;
933
- cursor: number | string | null;
934
- }>`
935
- select
936
- ${sql.ref('subscription_id')} as subscription_id,
937
- ${sql.ref('cursor')} as cursor
938
- from ${sql.table('sync_subscription_state')}
1031
+ await sql`
1032
+ delete from ${sql.table('sync_subscription_state')}
939
1033
  where ${sql.ref('state_id')} = ${sql.val(stateId)}
1034
+ and ${sql.ref('subscription_id')} = ${sql.val(row.subscription_id)}
940
1035
  `.execute(trx);
941
- const latestCursorBySubscriptionId = new Map<string, number | null>();
942
- for (const row of latestStateRows.rows) {
943
- const raw = row.cursor;
944
- const cursor =
945
- typeof raw === 'number'
946
- ? raw
947
- : raw === null || raw === undefined
948
- ? null
949
- : Number(raw);
950
- latestCursorBySubscriptionId.set(row.subscription_id, cursor);
951
- }
1036
+ }
1037
+ }
952
1038
 
953
- for (const sub of responseToApply.subscriptions) {
954
- const def = subsById.get(sub.id);
955
- const prev = existingById.get(sub.id);
956
- const prevCursorRaw = prev?.cursor;
957
- const prevCursor =
958
- typeof prevCursorRaw === 'number'
959
- ? prevCursorRaw
960
- : prevCursorRaw === null || prevCursorRaw === undefined
961
- ? null
962
- : Number(prevCursorRaw);
963
- const latestCursorRaw = latestCursorBySubscriptionId.get(sub.id);
964
- const latestCursor =
965
- typeof latestCursorRaw === 'number'
966
- ? latestCursorRaw
967
- : latestCursorRaw === null || latestCursorRaw === undefined
968
- ? null
969
- : Number(latestCursorRaw);
970
- const effectiveCursor =
971
- prevCursor !== null &&
972
- Number.isFinite(prevCursor) &&
973
- latestCursor !== null &&
974
- Number.isFinite(latestCursor)
975
- ? Math.max(prevCursor, latestCursor)
976
- : prevCursor !== null && Number.isFinite(prevCursor)
977
- ? prevCursor
978
- : latestCursor !== null && Number.isFinite(latestCursor)
979
- ? latestCursor
980
- : null;
981
- const staleIncrementalResponse =
982
- !sub.bootstrap &&
983
- effectiveCursor !== null &&
984
- sub.nextCursor < effectiveCursor;
985
-
986
- // Guard against out-of-order duplicate pull responses from older requests.
987
- if (staleIncrementalResponse) {
988
- continue;
989
- }
1039
+ async function readLatestSubscriptionCursor<DB extends SyncClientDb>(
1040
+ trx: Transaction<DB>,
1041
+ stateId: string,
1042
+ subscriptionId: string
1043
+ ): Promise<number | null> {
1044
+ const result = await sql<{ cursor: number | string | null }>`
1045
+ select ${sql.ref('cursor')} as cursor
1046
+ from ${sql.table('sync_subscription_state')}
1047
+ where ${sql.ref('state_id')} = ${sql.val(stateId)}
1048
+ and ${sql.ref('subscription_id')} = ${sql.val(subscriptionId)}
1049
+ limit 1
1050
+ `.execute(trx);
1051
+ const raw = result.rows[0]?.cursor;
1052
+ return typeof raw === 'number'
1053
+ ? raw
1054
+ : raw === null || raw === undefined
1055
+ ? null
1056
+ : Number(raw);
1057
+ }
990
1058
 
991
- // Revoked: clear data and drop the subscription row.
992
- if (sub.status === 'revoked') {
993
- if (prev?.table) {
994
- try {
995
- const scopes = parseScopeValuesJson(prev.scopes_json);
996
- await getClientHandlerOrThrow(handlers, prev.table).clearAll({
997
- trx,
998
- scopes,
999
- });
1000
- } catch {
1001
- // ignore missing handler
1002
- }
1003
- }
1059
+ async function applySubscriptionResponse<DB extends SyncClientDb>(args: {
1060
+ trx: Transaction<DB>;
1061
+ handlers: ClientHandlerCollection<DB>;
1062
+ transport: SyncTransport;
1063
+ options: SyncPullOnceOptions;
1064
+ stateId: string;
1065
+ existingById: Map<string, SyncSubscriptionStateTable>;
1066
+ subsById: Map<string, Omit<SyncSubscriptionRequest, 'cursor'> | undefined>;
1067
+ sub: SyncPullSubscriptionResponse;
1068
+ }): Promise<void> {
1069
+ const {
1070
+ trx,
1071
+ handlers,
1072
+ transport,
1073
+ options,
1074
+ stateId,
1075
+ existingById,
1076
+ subsById,
1077
+ sub,
1078
+ } = args;
1079
+ const def = subsById.get(sub.id);
1080
+ const prev = existingById.get(sub.id);
1081
+ const prevCursorRaw = prev?.cursor;
1082
+ const prevCursor =
1083
+ typeof prevCursorRaw === 'number'
1084
+ ? prevCursorRaw
1085
+ : prevCursorRaw === null || prevCursorRaw === undefined
1086
+ ? null
1087
+ : Number(prevCursorRaw);
1088
+ const latestCursor = await readLatestSubscriptionCursor(trx, stateId, sub.id);
1089
+ const effectiveCursor =
1090
+ prevCursor !== null &&
1091
+ Number.isFinite(prevCursor) &&
1092
+ latestCursor !== null &&
1093
+ Number.isFinite(latestCursor)
1094
+ ? Math.max(prevCursor, latestCursor)
1095
+ : prevCursor !== null && Number.isFinite(prevCursor)
1096
+ ? prevCursor
1097
+ : latestCursor !== null && Number.isFinite(latestCursor)
1098
+ ? latestCursor
1099
+ : null;
1100
+ const staleIncrementalResponse =
1101
+ !sub.bootstrap &&
1102
+ effectiveCursor !== null &&
1103
+ sub.nextCursor < effectiveCursor;
1104
+
1105
+ if (staleIncrementalResponse) {
1106
+ return;
1107
+ }
1004
1108
 
1005
- await sql`
1006
- delete from ${sql.table('sync_subscription_state')}
1007
- where ${sql.ref('state_id')} = ${sql.val(stateId)}
1008
- and ${sql.ref('subscription_id')} = ${sql.val(sub.id)}
1009
- `.execute(trx);
1010
- latestCursorBySubscriptionId.delete(sub.id);
1011
- continue;
1109
+ if (sub.status === 'revoked') {
1110
+ if (prev?.table) {
1111
+ try {
1112
+ const scopes = parseScopeValuesJson(prev.scopes_json);
1113
+ await getClientHandlerOrThrow(handlers, prev.table).clearAll({
1114
+ trx,
1115
+ scopes,
1116
+ });
1117
+ } catch {
1118
+ // ignore missing handler
1012
1119
  }
1120
+ }
1121
+
1122
+ await sql`
1123
+ delete from ${sql.table('sync_subscription_state')}
1124
+ where ${sql.ref('state_id')} = ${sql.val(stateId)}
1125
+ and ${sql.ref('subscription_id')} = ${sql.val(sub.id)}
1126
+ `.execute(trx);
1127
+ return;
1128
+ }
1013
1129
 
1014
- const nextScopes = sub.scopes ?? def?.scopes ?? {};
1015
- const previousScopes = parseScopeValuesJson(prev?.scopes_json);
1016
- const scopesChanged = !scopeValuesEqual(previousScopes, nextScopes);
1130
+ const nextScopes = sub.scopes ?? def?.scopes ?? {};
1131
+ const previousScopes = parseScopeValuesJson(prev?.scopes_json);
1132
+ const scopesChanged = !scopeValuesEqual(previousScopes, nextScopes);
1017
1133
 
1018
- if (sub.bootstrap && prev?.table && scopesChanged) {
1019
- try {
1020
- const clearScopes = resolveBootstrapClearScopes(
1021
- previousScopes,
1022
- nextScopes
1023
- );
1024
- if (clearScopes !== 'none') {
1025
- await getClientHandlerOrThrow(handlers, prev.table).clearAll({
1026
- trx,
1027
- scopes: clearScopes ?? previousScopes,
1028
- });
1029
- }
1030
- } catch {
1031
- // ignore missing handler
1032
- }
1134
+ if (sub.bootstrap && prev?.table && scopesChanged) {
1135
+ try {
1136
+ const clearScopes = resolveBootstrapClearScopes(
1137
+ previousScopes,
1138
+ nextScopes
1139
+ );
1140
+ if (clearScopes !== 'none') {
1141
+ await getClientHandlerOrThrow(handlers, prev.table).clearAll({
1142
+ trx,
1143
+ scopes: clearScopes ?? previousScopes,
1144
+ });
1033
1145
  }
1146
+ } catch {
1147
+ // ignore missing handler
1148
+ }
1149
+ }
1034
1150
 
1035
- // Apply snapshots (bootstrap mode)
1036
- if (sub.bootstrap) {
1037
- for (const snapshot of sub.snapshots ?? []) {
1038
- const handler = getClientHandlerOrThrow(handlers, snapshot.table);
1039
- const hasChunkRefs =
1040
- Array.isArray(snapshot.chunks) && snapshot.chunks.length > 0;
1041
-
1042
- // Call onSnapshotStart hook when starting a new snapshot
1043
- if (snapshot.isFirstPage && handler.onSnapshotStart) {
1044
- await handler.onSnapshotStart({
1045
- trx,
1046
- table: snapshot.table,
1047
- scopes: sub.scopes,
1048
- });
1049
- }
1050
-
1051
- if (hasChunkRefs) {
1052
- await applyChunkedSnapshot(
1053
- transport,
1054
- handler,
1055
- trx,
1056
- snapshot,
1057
- sub.scopes,
1058
- options.sha256
1059
- );
1060
- } else {
1061
- await handler.applySnapshot({ trx }, snapshot);
1062
- }
1151
+ if (sub.bootstrap) {
1152
+ for (const snapshot of sub.snapshots ?? []) {
1153
+ const handler = getClientHandlerOrThrow(handlers, snapshot.table);
1154
+ const hasChunkRefs =
1155
+ Array.isArray(snapshot.chunks) && snapshot.chunks.length > 0;
1156
+
1157
+ if (snapshot.isFirstPage && handler.onSnapshotStart) {
1158
+ await handler.onSnapshotStart({
1159
+ trx,
1160
+ table: snapshot.table,
1161
+ scopes: sub.scopes,
1162
+ });
1163
+ }
1063
1164
 
1064
- // Call onSnapshotEnd hook when snapshot is complete
1065
- if (snapshot.isLastPage && handler.onSnapshotEnd) {
1066
- await handler.onSnapshotEnd({
1067
- trx,
1068
- table: snapshot.table,
1069
- scopes: sub.scopes,
1070
- });
1071
- }
1072
- }
1165
+ if (hasChunkRefs) {
1166
+ await applyChunkedSnapshot(
1167
+ transport,
1168
+ handler,
1169
+ trx,
1170
+ snapshot,
1171
+ sub.scopes,
1172
+ options.sha256
1173
+ );
1073
1174
  } else {
1074
- // Apply incremental changes
1075
- for (const commit of sub.commits) {
1076
- await applyIncrementalCommitChanges(handlers, trx, {
1077
- changes: commit.changes,
1078
- commitSeq: commit.commitSeq ?? null,
1079
- actorId: commit.actorId ?? null,
1080
- createdAt: commit.createdAt ?? null,
1081
- });
1082
- }
1175
+ await handler.applySnapshot({ trx }, snapshot);
1083
1176
  }
1084
1177
 
1085
- // Persist subscription cursor + metadata.
1086
- // Use cached JSON serialization to avoid repeated stringification
1087
- const now = Date.now();
1088
- const paramsJson = serializeJsonCached(def?.params ?? {});
1089
- const scopesJson = serializeJsonCached(nextScopes);
1090
- const bootstrapStateJson = sub.bootstrap
1091
- ? sub.bootstrapState
1092
- ? serializeJsonCached(sub.bootstrapState)
1093
- : null
1094
- : null;
1095
-
1096
- const table = def?.table ?? 'unknown';
1097
- await sql`
1098
- insert into ${sql.table('sync_subscription_state')} (
1099
- ${sql.join([
1100
- sql.ref('state_id'),
1101
- sql.ref('subscription_id'),
1102
- sql.ref('table'),
1103
- sql.ref('scopes_json'),
1104
- sql.ref('params_json'),
1105
- sql.ref('cursor'),
1106
- sql.ref('bootstrap_state_json'),
1107
- sql.ref('status'),
1108
- sql.ref('created_at'),
1109
- sql.ref('updated_at'),
1110
- ])}
1111
- ) values (
1112
- ${sql.join([
1113
- sql.val(stateId),
1114
- sql.val(sub.id),
1115
- sql.val(table),
1116
- sql.val(scopesJson),
1117
- sql.val(paramsJson),
1118
- sql.val(sub.nextCursor),
1119
- sql.val(bootstrapStateJson),
1120
- sql.val('active'),
1121
- sql.val(now),
1122
- sql.val(now),
1123
- ])}
1124
- )
1125
- on conflict (${sql.join([sql.ref('state_id'), sql.ref('subscription_id')])})
1126
- do update set
1127
- ${sql.ref('table')} = ${sql.val(table)},
1128
- ${sql.ref('scopes_json')} = ${sql.val(scopesJson)},
1129
- ${sql.ref('params_json')} = ${sql.val(paramsJson)},
1130
- ${sql.ref('cursor')} = ${sql.val(sub.nextCursor)},
1131
- ${sql.ref('bootstrap_state_json')} = ${sql.val(bootstrapStateJson)},
1132
- ${sql.ref('status')} = ${sql.val('active')},
1133
- ${sql.ref('updated_at')} = ${sql.val(now)}
1134
- `.execute(trx);
1135
- latestCursorBySubscriptionId.set(sub.id, sub.nextCursor);
1178
+ if (snapshot.isLastPage && handler.onSnapshotEnd) {
1179
+ await handler.onSnapshotEnd({
1180
+ trx,
1181
+ table: snapshot.table,
1182
+ scopes: sub.scopes,
1183
+ });
1184
+ }
1136
1185
  }
1137
- });
1186
+ } else {
1187
+ for (const commit of sub.commits) {
1188
+ await applyIncrementalCommitChanges(handlers, trx, {
1189
+ changes: commit.changes,
1190
+ commitSeq: commit.commitSeq ?? null,
1191
+ actorId: commit.actorId ?? null,
1192
+ createdAt: commit.createdAt ?? null,
1193
+ });
1194
+ }
1195
+ }
1138
1196
 
1139
- return responseToApply;
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);
1140
1245
  }
1141
1246
 
1142
1247
  export async function syncPullOnce<DB extends SyncClientDb>(