@tanstack/electric-db-collection 0.4.7 → 0.4.10

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,14 +1,27 @@
1
- import { ShapeStream, isChangeMessage, isVisibleInSnapshot, isControlMessage } from "@electric-sql/client";
1
+ import { isVisibleInSnapshot, isChangeMessage, ShapeStream, isControlMessage } from "@electric-sql/client";
2
2
  import { isChangeMessage as isChangeMessage2, isControlMessage as isControlMessage2 } from "@electric-sql/client";
3
- import { Store } from "@tanstack/store";
4
3
  import DebugModule from "debug";
5
- import { withCollectionConfigFactory, DeduplicatedLoadSubset, and } from "@tanstack/db";
4
+ import { withCollectionSyncConfigFactory, withCollectionConfigFactory, withCollectionSyncConfigCleanup, DeduplicatedLoadSubset, and, LoadSubsetOperationAbortedError } from "@tanstack/db";
6
5
  import { StreamAbortedError, ExpectedNumberInAwaitTxIdError, TimeoutWaitingForTxIdError, TimeoutWaitingForMatchError } from "./errors.js";
7
6
  import { compileSQL } from "./sql-compiler.js";
8
7
  import { isMoveOutMessage, isMoveInMessage, deriveDisjunctPositions, getTagLength, addTagToIndex, parseTag, removeTagFromIndex, findRowsMatchingPattern, rowVisible, tagMatchesPattern } from "./tag-index.js";
9
8
  const debug = DebugModule.debug(`ts/db:electric`);
10
9
  const FORCE_DISCONNECT_AND_REFRESH_TIMEOUT_MS = 250;
11
10
  const ELECTRIC_TEST_HOOKS = /* @__PURE__ */ Symbol(`electricTestHooks`);
