@tanstack/electric-db-collection 0.4.10 → 0.5.1

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.
@@ -7,7 +7,6 @@ const errors = require("./errors.cjs");
7
7
  const sqlCompiler = require("./sql-compiler.cjs");
8
8
  const tagIndex = require("./tag-index.cjs");
9
9
  const debug = DebugModule.debug(`ts/db:electric`);
10
- const FORCE_DISCONNECT_AND_REFRESH_TIMEOUT_MS = 250;
11
10
  const ELECTRIC_TEST_HOOKS = /* @__PURE__ */ Symbol(`electricTestHooks`);
12
11
  function exportElectricSyncMeta(evidence) {
13
12
  const resume = evidence.hydratedResumeState;
@@ -159,6 +158,7 @@ function createLoadSubsetDedupe({
159
158
  commit,
160
159
  getCommitCursor,
161
160
  waitForCommitsAfter,
161
+ recordSnapshotRow,
162
162
  collectionId,
163
163
  encodeColumnName,
164
164
  signal
@@ -201,7 +201,9 @@ function createLoadSubsetDedupe({
201
201
  metadata: { ...row.headers }
202
202
  });
203
203
  }
204
- await commit(opts.signal);
204
+ const applied = commit(opts.signal);
205
+ for (const row of rows) recordSnapshotRow?.(row.value, row);
206
+ if (applied !== true) await applied;
205
207
  debug(`${logPrefix}Applied snapshot with ${rows.length} rows`);
206
208
  }
207
209
  } catch (error) {
@@ -217,47 +219,6 @@ function createLoadSubsetDedupe({
217
219
  return;
218
220
  }
219
221
  const { cursor, where, orderBy, limit } = opts;
220
- if (stream.isUpToDate) {
221
- let timeoutId;
222
- const abortSignals = [signal, opts.signal].filter(
223
- (candidate) => candidate !== void 0
224
- );
225
- let rejectAbort = () => {
226
- };
227
- const aborted = new Promise((_resolve, reject) => {
228
- rejectAbort = reject;
229
- });
230
- const abort = (event) => rejectAbort(abortReason(event.currentTarget));
231
- for (const abortSignal of abortSignals) {
232
- abortSignal.addEventListener(`abort`, abort, { once: true });
233
- }
234
- try {
235
- await Promise.race([
236
- stream.forceDisconnectAndRefresh(),
237
- new Promise((resolve) => {
238
- timeoutId = setTimeout(
239
- resolve,
240
- FORCE_DISCONNECT_AND_REFRESH_TIMEOUT_MS
241
- );
242
- }),
243
- aborted
244
- ]);
245
- } catch (error) {
246
- if (signal.aborted || opts.signal?.aborted) throw error;
247
- if (handleSnapshotError(error, `forceDisconnectAndRefresh`)) {
248
- return;
249
- }
250
- debug(
251
- `${logPrefix}forceDisconnectAndRefresh failed, proceeding to requestSnapshot: %o`,
252
- error
253
- );
254
- } finally {
255
- clearTimeout(timeoutId);
256
- for (const abortSignal of abortSignals) {
257
- abortSignal.removeEventListener(`abort`, abort);
258
- }
259
- }
260
- }
261
222
  throwIfAborted();
262
223
  try {
263
224
  if (cursor) {
@@ -377,7 +338,7 @@ class ElectricLifecycle {
377
338
  waiter.resolve(true);
378
339
  }
379
340
  }
380
- async awaitTxId(txId, timeout = 5e3) {
341
+ async awaitTxId(txId, timeout = 15e3) {
381
342
  debug(
382
343
  `${this.collectionId ? `[${this.collectionId}] ` : ``}awaitTxId called with txid %d`,
383
344
  txId
@@ -400,7 +361,7 @@ class ElectricLifecycle {
400
361
  });
401
362
  });
402
363
  }
403
- async awaitMatch(matchFn, timeout = 3e3) {
364
+ async awaitMatch(matchFn, timeout = 15e3) {
404
365
  debug(
405
366
  `${this.collectionId ? `[${this.collectionId}] ` : ``}awaitMatch called with custom function`
406
367
  );
@@ -496,12 +457,20 @@ function electricCollectionOptions(config) {
496
457
  });
497
458
  const sync = createSync();
498
459
  const processMatchingStrategy = async (result, waitForTxId) => {
499
- if (result && `txid` in result) {
500
- const timeout = result.timeout;
501
- if (Array.isArray(result.txid)) {
502
- await Promise.all(result.txid.map((txid) => waitForTxId(txid, timeout)));
460
+ const canHaveProperties = typeof result === `object` && result !== null || typeof result === `function`;
461
+ if (canHaveProperties && `txid` in result) {
462
+ const strategy = result;
463
+ db.warnOnce(
464
+ "electric-collection-txid-return",
465
+ "[TanStack DB] DEPRECATED: Returning { txid } from mutation handlers is deprecated and will be removed in v1.0. Use `await collection.utils.awaitTxId(txid)` instead of returning { txid }. See migration guide: https://tanstack.com/db/latest/docs/collections/electric-collection#persistence-handlers--synchronization"
466
+ );
467
+ const timeout = strategy.timeout;
468
+ if (Array.isArray(strategy.txid)) {
469
+ await Promise.all(
470
+ strategy.txid.map((txid) => waitForTxId(txid, timeout))
471
+ );
503
472
  } else {
504
- await waitForTxId(result.txid, timeout);
473
+ await waitForTxId(strategy.txid, timeout);
505
474
  }
506
475
  }
507
476
  };
@@ -515,7 +484,6 @@ function electricCollectionOptions(config) {
515
484
  handlerResult,
516
485
  getMutationAwaitTxId(params)
517
486
  );
518
- return handlerResult;
519
487
  } : void 0;
520
488
  const wrappedOnUpdate = config.onUpdate ? async (params) => {
521
489
  const handlerResult = await config.onUpdate(params);
@@ -523,7 +491,6 @@ function electricCollectionOptions(config) {
523
491
  handlerResult,
524
492
  getMutationAwaitTxId(params)
525
493
  );
526
- return handlerResult;
527
494
  } : void 0;
528
495
  const wrappedOnDelete = config.onDelete ? async (params) => {
529
496
  const handlerResult = await config.onDelete(params);
@@ -531,7 +498,6 @@ function electricCollectionOptions(config) {
531
498
  handlerResult,
532
499
  getMutationAwaitTxId(params)
533
500
  );
534
- return handlerResult;
535
501
  } : void 0;
536
502
  const {
537
503
  shapeOptions: _shapeOptions,
@@ -602,7 +568,6 @@ function electricCollectionOptions(config) {
602
568
  function createElectricSync(shapeOptions, options) {
603
569
  const { getLifecycle, syncMode, collectionId, testHooks } = options;
604
570
  let relationSchema;
605
- let warnedUnverifiableResume = false;
606
571
  const createTagState = () => {
607
572
  const tagCache = /* @__PURE__ */ new Map();
608
573
  const parseTag = (tag) => {
@@ -844,9 +809,13 @@ function createElectricSync(shapeOptions, options) {
844
809
  const persistedResumeState2 = metadata?.collection.get(`electric:resume`);
845
810
  return parseElectricResumeState(persistedResumeState2);
846
811
  };
847
- const persistedMetadata = metadata;
848
- const scanPersisted = persistedMetadata?.row.scanPersisted;
849
- const whenHydrated = persistedMetadata?.row.whenHydrated;
812
+ const persistence = metadata === void 0 ? null : db.validateSyncPersistenceCapability(metadata.persistence);
813
+ const hydrateBaseline = persistence?.hydrateBaseline;
814
+ const resumeSnapshot = persistence?.resumeSnapshot;
815
+ const certifyResumeSnapshot = resumeSnapshot?.certify;
816
+ const getKeySetEvidence = resumeSnapshot?.getKeySetEvidence;
817
+ const expectCurrentCommit = resumeSnapshot?.expectCurrentCommit;
818
+ const persistedKeySetEvidence = getKeySetEvidence?.();
850
819
  const persistedResumeState = getNewestElectricResumeState(
851
820
  readPersistedResumeState(),
852
821
  lifecycle.resumeState
@@ -856,24 +825,19 @@ function createElectricSync(shapeOptions, options) {
856
825
  params: shapeOptions.params
857
826
  });
858
827
  const hasIncompatiblePersistedResume = persistedResumeState?.kind === `resume` && persistedResumeState.shapeId !== shapeIdentity;
859
- const hasUnverifiablePersistedResume = shapeOptions.offset === void 0 && shapeOptions.handle === void 0 && persistedResumeState?.kind === `resume` && scanPersisted !== void 0 && whenHydrated === void 0;
860
- if (hasUnverifiablePersistedResume && !warnedUnverifiableResume) {
861
- warnedUnverifiableResume = true;
862
- console.warn(
863
- `Electric persistence cannot verify hydration for saved resume state. Update the persistence adapter alongside Electric to enable safe resume.`
864
- );
865
- }
866
- const needsFullSnapshot = shapeOptions.offset === void 0 && shapeOptions.handle === void 0 && persistedResumeState !== void 0 && (persistedResumeState.kind === `reset` || !retainsTagState && persistedResumeState.requiresTagState !== false);
867
- const canUsePersistedResume = shapeOptions.offset === void 0 && shapeOptions.handle === void 0 && persistedResumeState?.kind === `resume` && !hasIncompatiblePersistedResume && !hasUnverifiablePersistedResume && // Cached rows do not contain authoritative tag/active-condition state.
868
- // Unknown (older) metadata is conservative; untagged shapes still resume.
828
+ const lacksCompletePersistedKeySet = persistedResumeState?.kind === `resume` && getKeySetEvidence !== void 0 && persistedKeySetEvidence?.status !== `consistent`;
829
+ const needsFullSnapshot = shapeOptions.offset === void 0 && shapeOptions.handle === void 0 && persistedResumeState !== void 0 && (persistedResumeState.kind === `reset` || lacksCompletePersistedKeySet || !retainsTagState && persistedResumeState.requiresTagState !== false);
830
+ const canUsePersistedResume = shapeOptions.offset === void 0 && shapeOptions.handle === void 0 && persistedResumeState?.kind === `resume` && !hasIncompatiblePersistedResume && // Cached rows do not contain authoritative tag/active-condition state.
831
+ // Only a complete adapter ledger can justify a persisted cursor.
869
832
  !needsFullSnapshot;
870
833
  const hasExplicitResumeOffset = shapeOptions.offset !== void 0 && shapeOptions.offset !== `-1`;
871
834
  if (!canUsePersistedResume && !hasExplicitResumeOffset) {
872
835
  clearTagTrackingState();
873
836
  }
874
837
  const receivesCompleteRows = shapeOptions.params?.replica === `full`;
838
+ const requiresKeySetCertification = canUsePersistedResume && certifyResumeSnapshot !== void 0;
875
839
  const requiresCompleteResume = syncMode !== `on-demand` && (canUsePersistedResume || hasExplicitResumeOffset && !receivesCompleteRows);
876
- let freshSnapshotPending = (syncMode === `eager` || needsFullSnapshot) && !canUsePersistedResume && !hasExplicitResumeOffset && whenHydrated !== void 0;
840
+ let freshSnapshotPending = (syncMode === `eager` || needsFullSnapshot) && !canUsePersistedResume && !hasExplicitResumeOffset && hydrateBaseline !== void 0;
877
841
  let progressiveReadyGate = null;
878
842
  let streamErrorVersion = 0;
879
843
  const wrappedMarkReady = (isBuffering, expectedErrorVersion = streamErrorVersion) => {
@@ -940,6 +904,39 @@ function createElectricSync(shapeOptions, options) {
940
904
  let resetGeneration = 0;
941
905
  const isBufferingInitialSync = () => syncMode === `progressive` && !hasReceivedUpToDate && !isResettingSnapshot;
942
906
  const bufferedMessages = [];
907
+ const progressiveSnapshotRows = /* @__PURE__ */ new Map();
908
+ const pendingPresence = /* @__PURE__ */ new Map();
909
+ let usesBaseline = true;
910
+ let pendingPresenceRevision = 0;
911
+ const recordPendingPresence = (rowId, present) => {
912
+ pendingPresenceRevision++;
913
+ pendingPresence.set(rowId, present);
914
+ };
915
+ const recordPendingTruncate = () => {
916
+ pendingPresenceRevision++;
917
+ pendingPresence.clear();
918
+ progressiveSnapshotRows.clear();
919
+ usesBaseline = false;
920
+ };
921
+ const commitSourceTransaction = () => {
922
+ const committedPresenceRevision = pendingPresenceRevision;
923
+ const applied = commit();
924
+ if (metadata?.persistence) {
925
+ const retireAppliedPresence = () => {
926
+ if (pendingPresenceRevision === committedPresenceRevision) {
927
+ pendingPresence.clear();
928
+ usesBaseline = true;
929
+ }
930
+ };
931
+ if (applied === true) retireAppliedPresence();
932
+ else void applied.then(retireAppliedPresence, () => void 0);
933
+ }
934
+ return applied;
935
+ };
936
+ const beginSourceTransaction = () => {
937
+ if (metadata?.persistence) begin({ immediate: true });
938
+ else begin();
939
+ };
943
940
  const syncedKeys = /* @__PURE__ */ new Set();
944
941
  let resumeInvalid = false;
945
942
  const stageResumeMetadata = () => {
@@ -962,7 +959,7 @@ function createElectricSync(shapeOptions, options) {
962
959
  lifecycle.resumeState = resumeState;
963
960
  metadata?.collection.set(`electric:resume`, resumeState);
964
961
  };
965
- const commitResetResumeMetadataImmediately = () => {
962
+ const commitResetResumeMetadataImmediately = (expectInResumeSnapshot = false) => {
966
963
  const resetState = {
967
964
  kind: `reset`,
968
965
  updatedAt: Date.now()
@@ -971,11 +968,14 @@ function createElectricSync(shapeOptions, options) {
971
968
  if (metadata) {
972
969
  begin({ immediate: true });
973
970
  metadata.collection.set(`electric:resume`, resetState);
971
+ if (expectInResumeSnapshot) {
972
+ expectCurrentCommit?.();
973
+ }
974
974
  commit();
975
975
  }
976
976
  };
977
- if (hasIncompatiblePersistedResume || hasUnverifiablePersistedResume || needsFullSnapshot && persistedResumeState.kind === `resume`) {
978
- commitResetResumeMetadataImmediately();
977
+ if (hasIncompatiblePersistedResume || needsFullSnapshot && persistedResumeState.kind === `resume`) {
978
+ commitResetResumeMetadataImmediately(true);
979
979
  }
980
980
  const processChangeMessage = (changeMessage) => {
981
981
  if (!client.isChangeMessage(changeMessage)) {
@@ -1022,6 +1022,10 @@ function createElectricSync(shapeOptions, options) {
1022
1022
  commit,
1023
1023
  getCommitCursor: () => commitSequence,
1024
1024
  waitForCommitsAfter,
1025
+ recordSnapshotRow: (row, message) => {
1026
+ recordPendingPresence(collection.getKeyFromItem(row), true);
1027
+ progressiveSnapshotRows.set(collection.getKeyFromItem(row), message);
1028
+ },
1025
1029
  collectionId,
1026
1030
  // Pass the columnMapper's encode function to transform column names
1027
1031
  // (e.g., camelCase to snake_case) when compiling SQL for subset queries
@@ -1029,7 +1033,23 @@ function createElectricSync(shapeOptions, options) {
1029
1033
  // Pass abort signal so requestSnapshot errors can be ignored during cleanup
1030
1034
  signal: abortController.signal
1031
1035
  });
1032
- const resumeKeysPromise = requiresCompleteResume || freshSnapshotPending ? whenHydrated?.() : void 0;
1036
+ const resumeKeysPromise = requiresCompleteResume || freshSnapshotPending ? hydrateBaseline ? (async () => {
1037
+ await hydrateBaseline();
1038
+ const currentKeySetEvidence = getKeySetEvidence?.();
1039
+ if (canUsePersistedResume && currentKeySetEvidence?.status !== `consistent`) {
1040
+ throw new Error(
1041
+ `Electric persisted resume baseline could not be certified during hydration`
1042
+ );
1043
+ }
1044
+ })() : void 0 : requiresKeySetCertification ? (async () => {
1045
+ await certifyResumeSnapshot();
1046
+ const currentKeySetEvidence = getKeySetEvidence?.();
1047
+ if (currentKeySetEvidence?.status !== `consistent`) {
1048
+ throw new Error(
1049
+ `Electric persisted resume baseline could not be certified`
1050
+ );
1051
+ }
1052
+ })() : void 0;
1033
1053
  let areResumeKeysReady = !resumeKeysPromise;
1034
1054
  const pendingResumeBatches = [];
1035
1055
  let unsubscribeStream = () => {
@@ -1061,31 +1081,34 @@ function createElectricSync(shapeOptions, options) {
1061
1081
  }
1062
1082
  if (freshSnapshotPending) {
1063
1083
  freshSnapshotPending = false;
1064
- begin();
1084
+ beginSourceTransaction();
1065
1085
  transactionStarted = true;
1066
1086
  truncate();
1087
+ recordPendingTruncate();
1067
1088
  syncedKeys.clear();
1068
1089
  clearTagTrackingState();
1069
1090
  isResettingSnapshot = true;
1070
1091
  resetGeneration++;
1071
1092
  }
1072
- const pendingPresence = /* @__PURE__ */ new Map();
1073
- let usesBaseline = true;
1074
- for (const pending of collection._state.pendingSyncedTransactions) {
1075
- if (pending.truncate) {
1076
- pendingPresence.clear();
1077
- usesBaseline = false;
1078
- }
1079
- for (const operation of pending.operations) {
1080
- pendingPresence.set(operation.key, operation.type !== `delete`);
1093
+ if (!metadata?.persistence && !transactionStarted) {
1094
+ pendingPresence.clear();
1095
+ usesBaseline = true;
1096
+ for (const pending of collection._state.pendingSyncedTransactions) {
1097
+ if (pending.truncate) {
1098
+ pendingPresence.clear();
1099
+ usesBaseline = false;
1100
+ }
1101
+ for (const operation of pending.operations) {
1102
+ pendingPresence.set(operation.key, operation.type !== `delete`);
1103
+ }
1081
1104
  }
1082
- }
1083
- for (const message of bufferedMessages) {
1084
- if (client.isChangeMessage(message)) {
1085
- pendingPresence.set(
1086
- collection.getKeyFromItem(message.value),
1087
- message.headers.operation !== `delete`
1088
- );
1105
+ for (const message of bufferedMessages) {
1106
+ if (client.isChangeMessage(message)) {
1107
+ pendingPresence.set(
1108
+ collection.getKeyFromItem(message.value),
1109
+ message.headers.operation !== `delete`
1110
+ );
1111
+ }
1089
1112
  }
1090
1113
  }
1091
1114
  let commitPoint = null;
@@ -1107,7 +1130,10 @@ function createElectricSync(shapeOptions, options) {
1107
1130
  }
1108
1131
  if (!receivesCompleteRows) continue;
1109
1132
  }
1110
- pendingPresence.set(rowId, operation !== `delete`);
1133
+ recordPendingPresence(rowId, operation !== `delete`);
1134
+ if (operation !== `update`) {
1135
+ progressiveSnapshotRows.delete(rowId);
1136
+ }
1111
1137
  }
1112
1138
  if (client.isChangeMessage(message)) {
1113
1139
  const schema = message.headers.schema;
@@ -1115,10 +1141,18 @@ function createElectricSync(shapeOptions, options) {
1115
1141
  relationSchema = schema;
1116
1142
  }
1117
1143
  if (isBufferingInitialSync() && !transactionStarted) {
1144
+ if (message.headers.operation === `update`) {
1145
+ const rowId = collection.getKeyFromItem(message.value);
1146
+ const snapshotRow = progressiveSnapshotRows.get(rowId);
1147
+ if (snapshotRow) {
1148
+ bufferedMessages.push(snapshotRow);
1149
+ progressiveSnapshotRows.delete(rowId);
1150
+ }
1151
+ }
1118
1152
  bufferedMessages.push(message);
1119
1153
  } else {
1120
1154
  if (!transactionStarted) {
1121
- begin();
1155
+ beginSourceTransaction();
1122
1156
  transactionStarted = true;
1123
1157
  }
1124
1158
  processChangeMessage(message);
@@ -1139,11 +1173,11 @@ function createElectricSync(shapeOptions, options) {
1139
1173
  } else {
1140
1174
  transactionStarted = processMoveOutEvent(
1141
1175
  message.headers.patterns,
1142
- begin,
1176
+ beginSourceTransaction,
1143
1177
  write,
1144
1178
  transactionStarted,
1145
1179
  (rowId) => {
1146
- pendingPresence.set(rowId, false);
1180
+ recordPendingPresence(rowId, false);
1147
1181
  syncedKeys.delete(rowId);
1148
1182
  }
1149
1183
  );
@@ -1160,20 +1194,20 @@ function createElectricSync(shapeOptions, options) {
1160
1194
  );
1161
1195
  commitResetResumeMetadataImmediately();
1162
1196
  if (!transactionStarted) {
1163
- begin();
1197
+ beginSourceTransaction();
1164
1198
  transactionStarted = true;
1165
1199
  }
1166
1200
  truncate();
1167
1201
  clearTagTrackingState();
1168
1202
  syncedKeys.clear();
1169
- pendingPresence.clear();
1170
- usesBaseline = false;
1203
+ recordPendingTruncate();
1171
1204
  isResettingSnapshot = true;
1172
1205
  resetGeneration++;
1173
1206
  loadSubsetDedupe?.reset();
1174
1207
  commitPoint = null;
1175
1208
  hasReceivedUpToDate = false;
1176
1209
  bufferedMessages.length = 0;
1210
+ progressiveSnapshotRows.clear();
1177
1211
  }
1178
1212
  }
1179
1213
  if (needsFullSnapshot && isResettingSnapshot && commitPoint === `subset-end`)
@@ -1187,7 +1221,7 @@ function createElectricSync(shapeOptions, options) {
1187
1221
  debug(
1188
1222
  `${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Performing atomic swap with ${bufferedMessages.length} buffered messages`
1189
1223
  );
1190
- begin();
1224
+ beginSourceTransaction();
1191
1225
  truncate();
1192
1226
  clearTagTrackingState();
1193
1227
  syncedKeys.clear();
@@ -1210,7 +1244,7 @@ function createElectricSync(shapeOptions, options) {
1210
1244
  // normal-stream transactionStarted flag is still false.
1211
1245
  true,
1212
1246
  (rowId) => {
1213
- pendingPresence.set(rowId, false);
1247
+ recordPendingPresence(rowId, false);
1214
1248
  syncedKeys.delete(rowId);
1215
1249
  }
1216
1250
  );
@@ -1219,8 +1253,9 @@ function createElectricSync(shapeOptions, options) {
1219
1253
  }
1220
1254
  }
1221
1255
  stageResumeMetadata();
1222
- applied = commit();
1256
+ applied = commitSourceTransaction();
1223
1257
  bufferedMessages.length = 0;
1258
+ progressiveSnapshotRows.clear();
1224
1259
  debug(
1225
1260
  `${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Atomic swap complete, now in normal sync mode`
1226
1261
  );
@@ -1229,12 +1264,12 @@ function createElectricSync(shapeOptions, options) {
1229
1264
  if (!isResettingSnapshot || finishesReset) {
1230
1265
  stageResumeMetadata();
1231
1266
  }
1232
- applied = commit();
1267
+ applied = commitSourceTransaction();
1233
1268
  transactionStarted = false;
1234
1269
  } else if (commitPoint === `up-to-date` && metadata) {
1235
- begin();
1270
+ beginSourceTransaction();
1236
1271
  stageResumeMetadata();
1237
- applied = commit();
1272
+ applied = commitSourceTransaction();
1238
1273
  }
1239
1274
  }
1240
1275
  const readyErrorVersion = streamErrorVersion;
@@ -1243,7 +1278,15 @@ function createElectricSync(shapeOptions, options) {
1243
1278
  } else {
1244
1279
  void applied.then(
1245
1280
  () => wrappedMarkReady(wasBufferingInitialSync, readyErrorVersion),
1246
- () => void 0
1281
+ (error) => {
1282
+ if (!isActiveLifecycle() || abortController.signal.aborted) {
1283
+ return;
1284
+ }
1285
+ streamErrorVersion++;
1286
+ unsubscribeStream();
1287
+ abortController.abort();
1288
+ if (collection.status !== `error`) markError(error);
1289
+ }
1247
1290
  );
1248
1291
  }
1249
1292
  if (finishesReset) {