@tanstack/electric-db-collection 0.4.8 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,27 @@
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");
8
7
  const sqlCompiler = require("./sql-compiler.cjs");
9
8
  const tagIndex = require("./tag-index.cjs");
10
9
  const debug = DebugModule.debug(`ts/db:electric`);
11
- const FORCE_DISCONNECT_AND_REFRESH_TIMEOUT_MS = 250;
12
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
+ }
13
25
  function parseElectricResumeState(value) {
14
26
  if (!value || typeof value !== `object`) {
15
27
  return void 0;
@@ -21,7 +33,10 @@ function parseElectricResumeState(value) {
21
33
  offset: record.offset,
22
34
  handle: record.handle,
23
35
  shapeId: record.shapeId,
24
- updatedAt: record.updatedAt
36
+ updatedAt: record.updatedAt,
37
+ ...typeof record.requiresTagState === `boolean` && {
38
+ requiresTagState: record.requiresTagState
39
+ }
25
40
  };
26
41
  }
27
42
  if (record.kind === `reset` && typeof record.updatedAt === `number` && Number.isFinite(record.updatedAt)) {
@@ -76,7 +91,10 @@ function mergeElectricSyncMeta(current, incoming) {
76
91
  function getNewestElectricResumeState(current, incoming) {
77
92
  if (!current) return incoming;
78
93
  if (!incoming) return current;
79
- 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 };
80
98
  }
81
99
  function isUpToDateMessage(message) {
82
100
  return client.isControlMessage(message) && message.headers.control === `up-to-date`;
@@ -140,6 +158,7 @@ function createLoadSubsetDedupe({
140
158
  commit,
141
159
  getCommitCursor,
142
160
  waitForCommitsAfter,
161
+ recordSnapshotRow,
143
162
  collectionId,
144
163
  encodeColumnName,
145
164
  signal
@@ -182,7 +201,9 @@ function createLoadSubsetDedupe({
182
201
  metadata: { ...row.headers }
183
202
  });
184
203
  }
185
- await commit(opts.signal);
204
+ const applied = commit(opts.signal);
205
+ for (const row of rows) recordSnapshotRow?.(row.value, row);
206
+ if (applied !== true) await applied;
186
207
  debug(`${logPrefix}Applied snapshot with ${rows.length} rows`);
187
208
  }
188
209
  } catch (error) {
@@ -198,47 +219,6 @@ function createLoadSubsetDedupe({
198
219
  return;
199
220
  }
200
221
  const { cursor, where, orderBy, limit } = opts;
201
- if (stream.isUpToDate) {
202
- let timeoutId;
203
- const abortSignals = [signal, opts.signal].filter(
204
- (candidate) => candidate !== void 0
205
- );
206
- let rejectAbort = () => {
207
- };
208
- const aborted = new Promise((_resolve, reject) => {
209
- rejectAbort = reject;
210
- });
211
- const abort = (event) => rejectAbort(abortReason(event.currentTarget));
212
- for (const abortSignal of abortSignals) {
213
- abortSignal.addEventListener(`abort`, abort, { once: true });
214
- }
215
- try {
216
- await Promise.race([
217
- stream.forceDisconnectAndRefresh(),
218
- new Promise((resolve) => {
219
- timeoutId = setTimeout(
220
- resolve,
221
- FORCE_DISCONNECT_AND_REFRESH_TIMEOUT_MS
222
- );
223
- }),
224
- aborted
225
- ]);
226
- } catch (error) {
227
- if (signal.aborted || opts.signal?.aborted) throw error;
228
- if (handleSnapshotError(error, `forceDisconnectAndRefresh`)) {
229
- return;
230
- }
231
- debug(
232
- `${logPrefix}forceDisconnectAndRefresh failed, proceeding to requestSnapshot: %o`,
233
- error
234
- );
235
- } finally {
236
- clearTimeout(timeoutId);
237
- for (const abortSignal of abortSignals) {
238
- abortSignal.removeEventListener(`abort`, abort);
239
- }
240
- }
241
- }
242
222
  throwIfAborted();
243
223
  try {
244
224
  if (cursor) {
@@ -279,204 +259,245 @@ function createLoadSubsetDedupe({
279
259
  };
280
260
  return new db.DeduplicatedLoadSubset({ loadSubset });
281
261
  }
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
- });
262
+ class ElectricLifecycle {
263
+ constructor(collectionId) {
264
+ this.collectionId = collectionId;
265
+ this.evidence = {
266
+ seenTxids: /* @__PURE__ */ new Set(),
267
+ seenSnapshots: []
268
+ };
269
+ this.pendingMatches = /* @__PURE__ */ new Map();
270
+ this.pendingTxidWaits = /* @__PURE__ */ new Map();
271
+ this.matchBuffer = {
272
+ committedMessages: [],
273
+ pendingMessages: []
274
+ };
275
+ this.nextWaiterId = 0;
276
+ this.epoch = 0;
277
+ this.active = false;
278
+ this.utils = {
279
+ awaitTxId: (txId, timeout) => this.awaitTxId(txId, timeout),
280
+ awaitMatch: (matchFn, timeout) => this.awaitMatch(matchFn, timeout)
281
+ };
282
+ }
283
+ start() {
284
+ if (this.active) this.retire();
285
+ this.active = true;
286
+ this.epoch++;
287
+ this.matchBuffer = { committedMessages: [], pendingMessages: [] };
288
+ return this.epoch;
289
+ }
290
+ isActive(epoch) {
291
+ return this.active && this.epoch === epoch;
292
+ }
293
+ retire(epoch) {
294
+ if (epoch !== void 0 && !this.isActive(epoch)) return;
295
+ this.active = false;
296
+ this.epoch++;
297
+ for (const match of this.pendingMatches.values()) {
298
+ clearTimeout(match.timeoutId);
299
+ match.reject(new errors.StreamAbortedError(this.collectionId));
300
300
  }
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) => {
301
+ this.pendingMatches.clear();
302
+ for (const waiter of this.pendingTxidWaits.values()) {
303
+ clearTimeout(waiter.timeoutId);
304
+ waiter.reject(new errors.StreamAbortedError(this.collectionId));
305
+ }
306
+ this.pendingTxidWaits.clear();
307
+ this.matchBuffer = { committedMessages: [], pendingMessages: [] };
308
+ this.evidence.hydratedResumeState = void 0;
309
+ }
310
+ exportMeta() {
311
+ return exportElectricSyncMeta(this.evidence);
312
+ }
313
+ importMeta(meta) {
314
+ importElectricSyncMeta(this.evidence, meta);
315
+ this.resolveTxidWaiters();
316
+ }
317
+ get resumeState() {
318
+ return this.evidence.hydratedResumeState;
319
+ }
320
+ set resumeState(value) {
321
+ this.evidence.hydratedResumeState = value;
322
+ }
323
+ publishEvidence(txids, snapshots) {
324
+ txids.forEach((txid) => this.evidence.seenTxids.add(txid));
325
+ this.evidence.seenSnapshots.push(...snapshots);
326
+ this.resolveTxidWaiters();
327
+ }
328
+ hasTxid(txId) {
329
+ return this.evidence.seenTxids.has(txId) || this.evidence.seenSnapshots.some(
330
+ (snapshot) => client.isVisibleInSnapshot(txId, snapshot)
331
+ );
332
+ }
333
+ resolveTxidWaiters() {
334
+ for (const [waitId, waiter] of this.pendingTxidWaits) {
335
+ if (!this.hasTxid(waiter.txId)) continue;
336
+ clearTimeout(waiter.timeoutId);
337
+ this.pendingTxidWaits.delete(waitId);
338
+ waiter.resolve(true);
339
+ }
340
+ }
341
+ async awaitTxId(txId, timeout = 15e3) {
331
342
  debug(
332
- `${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`,
343
+ `${this.collectionId ? `[${this.collectionId}] ` : ``}awaitTxId called with txid %d`,
333
344
  txId
334
345
  );
335
346
  if (typeof txId !== `number`) {
336
- throw new errors.ExpectedNumberInAwaitTxIdError(typeof txId, config.id);
347
+ throw new errors.ExpectedNumberInAwaitTxIdError(typeof txId, this.collectionId);
337
348
  }
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;
349
+ if (this.hasTxid(txId)) return true;
344
350
  return new Promise((resolve, reject) => {
345
- const cleanup = () => {
346
- clearTimeout(timeoutId);
347
- subSeenTxids.unsubscribe();
348
- subSeenSnapshots.unsubscribe();
349
- };
351
+ const waitId = this.nextWaiterId++;
350
352
  const timeoutId = setTimeout(() => {
351
- cleanup();
352
- reject(new errors.TimeoutWaitingForTxIdError(txId, config.id));
353
+ this.pendingTxidWaits.delete(waitId);
354
+ reject(new errors.TimeoutWaitingForTxIdError(txId, this.collectionId));
353
355
  }, 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
- }
356
+ this.pendingTxidWaits.set(waitId, {
357
+ txId,
358
+ resolve,
359
+ reject,
360
+ timeoutId
377
361
  });
378
362
  });
379
- };
380
- const awaitMatch = async (matchFn, timeout = 3e3) => {
363
+ }
364
+ async awaitMatch(matchFn, timeout = 15e3) {
381
365
  debug(
382
- `${config.id ? `[${config.id}] ` : ``}awaitMatch called with custom function`
366
+ `${this.collectionId ? `[${this.collectionId}] ` : ``}awaitMatch called with custom function`
383
367
  );
368
+ for (const message of this.matchBuffer.committedMessages) {
369
+ if (!matchFn(message)) continue;
370
+ return true;
371
+ }
372
+ for (const message of this.matchBuffer.pendingMessages) {
373
+ if (matchFn(message)) return this.registerMatch(matchFn, timeout, true);
374
+ }
375
+ return this.registerMatch(matchFn, timeout, false);
376
+ }
377
+ registerMatch(matchFn, timeout, matched) {
384
378
  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;
379
+ const matchId = this.nextWaiterId++;
380
+ const timeoutId = setTimeout(() => {
381
+ this.pendingMatches.delete(matchId);
382
+ reject(new errors.TimeoutWaitingForMatchError(this.collectionId));
383
+ }, timeout);
384
+ this.pendingMatches.set(matchId, {
385
+ matchFn,
386
+ resolve,
387
+ reject,
388
+ timeoutId,
389
+ matched
453
390
  });
454
391
  });
455
- };
456
- const processMatchingStrategy = async (result) => {
457
- if (result && `txid` in result) {
458
- const timeout = result.timeout;
459
- if (Array.isArray(result.txid)) {
460
- await Promise.all(result.txid.map((txid) => awaitTxId(txid, timeout)));
392
+ }
393
+ beginMatchGeneration(messages) {
394
+ if (!messages.some(isMustRefetchMessage)) return;
395
+ this.matchBuffer = { committedMessages: [], pendingMessages: [] };
396
+ for (const match of this.pendingMatches.values()) match.matched = false;
397
+ }
398
+ observeMatchMessage(message) {
399
+ if (client.isChangeMessage(message) || tagIndex.isMoveOutMessage(message) || tagIndex.isMoveInMessage(message)) {
400
+ this.matchBuffer.pendingMessages.push(message);
401
+ let overflow = this.matchBuffer.committedMessages.length + this.matchBuffer.pendingMessages.length - 1e3;
402
+ if (overflow > 0) {
403
+ const committedOverflow = Math.min(
404
+ overflow,
405
+ this.matchBuffer.committedMessages.length
406
+ );
407
+ this.matchBuffer.committedMessages.splice(0, committedOverflow);
408
+ overflow -= committedOverflow;
409
+ if (overflow > 0) {
410
+ this.matchBuffer.pendingMessages.splice(0, overflow);
411
+ }
412
+ }
413
+ }
414
+ const epoch = this.epoch;
415
+ for (const [matchId, match] of this.pendingMatches) {
416
+ if (match.matched) continue;
417
+ try {
418
+ match.matched = match.matchFn(message);
419
+ } catch (error) {
420
+ clearTimeout(match.timeoutId);
421
+ this.pendingMatches.delete(matchId);
422
+ match.reject(error instanceof Error ? error : new Error(String(error)));
423
+ }
424
+ if (!this.isActive(epoch)) return;
425
+ }
426
+ }
427
+ commitMatches() {
428
+ this.matchBuffer.committedMessages.push(...this.matchBuffer.pendingMessages);
429
+ this.matchBuffer.pendingMessages = [];
430
+ if (this.matchBuffer.committedMessages.length > 1e3) {
431
+ this.matchBuffer.committedMessages.splice(
432
+ 0,
433
+ this.matchBuffer.committedMessages.length - 1e3
434
+ );
435
+ }
436
+ for (const [matchId, match] of this.pendingMatches) {
437
+ if (!match.matched) continue;
438
+ clearTimeout(match.timeoutId);
439
+ this.pendingMatches.delete(matchId);
440
+ match.resolve(true);
441
+ }
442
+ }
443
+ }
444
+ function electricCollectionOptions(config) {
445
+ let descriptorLifecycle = new ElectricLifecycle(config.id);
446
+ let utilityLifecycle = descriptorLifecycle;
447
+ const internalSyncMode = config.syncMode ?? `eager`;
448
+ const finalSyncMode = internalSyncMode === `progressive` ? `on-demand` : internalSyncMode;
449
+ const boundLifecycles = /* @__PURE__ */ new WeakMap();
450
+ const awaitTxId = (txId, timeout) => utilityLifecycle.utils.awaitTxId(txId, timeout);
451
+ const awaitMatch = (matchFn, timeout) => utilityLifecycle.utils.awaitMatch(matchFn, timeout);
452
+ const createSync = () => createElectricSync(config.shapeOptions, {
453
+ getLifecycle: (collection) => boundLifecycles.get(collection) ?? descriptorLifecycle,
454
+ syncMode: internalSyncMode,
455
+ collectionId: config.id,
456
+ testHooks: config[ELECTRIC_TEST_HOOKS]
457
+ });
458
+ const sync = createSync();
459
+ const processMatchingStrategy = async (result, waitForTxId) => {
460
+ const canHaveProperties = typeof result === `object` && result !== null || typeof result === `function`;
461
+ if (canHaveProperties && `txid` in result) {
462
+ const strategy = result;
463
+ db.warnOnce(
464
+ "electric-collection-txid-return",
465
+ "[TanStack DB] DEPRECATED: Returning { txid } from mutation handlers is deprecated and will be removed in v1.0. Use `await collection.utils.awaitTxId(txid)` instead of returning { txid }. See migration guide: https://tanstack.com/db/latest/docs/collections/electric-collection#persistence-handlers--synchronization"
466
+ );
467
+ const timeout = strategy.timeout;
468
+ if (Array.isArray(strategy.txid)) {
469
+ await Promise.all(
470
+ strategy.txid.map((txid) => waitForTxId(txid, timeout))
471
+ );
461
472
  } else {
462
- await awaitTxId(result.txid, timeout);
473
+ await waitForTxId(strategy.txid, timeout);
463
474
  }
464
475
  }
465
476
  };
477
+ const getMutationAwaitTxId = (params) => {
478
+ const collection = params.collection;
479
+ return collection?.utils?.awaitTxId ?? awaitTxId;
480
+ };
466
481
  const wrappedOnInsert = config.onInsert ? async (params) => {
467
482
  const handlerResult = await config.onInsert(params);
468
- await processMatchingStrategy(handlerResult);
469
- return handlerResult;
483
+ await processMatchingStrategy(
484
+ handlerResult,
485
+ getMutationAwaitTxId(params)
486
+ );
470
487
  } : void 0;
471
488
  const wrappedOnUpdate = config.onUpdate ? async (params) => {
472
489
  const handlerResult = await config.onUpdate(params);
473
- await processMatchingStrategy(handlerResult);
474
- return handlerResult;
490
+ await processMatchingStrategy(
491
+ handlerResult,
492
+ getMutationAwaitTxId(params)
493
+ );
475
494
  } : void 0;
476
495
  const wrappedOnDelete = config.onDelete ? async (params) => {
477
496
  const handlerResult = await config.onDelete(params);
478
- await processMatchingStrategy(handlerResult);
479
- return handlerResult;
497
+ await processMatchingStrategy(
498
+ handlerResult,
499
+ getMutationAwaitTxId(params)
500
+ );
480
501
  } : void 0;
481
502
  const {
482
503
  shapeOptions: _shapeOptions,
@@ -485,236 +506,274 @@ function electricCollectionOptions(config) {
485
506
  onDelete: _onDelete,
486
507
  ...restConfig
487
508
  } = 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;
509
+ const utilityTemplate = {
510
+ awaitTxId,
511
+ awaitMatch
512
+ };
513
+ const consumeDescriptorLifecycle = () => {
514
+ const lifecycle = descriptorLifecycle;
515
+ descriptorLifecycle = new ElectricLifecycle(config.id);
516
+ utilityLifecycle = lifecycle;
517
+ return lifecycle;
518
+ };
519
+ const createBoundSync = (source, utilities) => {
520
+ const lifecycle = consumeDescriptorLifecycle();
521
+ let collectionKey;
522
+ Object.assign(utilities, lifecycle.utils);
523
+ const boundSync = {
524
+ ...source,
525
+ sync: (params) => {
526
+ if (!boundLifecycles.has(params.collection)) {
527
+ collectionKey = params.collection;
528
+ boundLifecycles.set(params.collection, lifecycle);
502
529
  }
503
- hydratedResumeState.setState(() => parsed.resume);
504
- seenTxids.setState(() => new Set(parsed.seenTxids));
530
+ return source.sync(params);
505
531
  },
532
+ exportSyncMeta: () => lifecycle.exportMeta(),
533
+ importSyncMeta: (meta) => lifecycle.importMeta(meta),
534
+ mergeSyncMeta: mergeElectricSyncMeta
535
+ };
536
+ return db.withCollectionSyncConfigCleanup(boundSync, () => {
537
+ lifecycle.retire();
538
+ if (collectionKey) boundLifecycles.delete(collectionKey);
539
+ });
540
+ };
541
+ const syncTemplate = db.withCollectionSyncConfigFactory(
542
+ {
543
+ ...sync,
544
+ exportSyncMeta: () => descriptorLifecycle.exportMeta(),
545
+ importSyncMeta: (meta) => descriptorLifecycle.importMeta(meta),
506
546
  mergeSyncMeta: mergeElectricSyncMeta
507
547
  },
548
+ createBoundSync
549
+ );
550
+ const options = {
551
+ ...restConfig,
552
+ syncMode: finalSyncMode,
553
+ sync: syncTemplate,
508
554
  onInsert: wrappedOnInsert,
509
555
  onUpdate: wrappedOnUpdate,
510
556
  onDelete: wrappedOnDelete,
511
- utils: {
512
- awaitTxId,
513
- awaitMatch
514
- }
557
+ utils: utilityTemplate
515
558
  };
559
+ Object.defineProperty(options, `utils`, {
560
+ enumerable: true,
561
+ get: () => ({ ...utilityTemplate })
562
+ });
516
563
  return db.withCollectionConfigFactory(
517
564
  options,
518
565
  () => electricCollectionOptions(config)
519
566
  );
520
567
  }
521
568
  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();
569
+ const { getLifecycle, syncMode, collectionId, testHooks } = options;
570
+ let relationSchema;
571
+ const createTagState = () => {
572
+ const tagCache = /* @__PURE__ */ new Map();
573
+ const parseTag = (tag) => {
574
+ const cachedTag = tagCache.get(tag);
575
+ if (cachedTag) {
576
+ return cachedTag;
556
577
  }
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);
578
+ const parsedTag = tagIndex.parseTag(tag);
579
+ tagCache.set(tag, parsedTag);
580
+ return parsedTag;
581
+ };
582
+ const rowTagSets = /* @__PURE__ */ new Map();
583
+ const tagIndex$1 = [];
584
+ let tagLength = void 0;
585
+ const rowActiveConditions = /* @__PURE__ */ new Map();
586
+ let disjunctPositions = void 0;
587
+ const initializeTagIndex = (length) => {
588
+ if (tagIndex$1.length < length) {
589
+ for (let i = tagIndex$1.length; i < length; i++) {
590
+ tagIndex$1[i] = /* @__PURE__ */ new Map();
591
+ }
565
592
  }
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;
593
+ };
594
+ const addTagsToRow = (tags, rowId, rowTagSet) => {
595
+ for (const tag of tags) {
596
+ const parsedTag = parseTag(tag);
597
+ if (tagLength === void 0) {
598
+ tagLength = tagIndex.getTagLength(parsedTag);
599
+ initializeTagIndex(tagLength);
600
+ }
601
+ const currentTagLength = tagIndex.getTagLength(parsedTag);
602
+ if (currentTagLength !== tagLength) {
603
+ debug(
604
+ `${collectionId ? `[${collectionId}] ` : ``}Tag length mismatch: expected ${tagLength}, got ${currentTagLength}`
605
+ );
606
+ continue;
607
+ }
608
+ rowTagSet.add(tag);
609
+ tagIndex.addTagToIndex(parsedTag, rowId, tagIndex$1, tagLength);
572
610
  }
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);
611
+ };
612
+ const removeTagsFromRow = (removedTags, rowId, rowTagSet) => {
613
+ if (tagLength === void 0) {
614
+ return;
598
615
  }
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) {
616
+ for (const tag of removedTags) {
617
+ const parsedTag = parseTag(tag);
618
+ rowTagSet.delete(tag);
627
619
  tagIndex.removeTagFromIndex(parsedTag, rowId, tagIndex$1, tagLength);
620
+ tagCache.delete(tag);
628
621
  }
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);
622
+ };
623
+ const processTagsForChangeMessage = (tags, removedTags, rowId, activeConditions) => {
624
+ if (!rowTagSets.has(rowId)) {
625
+ rowTagSets.set(rowId, /* @__PURE__ */ new Set());
626
+ }
627
+ const rowTagSet = rowTagSets.get(rowId);
628
+ if (tags) {
629
+ addTagsToRow(tags, rowId, rowTagSet);
630
+ if (disjunctPositions === void 0) {
631
+ const parsedTags = tags.map(parseTag);
632
+ disjunctPositions = tagIndex.deriveDisjunctPositions(parsedTags);
633
+ }
634
+ }
635
+ if (removedTags) {
636
+ removeTagsFromRow(removedTags, rowId, rowTagSet);
637
+ }
638
+ if (activeConditions && activeConditions.length > 0) {
639
+ rowActiveConditions.set(rowId, [...activeConditions]);
640
+ }
641
+ return rowTagSet;
642
+ };
643
+ const clearTagTrackingState = () => {
644
+ rowTagSets.clear();
645
+ tagIndex$1.length = 0;
646
+ tagLength = void 0;
647
+ rowActiveConditions.clear();
648
+ disjunctPositions = void 0;
649
+ };
650
+ const clearTagsForRow = (rowId) => {
651
+ if (tagLength === void 0) {
652
+ return;
653
+ }
654
+ const rowTagSet = rowTagSets.get(rowId);
655
+ if (!rowTagSet) {
656
+ return;
657
+ }
658
+ for (const tag of rowTagSet) {
659
+ const parsedTag = parseTag(tag);
660
+ const currentTagLength = tagIndex.getTagLength(parsedTag);
661
+ if (currentTagLength === tagLength) {
645
662
  tagIndex.removeTagFromIndex(parsedTag, rowId, tagIndex$1, tagLength);
646
- tagCache.delete(tag);
647
663
  }
664
+ tagCache.delete(tag);
665
+ }
666
+ rowTagSets.delete(rowId);
667
+ rowActiveConditions.delete(rowId);
668
+ };
669
+ const removeMatchingTagsFromRow = (rowId, pattern) => {
670
+ const rowTagSet = rowTagSets.get(rowId);
671
+ if (!rowTagSet) {
672
+ return false;
673
+ }
674
+ const activeConditions = rowActiveConditions.get(rowId);
675
+ if (activeConditions && disjunctPositions) {
676
+ activeConditions[pattern.pos] = false;
677
+ if (!tagIndex.rowVisible(activeConditions, disjunctPositions)) {
678
+ for (const tag of rowTagSet) {
679
+ const parsedTag = parseTag(tag);
680
+ tagIndex.removeTagFromIndex(parsedTag, rowId, tagIndex$1, tagLength);
681
+ tagCache.delete(tag);
682
+ }
683
+ rowTagSets.delete(rowId);
684
+ rowActiveConditions.delete(rowId);
685
+ return true;
686
+ }
687
+ return false;
688
+ }
689
+ for (const tag of rowTagSet) {
690
+ const parsedTag = parseTag(tag);
691
+ if (tagIndex.tagMatchesPattern(parsedTag, pattern)) {
692
+ rowTagSet.delete(tag);
693
+ tagIndex.removeTagFromIndex(parsedTag, rowId, tagIndex$1, tagLength);
694
+ }
695
+ }
696
+ if (rowTagSet.size === 0) {
648
697
  rowTagSets.delete(rowId);
649
- rowActiveConditions.delete(rowId);
650
698
  return true;
651
699
  }
652
700
  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);
701
+ };
702
+ const processMoveOutEvent = (patterns, begin, write, transactionStarted, onDelete) => {
703
+ if (tagLength === void 0) {
704
+ debug(
705
+ `${collectionId ? `[${collectionId}] ` : ``}Received move-out message but no tag length set yet, ignoring`
706
+ );
707
+ return transactionStarted;
659
708
  }
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;
709
+ let txStarted = transactionStarted;
710
+ for (const pattern of patterns) {
711
+ const affectedRowIds = tagIndex.findRowsMatchingPattern(pattern, tagIndex$1);
712
+ for (const rowId of affectedRowIds) {
713
+ if (removeMatchingTagsFromRow(rowId, pattern)) {
714
+ if (!txStarted) {
715
+ begin();
716
+ txStarted = true;
717
+ }
718
+ write({
719
+ type: `delete`,
720
+ key: rowId
721
+ });
722
+ onDelete(rowId);
682
723
  }
683
- write({
684
- type: `delete`,
685
- key: rowId
686
- });
687
724
  }
688
725
  }
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;
726
+ return txStarted;
727
+ };
728
+ const processMoveInEvent = (patterns) => {
729
+ if (tagLength === void 0) {
730
+ debug(
731
+ `${collectionId ? `[${collectionId}] ` : ``}Received move-in message but no tag length set yet, ignoring`
732
+ );
733
+ return;
734
+ }
735
+ for (const pattern of patterns) {
736
+ const affectedRowIds = tagIndex.findRowsMatchingPattern(pattern, tagIndex$1);
737
+ for (const rowId of affectedRowIds) {
738
+ const activeConditions = rowActiveConditions.get(rowId);
739
+ if (activeConditions) {
740
+ activeConditions[pattern.pos] = true;
741
+ }
705
742
  }
706
743
  }
707
- }
708
- };
709
- const getSyncMetadata = () => {
710
- const schema = relationSchema.state || `public`;
744
+ };
711
745
  return {
712
- relation: shapeOptions.params?.table ? [schema, shapeOptions.params.table] : void 0
746
+ hasTags: () => tagLength !== void 0,
747
+ processTagsForChangeMessage,
748
+ clearTagTrackingState,
749
+ clearTagsForRow,
750
+ processMoveOutEvent,
751
+ processMoveInEvent
713
752
  };
714
753
  };