11
+ function exportElectricSyncMeta(evidence) {
12
+ const resume = evidence.hydratedResumeState;
13
+ return {
14
+ version: 1,
15
+ ...resume ? { resume } : {},
16
+ seenTxids: Array.from(evidence.seenTxids).sort((a, b) => a - b)
17
+ };
18
+ }
19
+ function importElectricSyncMeta(evidence, meta) {
20
+ const parsed = parseElectricSyncMeta(meta);
21
+ if (!parsed) return;
22
+ evidence.hydratedResumeState = parsed.resume;
23
+ evidence.seenTxids = new Set(parsed.seenTxids);
24
+ }
12
25
  function parseElectricResumeState(value) {
13
26
  if (!value || typeof value !== `object`) {
14
27
  return void 0;
@@ -20,7 +33,10 @@ function parseElectricResumeState(value) {
20
33
  offset: record.offset,
21
34
  handle: record.handle,
22
35
  shapeId: record.shapeId,
23
- updatedAt: record.updatedAt
36
+ updatedAt: record.updatedAt,
37
+ ...typeof record.requiresTagState === `boolean` && {
38
+ requiresTagState: record.requiresTagState
39
+ }
24
40
  };
25
41
  }
26
42
  if (record.kind === `reset` && typeof record.updatedAt === `number` && Number.isFinite(record.updatedAt)) {
@@ -75,7 +91,10 @@ function mergeElectricSyncMeta(current, incoming) {
75
91
  function getNewestElectricResumeState(current, incoming) {
76
92
  if (!current) return incoming;
77
93
  if (!incoming) return current;
78
- return incoming.updatedAt >= current.updatedAt ? incoming : current;
94
+ if (incoming.updatedAt > current.updatedAt) return incoming;
95
+ if (incoming.updatedAt < current.updatedAt) return current;
96
+ const isSameState = current.kind === incoming.kind && (current.kind === `reset` || incoming.kind === `resume` && current.offset === incoming.offset && current.handle === incoming.handle && current.shapeId === incoming.shapeId);
97
+ return isSameState ? current : { kind: `reset`, updatedAt: current.updatedAt };
79
98
  }
80
99
  function isUpToDateMessage(message) {
81
100
  return isControlMessage(message) && message.headers.control === `up-to-date`;
@@ -148,6 +167,7 @@ function createLoadSubsetDedupe({
148
167
  }
149
168
  const compileOptions = encodeColumnName ? { encodeColumnName } : void 0;
150
169
  const logPrefix = collectionId ? `[${collectionId}] ` : ``;
170
+ const abortReason = (abortedSignal) => abortedSignal.reason ?? new LoadSubsetOperationAbortedError();
151
171
  function handleSnapshotError(error, operation) {
152
172
  if (signal.aborted) {
153
173
  debug(`${logPrefix}Ignoring ${operation} error during cleanup: %o`, error);
@@ -158,7 +178,11 @@ function createLoadSubsetDedupe({
158
178
  }
159
179
  const loadSubset = async (opts) => {
160
180
  const commitCursor = getCommitCursor();
161
- if (opts.signal?.aborted) return;
181
+ const throwIfAborted = () => {
182
+ if (signal.aborted) throw abortReason(signal);
183
+ if (opts.signal?.aborted) throw abortReason(opts.signal);
184
+ };
185
+ throwIfAborted();
162
186
  if (isBufferingInitialSync()) {
163
187
  const snapshotParams = compileSQL(opts, compileOptions);
164
188
  try {
@@ -194,6 +218,18 @@ function createLoadSubsetDedupe({
194
218
  const { cursor, where, orderBy, limit } = opts;
195
219
  if (stream.isUpToDate) {
196
220
  let timeoutId;
221
+ const abortSignals = [signal, opts.signal].filter(
222
+ (candidate) => candidate !== void 0
223
+ );
224
+ let rejectAbort = () => {
225
+ };
226
+ const aborted = new Promise((_resolve, reject) => {
227
+ rejectAbort = reject;
228
+ });
229
+ const abort = (event) => rejectAbort(abortReason(event.currentTarget));
230
+ for (const abortSignal of abortSignals) {
231
+ abortSignal.addEventListener(`abort`, abort, { once: true });
232
+ }
197
233
  try {
198
234
  await Promise.race([
199
235
  stream.forceDisconnectAndRefresh(),
@@ -202,9 +238,11 @@ function createLoadSubsetDedupe({
202
238
  resolve,
203
239
  FORCE_DISCONNECT_AND_REFRESH_TIMEOUT_MS
204
240
  );
205
- })
241
+ }),
242
+ aborted
206
243
  ]);
207
244
  } catch (error) {
245
+ if (signal.aborted || opts.signal?.aborted) throw error;
208
246
  if (handleSnapshotError(error, `forceDisconnectAndRefresh`)) {
209
247
  return;
210
248
  }
@@ -214,9 +252,12 @@ function createLoadSubsetDedupe({
214
252
  );
215
253
  } finally {
216
254
  clearTimeout(timeoutId);
255
+ for (const abortSignal of abortSignals) {
256
+ abortSignal.removeEventListener(`abort`, abort);
257
+ }
217
258
  }
218
259
  }
219
- if (opts.signal?.aborted) return;
260
+ throwIfAborted();
220
261
  try {
221
262
  if (cursor) {
222
263
  const whereCurrentOpts = {
@@ -256,203 +297,239 @@ function createLoadSubsetDedupe({
256
297
  };
257
298
  return new DeduplicatedLoadSubset({ loadSubset });
258
299
  }
259
- function electricCollectionOptions(config) {
260
- const seenTxids = new Store(/* @__PURE__ */ new Set([]));
261
- const seenSnapshots = new Store([]);
262
- const hydratedResumeState = new Store(
263
- void 0
264
- );
265
- const internalSyncMode = config.syncMode ?? `eager`;
266
- const finalSyncMode = internalSyncMode === `progressive` ? `on-demand` : internalSyncMode;
267
- const pendingMatches = new Store(/* @__PURE__ */ new Map());
268
- const currentBatchMessages = new Store([]);
269
- const batchCommitted = new Store(false);
270
- const removePendingMatches = (matchIds) => {
271
- if (matchIds.length > 0) {
272
- pendingMatches.setState((current) => {
273
- const newMatches = new Map(current);
274
- matchIds.forEach((id) => newMatches.delete(id));
275
- return newMatches;
276
- });
300
+ class ElectricLifecycle {
301
+ constructor(collectionId) {
302
+ this.collectionId = collectionId;
303
+ this.evidence = {
304
+ seenTxids: /* @__PURE__ */ new Set(),
305
+ seenSnapshots: []
306
+ };
307
+ this.pendingMatches = /* @__PURE__ */ new Map();
308
+ this.pendingTxidWaits = /* @__PURE__ */ new Map();
309
+ this.matchBuffer = {
310
+ committedMessages: [],
311
+ pendingMessages: []
312
+ };
313
+ this.nextWaiterId = 0;
314
+ this.epoch = 0;
315
+ this.active = false;
316
+ this.utils = {
317
+ awaitTxId: (txId, timeout) => this.awaitTxId(txId, timeout),
318
+ awaitMatch: (matchFn, timeout) => this.awaitMatch(matchFn, timeout)
319
+ };
320
+ }
321
+ start() {
322
+ if (this.active) this.retire();
323
+ this.active = true;
324
+ this.epoch++;
325
+ this.matchBuffer = { committedMessages: [], pendingMessages: [] };
326
+ return this.epoch;
327
+ }
328
+ isActive(epoch) {
329
+ return this.active && this.epoch === epoch;
330
+ }
331
+ retire(epoch) {
332
+ if (epoch !== void 0 && !this.isActive(epoch)) return;
333
+ this.active = false;
334
+ this.epoch++;
335
+ for (const match of this.pendingMatches.values()) {
336
+ clearTimeout(match.timeoutId);
337
+ match.reject(new StreamAbortedError(this.collectionId));
277
338
  }
278
- };
279
- const resolveMatchedPendingMatches = () => {
280
- const matchesToResolve = [];
281
- pendingMatches.state.forEach((match, matchId) => {
282
- if (match.matched) {
283
- clearTimeout(match.timeoutId);
284
- match.resolve(true);
285
- matchesToResolve.push(matchId);
286
- debug(
287
- `${config.id ? `[${config.id}] ` : ``}awaitMatch resolved on up-to-date for match %s`,
288
- matchId
289
- );
290
- }
291
- });
292
- removePendingMatches(matchesToResolve);
293
- };
294
- const sync = createElectricSync(config.shapeOptions, {
295
- seenTxids,
296
- seenSnapshots,
297
- hydratedResumeState,
298
- syncMode: internalSyncMode,
299
- pendingMatches,
300
- currentBatchMessages,
301
- batchCommitted,
302
- removePendingMatches,
303
- resolveMatchedPendingMatches,
304
- collectionId: config.id,
305
- testHooks: config[ELECTRIC_TEST_HOOKS]
306
- });
307
- const awaitTxId = async (txId, timeout = 5e3) => {
339
+ this.pendingMatches.clear();
340
+ for (const waiter of this.pendingTxidWaits.values()) {
341
+ clearTimeout(waiter.timeoutId);
342
+ waiter.reject(new StreamAbortedError(this.collectionId));
343
+ }
344
+ this.pendingTxidWaits.clear();
345
+ this.matchBuffer = { committedMessages: [], pendingMessages: [] };
346
+ this.evidence.hydratedResumeState = void 0;
347
+ }
348
+ exportMeta() {
349
+ return exportElectricSyncMeta(this.evidence);
350
+ }
351
+ importMeta(meta) {
352
+ importElectricSyncMeta(this.evidence, meta);
353
+ this.resolveTxidWaiters();
354
+ }
355
+ get resumeState() {
356
+ return this.evidence.hydratedResumeState;
357
+ }
358
+ set resumeState(value) {
359
+ this.evidence.hydratedResumeState = value;
360
+ }
361
+ publishEvidence(txids, snapshots) {
362
+ txids.forEach((txid) => this.evidence.seenTxids.add(txid));
363
+ this.evidence.seenSnapshots.push(...snapshots);
364
+ this.resolveTxidWaiters();
365
+ }
366
+ hasTxid(txId) {
367
+ return this.evidence.seenTxids.has(txId) || this.evidence.seenSnapshots.some(
368
+ (snapshot) => isVisibleInSnapshot(txId, snapshot)
369
+ );
370
+ }
371
+ resolveTxidWaiters() {
372
+ for (const [waitId, waiter] of this.pendingTxidWaits) {
373
+ if (!this.hasTxid(waiter.txId)) continue;
374
+ clearTimeout(waiter.timeoutId);
375
+ this.pendingTxidWaits.delete(waitId);
376
+ waiter.resolve(true);
377
+ }
378
+ }
379
+ async awaitTxId(txId, timeout = 5e3) {
308
380
  debug(
309
- `${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`,
381
+ `${this.collectionId ? `[${this.collectionId}] ` : ``}awaitTxId called with txid %d`,
310
382
  txId
311
383
  );
312
384
  if (typeof txId !== `number`) {
313
- throw new ExpectedNumberInAwaitTxIdError(typeof txId, config.id);
385
+ throw new ExpectedNumberInAwaitTxIdError(typeof txId, this.collectionId);
314
386
  }
315
- const hasTxid = seenTxids.state.has(txId);
316
- if (hasTxid) return true;
317
- const hasSnapshot = seenSnapshots.state.some(
318
- (snapshot) => isVisibleInSnapshot(txId, snapshot)
319
- );
320
- if (hasSnapshot) return true;
387
+ if (this.hasTxid(txId)) return true;
321
388
  return new Promise((resolve, reject) => {
322
- const cleanup = () => {
323
- clearTimeout(timeoutId);
324
- subSeenTxids.unsubscribe();
325
- subSeenSnapshots.unsubscribe();
326
- };
389
+ const waitId = this.nextWaiterId++;
327
390
  const timeoutId = setTimeout(() => {
328
- cleanup();
329
- reject(new TimeoutWaitingForTxIdError(txId, config.id));
391
+ this.pendingTxidWaits.delete(waitId);
392
+ reject(new TimeoutWaitingForTxIdError(txId, this.collectionId));
330
393
  }, timeout);
331
- const subSeenTxids = seenTxids.subscribe(() => {
332
- if (seenTxids.state.has(txId)) {
333
- debug(
334
- `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`,
335
- txId
336
- );
337
- cleanup();
338
- resolve(true);
339
- }
340
- });
341
- const subSeenSnapshots = seenSnapshots.subscribe(() => {
342
- const visibleSnapshot = seenSnapshots.state.find(
343
- (snapshot) => isVisibleInSnapshot(txId, snapshot)
344
- );
345
- if (visibleSnapshot) {
346
- debug(
347
- `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o in snapshot %o`,
348
- txId,
349
- visibleSnapshot
350
- );
351
- cleanup();
352
- resolve(true);
353
- }
394
+ this.pendingTxidWaits.set(waitId, {
395
+ txId,
396
+ resolve,
397
+ reject,
398
+ timeoutId
354
399
  });
355
400
  });
356
- };
357
- const awaitMatch = async (matchFn, timeout = 3e3) => {
401
+ }
402
+ async awaitMatch(matchFn, timeout = 3e3) {
358
403
  debug(
359
- `${config.id ? `[${config.id}] ` : ``}awaitMatch called with custom function`
404
+ `${this.collectionId ? `[${this.collectionId}] ` : ``}awaitMatch called with custom function`
360
405
  );
406
+ for (const message of this.matchBuffer.committedMessages) {
407
+ if (!matchFn(message)) continue;
408
+ return true;
409
+ }
410
+ for (const message of this.matchBuffer.pendingMessages) {
411
+ if (matchFn(message)) return this.registerMatch(matchFn, timeout, true);
412
+ }
413
+ return this.registerMatch(matchFn, timeout, false);
414
+ }
415
+ registerMatch(matchFn, timeout, matched) {
361
416
  return new Promise((resolve, reject) => {
362
- const matchId = Math.random().toString(36);
363
- const cleanupMatch = () => {
364
- pendingMatches.setState((current) => {
365
- const newMatches = new Map(current);
366
- newMatches.delete(matchId);
367
- return newMatches;
368
- });
369
- };
370
- const onTimeout = () => {
371
- cleanupMatch();
372
- reject(new TimeoutWaitingForMatchError(config.id));
373
- };
374
- const timeoutId = setTimeout(onTimeout, timeout);
375
- const checkMatch = (message) => {
376
- if (matchFn(message)) {
377
- debug(
378
- `${config.id ? `[${config.id}] ` : ``}awaitMatch found matching message, waiting for up-to-date`
379
- );
380
- pendingMatches.setState((current) => {
381
- const newMatches = new Map(current);
382
- const existing = newMatches.get(matchId);
383
- if (existing) {
384
- newMatches.set(matchId, { ...existing, matched: true });
385
- }
386
- return newMatches;
387
- });
388
- return true;
389
- }
390
- return false;
391
- };
392
- for (const message of currentBatchMessages.state) {
393
- if (matchFn(message)) {
394
- if (batchCommitted.state) {
395
- debug(
396
- `${config.id ? `[${config.id}] ` : ``}awaitMatch found immediate match in committed batch, resolving immediately`
397
- );
398
- clearTimeout(timeoutId);
399
- resolve(true);
400
- return;
401
- }
402
- debug(
403
- `${config.id ? `[${config.id}] ` : ``}awaitMatch found immediate match in current batch, waiting for up-to-date`
404
- );
405
- pendingMatches.setState((current) => {
406
- const newMatches = new Map(current);
407
- newMatches.set(matchId, {
408
- matchFn: checkMatch,
409
- resolve,
410
- reject,
411
- timeoutId,
412
- matched: true
413
- // Already matched, will resolve on up-to-date
414
- });
415
- return newMatches;
416
- });
417
- return;
418
- }
419
- }
420
- pendingMatches.setState((current) => {
421
- const newMatches = new Map(current);
422
- newMatches.set(matchId, {
423
- matchFn: checkMatch,
424
- resolve,
425
- reject,
426
- timeoutId,
427
- matched: false
428
- });
429
- return newMatches;
417
+ const matchId = this.nextWaiterId++;
418
+ const timeoutId = setTimeout(() => {
419
+ this.pendingMatches.delete(matchId);
420
+ reject(new TimeoutWaitingForMatchError(this.collectionId));
421
+ }, timeout);
422
+ this.pendingMatches.set(matchId, {
423
+ matchFn,
424
+ resolve,
425
+ reject,
426
+ timeoutId,
427
+ matched
430
428
  });
431
429
  });
432
- };
433
- const processMatchingStrategy = async (result) => {
430
+ }
431
+ beginMatchGeneration(messages) {
432
+ if (!messages.some(isMustRefetchMessage)) return;
433
+ this.matchBuffer = { committedMessages: [], pendingMessages: [] };
434
+ for (const match of this.pendingMatches.values()) match.matched = false;
435
+ }
436
+ observeMatchMessage(message) {
437
+ if (isChangeMessage(message) || isMoveOutMessage(message) || isMoveInMessage(message)) {
438
+ this.matchBuffer.pendingMessages.push(message);
439
+ let overflow = this.matchBuffer.committedMessages.length + this.matchBuffer.pendingMessages.length - 1e3;
440
+ if (overflow > 0) {
441
+ const committedOverflow = Math.min(
442
+ overflow,
443
+ this.matchBuffer.committedMessages.length
444
+ );
445
+ this.matchBuffer.committedMessages.splice(0, committedOverflow);
446
+ overflow -= committedOverflow;
447
+ if (overflow > 0) {
448
+ this.matchBuffer.pendingMessages.splice(0, overflow);
449
+ }
450
+ }
451
+ }
452
+ const epoch = this.epoch;
453
+ for (const [matchId, match] of this.pendingMatches) {
454
+ if (match.matched) continue;
455
+ try {
456
+ match.matched = match.matchFn(message);
457
+ } catch (error) {
458
+ clearTimeout(match.timeoutId);
459
+ this.pendingMatches.delete(matchId);
460
+ match.reject(error instanceof Error ? error : new Error(String(error)));
461
+ }
462
+ if (!this.isActive(epoch)) return;
463
+ }
464
+ }
465
+ commitMatches() {
466
+ this.matchBuffer.committedMessages.push(...this.matchBuffer.pendingMessages);
467
+ this.matchBuffer.pendingMessages = [];
468
+ if (this.matchBuffer.committedMessages.length > 1e3) {
469
+ this.matchBuffer.committedMessages.splice(
470
+ 0,
471
+ this.matchBuffer.committedMessages.length - 1e3
472
+ );
473
+ }
474
+ for (const [matchId, match] of this.pendingMatches) {
475
+ if (!match.matched) continue;
476
+ clearTimeout(match.timeoutId);
477
+ this.pendingMatches.delete(matchId);
478
+ match.resolve(true);
479
+ }
480
+ }
481
+ }
482
+ function electricCollectionOptions(config) {
483
+ let descriptorLifecycle = new ElectricLifecycle(config.id);
484
+ let utilityLifecycle = descriptorLifecycle;
485
+ const internalSyncMode = config.syncMode ?? `eager`;
486
+ const finalSyncMode = internalSyncMode === `progressive` ? `on-demand` : internalSyncMode;
487
+ const boundLifecycles = /* @__PURE__ */ new WeakMap();
488
+ const awaitTxId = (txId, timeout) => utilityLifecycle.utils.awaitTxId(txId, timeout);
489
+ const awaitMatch = (matchFn, timeout) => utilityLifecycle.utils.awaitMatch(matchFn, timeout);
490
+ const createSync = () => createElectricSync(config.shapeOptions, {
491
+ getLifecycle: (collection) => boundLifecycles.get(collection) ?? descriptorLifecycle,
492
+ syncMode: internalSyncMode,
493
+ collectionId: config.id,
494
+ testHooks: config[ELECTRIC_TEST_HOOKS]
495
+ });
496
+ const sync = createSync();
497
+ const processMatchingStrategy = async (result, waitForTxId) => {
434
498
  if (result && `txid` in result) {
435
499
  const timeout = result.timeout;
436
500
  if (Array.isArray(result.txid)) {
437
- await Promise.all(result.txid.map((txid) => awaitTxId(txid, timeout)));
501
+ await Promise.all(result.txid.map((txid) => waitForTxId(txid, timeout)));
438
502
  } else {
439
- await awaitTxId(result.txid, timeout);
503
+ await waitForTxId(result.txid, timeout);
440
504
  }
441
505
  }
442
506
  };
507
+ const getMutationAwaitTxId = (params) => {
508
+ const collection = params.collection;
509
+ return collection?.utils?.awaitTxId ?? awaitTxId;
510
+ };
443
511
  const wrappedOnInsert = config.onInsert ? async (params) => {
444
512
  const handlerResult = await config.onInsert(params);
445
- await processMatchingStrategy(handlerResult);
513
+ await processMatchingStrategy(
514
+ handlerResult,
515
+ getMutationAwaitTxId(params)
516
+ );
446
517
  return handlerResult;
447
518
  } : void 0;
448
519
  const wrappedOnUpdate = config.onUpdate ? async (params) => {
449
520
  const handlerResult = await config.onUpdate(params);
450
- await processMatchingStrategy(handlerResult);
521
+ await processMatchingStrategy(
522
+ handlerResult,
523
+ getMutationAwaitTxId(params)
524
+ );
451
525
  return handlerResult;
452
526
  } : void 0;
453
527
  const wrappedOnDelete = config.onDelete ? async (params) => {
454
528
  const handlerResult = await config.onDelete(params);
455
- await processMatchingStrategy(handlerResult);
529
+ await processMatchingStrategy(
530
+ handlerResult,
531
+ getMutationAwaitTxId(params)
532
+ );
456
533
  return handlerResult;
457
534
  } : void 0;
458
535
  const {
@@ -462,236 +539,275 @@ function electricCollectionOptions(config) {
462
539
  onDelete: _onDelete,
463
540
  ...restConfig
464
541
  } = config;
465
- const options = {
466
- ...restConfig,
467
- syncMode: finalSyncMode,
468
- sync: {
469
- ...sync,
470
- exportSyncMeta: () => ({
471
- version: 1,
472
- ...hydratedResumeState.state ? { resume: hydratedResumeState.state } : {},
473
- seenTxids: Array.from(seenTxids.state).sort((a, b) => a - b)
474
- }),
475
- importSyncMeta: (meta) => {
476
- const parsed = parseElectricSyncMeta(meta);
477
- if (!parsed) {
478
- return;
542
+ const utilityTemplate = {
543
+ awaitTxId,
544
+ awaitMatch
545
+ };
546
+ const consumeDescriptorLifecycle = () => {
547
+ const lifecycle = descriptorLifecycle;
548
+ descriptorLifecycle = new ElectricLifecycle(config.id);
549
+ utilityLifecycle = lifecycle;
550
+ return lifecycle;
551
+ };
552
+ const createBoundSync = (source, utilities) => {
553
+ const lifecycle = consumeDescriptorLifecycle();
554
+ let collectionKey;
555
+ Object.assign(utilities, lifecycle.utils);
556
+ const boundSync = {
557
+ ...source,
558
+ sync: (params) => {
559
+ if (!boundLifecycles.has(params.collection)) {
560
+ collectionKey = params.collection;
561
+ boundLifecycles.set(params.collection, lifecycle);
479
562
  }
480
- hydratedResumeState.setState(() => parsed.resume);
481
- seenTxids.setState(() => new Set(parsed.seenTxids));
563
+ return source.sync(params);
482
564
  },
565
+ exportSyncMeta: () => lifecycle.exportMeta(),
566
+ importSyncMeta: (meta) => lifecycle.importMeta(meta),
567
+ mergeSyncMeta: mergeElectricSyncMeta
568
+ };
569
+ return withCollectionSyncConfigCleanup(boundSync, () => {
570
+ lifecycle.retire();
571
+ if (collectionKey) boundLifecycles.delete(collectionKey);
572
+ });
573
+ };
574
+ const syncTemplate = withCollectionSyncConfigFactory(
575
+ {
576
+ ...sync,
577
+ exportSyncMeta: () => descriptorLifecycle.exportMeta(),
578
+ importSyncMeta: (meta) => descriptorLifecycle.importMeta(meta),
483
579
  mergeSyncMeta: mergeElectricSyncMeta
484
580
  },
581
+ createBoundSync
582
+ );
583
+ const options = {
584
+ ...restConfig,
585
+ syncMode: finalSyncMode,
586
+ sync: syncTemplate,
485
587
  onInsert: wrappedOnInsert,
486
588
  onUpdate: wrappedOnUpdate,
487
589
  onDelete: wrappedOnDelete,
488
- utils: {
489
- awaitTxId,
490
- awaitMatch
491
- }
590
+ utils: utilityTemplate
492
591
  };
592
+ Object.defineProperty(options, `utils`, {
593
+ enumerable: true,
594
+ get: () => ({ ...utilityTemplate })
595
+ });
493
596
  return withCollectionConfigFactory(
494
597
  options,
495
598
  () => electricCollectionOptions(config)
496
599
  );
497
600
  }
498
601
  function createElectricSync(shapeOptions, options) {
499
- const {
500
- seenTxids,
501
- seenSnapshots,
502
- hydratedResumeState,
503
- syncMode,
504
- pendingMatches,
505
- currentBatchMessages,
506
- batchCommitted,
507
- removePendingMatches,
508
- resolveMatchedPendingMatches,
509
- collectionId,
510
- testHooks
511
- } = options;
512
- const MAX_BATCH_MESSAGES = 1e3;
513
- const relationSchema = new Store(void 0);
514
- const tagCache = /* @__PURE__ */ new Map();
515
- const parseTag$1 = (tag) => {
516
- const cachedTag = tagCache.get(tag);
517
- if (cachedTag) {
518
- return cachedTag;
519
- }
520
- const parsedTag = parseTag(tag);
521
- tagCache.set(tag, parsedTag);
522
- return parsedTag;
523
- };
524
- const rowTagSets = /* @__PURE__ */ new Map();
525
- const tagIndex = [];
526
- let tagLength = void 0;
527
- const rowActiveConditions = /* @__PURE__ */ new Map();
528
- let disjunctPositions = void 0;
529
- const initializeTagIndex = (length) => {
530
- if (tagIndex.length < length) {
531
- for (let i = tagIndex.length; i < length; i++) {
532
- tagIndex[i] = /* @__PURE__ */ new Map();
602
+ const { getLifecycle, syncMode, collectionId, testHooks } = options;
603
+ let relationSchema;
604
+ let warnedUnverifiableResume = false;
605
+ const createTagState = () => {
606
+ const tagCache = /* @__PURE__ */ new Map();
607
+ const parseTag$1 = (tag) => {
608
+ const cachedTag = tagCache.get(tag);
609
+ if (cachedTag) {
610
+ return cachedTag;
533
611
  }
534
- }
535
- };
536
- const addTagsToRow = (tags, rowId, rowTagSet) => {
537
- for (const tag of tags) {
538
- const parsedTag = parseTag$1(tag);
539
- if (tagLength === void 0) {
540
- tagLength = getTagLength(parsedTag);
541
- initializeTagIndex(tagLength);
612
+ const parsedTag = parseTag(tag);
613
+ tagCache.set(tag, parsedTag);
614
+ return parsedTag;
615
+ };
616
+ const rowTagSets = /* @__PURE__ */ new Map();
617
+ const tagIndex = [];
618
+ let tagLength = void 0;
619
+ const rowActiveConditions = /* @__PURE__ */ new Map();
620
+ let disjunctPositions = void 0;
621
+ const initializeTagIndex = (length) => {
622
+ if (tagIndex.length < length) {
623
+ for (let i = tagIndex.length; i < length; i++) {
624
+ tagIndex[i] = /* @__PURE__ */ new Map();
625
+ }
542
626
  }
543
- const currentTagLength = getTagLength(parsedTag);
544
- if (currentTagLength !== tagLength) {
545
- debug(
546
- `${collectionId ? `[${collectionId}] ` : ``}Tag length mismatch: expected ${tagLength}, got ${currentTagLength}`
547
- );
548
- continue;
627
+ };
628
+ const addTagsToRow = (tags, rowId, rowTagSet) => {
629
+ for (const tag of tags) {
630
+ const parsedTag = parseTag$1(tag);
631
+ if (tagLength === void 0) {
632
+ tagLength = getTagLength(parsedTag);
633
+ initializeTagIndex(tagLength);
634
+ }
635
+ const currentTagLength = getTagLength(parsedTag);
636
+ if (currentTagLength !== tagLength) {
637
+ debug(
638
+ `${collectionId ? `[${collectionId}] ` : ``}Tag length mismatch: expected ${tagLength}, got ${currentTagLength}`
639
+ );
640
+ continue;
641
+ }
642
+ rowTagSet.add(tag);
643
+ addTagToIndex(parsedTag, rowId, tagIndex, tagLength);
549
644
  }
550
- rowTagSet.add(tag);
551
- addTagToIndex(parsedTag, rowId, tagIndex, tagLength);
552
- }
553
- };
554
- const removeTagsFromRow = (removedTags, rowId, rowTagSet) => {
555
- if (tagLength === void 0) {
556
- return;
557
- }
558
- for (const tag of removedTags) {
559
- const parsedTag = parseTag$1(tag);
560
- rowTagSet.delete(tag);
561
- removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength);
562
- tagCache.delete(tag);
563
- }
564
- };
565
- const processTagsForChangeMessage = (tags, removedTags, rowId, activeConditions) => {
566
- if (!rowTagSets.has(rowId)) {
567
- rowTagSets.set(rowId, /* @__PURE__ */ new Set());
568
- }
569
- const rowTagSet = rowTagSets.get(rowId);
570
- if (tags) {
571
- addTagsToRow(tags, rowId, rowTagSet);
572
- if (disjunctPositions === void 0) {
573
- const parsedTags = tags.map(parseTag$1);
574
- disjunctPositions = deriveDisjunctPositions(parsedTags);
645
+ };
646
+ const removeTagsFromRow = (removedTags, rowId, rowTagSet) => {
647
+ if (tagLength === void 0) {
648
+ return;
575
649
  }
576
- }
577
- if (removedTags) {
578
- removeTagsFromRow(removedTags, rowId, rowTagSet);
579
- }
580
- if (activeConditions && activeConditions.length > 0) {
581
- rowActiveConditions.set(rowId, [...activeConditions]);
582
- }
583
- return rowTagSet;
584
- };
585
- const clearTagTrackingState = () => {
586
- rowTagSets.clear();
587
- tagIndex.length = 0;
588
- tagLength = void 0;
589
- rowActiveConditions.clear();
590
- disjunctPositions = void 0;
591
- };
592
- const clearTagsForRow = (rowId) => {
593
- if (tagLength === void 0) {
594
- return;
595
- }
596
- const rowTagSet = rowTagSets.get(rowId);
597
- if (!rowTagSet) {
598
- return;
599
- }
600
- for (const tag of rowTagSet) {
601
- const parsedTag = parseTag$1(tag);
602
- const currentTagLength = getTagLength(parsedTag);
603
- if (currentTagLength === tagLength) {
650
+ for (const tag of removedTags) {
651
+ const parsedTag = parseTag$1(tag);
652
+ rowTagSet.delete(tag);
604
653
  removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength);
654
+ tagCache.delete(tag);
605
655
  }
606
- tagCache.delete(tag);
607
- }
608
- rowTagSets.delete(rowId);
609
- rowActiveConditions.delete(rowId);
610
- };
611
- const removeMatchingTagsFromRow = (rowId, pattern) => {
612
- const rowTagSet = rowTagSets.get(rowId);
613
- if (!rowTagSet) {
614
- return false;
615
- }
616
- const activeConditions = rowActiveConditions.get(rowId);
617
- if (activeConditions && disjunctPositions) {
618
- activeConditions[pattern.pos] = false;
619
- if (!rowVisible(activeConditions, disjunctPositions)) {
620
- for (const tag of rowTagSet) {
621
- const parsedTag = parseTag$1(tag);
656
+ };
657
+ const processTagsForChangeMessage = (tags, removedTags, rowId, activeConditions) => {
658
+ if (!rowTagSets.has(rowId)) {
659
+ rowTagSets.set(rowId, /* @__PURE__ */ new Set());
660
+ }
661
+ const rowTagSet = rowTagSets.get(rowId);
662
+ if (tags) {
663
+ addTagsToRow(tags, rowId, rowTagSet);
664
+ if (disjunctPositions === void 0) {
665
+ const parsedTags = tags.map(parseTag$1);
666
+ disjunctPositions = deriveDisjunctPositions(parsedTags);
667
+ }
668
+ }
669
+ if (removedTags) {
670
+ removeTagsFromRow(removedTags, rowId, rowTagSet);
671
+ }
672
+ if (activeConditions && activeConditions.length > 0) {
673
+ rowActiveConditions.set(rowId, [...activeConditions]);
674
+ }
675
+ return rowTagSet;
676
+ };
677
+ const clearTagTrackingState = () => {
678
+ rowTagSets.clear();
679
+ tagIndex.length = 0;
680
+ tagLength = void 0;
681
+ rowActiveConditions.clear();
682
+ disjunctPositions = void 0;
683
+ };
684
+ const clearTagsForRow = (rowId) => {
685
+ if (tagLength === void 0) {
686
+ return;
687
+ }
688
+ const rowTagSet = rowTagSets.get(rowId);
689
+ if (!rowTagSet) {
690
+ return;
691
+ }
692
+ for (const tag of rowTagSet) {
693
+ const parsedTag = parseTag$1(tag);
694
+ const currentTagLength = getTagLength(parsedTag);
695
+ if (currentTagLength === tagLength) {
622
696
  removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength);
623
- tagCache.delete(tag);
624
697
  }
698
+ tagCache.delete(tag);
699
+ }
700
+ rowTagSets.delete(rowId);
701
+ rowActiveConditions.delete(rowId);
702
+ };
703
+ const removeMatchingTagsFromRow = (rowId, pattern) => {
704
+ const rowTagSet = rowTagSets.get(rowId);
705
+ if (!rowTagSet) {
706
+ return false;
707
+ }
708
+ const activeConditions = rowActiveConditions.get(rowId);
709
+ if (activeConditions && disjunctPositions) {
710
+ activeConditions[pattern.pos] = false;
711
+ if (!rowVisible(activeConditions, disjunctPositions)) {
712
+ for (const tag of rowTagSet) {
713
+ const parsedTag = parseTag$1(tag);
714
+ removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength);
715
+ tagCache.delete(tag);
716
+ }
717
+ rowTagSets.delete(rowId);
718
+ rowActiveConditions.delete(rowId);
719
+ return true;
720
+ }
721
+ return false;
722
+ }
723
+ for (const tag of rowTagSet) {
724
+ const parsedTag = parseTag$1(tag);
725
+ if (tagMatchesPattern(parsedTag, pattern)) {
726
+ rowTagSet.delete(tag);
727
+ removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength);
728
+ }
729
+ }
730
+ if (rowTagSet.size === 0) {
625
731
  rowTagSets.delete(rowId);
626
- rowActiveConditions.delete(rowId);
627
732
  return true;
628
733
  }
629
734
  return false;
630
- }
631
- for (const tag of rowTagSet) {
632
- const parsedTag = parseTag$1(tag);
633
- if (tagMatchesPattern(parsedTag, pattern)) {
634
- rowTagSet.delete(tag);
635
- removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength);
735
+ };
736
+ const processMoveOutEvent = (patterns, begin, write, transactionStarted, onDelete) => {
737
+ if (tagLength === void 0) {
738
+ debug(
739
+ `${collectionId ? `[${collectionId}] ` : ``}Received move-out message but no tag length set yet, ignoring`
740
+ );
741
+ return transactionStarted;
636
742
  }
637
- }
638
- if (rowTagSet.size === 0) {
639
- rowTagSets.delete(rowId);
640
- return true;
641
- }
642
- return false;
643
- };
644
- const processMoveOutEvent = (patterns, begin, write, transactionStarted) => {
645
- if (tagLength === void 0) {
646
- debug(
647
- `${collectionId ? `[${collectionId}] ` : ``}Received move-out message but no tag length set yet, ignoring`
648
- );
649
- return transactionStarted;
650
- }
651
- let txStarted = transactionStarted;
652
- for (const pattern of patterns) {
653
- const affectedRowIds = findRowsMatchingPattern(pattern, tagIndex);
654
- for (const rowId of affectedRowIds) {
655
- if (removeMatchingTagsFromRow(rowId, pattern)) {
656
- if (!txStarted) {
657
- begin();
658
- txStarted = true;
743
+ let txStarted = transactionStarted;
744
+ for (const pattern of patterns) {
745
+ const affectedRowIds = findRowsMatchingPattern(pattern, tagIndex);
746
+ for (const rowId of affectedRowIds) {
747
+ if (removeMatchingTagsFromRow(rowId, pattern)) {
748
+ if (!txStarted) {
749
+ begin();
750
+ txStarted = true;
751
+ }
752
+ write({
753
+ type: `delete`,
754
+ key: rowId
755
+ });
756
+ onDelete(rowId);
659
757
  }
660
- write({
661
- type: `delete`,
662
- key: rowId
663
- });
664
758
  }
665
759
  }
666
- }
667
- return txStarted;
668
- };
669
- const processMoveInEvent = (patterns) => {
670
- if (tagLength === void 0) {
671
- debug(
672
- `${collectionId ? `[${collectionId}] ` : ``}Received move-in message but no tag length set yet, ignoring`
673
- );
674
- return;
675
- }
676
- for (const pattern of patterns) {
677
- const affectedRowIds = findRowsMatchingPattern(pattern, tagIndex);
678
- for (const rowId of affectedRowIds) {
679
- const activeConditions = rowActiveConditions.get(rowId);
680
- if (activeConditions) {
681
- activeConditions[pattern.pos] = true;
760
+ return txStarted;
761
+ };
762
+ const processMoveInEvent = (patterns) => {
763
+ if (tagLength === void 0) {
764
+ debug(
765
+ `${collectionId ? `[${collectionId}] ` : ``}Received move-in message but no tag length set yet, ignoring`
766
+ );
767
+ return;
768
+ }
769
+ for (const pattern of patterns) {
770
+ const affectedRowIds = findRowsMatchingPattern(pattern, tagIndex);
771
+ for (const rowId of affectedRowIds) {
772
+ const activeConditions = rowActiveConditions.get(rowId);
773
+ if (activeConditions) {
774
+ activeConditions[pattern.pos] = true;
775
+ }
682
776
  }
683
777
  }
684
- }
685
- };
686
- const getSyncMetadata = () => {
687
- const schema = relationSchema.state || `public`;
778
+ };
688
779
  return {
689
- relation: shapeOptions.params?.table ? [schema, shapeOptions.params.table] : void 0
780
+ hasTags: () => tagLength !== void 0,
781
+ processTagsForChangeMessage,
782
+ clearTagTrackingState,
783
+ clearTagsForRow,
784
+ processMoveOutEvent,
785
+ processMoveInEvent
690
786
  };
691
787
  };
692
- let unsubscribeStream;
788
+ const collectionTags = /* @__PURE__ */ new WeakMap();
693
789
  return {
790
+ getSyncMetadata: () => ({
791
+ relation: shapeOptions.params?.table ? [relationSchema || `public`, shapeOptions.params.table] : void 0
792
+ }),
694
793
  sync: (params) => {
794
+ const retainsTagState = collectionTags.has(params.collection);
795
+ let tagState = collectionTags.get(params.collection);
796
+ if (!tagState) {
797
+ tagState = createTagState();
798
+ collectionTags.set(params.collection, tagState);
799
+ }
800
+ const {
801
+ processTagsForChangeMessage,
802
+ clearTagTrackingState,
803
+ clearTagsForRow,
804
+ processMoveOutEvent,
805
+ processMoveInEvent
806
+ } = tagState;
807
+ const lifecycle = getLifecycle(params.collection);
808
+ const lifecycleEpoch = lifecycle.start();
809
+ const isActiveLifecycle = () => lifecycle.isActive(lifecycleEpoch);
810
+ Object.assign(params.collection.utils, lifecycle.utils);
695
811
  const {
696
812
  begin,
697
813
  write,
@@ -727,16 +843,36 @@ function createElectricSync(shapeOptions, options) {
727
843
  const persistedResumeState2 = metadata?.collection.get(`electric:resume`);
728
844
  return parseElectricResumeState(persistedResumeState2);
729
845
  };
846
+ const persistedMetadata = metadata;
847
+ const scanPersisted = persistedMetadata?.row.scanPersisted;
848
+ const whenHydrated = persistedMetadata?.row.whenHydrated;
730
849
  const persistedResumeState = getNewestElectricResumeState(
731
850
  readPersistedResumeState(),
732
- hydratedResumeState.state
851
+ lifecycle.resumeState
733
852
  );
734
853
  const shapeIdentity = getStableShapeIdentity({
735
854
  url: shapeOptions.url,
736
855
  params: shapeOptions.params
737
856
  });
738
857
  const hasIncompatiblePersistedResume = persistedResumeState?.kind === `resume` && persistedResumeState.shapeId !== shapeIdentity;
739
- const canUsePersistedResume = shapeOptions.offset === void 0 && shapeOptions.handle === void 0 && persistedResumeState?.kind === `resume` && !hasIncompatiblePersistedResume;
858
+ const hasUnverifiablePersistedResume = shapeOptions.offset === void 0 && shapeOptions.handle === void 0 && persistedResumeState?.kind === `resume` && scanPersisted !== void 0 && whenHydrated === void 0;
859
+ if (hasUnverifiablePersistedResume && !warnedUnverifiableResume) {
860
+ warnedUnverifiableResume = true;
861
+ console.warn(
862
+ `Electric persistence cannot verify hydration for saved resume state. Update the persistence adapter alongside Electric to enable safe resume.`
863
+ );
864
+ }
865
+ const needsFullSnapshot = shapeOptions.offset === void 0 && shapeOptions.handle === void 0 && persistedResumeState !== void 0 && (persistedResumeState.kind === `reset` || !retainsTagState && persistedResumeState.requiresTagState !== false);
866
+ 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.
867
+ // Unknown (older) metadata is conservative; untagged shapes still resume.
868
+ !needsFullSnapshot;
869
+ const hasExplicitResumeOffset = shapeOptions.offset !== void 0 && shapeOptions.offset !== `-1`;
870
+ if (!canUsePersistedResume && !hasExplicitResumeOffset) {
871
+ clearTagTrackingState();
872
+ }
873
+ const receivesCompleteRows = shapeOptions.params?.replica === `full`;
874
+ const requiresCompleteResume = syncMode !== `on-demand` && (canUsePersistedResume || hasExplicitResumeOffset && !receivesCompleteRows);
875
+ let freshSnapshotPending = (syncMode === `eager` || needsFullSnapshot) && !canUsePersistedResume && !hasExplicitResumeOffset && whenHydrated !== void 0;
740
876
  let progressiveReadyGate = null;
741
877
  let streamErrorVersion = 0;
742
878
  const wrappedMarkReady = (isBuffering, expectedErrorVersion = streamErrorVersion) => {
@@ -753,36 +889,26 @@ function createElectricSync(shapeOptions, options) {
753
889
  }
754
890
  };
755
891
  const abortController = new AbortController();
892
+ const forwardExternalAbort = () => abortController.abort();
756
893
  if (shapeOptions.signal) {
757
- shapeOptions.signal.addEventListener(
758
- `abort`,
759
- () => {
760
- abortController.abort();
761
- },
762
- {
763
- once: true
764
- }
765
- );
894
+ shapeOptions.signal.addEventListener(`abort`, forwardExternalAbort, {
895
+ once: true
896
+ });
766
897
  if (shapeOptions.signal.aborted) {
767
898
  abortController.abort();
768
899
  }
769
900
  }
770
901
  abortController.signal.addEventListener(`abort`, () => {
771
- pendingMatches.setState((current) => {
772
- current.forEach((match) => {
773
- clearTimeout(match.timeoutId);
774
- match.reject(new StreamAbortedError());
775
- });
776
- return /* @__PURE__ */ new Map();
777
- });
902
+ lifecycle.retire(lifecycleEpoch);
778
903
  });
779
904
  const stream = new ShapeStream({
780
905
  ...shapeOptions,
781
- // In on-demand mode, we only want to sync changes, so we set the log to `changes_only`
782
- log: syncMode === `on-demand` ? `changes_only` : void 0,
906
+ // Recovery needs a complete snapshot even for on-demand shapes.
907
+ // Normal on-demand startup still subscribes to changes only.
908
+ log: syncMode === `on-demand` && !needsFullSnapshot ? `changes_only` : void 0,
783
909
  // In on-demand mode, we only need the changes from the point of time the collection was created
784
910
  // so we default to `now` when there is no saved offset.
785
- offset: shapeOptions.offset ?? (canUsePersistedResume ? persistedResumeState.offset : syncMode === `on-demand` ? `now` : void 0),
911
+ offset: shapeOptions.offset ?? (canUsePersistedResume ? persistedResumeState.offset : syncMode === `on-demand` && !needsFullSnapshot ? `now` : void 0),
786
912
  handle: shapeOptions.handle ?? (canUsePersistedResume ? persistedResumeState.handle : void 0),
787
913
  signal: abortController.signal,
788
914
  onError: (errorParams) => {
@@ -808,11 +934,17 @@ function createElectricSync(shapeOptions, options) {
808
934
  let transactionStarted = false;
809
935
  const newTxids = /* @__PURE__ */ new Set();
810
936
  const newSnapshots = [];
811
- let hasReceivedUpToDate = syncMode === `progressive` && canUsePersistedResume;
812
- const isBufferingInitialSync = () => syncMode === `progressive` && !hasReceivedUpToDate;
937
+ let hasReceivedUpToDate = syncMode === `progressive` && requiresCompleteResume;
938
+ let isResettingSnapshot = false;
939
+ let resetGeneration = 0;
940
+ const isBufferingInitialSync = () => syncMode === `progressive` && !hasReceivedUpToDate && !isResettingSnapshot;
813
941
  const bufferedMessages = [];
814
942
  const syncedKeys = /* @__PURE__ */ new Set();
943
+ let resumeInvalid = false;
815
944
  const stageResumeMetadata = () => {
945
+ if (!isActiveLifecycle() || resumeInvalid) {
946
+ return;
947
+ }
816
948
  const shapeHandle = stream.shapeHandle;
817
949
  const lastOffset = stream.lastOffset;
818
950
  if (!shapeHandle || lastOffset === `-1`) {
@@ -823,9 +955,10 @@ function createElectricSync(shapeOptions, options) {
823
955
  offset: lastOffset,
824
956
  handle: shapeHandle,
825
957
  shapeId: shapeIdentity,
826
- updatedAt: Date.now()
958
+ updatedAt: Date.now(),
959
+ requiresTagState: tagState.hasTags()
827
960
  };
828
- hydratedResumeState.setState(() => resumeState);
961
+ lifecycle.resumeState = resumeState;
829
962
  metadata?.collection.set(`electric:resume`, resumeState);
830
963
  };
831
964
  const commitResetResumeMetadataImmediately = () => {
@@ -833,14 +966,14 @@ function createElectricSync(shapeOptions, options) {
833
966
  kind: `reset`,
834
967
  updatedAt: Date.now()
835
968
  };
836
- hydratedResumeState.setState(() => resetState);
969
+ lifecycle.resumeState = resetState;
837
970
  if (metadata) {
838
971
  begin({ immediate: true });
839
972
  metadata.collection.set(`electric:resume`, resetState);
840
973
  commit();
841
974
  }
842
975
  };
843
- if (hasIncompatiblePersistedResume) {
976
+ if (hasIncompatiblePersistedResume || hasUnverifiablePersistedResume || needsFullSnapshot && persistedResumeState.kind === `resume`) {
844
977
  commitResetResumeMetadataImmediately();
845
978
  }
846
979
  const processChangeMessage = (changeMessage) => {
@@ -895,42 +1028,90 @@ function createElectricSync(shapeOptions, options) {
895
1028
  // Pass abort signal so requestSnapshot errors can be ignored during cleanup
896
1029
  signal: abortController.signal
897
1030
  });
898
- unsubscribeStream = stream.subscribe((messages) => {
1031
+ const resumeKeysPromise = requiresCompleteResume || freshSnapshotPending ? whenHydrated?.() : void 0;
1032
+ let areResumeKeysReady = !resumeKeysPromise;
1033
+ const pendingResumeBatches = [];
1034
+ let unsubscribeStream = () => {
1035
+ };
1036
+ const invalidateResume = () => {
1037
+ resumeInvalid = true;
1038
+ if (transactionStarted) {
1039
+ const cancellation = new AbortController();
1040
+ cancellation.abort();
1041
+ commit(cancellation.signal);
1042
+ transactionStarted = false;
1043
+ }
1044
+ syncedKeys.clear();
1045
+ newTxids.clear();
1046
+ newSnapshots.length = 0;
1047
+ commitResetResumeMetadataImmediately();
1048
+ streamErrorVersion++;
1049
+ unsubscribeStream();
1050
+ abortController.abort();
1051
+ markError(
1052
+ new Error(
1053
+ `Electric resume state referenced an unseen row; a full snapshot is required`
1054
+ )
1055
+ );
1056
+ };
1057
+ const processMessages = (messages) => {
1058
+ if (!isActiveLifecycle() || resumeInvalid) {
1059
+ return;
1060
+ }
1061
+ if (freshSnapshotPending) {
1062
+ freshSnapshotPending = false;
1063
+ begin();
1064
+ transactionStarted = true;
1065
+ truncate();
1066
+ syncedKeys.clear();
1067
+ clearTagTrackingState();
1068
+ isResettingSnapshot = true;
1069
+ resetGeneration++;
1070
+ }
1071
+ const pendingPresence = /* @__PURE__ */ new Map();
1072
+ let usesBaseline = true;
1073
+ for (const pending of collection._state.pendingSyncedTransactions) {
1074
+ if (pending.truncate) {
1075
+ pendingPresence.clear();
1076
+ usesBaseline = false;
1077
+ }
1078
+ for (const operation of pending.operations) {
1079
+ pendingPresence.set(operation.key, operation.type !== `delete`);
1080
+ }
1081
+ }
1082
+ for (const message of bufferedMessages) {
1083
+ if (isChangeMessage(message)) {
1084
+ pendingPresence.set(
1085
+ collection.getKeyFromItem(message.value),
1086
+ message.headers.operation !== `delete`
1087
+ );
1088
+ }
1089
+ }
899
1090
  let commitPoint = null;
900
- batchCommitted.setState(() => false);
1091
+ lifecycle.beginMatchGeneration(messages);
901
1092
  for (const message of messages) {
902
- if (isChangeMessage(message) || isMoveOutMessage(message) || isMoveInMessage(message)) {
903
- currentBatchMessages.setState((currentBuffer) => {
904
- const newBuffer = [...currentBuffer, message];
905
- if (newBuffer.length > MAX_BATCH_MESSAGES) {
906
- newBuffer.splice(0, newBuffer.length - MAX_BATCH_MESSAGES);
907
- }
908
- return newBuffer;
909
- });
910
- }
1093
+ lifecycle.observeMatchMessage(message);
1094
+ if (!isActiveLifecycle()) return;
911
1095
  if (hasTxids(message) && (!isBufferingInitialSync() || transactionStarted)) {
912
1096
  message.headers.txids?.forEach((txid) => newTxids.add(txid));
913
1097
  }
914
- const matchesToRemove = [];
915
- pendingMatches.state.forEach((match, matchId) => {
916
- if (!match.matched) {
917
- try {
918
- match.matchFn(message);
919
- } catch (err) {
920
- clearTimeout(match.timeoutId);
921
- match.reject(
922
- err instanceof Error ? err : new Error(String(err))
923
- );
924
- matchesToRemove.push(matchId);
925
- debug(`matchFn error: %o`, err);
1098
+ if (isChangeMessage(message)) {
1099
+ const rowId = collection.getKeyFromItem(message.value);
1100
+ const operation = message.headers.operation;
1101
+ const hasKnownRow = pendingPresence.get(rowId) ?? (usesBaseline && collection._state.syncedData.has(rowId));
1102
+ if (operation === `update` && !hasKnownRow) {
1103
+ if (requiresCompleteResume && !isResettingSnapshot) {
1104
+ invalidateResume();
1105
+ return;
926
1106
  }
1107
+ if (!receivesCompleteRows) continue;
927
1108
  }
928
- });
929
- removePendingMatches(matchesToRemove);
1109
+ pendingPresence.set(rowId, operation !== `delete`);
1110
+ }
930
1111
  if (isChangeMessage(message)) {
931
1112
  const schema = message.headers.schema;
932
1113
  if (schema && typeof schema === `string`) {
933
- relationSchema.setState(() => schema);
1114
+ relationSchema = schema;
934
1115
  }
935
1116
  if (isBufferingInitialSync() && !transactionStarted) {
936
1117
  bufferedMessages.push(message);
@@ -959,7 +1140,11 @@ function createElectricSync(shapeOptions, options) {
959
1140
  message.headers.patterns,
960
1141
  begin,
961
1142
  write,
962
- transactionStarted
1143
+ transactionStarted,
1144
+ (rowId) => {
1145
+ pendingPresence.set(rowId, false);
1146
+ syncedKeys.delete(rowId);
1147
+ }
963
1148
  );
964
1149
  }
965
1150
  } else if (isMoveInMessage(message)) {
@@ -980,15 +1165,23 @@ function createElectricSync(shapeOptions, options) {
980
1165
  truncate();
981
1166
  clearTagTrackingState();
982
1167
  syncedKeys.clear();
1168
+ pendingPresence.clear();
1169
+ usesBaseline = false;
1170
+ isResettingSnapshot = true;
1171
+ resetGeneration++;
983
1172
  loadSubsetDedupe?.reset();
984
1173
  commitPoint = null;
985
1174
  hasReceivedUpToDate = false;
986
1175
  bufferedMessages.length = 0;
987
1176
  }
988
1177
  }
1178
+ if (needsFullSnapshot && isResettingSnapshot && commitPoint === `subset-end`)
1179
+ return;
989
1180
  if (commitPoint !== null) {
990
1181
  let applied = true;
991
1182
  const wasBufferingInitialSync = isBufferingInitialSync();
1183
+ const finishesReset = isResettingSnapshot && commitPoint === `up-to-date`;
1184
+ const finishingResetGeneration = resetGeneration;
992
1185
  if (isBufferingInitialSync() && commitPoint === `up-to-date` && !transactionStarted) {
993
1186
  debug(
994
1187
  `${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Performing atomic swap with ${bufferedMessages.length} buffered messages`
@@ -1012,7 +1205,13 @@ function createElectricSync(shapeOptions, options) {
1012
1205
  bufferedMsg.headers.patterns,
1013
1206
  begin,
1014
1207
  write,
1015
- transactionStarted
1208
+ // The swap already opened a transaction, even though the
1209
+ // normal-stream transactionStarted flag is still false.
1210
+ true,
1211
+ (rowId) => {
1212
+ pendingPresence.set(rowId, false);
1213
+ syncedKeys.delete(rowId);
1214
+ }
1016
1215
  );
1017
1216
  } else if (isMoveInMessage(bufferedMsg)) {
1018
1217
  processMoveInEvent(bufferedMsg.headers.patterns);
@@ -1026,7 +1225,9 @@ function createElectricSync(shapeOptions, options) {
1026
1225
  );
1027
1226
  } else {
1028
1227
  if (transactionStarted) {
1029
- stageResumeMetadata();
1228
+ if (!isResettingSnapshot || finishesReset) {
1229
+ stageResumeMetadata();
1230
+ }
1030
1231
  applied = commit();
1031
1232
  transactionStarted = false;
1032
1233
  } else if (commitPoint === `up-to-date` && metadata) {
@@ -1044,48 +1245,81 @@ function createElectricSync(shapeOptions, options) {
1044
1245
  () => void 0
1045
1246
  );
1046
1247
  }
1248
+ if (finishesReset) {
1249
+ const finishReset = () => {
1250
+ if (resetGeneration === finishingResetGeneration) {
1251
+ isResettingSnapshot = false;
1252
+ }
1253
+ };
1254
+ if (applied === true) {
1255
+ finishReset();
1256
+ } else {
1257
+ void applied.then(finishReset, () => void 0);
1258
+ }
1259
+ }
1047
1260
  if (commitPoint === `up-to-date`) {
1048
1261
  hasReceivedUpToDate = true;
1049
1262
  }
1050
- seenTxids.setState((currentTxids) => {
1051
- const clonedSeen = new Set(currentTxids);
1052
- if (newTxids.size > 0) {
1053
- debug(
1054
- `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
1055
- Array.from(newTxids)
1056
- );
1057
- }
1058
- newTxids.forEach((txid) => clonedSeen.add(txid));
1059
- newTxids.clear();
1060
- return clonedSeen;
1061
- });
1062
- seenSnapshots.setState((currentSnapshots) => {
1063
- const seen = [...currentSnapshots, ...newSnapshots];
1064
- newSnapshots.forEach(
1065
- (snapshot) => debug(
1066
- `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
1067
- snapshot
1068
- )
1263
+ if (newTxids.size > 0) {
1264
+ debug(
1265
+ `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
1266
+ Array.from(newTxids)
1069
1267
  );
1070
- newSnapshots.length = 0;
1071
- return seen;
1072
- });
1073
- batchCommitted.setState(() => true);
1074
- resolveMatchedPendingMatches();
1268
+ }
1269
+ newSnapshots.forEach(
1270
+ (snapshot) => debug(
1271
+ `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
1272
+ snapshot
1273
+ )
1274
+ );
1275
+ lifecycle.publishEvidence(newTxids, newSnapshots);
1276
+ newTxids.clear();
1277
+ newSnapshots.length = 0;
1278
+ lifecycle.commitMatches();
1075
1279
  }
1280
+ };
1281
+ unsubscribeStream = stream.subscribe((messages) => {
1282
+ if (!areResumeKeysReady) {
1283
+ pendingResumeBatches.push([...messages]);
1284
+ return;
1285
+ }
1286
+ processMessages(messages);
1076
1287
  });
1288
+ if (!areResumeKeysReady && resumeKeysPromise) {
1289
+ void resumeKeysPromise.then(
1290
+ () => {
1291
+ if (abortController.signal.aborted) return;
1292
+ areResumeKeysReady = true;
1293
+ const queuedBatches = pendingResumeBatches.splice(0);
1294
+ queuedBatches.forEach(processMessages);
1295
+ },
1296
+ (error) => {
1297
+ if (abortController.signal.aborted) return;
1298
+ pendingResumeBatches.length = 0;
1299
+ resumeInvalid = true;
1300
+ commitResetResumeMetadataImmediately();
1301
+ streamErrorVersion++;
1302
+ unsubscribeStream();
1303
+ abortController.abort();
1304
+ markError(error);
1305
+ }
1306
+ );
1307
+ }
1077
1308
  return {
1078
1309
  loadSubset: loadSubsetDedupe?.loadSubset,
1079
1310
  cleanup: () => {
1311
+ shapeOptions.signal?.removeEventListener(
1312
+ `abort`,
1313
+ forwardExternalAbort
1314
+ );
1080
1315
  unsubscribeStream();
1081
1316
  abortController.abort();
1317
+ pendingResumeBatches.length = 0;
1082
1318
  loadSubsetDedupe?.reset();
1083
- hydratedResumeState.setState(() => void 0);
1319
+ lifecycle.retire(lifecycleEpoch);
1084
1320
  }
1085
1321
  };
1086
- },
1087
- // Expose the getSyncMetadata function
1088
- getSyncMetadata
1322
+ }
1089
1323
  };
1090
1324
  }
1091
1325
  export {