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