715
- let unsubscribeStream;
754
+ const collectionTags = /* @__PURE__ */ new WeakMap();
716
755
  return {
756
+ getSyncMetadata: () => ({
757
+ relation: shapeOptions.params?.table ? [relationSchema || `public`, shapeOptions.params.table] : void 0
758
+ }),
717
759
  sync: (params) => {
760
+ const retainsTagState = collectionTags.has(params.collection);
761
+ let tagState = collectionTags.get(params.collection);
762
+ if (!tagState) {
763
+ tagState = createTagState();
764
+ collectionTags.set(params.collection, tagState);
765
+ }
766
+ const {
767
+ processTagsForChangeMessage,
768
+ clearTagTrackingState,
769
+ clearTagsForRow,
770
+ processMoveOutEvent,
771
+ processMoveInEvent
772
+ } = tagState;
773
+ const lifecycle = getLifecycle(params.collection);
774
+ const lifecycleEpoch = lifecycle.start();
775
+ const isActiveLifecycle = () => lifecycle.isActive(lifecycleEpoch);
776
+ Object.assign(params.collection.utils, lifecycle.utils);
718
777
  const {
719
778
  begin,
720
779
  write,
@@ -750,16 +809,35 @@ function createElectricSync(shapeOptions, options) {
750
809
  const persistedResumeState2 = metadata?.collection.get(`electric:resume`);
751
810
  return parseElectricResumeState(persistedResumeState2);
752
811
  };
812
+ const persistence = metadata === void 0 ? null : db.validateSyncPersistenceCapability(metadata.persistence);
813
+ const hydrateBaseline = persistence?.hydrateBaseline;
814
+ const resumeSnapshot = persistence?.resumeSnapshot;
815
+ const certifyResumeSnapshot = resumeSnapshot?.certify;
816
+ const getKeySetEvidence = resumeSnapshot?.getKeySetEvidence;
817
+ const expectCurrentCommit = resumeSnapshot?.expectCurrentCommit;
818
+ const persistedKeySetEvidence = getKeySetEvidence?.();
753
819
  const persistedResumeState = getNewestElectricResumeState(
754
820
  readPersistedResumeState(),
755
- hydratedResumeState.state
821
+ lifecycle.resumeState
756
822
  );
757
823
  const shapeIdentity = getStableShapeIdentity({
758
824
  url: shapeOptions.url,
759
825
  params: shapeOptions.params
760
826
  });
761
827
  const hasIncompatiblePersistedResume = persistedResumeState?.kind === `resume` && persistedResumeState.shapeId !== shapeIdentity;
762
- const canUsePersistedResume = shapeOptions.offset === void 0 && shapeOptions.handle === void 0 && persistedResumeState?.kind === `resume` && !hasIncompatiblePersistedResume;
828
+ const lacksCompletePersistedKeySet = persistedResumeState?.kind === `resume` && getKeySetEvidence !== void 0 && persistedKeySetEvidence?.status !== `consistent`;
829
+ const needsFullSnapshot = shapeOptions.offset === void 0 && shapeOptions.handle === void 0 && persistedResumeState !== void 0 && (persistedResumeState.kind === `reset` || lacksCompletePersistedKeySet || !retainsTagState && persistedResumeState.requiresTagState !== false);
830
+ const canUsePersistedResume = shapeOptions.offset === void 0 && shapeOptions.handle === void 0 && persistedResumeState?.kind === `resume` && !hasIncompatiblePersistedResume && // Cached rows do not contain authoritative tag/active-condition state.
831
+ // Only a complete adapter ledger can justify a persisted cursor.
832
+ !needsFullSnapshot;
833
+ const hasExplicitResumeOffset = shapeOptions.offset !== void 0 && shapeOptions.offset !== `-1`;
834
+ if (!canUsePersistedResume && !hasExplicitResumeOffset) {
835
+ clearTagTrackingState();
836
+ }
837
+ const receivesCompleteRows = shapeOptions.params?.replica === `full`;
838
+ const requiresKeySetCertification = canUsePersistedResume && certifyResumeSnapshot !== void 0;
839
+ const requiresCompleteResume = syncMode !== `on-demand` && (canUsePersistedResume || hasExplicitResumeOffset && !receivesCompleteRows);
840
+ let freshSnapshotPending = (syncMode === `eager` || needsFullSnapshot) && !canUsePersistedResume && !hasExplicitResumeOffset && hydrateBaseline !== void 0;
763
841
  let progressiveReadyGate = null;
764
842
  let streamErrorVersion = 0;
765
843
  const wrappedMarkReady = (isBuffering, expectedErrorVersion = streamErrorVersion) => {
@@ -786,21 +864,16 @@ function createElectricSync(shapeOptions, options) {
786
864
  }
787
865
  }
788
866
  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
- });
867
+ lifecycle.retire(lifecycleEpoch);
796
868
  });
