@tanstack/electric-db-collection 0.4.7 → 0.4.10

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