@tanstack/electric-db-collection 0.4.8 → 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, LoadSubsetOperationAbortedError } 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`;
@@ -278,203 +297,239 @@ function createLoadSubsetDedupe({
278
297
  };
279
298
  return new DeduplicatedLoadSubset({ loadSubset });
280
299
  }
281
- function electricCollectionOptions(config) {
282
- const seenTxids = new Store(/* @__PURE__ */ new Set([]));
283
- const seenSnapshots = new Store([]);
284
- const hydratedResumeState = new Store(
285
- void 0
286
- );
287
- const internalSyncMode = config.syncMode ?? `eager`;
288
- const finalSyncMode = internalSyncMode === `progressive` ? `on-demand` : internalSyncMode;
289
- const pendingMatches = new Store(/* @__PURE__ */ new Map());
290
- const currentBatchMessages = new Store([]);
291
- const batchCommitted = new Store(false);
292
- const removePendingMatches = (matchIds) => {
293
- if (matchIds.length > 0) {
294
- pendingMatches.setState((current) => {
295
- const newMatches = new Map(current);
296
- matchIds.forEach((id) => newMatches.delete(id));
297
- return newMatches;
298
- });
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));
299
338
  }
300
- };
301
- const resolveMatchedPendingMatches = () => {
302
- const matchesToResolve = [];
303
- pendingMatches.state.forEach((match, matchId) => {
304
- if (match.matched) {
305
- clearTimeout(match.timeoutId);
306
- match.resolve(true);
307
- matchesToResolve.push(matchId);
308
- debug(
309
- `${config.id ? `[${config.id}] ` : ``}awaitMatch resolved on up-to-date for match %s`,
310
- matchId
311
- );
312
- }
313
- });
314
- removePendingMatches(matchesToResolve);
315
- };
316
- const sync = createElectricSync(config.shapeOptions, {
317
- seenTxids,
318
- seenSnapshots,
319
- hydratedResumeState,
320
- syncMode: internalSyncMode,
321
- pendingMatches,
322
- currentBatchMessages,
323
- batchCommitted,
324
- removePendingMatches,
325
- resolveMatchedPendingMatches,
326
- collectionId: config.id,
327
- testHooks: config[ELECTRIC_TEST_HOOKS]
328
- });
329
- 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) {
330
380
  debug(
331
- `${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`,
381
+ `${this.collectionId ? `[${this.collectionId}] ` : ``}awaitTxId called with txid %d`,
332
382
  txId
333
383
  );
334
384
  if (typeof txId !== `number`) {
335
- throw new ExpectedNumberInAwaitTxIdError(typeof txId, config.id);
385
+ throw new ExpectedNumberInAwaitTxIdError(typeof txId, this.collectionId);
336
386
  }
337
- const hasTxid = seenTxids.state.has(txId);
338
- if (hasTxid) return true;
339
- const hasSnapshot = seenSnapshots.state.some(
340
- (snapshot) => isVisibleInSnapshot(txId, snapshot)
341
- );
342
- if (hasSnapshot) return true;
387
+ if (this.hasTxid(txId)) return true;
343
388
  return new Promise((resolve, reject) => {
344
- const cleanup = () => {
345
- clearTimeout(timeoutId);
346
- subSeenTxids.unsubscribe();
347
- subSeenSnapshots.unsubscribe();
348
- };
389
+ const waitId = this.nextWaiterId++;
349
390
  const timeoutId = setTimeout(() => {
350
- cleanup();
351
- reject(new TimeoutWaitingForTxIdError(txId, config.id));
391
+ this.pendingTxidWaits.delete(waitId);
392
+ reject(new TimeoutWaitingForTxIdError(txId, this.collectionId));
352
393
  }, timeout);
353
- const subSeenTxids = seenTxids.subscribe(() => {
354
- if (seenTxids.state.has(txId)) {
355
- debug(
356
- `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`,
357
- txId
358
- );
359
- cleanup();
360
- resolve(true);
361
- }
362
- });
363
- const subSeenSnapshots = seenSnapshots.subscribe(() => {
364
- const visibleSnapshot = seenSnapshots.state.find(
365
- (snapshot) => isVisibleInSnapshot(txId, snapshot)
366
- );
367
- if (visibleSnapshot) {
368
- debug(
369
- `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o in snapshot %o`,
370
- txId,
371
- visibleSnapshot
372
- );
373
- cleanup();
374
- resolve(true);
375
- }
394
+ this.pendingTxidWaits.set(waitId, {
395
+ txId,
396
+ resolve,
397
+ reject,
398
+ timeoutId
376
399
  });
377
400
  });
378
- };
379
- const awaitMatch = async (matchFn, timeout = 3e3) => {
401
+ }
402
+ async awaitMatch(matchFn, timeout = 3e3) {
380
403
  debug(
381
- `${config.id ? `[${config.id}] ` : ``}awaitMatch called with custom function`
404
+ `${this.collectionId ? `[${this.collectionId}] ` : ``}awaitMatch called with custom function`
382
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) {
383
416
  return new Promise((resolve, reject) => {
384
- const matchId = Math.random().toString(36);
385
- const cleanupMatch = () => {
386
- pendingMatches.setState((current) => {
387
- const newMatches = new Map(current);
388
- newMatches.delete(matchId);
389
- return newMatches;
390
- });
391
- };
392
- const onTimeout = () => {
393
- cleanupMatch();
394
- reject(new TimeoutWaitingForMatchError(config.id));
395
- };
396
- const timeoutId = setTimeout(onTimeout, timeout);
397
- const checkMatch = (message) => {
398
- if (matchFn(message)) {
399
- debug(
400
- `${config.id ? `[${config.id}] ` : ``}awaitMatch found matching message, waiting for up-to-date`
401
- );
402
- pendingMatches.setState((current) => {
403
- const newMatches = new Map(current);
404
- const existing = newMatches.get(matchId);
405
- if (existing) {
406
- newMatches.set(matchId, { ...existing, matched: true });
407
- }
408
- return newMatches;
409
- });
410
- return true;
411
- }
412
- return false;
413
- };
414
- for (const message of currentBatchMessages.state) {
415
- if (matchFn(message)) {
416
- if (batchCommitted.state) {
417
- debug(
418
- `${config.id ? `[${config.id}] ` : ``}awaitMatch found immediate match in committed batch, resolving immediately`
419
- );
420
- clearTimeout(timeoutId);
421
- resolve(true);
422
- return;
423
- }
424
- debug(
425
- `${config.id ? `[${config.id}] ` : ``}awaitMatch found immediate match in current batch, waiting for up-to-date`
426
- );
427
- pendingMatches.setState((current) => {
428
- const newMatches = new Map(current);
429
- newMatches.set(matchId, {
430
- matchFn: checkMatch,
431
- resolve,
432
- reject,
433
- timeoutId,
434
- matched: true
435
- // Already matched, will resolve on up-to-date
436
- });
437
- return newMatches;
438
- });
439
- return;
440
- }
441
- }
442
- pendingMatches.setState((current) => {
443
- const newMatches = new Map(current);
444
- newMatches.set(matchId, {
445
- matchFn: checkMatch,
446
- resolve,
447
- reject,
448
- timeoutId,
449
- matched: false
450
- });
451
- 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
452
428
  });
453
429
  });
454
- };
455
- 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) => {
456
498
  if (result && `txid` in result) {
457
499
  const timeout = result.timeout;
458
500
  if (Array.isArray(result.txid)) {
459
- await Promise.all(result.txid.map((txid) => awaitTxId(txid, timeout)));
501
+ await Promise.all(result.txid.map((txid) => waitForTxId(txid, timeout)));
460
502
  } else {
461
- await awaitTxId(result.txid, timeout);
503
+ await waitForTxId(result.txid, timeout);
462
504
  }
463
505
  }
464
506
  };
507
+ const getMutationAwaitTxId = (params) => {
508
+ const collection = params.collection;
509
+ return collection?.utils?.awaitTxId ?? awaitTxId;
510
+ };
465
511
  const wrappedOnInsert = config.onInsert ? async (params) => {
466
512
  const handlerResult = await config.onInsert(params);
467
- await processMatchingStrategy(handlerResult);
513
+ await processMatchingStrategy(
514
+ handlerResult,
515
+ getMutationAwaitTxId(params)
516
+ );
468
517
  return handlerResult;
469
518
  } : void 0;
470
519
  const wrappedOnUpdate = config.onUpdate ? async (params) => {
471
520
  const handlerResult = await config.onUpdate(params);
472
- await processMatchingStrategy(handlerResult);
521
+ await processMatchingStrategy(
522
+ handlerResult,
523
+ getMutationAwaitTxId(params)
524
+ );
473
525
  return handlerResult;
474
526
  } : void 0;
475
527
  const wrappedOnDelete = config.onDelete ? async (params) => {
476
528
  const handlerResult = await config.onDelete(params);
477
- await processMatchingStrategy(handlerResult);
529
+ await processMatchingStrategy(
530
+ handlerResult,
531
+ getMutationAwaitTxId(params)
532
+ );
478
533
  return handlerResult;
479
534
  } : void 0;
480
535
  const {
@@ -484,236 +539,275 @@ function electricCollectionOptions(config) {
484
539
  onDelete: _onDelete,
485
540
  ...restConfig
486
541
  } = config;
487
- const options = {
488
- ...restConfig,
489
- syncMode: finalSyncMode,
490
- sync: {
491
- ...sync,
492
- exportSyncMeta: () => ({
493
- version: 1,
494
- ...hydratedResumeState.state ? { resume: hydratedResumeState.state } : {},
495
- seenTxids: Array.from(seenTxids.state).sort((a, b) => a - b)
496
- }),
497
- importSyncMeta: (meta) => {
498
- const parsed = parseElectricSyncMeta(meta);
499
- if (!parsed) {
500
- 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);
501
562
  }
502
- hydratedResumeState.setState(() => parsed.resume);
503
- seenTxids.setState(() => new Set(parsed.seenTxids));
563
+ return source.sync(params);
504
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),
505
579
  mergeSyncMeta: mergeElectricSyncMeta
506
580
  },
581
+ createBoundSync
582
+ );
583
+ const options = {
584
+ ...restConfig,
585
+ syncMode: finalSyncMode,
586
+ sync: syncTemplate,
507
587
  onInsert: wrappedOnInsert,
508
588
  onUpdate: wrappedOnUpdate,
509
589
  onDelete: wrappedOnDelete,
510
- utils: {
511
- awaitTxId,
512
- awaitMatch
513
- }
590
+ utils: utilityTemplate
514
591
  };
592
+ Object.defineProperty(options, `utils`, {
593
+ enumerable: true,
594
+ get: () => ({ ...utilityTemplate })
595
+ });
515
596
  return withCollectionConfigFactory(
516
597
  options,
517
598
  () => electricCollectionOptions(config)
518
599
  );
519
600
  }
520
601
  function createElectricSync(shapeOptions, options) {
521
- const {
522
- seenTxids,
523
- seenSnapshots,
524
- hydratedResumeState,
525
- syncMode,
526
- pendingMatches,
527
- currentBatchMessages,
528
- batchCommitted,
529
- removePendingMatches,
530
- resolveMatchedPendingMatches,
531
- collectionId,
532
- testHooks
533
- } = options;
534
- const MAX_BATCH_MESSAGES = 1e3;
535
- const relationSchema = new Store(void 0);
536
- const tagCache = /* @__PURE__ */ new Map();
537
- const parseTag$1 = (tag) => {
538
- const cachedTag = tagCache.get(tag);
539
- if (cachedTag) {
540
- return cachedTag;
541
- }
542
- const parsedTag = parseTag(tag);
543
- tagCache.set(tag, parsedTag);
544
- return parsedTag;
545
- };
546
- const rowTagSets = /* @__PURE__ */ new Map();
547
- const tagIndex = [];
548
- let tagLength = void 0;
549
- const rowActiveConditions = /* @__PURE__ */ new Map();
550
- let disjunctPositions = void 0;
551
- const initializeTagIndex = (length) => {
552
- if (tagIndex.length < length) {
553
- for (let i = tagIndex.length; i < length; i++) {
554
- 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;
555
611
  }
556
- }
557
- };
558
- const addTagsToRow = (tags, rowId, rowTagSet) => {
559
- for (const tag of tags) {
560
- const parsedTag = parseTag$1(tag);
561
- if (tagLength === void 0) {
562
- tagLength = getTagLength(parsedTag);
563
- 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
+ }
564
626
  }
565
- const currentTagLength = getTagLength(parsedTag);
566
- if (currentTagLength !== tagLength) {
567
- debug(
568
- `${collectionId ? `[${collectionId}] ` : ``}Tag length mismatch: expected ${tagLength}, got ${currentTagLength}`
569
- );
570
- 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);
571
644
  }
572
- rowTagSet.add(tag);
573
- addTagToIndex(parsedTag, rowId, tagIndex, tagLength);
574
- }
575
- };
576
- const removeTagsFromRow = (removedTags, rowId, rowTagSet) => {
577
- if (tagLength === void 0) {
578
- return;
579
- }
580
- for (const tag of removedTags) {
581
- const parsedTag = parseTag$1(tag);
582
- rowTagSet.delete(tag);
583
- removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength);
584
- tagCache.delete(tag);
585
- }
586
- };
587
- const processTagsForChangeMessage = (tags, removedTags, rowId, activeConditions) => {
588
- if (!rowTagSets.has(rowId)) {
589
- rowTagSets.set(rowId, /* @__PURE__ */ new Set());
590
- }
591
- const rowTagSet = rowTagSets.get(rowId);
592
- if (tags) {
593
- addTagsToRow(tags, rowId, rowTagSet);
594
- if (disjunctPositions === void 0) {
595
- const parsedTags = tags.map(parseTag$1);
596
- disjunctPositions = deriveDisjunctPositions(parsedTags);
645
+ };
646
+ const removeTagsFromRow = (removedTags, rowId, rowTagSet) => {
647
+ if (tagLength === void 0) {
648
+ return;
597
649
  }
598
- }
599
- if (removedTags) {
600
- removeTagsFromRow(removedTags, rowId, rowTagSet);
601
- }
602
- if (activeConditions && activeConditions.length > 0) {
603
- rowActiveConditions.set(rowId, [...activeConditions]);
604
- }
605
- return rowTagSet;
606
- };
607
- const clearTagTrackingState = () => {
608
- rowTagSets.clear();
609
- tagIndex.length = 0;
610
- tagLength = void 0;
611
- rowActiveConditions.clear();
612
- disjunctPositions = void 0;
613
- };
614
- const clearTagsForRow = (rowId) => {
615
- if (tagLength === void 0) {
616
- return;
617
- }
618
- const rowTagSet = rowTagSets.get(rowId);
619
- if (!rowTagSet) {
620
- return;
621
- }
622
- for (const tag of rowTagSet) {
623
- const parsedTag = parseTag$1(tag);
624
- const currentTagLength = getTagLength(parsedTag);
625
- if (currentTagLength === tagLength) {
650
+ for (const tag of removedTags) {
651
+ const parsedTag = parseTag$1(tag);
652
+ rowTagSet.delete(tag);
626
653
  removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength);
654
+ tagCache.delete(tag);
627
655
  }
628
- tagCache.delete(tag);
629
- }
630
- rowTagSets.delete(rowId);
631
- rowActiveConditions.delete(rowId);
632
- };
633
- const removeMatchingTagsFromRow = (rowId, pattern) => {
634
- const rowTagSet = rowTagSets.get(rowId);
635
- if (!rowTagSet) {
636
- return false;
637
- }
638
- const activeConditions = rowActiveConditions.get(rowId);
639
- if (activeConditions && disjunctPositions) {
640
- activeConditions[pattern.pos] = false;
641
- if (!rowVisible(activeConditions, disjunctPositions)) {
642
- for (const tag of rowTagSet) {
643
- 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) {
644
696
  removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength);
645
- tagCache.delete(tag);
646
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) {
647
731
  rowTagSets.delete(rowId);
648
- rowActiveConditions.delete(rowId);
649
732
  return true;
650
733
  }
651
734
  return false;
652
- }
653
- for (const tag of rowTagSet) {
654
- const parsedTag = parseTag$1(tag);
655
- if (tagMatchesPattern(parsedTag, pattern)) {
656
- rowTagSet.delete(tag);
657
- 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;
658
742
  }
659
- }
660
- if (rowTagSet.size === 0) {
661
- rowTagSets.delete(rowId);
662
- return true;
663
- }
664
- return false;
665
- };
666
- const processMoveOutEvent = (patterns, begin, write, transactionStarted) => {
667
- if (tagLength === void 0) {
668
- debug(
669
- `${collectionId ? `[${collectionId}] ` : ``}Received move-out message but no tag length set yet, ignoring`
670
- );
671
- return transactionStarted;
672
- }
673
- let txStarted = transactionStarted;
674
- for (const pattern of patterns) {
675
- const affectedRowIds = findRowsMatchingPattern(pattern, tagIndex);
676
- for (const rowId of affectedRowIds) {
677
- if (removeMatchingTagsFromRow(rowId, pattern)) {
678
- if (!txStarted) {
679
- begin();
680
- 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);
681
757
  }
682
- write({
683
- type: `delete`,
684
- key: rowId
685
- });
686
758
  }
687
759
  }
688
- }
689
- return txStarted;
690
- };
691
- const processMoveInEvent = (patterns) => {
692
- if (tagLength === void 0) {
693
- debug(
694
- `${collectionId ? `[${collectionId}] ` : ``}Received move-in message but no tag length set yet, ignoring`
695
- );
696
- return;
697
- }
698
- for (const pattern of patterns) {
699
- const affectedRowIds = findRowsMatchingPattern(pattern, tagIndex);
700
- for (const rowId of affectedRowIds) {
701
- const activeConditions = rowActiveConditions.get(rowId);
702
- if (activeConditions) {
703
- 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
+ }
704
776
  }
705
777
  }
706
- }
707
- };
708
- const getSyncMetadata = () => {
709
- const schema = relationSchema.state || `public`;
778
+ };
710
779
  return {
711
- 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
712
786
  };
713
787
  };
714
- let unsubscribeStream;
788
+ const collectionTags = /* @__PURE__ */ new WeakMap();
715
789
  return {
790
+ getSyncMetadata: () => ({
791
+ relation: shapeOptions.params?.table ? [relationSchema || `public`, shapeOptions.params.table] : void 0
792
+ }),
716
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);
717
811
  const {
718
812
  begin,
719
813
  write,
@@ -749,16 +843,36 @@ function createElectricSync(shapeOptions, options) {
749
843
  const persistedResumeState2 = metadata?.collection.get(`electric:resume`);
750
844
  return parseElectricResumeState(persistedResumeState2);
751
845
  };
846
+ const persistedMetadata = metadata;
847
+ const scanPersisted = persistedMetadata?.row.scanPersisted;
848
+ const whenHydrated = persistedMetadata?.row.whenHydrated;
752
849
  const persistedResumeState = getNewestElectricResumeState(
753
850
  readPersistedResumeState(),
754
- hydratedResumeState.state
851
+ lifecycle.resumeState
755
852
  );
756
853
  const shapeIdentity = getStableShapeIdentity({
757
854
  url: shapeOptions.url,
758
855
  params: shapeOptions.params
759
856
  });
760
857
  const hasIncompatiblePersistedResume = persistedResumeState?.kind === `resume` && persistedResumeState.shapeId !== shapeIdentity;
761
- 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;
762
876
  let progressiveReadyGate = null;
763
877
  let streamErrorVersion = 0;
764
878
  const wrappedMarkReady = (isBuffering, expectedErrorVersion = streamErrorVersion) => {
@@ -785,21 +899,16 @@ function createElectricSync(shapeOptions, options) {
785
899
  }
786
900
  }
787
901
  abortController.signal.addEventListener(`abort`, () => {
788
- pendingMatches.setState((current) => {
789
- current.forEach((match) => {
790
- clearTimeout(match.timeoutId);
791
- match.reject(new StreamAbortedError());
792
- });
793
- return /* @__PURE__ */ new Map();
794
- });
902
+ lifecycle.retire(lifecycleEpoch);
795
903
  });
796
904
  const stream = new ShapeStream({
797
905
  ...shapeOptions,
798
- // In on-demand mode, we only want to sync changes, so we set the log to `changes_only`
799
- 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,
800
909
  // In on-demand mode, we only need the changes from the point of time the collection was created
801
910
  // so we default to `now` when there is no saved offset.
802
- 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),
803
912
  handle: shapeOptions.handle ?? (canUsePersistedResume ? persistedResumeState.handle : void 0),
804
913
  signal: abortController.signal,
805
914
  onError: (errorParams) => {
@@ -825,11 +934,17 @@ function createElectricSync(shapeOptions, options) {
825
934
  let transactionStarted = false;
826
935
  const newTxids = /* @__PURE__ */ new Set();
827
936
  const newSnapshots = [];
828
- let hasReceivedUpToDate = syncMode === `progressive` && canUsePersistedResume;
829
- 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;
830
941
  const bufferedMessages = [];
831
942
  const syncedKeys = /* @__PURE__ */ new Set();
943
+ let resumeInvalid = false;
832
944
  const stageResumeMetadata = () => {
945
+ if (!isActiveLifecycle() || resumeInvalid) {
946
+ return;
947
+ }
833
948
  const shapeHandle = stream.shapeHandle;
834
949
  const lastOffset = stream.lastOffset;
835
950
  if (!shapeHandle || lastOffset === `-1`) {
@@ -840,9 +955,10 @@ function createElectricSync(shapeOptions, options) {
840
955
  offset: lastOffset,
841
956
  handle: shapeHandle,
842
957
  shapeId: shapeIdentity,
843
- updatedAt: Date.now()
958
+ updatedAt: Date.now(),
959
+ requiresTagState: tagState.hasTags()
844
960
  };
845
- hydratedResumeState.setState(() => resumeState);
961
+ lifecycle.resumeState = resumeState;
846
962
  metadata?.collection.set(`electric:resume`, resumeState);
847
963
  };
848
964
  const commitResetResumeMetadataImmediately = () => {
@@ -850,14 +966,14 @@ function createElectricSync(shapeOptions, options) {
850
966
  kind: `reset`,
851
967
  updatedAt: Date.now()
852
968
  };
853
- hydratedResumeState.setState(() => resetState);
969
+ lifecycle.resumeState = resetState;
854
970
  if (metadata) {
855
971
  begin({ immediate: true });
856
972
  metadata.collection.set(`electric:resume`, resetState);
857
973
  commit();
858
974
  }
859
975
  };
860
- if (hasIncompatiblePersistedResume) {
976
+ if (hasIncompatiblePersistedResume || hasUnverifiablePersistedResume || needsFullSnapshot && persistedResumeState.kind === `resume`) {
861
977
  commitResetResumeMetadataImmediately();
862
978
  }
863
979
  const processChangeMessage = (changeMessage) => {
@@ -912,42 +1028,90 @@ function createElectricSync(shapeOptions, options) {
912
1028
  // Pass abort signal so requestSnapshot errors can be ignored during cleanup
913
1029
  signal: abortController.signal
914
1030
  });
915
- 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
+ }
916
1090
  let commitPoint = null;
917
- batchCommitted.setState(() => false);
1091
+ lifecycle.beginMatchGeneration(messages);
918
1092
  for (const message of messages) {
919
- if (isChangeMessage(message) || isMoveOutMessage(message) || isMoveInMessage(message)) {
920
- currentBatchMessages.setState((currentBuffer) => {
921
- const newBuffer = [...currentBuffer, message];
922
- if (newBuffer.length > MAX_BATCH_MESSAGES) {
923
- newBuffer.splice(0, newBuffer.length - MAX_BATCH_MESSAGES);
924
- }
925
- return newBuffer;
926
- });
927
- }
1093
+ lifecycle.observeMatchMessage(message);
1094
+ if (!isActiveLifecycle()) return;
928
1095
  if (hasTxids(message) && (!isBufferingInitialSync() || transactionStarted)) {
929
1096
  message.headers.txids?.forEach((txid) => newTxids.add(txid));
930
1097
  }
931
- const matchesToRemove = [];
932
- pendingMatches.state.forEach((match, matchId) => {
933
- if (!match.matched) {
934
- try {
935
- match.matchFn(message);
936
- } catch (err) {
937
- clearTimeout(match.timeoutId);
938
- match.reject(
939
- err instanceof Error ? err : new Error(String(err))
940
- );
941
- matchesToRemove.push(matchId);
942
- 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;
943
1106
  }
1107
+ if (!receivesCompleteRows) continue;
944
1108
  }
945
- });
946
- removePendingMatches(matchesToRemove);
1109
+ pendingPresence.set(rowId, operation !== `delete`);
1110
+ }
947
1111
  if (isChangeMessage(message)) {
948
1112
  const schema = message.headers.schema;
949
1113
  if (schema && typeof schema === `string`) {
950
- relationSchema.setState(() => schema);
1114
+ relationSchema = schema;
951
1115
  }
952
1116
  if (isBufferingInitialSync() && !transactionStarted) {
953
1117
  bufferedMessages.push(message);
@@ -976,7 +1140,11 @@ function createElectricSync(shapeOptions, options) {
976
1140
  message.headers.patterns,
977
1141
  begin,
978
1142
  write,
979
- transactionStarted
1143
+ transactionStarted,
1144
+ (rowId) => {
1145
+ pendingPresence.set(rowId, false);
1146
+ syncedKeys.delete(rowId);
1147
+ }
980
1148
  );
981
1149
  }
982
1150
  } else if (isMoveInMessage(message)) {
@@ -997,15 +1165,23 @@ function createElectricSync(shapeOptions, options) {
997
1165
  truncate();
998
1166
  clearTagTrackingState();
999
1167
  syncedKeys.clear();
1168
+ pendingPresence.clear();
1169
+ usesBaseline = false;
1170
+ isResettingSnapshot = true;
1171
+ resetGeneration++;
1000
1172
  loadSubsetDedupe?.reset();
1001
1173
  commitPoint = null;
1002
1174
  hasReceivedUpToDate = false;
1003
1175
  bufferedMessages.length = 0;
1004
1176
  }
1005
1177
  }
1178
+ if (needsFullSnapshot && isResettingSnapshot && commitPoint === `subset-end`)
1179
+ return;
1006
1180
  if (commitPoint !== null) {
1007
1181
  let applied = true;
1008
1182
  const wasBufferingInitialSync = isBufferingInitialSync();
1183
+ const finishesReset = isResettingSnapshot && commitPoint === `up-to-date`;
1184
+ const finishingResetGeneration = resetGeneration;
1009
1185
  if (isBufferingInitialSync() && commitPoint === `up-to-date` && !transactionStarted) {
1010
1186
  debug(
1011
1187
  `${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Performing atomic swap with ${bufferedMessages.length} buffered messages`
@@ -1029,7 +1205,13 @@ function createElectricSync(shapeOptions, options) {
1029
1205
  bufferedMsg.headers.patterns,
1030
1206
  begin,
1031
1207
  write,
1032
- 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
+ }
1033
1215
  );
1034
1216
  } else if (isMoveInMessage(bufferedMsg)) {
1035
1217
  processMoveInEvent(bufferedMsg.headers.patterns);
@@ -1043,7 +1225,9 @@ function createElectricSync(shapeOptions, options) {
1043
1225
  );
1044
1226
  } else {
1045
1227
  if (transactionStarted) {
1046
- stageResumeMetadata();
1228
+ if (!isResettingSnapshot || finishesReset) {
1229
+ stageResumeMetadata();
1230
+ }
1047
1231
  applied = commit();
1048
1232
  transactionStarted = false;
1049
1233
  } else if (commitPoint === `up-to-date` && metadata) {
@@ -1061,36 +1245,66 @@ function createElectricSync(shapeOptions, options) {
1061
1245
  () => void 0
1062
1246
  );
1063
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
+ }
1064
1260
  if (commitPoint === `up-to-date`) {
1065
1261
  hasReceivedUpToDate = true;
1066
1262
  }
1067
- seenTxids.setState((currentTxids) => {
1068
- const clonedSeen = new Set(currentTxids);
1069
- if (newTxids.size > 0) {
1070
- debug(
1071
- `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
1072
- Array.from(newTxids)
1073
- );
1074
- }
1075
- newTxids.forEach((txid) => clonedSeen.add(txid));
1076
- newTxids.clear();
1077
- return clonedSeen;
1078
- });
1079
- seenSnapshots.setState((currentSnapshots) => {
1080
- const seen = [...currentSnapshots, ...newSnapshots];
1081
- newSnapshots.forEach(
1082
- (snapshot) => debug(
1083
- `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
1084
- snapshot
1085
- )
1263
+ if (newTxids.size > 0) {
1264
+ debug(
1265
+ `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
1266
+ Array.from(newTxids)
1086
1267
  );
1087
- newSnapshots.length = 0;
1088
- return seen;
1089
- });
1090
- batchCommitted.setState(() => true);
1091
- 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();
1279
+ }
1280
+ };
1281
+ unsubscribeStream = stream.subscribe((messages) => {
1282
+ if (!areResumeKeysReady) {
1283
+ pendingResumeBatches.push([...messages]);
1284
+ return;
1092
1285
  }
1286
+ processMessages(messages);
1093
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
+ }
1094
1308
  return {
1095
1309
  loadSubset: loadSubsetDedupe?.loadSubset,
1096
1310
  cleanup: () => {
@@ -1100,13 +1314,12 @@ function createElectricSync(shapeOptions, options) {
1100
1314
  );
1101
1315
  unsubscribeStream();
1102
1316
  abortController.abort();
1317
+ pendingResumeBatches.length = 0;
1103
1318
  loadSubsetDedupe?.reset();
1104
- hydratedResumeState.setState(() => void 0);
1319
+ lifecycle.retire(lifecycleEpoch);
1105
1320
  }
1106
1321
  };
1107
- },
1108
- // Expose the getSyncMetadata function
1109
- getSyncMetadata
1322
+ }
1110
1323
  };
1111
1324
  }
1112
1325
  export {