797
869
  const stream = new client.ShapeStream({
798
870
  ...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,
871
+ // Recovery needs a complete snapshot even for on-demand shapes.
872
+ // Normal on-demand startup still subscribes to changes only.
873
+ log: syncMode === `on-demand` && !needsFullSnapshot ? `changes_only` : void 0,
801
874
  // In on-demand mode, we only need the changes from the point of time the collection was created
802
875
  // so we default to `now` when there is no saved offset.
803
- offset: shapeOptions.offset ?? (canUsePersistedResume ? persistedResumeState.offset : syncMode === `on-demand` ? `now` : void 0),
876
+ offset: shapeOptions.offset ?? (canUsePersistedResume ? persistedResumeState.offset : syncMode === `on-demand` && !needsFullSnapshot ? `now` : void 0),
804
877
  handle: shapeOptions.handle ?? (canUsePersistedResume ? persistedResumeState.handle : void 0),
805
878
  signal: abortController.signal,
806
879
  onError: (errorParams) => {
@@ -826,11 +899,50 @@ function createElectricSync(shapeOptions, options) {
826
899
  let transactionStarted = false;
827
900
  const newTxids = /* @__PURE__ */ new Set();
828
901
  const newSnapshots = [];
829
- let hasReceivedUpToDate = syncMode === `progressive` && canUsePersistedResume;
830
- const isBufferingInitialSync = () => syncMode === `progressive` && !hasReceivedUpToDate;
902
+ let hasReceivedUpToDate = syncMode === `progressive` && requiresCompleteResume;
903
+ let isResettingSnapshot = false;
904
+ let resetGeneration = 0;
905
+ const isBufferingInitialSync = () => syncMode === `progressive` && !hasReceivedUpToDate && !isResettingSnapshot;
831
906
  const bufferedMessages = [];
907
+ const progressiveSnapshotRows = /* @__PURE__ */ new Map();
908
+ const pendingPresence = /* @__PURE__ */ new Map();
909
+ let usesBaseline = true;
910
+ let pendingPresenceRevision = 0;
911
+ const recordPendingPresence = (rowId, present) => {
912
+ pendingPresenceRevision++;
913
+ pendingPresence.set(rowId, present);
914
+ };
915
+ const recordPendingTruncate = () => {
916
+ pendingPresenceRevision++;
917
+ pendingPresence.clear();
918
+ progressiveSnapshotRows.clear();
919
+ usesBaseline = false;
920
+ };
921
+ const commitSourceTransaction = () => {
922
+ const committedPresenceRevision = pendingPresenceRevision;
923
+ const applied = commit();
924
+ if (metadata?.persistence) {
925
+ const retireAppliedPresence = () => {
926
+ if (pendingPresenceRevision === committedPresenceRevision) {
927
+ pendingPresence.clear();
928
+ usesBaseline = true;
929
+ }
930
+ };
931
+ if (applied === true) retireAppliedPresence();
932
+ else void applied.then(retireAppliedPresence, () => void 0);
933
+ }
934
+ return applied;
935
+ };
936
+ const beginSourceTransaction = () => {
937
+ if (metadata?.persistence) begin({ immediate: true });
938
+ else begin();
939
+ };
832
940
  const syncedKeys = /* @__PURE__ */ new Set();
941
+ let resumeInvalid = false;
833
942
  const stageResumeMetadata = () => {
943
+ if (!isActiveLifecycle() || resumeInvalid) {
944
+ return;
945
+ }
834
946
  const shapeHandle = stream.shapeHandle;
835
947
  const lastOffset = stream.lastOffset;
836
948
  if (!shapeHandle || lastOffset === `-1`) {
@@ -841,25 +953,29 @@ function createElectricSync(shapeOptions, options) {
841
953
  offset: lastOffset,
842
954
  handle: shapeHandle,
843
955
  shapeId: shapeIdentity,
844
- updatedAt: Date.now()
956
+ updatedAt: Date.now(),
957
+ requiresTagState: tagState.hasTags()
845
958
  };
846
- hydratedResumeState.setState(() => resumeState);
959
+ lifecycle.resumeState = resumeState;
847
960
  metadata?.collection.set(`electric:resume`, resumeState);
848
961
  };
849
- const commitResetResumeMetadataImmediately = () => {
962
+ const commitResetResumeMetadataImmediately = (expectInResumeSnapshot = false) => {
850
963
  const resetState = {
851
964
  kind: `reset`,
852
965
  updatedAt: Date.now()
853
966
  };
854
- hydratedResumeState.setState(() => resetState);
967
+ lifecycle.resumeState = resetState;
855
968
  if (metadata) {
856
969
  begin({ immediate: true });
857
970
  metadata.collection.set(`electric:resume`, resetState);
971
+ if (expectInResumeSnapshot) {
972
+ expectCurrentCommit?.();
973
+ }
858
974
  commit();
859
975
  }
860
976
  };
861
- if (hasIncompatiblePersistedResume) {
862
- commitResetResumeMetadataImmediately();
977
+ if (hasIncompatiblePersistedResume || needsFullSnapshot && persistedResumeState.kind === `resume`) {
978
+ commitResetResumeMetadataImmediately(true);
863
979
  }
864
980
  const processChangeMessage = (changeMessage) => {
865
981
  if (!client.isChangeMessage(changeMessage)) {
@@ -906,6 +1022,10 @@ function createElectricSync(shapeOptions, options) {
906
1022
  commit,
907
1023
  getCommitCursor: () => commitSequence,
908
1024
  waitForCommitsAfter,
1025
+ recordSnapshotRow: (row, message) => {
1026
+ recordPendingPresence(collection.getKeyFromItem(row), true);
1027
+ progressiveSnapshotRows.set(collection.getKeyFromItem(row), message);
1028
+ },
909
1029
  collectionId,
910
1030
  // Pass the columnMapper's encode function to transform column names
911
1031
  // (e.g., camelCase to snake_case) when compiling SQL for subset queries
@@ -913,48 +1033,126 @@ function createElectricSync(shapeOptions, options) {
913
1033
  // Pass abort signal so requestSnapshot errors can be ignored during cleanup
914
1034
  signal: abortController.signal
915
1035
  });
916
- unsubscribeStream = stream.subscribe((messages) => {
1036
+ const resumeKeysPromise = requiresCompleteResume || freshSnapshotPending ? hydrateBaseline ? (async () => {
1037
+ await hydrateBaseline();
1038
+ const currentKeySetEvidence = getKeySetEvidence?.();
1039
+ if (canUsePersistedResume && currentKeySetEvidence?.status !== `consistent`) {
1040
+ throw new Error(
1041
+ `Electric persisted resume baseline could not be certified during hydration`
1042
+ );
1043
+ }
1044
+ })() : void 0 : requiresKeySetCertification ? (async () => {
1045
+ await certifyResumeSnapshot();
1046
+ const currentKeySetEvidence = getKeySetEvidence?.();
1047
+ if (currentKeySetEvidence?.status !== `consistent`) {
1048
+ throw new Error(
1049
+ `Electric persisted resume baseline could not be certified`
1050
+ );
1051
+ }
1052
+ })() : void 0;
1053
+ let areResumeKeysReady = !resumeKeysPromise;
1054
+ const pendingResumeBatches = [];
1055
+ let unsubscribeStream = () => {
1056
+ };
1057
+ const invalidateResume = () => {
1058
+ resumeInvalid = true;
1059
+ if (transactionStarted) {
1060
+ const cancellation = new AbortController();
1061
+ cancellation.abort();
1062
+ commit(cancellation.signal);
1063
+ transactionStarted = false;
1064
+ }
1065
+ syncedKeys.clear();
1066
+ newTxids.clear();
1067
+ newSnapshots.length = 0;
1068
+ commitResetResumeMetadataImmediately();
1069
+ streamErrorVersion++;
1070
+ unsubscribeStream();
1071
+ abortController.abort();
1072
+ markError(
1073
+ new Error(
1074
+ `Electric resume state referenced an unseen row; a full snapshot is required`
1075
+ )
1076
+ );
1077
+ };
1078
+ const processMessages = (messages) => {
1079
+ if (!isActiveLifecycle() || resumeInvalid) {
1080
+ return;
1081
+ }
1082
+ if (freshSnapshotPending) {
1083
+ freshSnapshotPending = false;
1084
+ beginSourceTransaction();
1085
+ transactionStarted = true;
1086
+ truncate();
1087
+ recordPendingTruncate();
1088
+ syncedKeys.clear();
1089
+ clearTagTrackingState();
1090
+ isResettingSnapshot = true;
1091
+ resetGeneration++;
1092
+ }
1093
+ if (!metadata?.persistence && !transactionStarted) {
1094
+ pendingPresence.clear();
1095
+ usesBaseline = true;
1096
+ for (const pending of collection._state.pendingSyncedTransactions) {
1097
+ if (pending.truncate) {
1098
+ pendingPresence.clear();
1099
+ usesBaseline = false;
1100
+ }
1101
+ for (const operation of pending.operations) {
1102
+ pendingPresence.set(operation.key, operation.type !== `delete`);
1103
+ }
1104
+ }
1105
+ for (const message of bufferedMessages) {
1106
+ if (client.isChangeMessage(message)) {
1107
+ pendingPresence.set(
1108
+ collection.getKeyFromItem(message.value),
1109
+ message.headers.operation !== `delete`
1110
+ );
1111
+ }
1112
+ }
1113
+ }
917
1114
  let commitPoint = null;
918
- batchCommitted.setState(() => false);
1115
+ lifecycle.beginMatchGeneration(messages);
919
1116
  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
- }
1117
+ lifecycle.observeMatchMessage(message);
1118
+ if (!isActiveLifecycle()) return;
929
1119
  if (hasTxids(message) && (!isBufferingInitialSync() || transactionStarted)) {
930
1120
  message.headers.txids?.forEach((txid) => newTxids.add(txid));
931
1121
  }
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);
1122
+ if (client.isChangeMessage(message)) {
1123
+ const rowId = collection.getKeyFromItem(message.value);
1124
+ const operation = message.headers.operation;
1125
+ const hasKnownRow = pendingPresence.get(rowId) ?? (usesBaseline && collection._state.syncedData.has(rowId));
1126
+ if (operation === `update` && !hasKnownRow) {
1127
+ if (requiresCompleteResume && !isResettingSnapshot) {
1128
+ invalidateResume();
1129
+ return;
944
1130
  }
1131
+ if (!receivesCompleteRows) continue;
945
1132
  }
946
- });
947
- removePendingMatches(matchesToRemove);
1133
+ recordPendingPresence(rowId, operation !== `delete`);
1134
+ if (operation !== `update`) {
1135
+ progressiveSnapshotRows.delete(rowId);
1136
+ }
1137
+ }
948
1138
  if (client.isChangeMessage(message)) {
949
1139
  const schema = message.headers.schema;
950
1140
  if (schema && typeof schema === `string`) {
951
- relationSchema.setState(() => schema);
1141
+ relationSchema = schema;
952
1142
  }
953
1143
  if (isBufferingInitialSync() && !transactionStarted) {
1144
+ if (message.headers.operation === `update`) {
1145
+ const rowId = collection.getKeyFromItem(message.value);
1146
+ const snapshotRow = progressiveSnapshotRows.get(rowId);
1147
+ if (snapshotRow) {
1148
+ bufferedMessages.push(snapshotRow);
1149
+ progressiveSnapshotRows.delete(rowId);
1150
+ }
1151
+ }
954
1152
  bufferedMessages.push(message);
955
1153
  } else {
956
1154
  if (!transactionStarted) {
957
- begin();
1155
+ beginSourceTransaction();
958
1156
  transactionStarted = true;
959
1157
  }
960
1158
  processChangeMessage(message);
@@ -975,9 +1173,13 @@ function createElectricSync(shapeOptions, options) {
975
1173
  } else {
976
1174
  transactionStarted = processMoveOutEvent(
977
1175
  message.headers.patterns,
978
- begin,
1176
+ beginSourceTransaction,
979
1177
  write,
980
- transactionStarted
1178
+ transactionStarted,
1179
+ (rowId) => {
1180
+ recordPendingPresence(rowId, false);
1181
+ syncedKeys.delete(rowId);
1182
+ }
981
1183
  );
982
1184
  }
983
1185
  } else if (tagIndex.isMoveInMessage(message)) {
@@ -992,26 +1194,34 @@ function createElectricSync(shapeOptions, options) {
992
1194
  );
993
1195
  commitResetResumeMetadataImmediately();
994
1196
  if (!transactionStarted) {
995
- begin();
1197
+ beginSourceTransaction();
996
1198
  transactionStarted = true;
997
1199
  }
998
1200
  truncate();
999
1201
  clearTagTrackingState();
1000
1202
  syncedKeys.clear();
1203
+ recordPendingTruncate();
1204
+ isResettingSnapshot = true;
1205
+ resetGeneration++;
1001
1206
  loadSubsetDedupe?.reset();
1002
1207
  commitPoint = null;
1003
1208
  hasReceivedUpToDate = false;
1004
1209
  bufferedMessages.length = 0;
1210
+ progressiveSnapshotRows.clear();
1005
1211
  }
1006
1212
  }
1213
+ if (needsFullSnapshot && isResettingSnapshot && commitPoint === `subset-end`)
1214
+ return;
1007
1215
  if (commitPoint !== null) {
1008
1216
  let applied = true;
1009
1217
  const wasBufferingInitialSync = isBufferingInitialSync();
1218
+ const finishesReset = isResettingSnapshot && commitPoint === `up-to-date`;
1219
+ const finishingResetGeneration = resetGeneration;
1010
1220
  if (isBufferingInitialSync() && commitPoint === `up-to-date` && !transactionStarted) {
1011
1221
  debug(
1012
1222
  `${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Performing atomic swap with ${bufferedMessages.length} buffered messages`
1013
1223
  );
1014
- begin();
1224
+ beginSourceTransaction();
1015
1225
  truncate();
1016
1226
  clearTagTrackingState();
1017
1227
  syncedKeys.clear();
@@ -1030,27 +1240,36 @@ function createElectricSync(shapeOptions, options) {
1030
1240
  bufferedMsg.headers.patterns,
1031
1241
  begin,
1032
1242
  write,
1033
- transactionStarted
1243
+ // The swap already opened a transaction, even though the
1244
+ // normal-stream transactionStarted flag is still false.
1245
+ true,
1246
+ (rowId) => {
1247
+ recordPendingPresence(rowId, false);
1248
+ syncedKeys.delete(rowId);
1249
+ }
1034
1250
  );
1035
1251
  } else if (tagIndex.isMoveInMessage(bufferedMsg)) {
1036
1252
  processMoveInEvent(bufferedMsg.headers.patterns);
1037
1253
  }
1038
1254
  }
1039
1255
  stageResumeMetadata();
1040
- applied = commit();
1256
+ applied = commitSourceTransaction();
1041
1257
  bufferedMessages.length = 0;
1258
+ progressiveSnapshotRows.clear();
1042
1259
  debug(
1043
1260
  `${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Atomic swap complete, now in normal sync mode`
1044
1261
  );
1045
1262
  } else {
1046
1263
  if (transactionStarted) {
1047
- stageResumeMetadata();
1048
- applied = commit();
1264
+ if (!isResettingSnapshot || finishesReset) {
1265
+ stageResumeMetadata();
1266
+ }
1267
+ applied = commitSourceTransaction();
1049
1268
  transactionStarted = false;
1050
1269
  } else if (commitPoint === `up-to-date` && metadata) {
1051
- begin();
1270
+ beginSourceTransaction();
1052
1271
  stageResumeMetadata();
1053
- applied = commit();
1272
+ applied = commitSourceTransaction();
1054
1273
  }
1055
1274
  }
1056
1275
  const readyErrorVersion = streamErrorVersion;
@@ -1059,39 +1278,77 @@ function createElectricSync(shapeOptions, options) {
1059
1278
  } else {
1060
1279
  void applied.then(
1061
1280
  () => wrappedMarkReady(wasBufferingInitialSync, readyErrorVersion),
1062
- () => void 0
1281
+ (error) => {
1282
+ if (!isActiveLifecycle() || abortController.signal.aborted) {
1283
+ return;
1284
+ }
1285
+ streamErrorVersion++;
1286
+ unsubscribeStream();
1287
+ abortController.abort();
1288
+ if (collection.status !== `error`) markError(error);
1289
+ }
1063
1290
  );
1064
1291
  }
1292
+ if (finishesReset) {
1293
+ const finishReset = () => {
1294
+ if (resetGeneration === finishingResetGeneration) {
1295
+ isResettingSnapshot = false;
1296
+ }
1297
+ };
1298
+ if (applied === true) {
1299
+ finishReset();
1300
+ } else {
1301
+ void applied.then(finishReset, () => void 0);
1302
+ }
1303
+ }
1065
1304
  if (commitPoint === `up-to-date`) {
1066
1305
  hasReceivedUpToDate = true;
1067
1306
  }
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
- )
1307
+ if (newTxids.size > 0) {
1308
+ debug(
1309
+ `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
1310
+ Array.from(newTxids)
1087
1311
  );
1088
- newSnapshots.length = 0;
1089
- return seen;
1090
- });
1091
- batchCommitted.setState(() => true);
1092
- resolveMatchedPendingMatches();
1312
+ }
1313
+ newSnapshots.forEach(
1314
+ (snapshot) => debug(
1315
+ `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
1316
+ snapshot
1317
+ )
1318
+ );
1319
+ lifecycle.publishEvidence(newTxids, newSnapshots);
1320
+ newTxids.clear();
1321
+ newSnapshots.length = 0;
1322
+ lifecycle.commitMatches();
1093
1323
  }
1324
+ };
1325
+ unsubscribeStream = stream.subscribe((messages) => {
1326
+ if (!areResumeKeysReady) {
1327
+ pendingResumeBatches.push([...messages]);
1328
+ return;
1329
+ }
1330
+ processMessages(messages);
1094
1331
  });
1332
+ if (!areResumeKeysReady && resumeKeysPromise) {
1333
+ void resumeKeysPromise.then(
1334
+ () => {
1335
+ if (abortController.signal.aborted) return;
1336
+ areResumeKeysReady = true;
1337
+ const queuedBatches = pendingResumeBatches.splice(0);
1338
+ queuedBatches.forEach(processMessages);
1339
+ },
1340
+ (error) => {
1341
+ if (abortController.signal.aborted) return;
1342
+ pendingResumeBatches.length = 0;
1343
+ resumeInvalid = true;
1344
+ commitResetResumeMetadataImmediately();
1345
+ streamErrorVersion++;
1346
+ unsubscribeStream();
1347
+ abortController.abort();
1348
+ markError(error);
1349
+ }
1350
+ );
1351
+ }
1095
1352
  return {
1096
1353
  loadSubset: loadSubsetDedupe?.loadSubset,
1097
1354
  cleanup: () => {
@@ -1101,13 +1358,12 @@ function createElectricSync(shapeOptions, options) {
1101
1358
  );
1102
1359
  unsubscribeStream();
1103
1360
  abortController.abort();
1361
+ pendingResumeBatches.length = 0;
1104
1362
  loadSubsetDedupe?.reset();
1105
- hydratedResumeState.setState(() => void 0);
1363
+ lifecycle.retire(lifecycleEpoch);
1106
1364
  }
1107
1365
  };
1108
- },
1109
- // Expose the getSyncMetadata function
1110
- getSyncMetadata
1366
+ }
1111
1367
  };
1112
1368
  }
1113
1369
  Object.defineProperty(exports, "isChangeMessage", {