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