From 6b54ece3f4395bebbc70f3149b683e1934afde4d Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 6 Oct 2025 14:27:55 +0100 Subject: [PATCH 1/5] first pass at adding support for snapshots to awaitTxid --- packages/electric-db-collection/package.json | 2 +- .../electric-db-collection/src/electric.ts | 87 +++++- pnpm-lock.yaml | 247 +++++++++++------- 3 files changed, 231 insertions(+), 105 deletions(-) diff --git a/packages/electric-db-collection/package.json b/packages/electric-db-collection/package.json index dc916885a..da54e24f9 100644 --- a/packages/electric-db-collection/package.json +++ b/packages/electric-db-collection/package.json @@ -3,8 +3,8 @@ "description": "ElectricSQL collection for TanStack DB", "version": "0.1.28", "dependencies": { + "@electric-sql/client": "^1.0.14", "@standard-schema/spec": "^1.0.0", - "@electric-sql/client": "^1.0.10", "@tanstack/db": "workspace:*", "@tanstack/store": "^0.7.7", "debug": "^4.4.3" diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index b4c507093..97910539a 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -2,6 +2,7 @@ import { ShapeStream, isChangeMessage, isControlMessage, + isVisibleInSnapshot, } from "@electric-sql/client" import { Store } from "@tanstack/store" import DebugModule from "debug" @@ -27,6 +28,7 @@ import type { ControlMessage, GetExtensions, Message, + PostgresSnapshot, Row, ShapeStreamOptions, } from "@electric-sql/client" @@ -38,6 +40,16 @@ const debug = DebugModule.debug(`ts/db:electric`) */ export type Txid = number +/** + * Type representing the result of an insert, update, or delete handler + */ +type HandlerResult = + | { + txid?: Txid | Array + } + | undefined + | null + // The `InferSchemaOutput` and `ResolveType` are copied from the `@tanstack/db` package // but we modified `InferSchemaOutput` slightly to restrict the schema output to `Row` // This is needed in order for `GetExtensions` to be able to infer the parser extensions type from the schema @@ -80,6 +92,22 @@ function isMustRefetchMessage>( return isControlMessage(message) && message.headers.control === `must-refetch` } +function isSnapshotEndMessage>( + message: Message +): message is ControlMessage & { headers: { control: `snapshot-end` } } { + return isControlMessage(message) && message.headers.control === `snapshot-end` +} + +function parseSnapshotMessage( + message: ControlMessage & { headers: { control: `snapshot-end` } } +): PostgresSnapshot { + return { + xmin: message.headers.xmin, + xmax: message.headers.xmax, + xip_list: message.headers.xip_list, + } +} + // Check if a message contains txids in its headers function hasTxids>( message: Message @@ -139,8 +167,10 @@ export function electricCollectionOptions( schema?: any } { const seenTxids = new Store>(new Set([])) + const seenSnapshots = new Store>([]) const sync = createElectricSync(config.shapeOptions, { seenTxids, + seenSnapshots, }) /** @@ -158,20 +188,45 @@ export function electricCollectionOptions( throw new ExpectedNumberInAwaitTxIdError(typeof txId) } + // First check if the txid is in the seenTxids store const hasTxid = seenTxids.state.has(txId) if (hasTxid) return true + // Then check if the txid is in any of the seen snapshots + const hasSnapshot = seenSnapshots.state.some((snapshot) => + isVisibleInSnapshot(txId, snapshot) + ) + if (hasSnapshot) return true + return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { - unsubscribe() + unsubscribeSeenTxids() reject(new TimeoutWaitingForTxIdError(txId)) }, timeout) - const unsubscribe = seenTxids.subscribe(() => { + const unsubscribeSeenTxids = seenTxids.subscribe(() => { if (seenTxids.state.has(txId)) { debug(`awaitTxId found match for txid %o`, txId) clearTimeout(timeoutId) - unsubscribe() + unsubscribeSeenTxids() + unsubscribeSeenSnapshots() + resolve(true) + } + }) + + const unsubscribeSeenSnapshots = seenSnapshots.subscribe(() => { + const visibleSnapshot = seenSnapshots.state.find((snapshot) => + isVisibleInSnapshot(txId, snapshot) + ) + if (visibleSnapshot) { + debug( + `awaitTxId found match for txid %o in snapshot %o`, + txId, + visibleSnapshot + ) + clearTimeout(timeoutId) + unsubscribeSeenSnapshots() + unsubscribeSeenTxids() resolve(true) } }) @@ -183,8 +238,9 @@ export function electricCollectionOptions( ? async (params: InsertMutationFnParams) => { // Runtime check (that doesn't follow type) - const handlerResult = (await config.onInsert!(params)) ?? {} - const txid = (handlerResult as { txid?: Txid | Array }).txid + const handlerResult = + ((await config.onInsert!(params)) as HandlerResult) ?? {} + const txid = handlerResult.txid if (!txid) { throw new ElectricInsertHandlerMustReturnTxIdError() @@ -205,8 +261,9 @@ export function electricCollectionOptions( ? async (params: UpdateMutationFnParams) => { // Runtime check (that doesn't follow type) - const handlerResult = (await config.onUpdate!(params)) ?? {} - const txid = (handlerResult as { txid?: Txid | Array }).txid + const handlerResult = + ((await config.onUpdate!(params)) as HandlerResult) ?? {} + const txid = handlerResult.txid if (!txid) { throw new ElectricUpdateHandlerMustReturnTxIdError() @@ -269,9 +326,11 @@ function createElectricSync>( shapeOptions: ShapeStreamOptions>, options: { seenTxids: Store> + seenSnapshots: Store> } ): SyncConfig { const { seenTxids } = options + const { seenSnapshots } = options // Store for the relation schema information const relationSchema = new Store(undefined) @@ -342,6 +401,7 @@ function createElectricSync>( }) let transactionStarted = false const newTxids = new Set() + const newSnapshots: Array = [] unsubscribeStream = stream.subscribe((messages: Array>) => { let hasUpToDate = false @@ -373,6 +433,8 @@ function createElectricSync>( ...message.headers, }, }) + } else if (isSnapshotEndMessage(message)) { + newSnapshots.push(parseSnapshotMessage(message)) } else if (isUpToDateMessage(message)) { hasUpToDate = true } else if (isMustRefetchMessage(message)) { @@ -413,6 +475,17 @@ function createElectricSync>( newTxids.clear() return clonedSeen }) + + // Always commit snapshots when we receive up-to-date, regardless of transaction state + seenSnapshots.setState((currentSnapshots) => { + const clonedSeen = currentSnapshots.slice() + newSnapshots.forEach((snapshot) => { + debug(`new snapshot synced from pg %o`, snapshot) + clonedSeen.push(snapshot) + }) + newSnapshots.length = 0 + return clonedSeen + }) } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d08d41e52..f0ec164d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,10 +146,10 @@ importers: devDependencies: '@angular/build': specifier: ^20.3.3 - version: 20.3.4(@angular/compiler-cli@20.3.3(@angular/compiler@20.3.3)(typescript@5.8.3))(@angular/compiler@20.3.3)(@angular/core@20.3.3(@angular/compiler@20.3.3)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.3(@angular/common@20.3.3(@angular/core@20.3.3(@angular/compiler@20.3.3)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.3(@angular/compiler@20.3.3)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@24.6.2)(chokidar@4.0.3)(jiti@1.21.7)(karma@6.4.4)(lightningcss@1.30.1)(postcss@8.5.6)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(tslib@2.8.1)(tsx@4.20.6)(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.6.2)(jiti@1.21.7)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1) + version: 20.3.4(@angular/compiler-cli@20.3.3(@angular/compiler@20.3.3)(typescript@5.8.3))(@angular/compiler@20.3.3)(@angular/core@20.3.3(@angular/compiler@20.3.3)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.3(@angular/common@20.3.3(@angular/core@20.3.3(@angular/compiler@20.3.3)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.3(@angular/compiler@20.3.3)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(chokidar@4.0.3)(jiti@1.21.7)(karma@6.4.4)(lightningcss@1.30.1)(postcss@8.5.6)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(tslib@2.8.1)(tsx@4.20.6)(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.8)(jiti@1.21.7)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1) '@angular/cli': specifier: ^20.3.3 - version: 20.3.4(@types/node@24.6.2)(chokidar@4.0.3) + version: 20.3.4(@types/node@22.18.8)(chokidar@4.0.3) '@angular/compiler-cli': specifier: ^20.3.2 version: 20.3.3(@angular/compiler@20.3.3)(typescript@5.8.3) @@ -197,10 +197,10 @@ importers: version: 5.90.2 '@tanstack/query-db-collection': specifier: ^0.2.25 - version: link:../../../packages/query-db-collection + version: 0.2.25(@tanstack/query-core@5.90.2)(typescript@5.9.3) '@tanstack/react-db': specifier: ^0.1.26 - version: link:../../../packages/react-db + version: 0.1.26(react@19.2.0)(typescript@5.9.3) '@tanstack/react-router': specifier: ^1.132.25 version: 1.132.33(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -628,8 +628,8 @@ importers: packages/electric-db-collection: dependencies: '@electric-sql/client': - specifier: ^1.0.10 - version: 1.0.10 + specifier: ^1.0.14 + version: 1.0.14 '@standard-schema/spec': specifier: ^1.0.0 version: 1.0.0 @@ -1372,6 +1372,9 @@ packages: '@electric-sql/client@1.0.10': resolution: {integrity: sha512-KcP6R2OGiruxbhAa0wMGiRIfpXDQQ8vznBmn34kG/UJ11sqA4NQBhoPhSsKkCPs5EKc2DoS6bcnqLkIHFdxmvg==} + '@electric-sql/client@1.0.14': + resolution: {integrity: sha512-LtPAfeMxXRiYS0hyDQ5hue2PjljUiK9stvzsVyVb4nwxWQxfOWTSF42bHTs/o5i3x1T4kAQ7mwHpxa4A+f8X7Q==} + '@emnapi/core@1.5.0': resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} @@ -2187,7 +2190,7 @@ packages: resolution: {integrity: sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==} engines: {node: '>=18'} peerDependencies: - '@types/node': '>=18' + '@types/node': ^22.18.8 peerDependenciesMeta: '@types/node': optional: true @@ -3409,6 +3412,11 @@ packages: peerDependencies: typescript: '>=4.7' + '@tanstack/db@0.4.4': + resolution: {integrity: sha512-G+abJtW6jBjAwMSgbaSYuwUJFUaxTY7kb+PyT3Y6r2pr795v+QQNHe6pniQeqrLp3M5nnx3rws+XfeqoDgN/VQ==} + peerDependencies: + typescript: '>=4.7' + '@tanstack/directive-functions-plugin@1.132.31': resolution: {integrity: sha512-u6TaLhTmllnvINZAoc1r7TbZ0H1IgnqGpoN0pUvWrqpKuunAugZO7fwD1TeYApGyB/RmSWarHMMkNbDffvlJvQ==} engines: {node: '>=12'} @@ -3439,6 +3447,17 @@ packages: '@tanstack/query-core': ^5.0.0 typescript: '>=4.7' + '@tanstack/query-db-collection@0.2.25': + resolution: {integrity: sha512-Io07hIX7VLg7cO2/+Y6c9Bf2Y8xWtxt31i2lLswW2lx0sTZvK6sIpLvld8SxbMeRJHrwtXra++V0O6a1Y2ALGA==} + peerDependencies: + '@tanstack/query-core': ^5.0.0 + typescript: '>=4.7' + + '@tanstack/react-db@0.1.26': + resolution: {integrity: sha512-8itSp4bd+PU7/yImMn3eAcMq2AEzDVuPhJ2K5Pyqh7f9qhXlMDdxcm1K9BpZpaQJOJEcLYls6FDnaNNkyc5/hQ==} + peerDependencies: + react: '>=16.8.0' + '@tanstack/react-query@5.90.2': resolution: {integrity: sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==} peerDependencies: @@ -8637,7 +8656,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular/build@20.3.4(@angular/compiler-cli@20.3.3(@angular/compiler@20.3.3)(typescript@5.8.3))(@angular/compiler@20.3.3)(@angular/core@20.3.3(@angular/compiler@20.3.3)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.3(@angular/common@20.3.3(@angular/core@20.3.3(@angular/compiler@20.3.3)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.3(@angular/compiler@20.3.3)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@24.6.2)(chokidar@4.0.3)(jiti@1.21.7)(karma@6.4.4)(lightningcss@1.30.1)(postcss@8.5.6)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(tslib@2.8.1)(tsx@4.20.6)(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.6.2)(jiti@1.21.7)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1)': + '@angular/build@20.3.4(@angular/compiler-cli@20.3.3(@angular/compiler@20.3.3)(typescript@5.8.3))(@angular/compiler@20.3.3)(@angular/core@20.3.3(@angular/compiler@20.3.3)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.3(@angular/common@20.3.3(@angular/core@20.3.3(@angular/compiler@20.3.3)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.3(@angular/compiler@20.3.3)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(chokidar@4.0.3)(jiti@1.21.7)(karma@6.4.4)(lightningcss@1.30.1)(postcss@8.5.6)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(tslib@2.8.1)(tsx@4.20.6)(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.8)(jiti@1.21.7)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2003.4(chokidar@4.0.3) @@ -8646,8 +8665,8 @@ snapshots: '@babel/core': 7.28.3 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-split-export-declaration': 7.24.7 - '@inquirer/confirm': 5.1.14(@types/node@24.6.2) - '@vitejs/plugin-basic-ssl': 2.1.0(vite@7.1.5(@types/node@24.6.2)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1)) + '@inquirer/confirm': 5.1.14(@types/node@22.18.8) + '@vitejs/plugin-basic-ssl': 2.1.0(vite@7.1.5(@types/node@22.18.8)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1)) beasties: 0.3.5 browserslist: 4.26.3 esbuild: 0.25.9 @@ -8667,7 +8686,7 @@ snapshots: tinyglobby: 0.2.14 tslib: 2.8.1 typescript: 5.8.3 - vite: 7.1.5(@types/node@24.6.2)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.5(@types/node@22.18.8)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) watchpack: 2.4.4 optionalDependencies: '@angular/core': 20.3.3(@angular/compiler@20.3.3)(rxjs@7.8.2)(zone.js@0.15.1) @@ -8676,7 +8695,7 @@ snapshots: lmdb: 3.4.2 postcss: 8.5.6 tailwindcss: 3.4.18(tsx@4.20.6)(yaml@2.8.1) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.6.2)(jiti@1.21.7)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.8)(jiti@1.21.7)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - chokidar @@ -8690,13 +8709,13 @@ snapshots: - tsx - yaml - '@angular/cli@20.3.4(@types/node@24.6.2)(chokidar@4.0.3)': + '@angular/cli@20.3.4(@types/node@22.18.8)(chokidar@4.0.3)': dependencies: '@angular-devkit/architect': 0.2003.4(chokidar@4.0.3) '@angular-devkit/core': 20.3.4(chokidar@4.0.3) '@angular-devkit/schematics': 20.3.4(chokidar@4.0.3) - '@inquirer/prompts': 7.8.2(@types/node@24.6.2) - '@listr2/prompt-adapter-inquirer': 3.0.1(@inquirer/prompts@7.8.2(@types/node@24.6.2))(@types/node@24.6.2)(listr2@9.0.1) + '@inquirer/prompts': 7.8.2(@types/node@22.18.8) + '@listr2/prompt-adapter-inquirer': 3.0.1(@inquirer/prompts@7.8.2(@types/node@22.18.8))(@types/node@22.18.8)(listr2@9.0.1) '@modelcontextprotocol/sdk': 1.17.3 '@schematics/angular': 20.3.4(chokidar@4.0.3) '@yarnpkg/lockfile': 1.1.0 @@ -9275,6 +9294,12 @@ snapshots: optionalDependencies: '@rollup/rollup-darwin-arm64': 4.52.4 + '@electric-sql/client@1.0.14': + dependencies: + '@microsoft/fetch-event-source': 2.0.1 + optionalDependencies: + '@rollup/rollup-darwin-arm64': 4.52.4 + '@emnapi/core@1.5.0': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -9928,58 +9953,65 @@ snapshots: '@inquirer/ansi@1.0.0': {} - '@inquirer/checkbox@4.2.4(@types/node@24.6.2)': + '@inquirer/checkbox@4.2.4(@types/node@22.18.8)': dependencies: '@inquirer/ansi': 1.0.0 - '@inquirer/core': 10.2.2(@types/node@24.6.2) + '@inquirer/core': 10.2.2(@types/node@22.18.8) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@24.6.2) + '@inquirer/type': 3.0.8(@types/node@22.18.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 - '@inquirer/confirm@5.1.14(@types/node@24.6.2)': + '@inquirer/confirm@5.1.14(@types/node@22.18.8)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.6.2) - '@inquirer/type': 3.0.8(@types/node@24.6.2) + '@inquirer/core': 10.2.2(@types/node@22.18.8) + '@inquirer/type': 3.0.8(@types/node@22.18.8) optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 - '@inquirer/confirm@5.1.18(@types/node@24.6.2)': + '@inquirer/confirm@5.1.18(@types/node@22.18.8)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.6.2) - '@inquirer/type': 3.0.8(@types/node@24.6.2) + '@inquirer/core': 10.2.2(@types/node@22.18.8) + '@inquirer/type': 3.0.8(@types/node@22.18.8) optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 - '@inquirer/core@10.2.2(@types/node@24.6.2)': + '@inquirer/core@10.2.2(@types/node@22.18.8)': dependencies: '@inquirer/ansi': 1.0.0 '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@24.6.2) + '@inquirer/type': 3.0.8(@types/node@22.18.8) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 - '@inquirer/editor@4.2.20(@types/node@24.6.2)': + '@inquirer/editor@4.2.20(@types/node@22.18.8)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.6.2) - '@inquirer/external-editor': 1.0.2(@types/node@24.6.2) - '@inquirer/type': 3.0.8(@types/node@24.6.2) + '@inquirer/core': 10.2.2(@types/node@22.18.8) + '@inquirer/external-editor': 1.0.2(@types/node@22.18.8) + '@inquirer/type': 3.0.8(@types/node@22.18.8) optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 - '@inquirer/expand@4.0.20(@types/node@24.6.2)': + '@inquirer/expand@4.0.20(@types/node@22.18.8)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.6.2) - '@inquirer/type': 3.0.8(@types/node@24.6.2) + '@inquirer/core': 10.2.2(@types/node@22.18.8) + '@inquirer/type': 3.0.8(@types/node@22.18.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 + + '@inquirer/external-editor@1.0.2(@types/node@22.18.8)': + dependencies: + chardet: 2.1.0 + iconv-lite: 0.7.0 + optionalDependencies: + '@types/node': 22.18.8 '@inquirer/external-editor@1.0.2(@types/node@24.6.2)': dependencies: @@ -9990,73 +10022,73 @@ snapshots: '@inquirer/figures@1.0.13': {} - '@inquirer/input@4.2.4(@types/node@24.6.2)': + '@inquirer/input@4.2.4(@types/node@22.18.8)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.6.2) - '@inquirer/type': 3.0.8(@types/node@24.6.2) + '@inquirer/core': 10.2.2(@types/node@22.18.8) + '@inquirer/type': 3.0.8(@types/node@22.18.8) optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 - '@inquirer/number@3.0.20(@types/node@24.6.2)': + '@inquirer/number@3.0.20(@types/node@22.18.8)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.6.2) - '@inquirer/type': 3.0.8(@types/node@24.6.2) + '@inquirer/core': 10.2.2(@types/node@22.18.8) + '@inquirer/type': 3.0.8(@types/node@22.18.8) optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 - '@inquirer/password@4.0.20(@types/node@24.6.2)': + '@inquirer/password@4.0.20(@types/node@22.18.8)': dependencies: '@inquirer/ansi': 1.0.0 - '@inquirer/core': 10.2.2(@types/node@24.6.2) - '@inquirer/type': 3.0.8(@types/node@24.6.2) + '@inquirer/core': 10.2.2(@types/node@22.18.8) + '@inquirer/type': 3.0.8(@types/node@22.18.8) optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 - '@inquirer/prompts@7.8.2(@types/node@24.6.2)': - dependencies: - '@inquirer/checkbox': 4.2.4(@types/node@24.6.2) - '@inquirer/confirm': 5.1.18(@types/node@24.6.2) - '@inquirer/editor': 4.2.20(@types/node@24.6.2) - '@inquirer/expand': 4.0.20(@types/node@24.6.2) - '@inquirer/input': 4.2.4(@types/node@24.6.2) - '@inquirer/number': 3.0.20(@types/node@24.6.2) - '@inquirer/password': 4.0.20(@types/node@24.6.2) - '@inquirer/rawlist': 4.1.8(@types/node@24.6.2) - '@inquirer/search': 3.1.3(@types/node@24.6.2) - '@inquirer/select': 4.3.4(@types/node@24.6.2) + '@inquirer/prompts@7.8.2(@types/node@22.18.8)': + dependencies: + '@inquirer/checkbox': 4.2.4(@types/node@22.18.8) + '@inquirer/confirm': 5.1.18(@types/node@22.18.8) + '@inquirer/editor': 4.2.20(@types/node@22.18.8) + '@inquirer/expand': 4.0.20(@types/node@22.18.8) + '@inquirer/input': 4.2.4(@types/node@22.18.8) + '@inquirer/number': 3.0.20(@types/node@22.18.8) + '@inquirer/password': 4.0.20(@types/node@22.18.8) + '@inquirer/rawlist': 4.1.8(@types/node@22.18.8) + '@inquirer/search': 3.1.3(@types/node@22.18.8) + '@inquirer/select': 4.3.4(@types/node@22.18.8) optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 - '@inquirer/rawlist@4.1.8(@types/node@24.6.2)': + '@inquirer/rawlist@4.1.8(@types/node@22.18.8)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.6.2) - '@inquirer/type': 3.0.8(@types/node@24.6.2) + '@inquirer/core': 10.2.2(@types/node@22.18.8) + '@inquirer/type': 3.0.8(@types/node@22.18.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 - '@inquirer/search@3.1.3(@types/node@24.6.2)': + '@inquirer/search@3.1.3(@types/node@22.18.8)': dependencies: - '@inquirer/core': 10.2.2(@types/node@24.6.2) + '@inquirer/core': 10.2.2(@types/node@22.18.8) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@24.6.2) + '@inquirer/type': 3.0.8(@types/node@22.18.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 - '@inquirer/select@4.3.4(@types/node@24.6.2)': + '@inquirer/select@4.3.4(@types/node@22.18.8)': dependencies: '@inquirer/ansi': 1.0.0 - '@inquirer/core': 10.2.2(@types/node@24.6.2) + '@inquirer/core': 10.2.2(@types/node@22.18.8) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@24.6.2) + '@inquirer/type': 3.0.8(@types/node@22.18.8) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 - '@inquirer/type@3.0.8(@types/node@24.6.2)': + '@inquirer/type@3.0.8(@types/node@22.18.8)': optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 '@isaacs/balanced-match@4.0.1': {} @@ -10108,10 +10140,10 @@ snapshots: '@levischuck/tiny-cbor@0.2.11': {} - '@listr2/prompt-adapter-inquirer@3.0.1(@inquirer/prompts@7.8.2(@types/node@24.6.2))(@types/node@24.6.2)(listr2@9.0.1)': + '@listr2/prompt-adapter-inquirer@3.0.1(@inquirer/prompts@7.8.2(@types/node@22.18.8))(@types/node@22.18.8)(listr2@9.0.1)': dependencies: - '@inquirer/prompts': 7.8.2(@types/node@24.6.2) - '@inquirer/type': 3.0.8(@types/node@24.6.2) + '@inquirer/prompts': 7.8.2(@types/node@22.18.8) + '@inquirer/type': 3.0.8(@types/node@22.18.8) listr2: 9.0.1 transitivePeerDependencies: - '@types/node' @@ -11193,6 +11225,12 @@ snapshots: '@tanstack/db-ivm': 0.1.9(typescript@5.9.3) typescript: 5.9.3 + '@tanstack/db@0.4.4(typescript@5.9.3)': + dependencies: + '@standard-schema/spec': 1.0.0 + '@tanstack/db-ivm': 0.1.9(typescript@5.9.3) + typescript: 5.9.3 + '@tanstack/directive-functions-plugin@1.132.31(vite@6.3.6(@types/node@22.18.8)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@babel/code-frame': 7.27.1 @@ -11266,6 +11304,21 @@ snapshots: '@tanstack/query-core': 5.90.2 typescript: 5.9.3 + '@tanstack/query-db-collection@0.2.25(@tanstack/query-core@5.90.2)(typescript@5.9.3)': + dependencies: + '@standard-schema/spec': 1.0.0 + '@tanstack/db': 0.4.4(typescript@5.9.3) + '@tanstack/query-core': 5.90.2 + typescript: 5.9.3 + + '@tanstack/react-db@0.1.26(react@19.2.0)(typescript@5.9.3)': + dependencies: + '@tanstack/db': 0.4.4(typescript@5.9.3) + react: 19.2.0 + use-sync-external-store: 1.6.0(react@19.2.0) + transitivePeerDependencies: + - typescript + '@tanstack/react-query@5.90.2(react@19.2.0)': dependencies: '@tanstack/query-core': 5.90.2 @@ -11873,7 +11926,7 @@ snapshots: '@types/send@0.17.5': dependencies: '@types/mime': 1.3.5 - '@types/node': 24.6.2 + '@types/node': 22.18.8 '@types/serve-static@1.15.8': dependencies: @@ -12051,9 +12104,9 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-basic-ssl@2.1.0(vite@7.1.5(@types/node@24.6.2)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1))': + '@vitejs/plugin-basic-ssl@2.1.0(vite@7.1.5(@types/node@22.18.8)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: - vite: 7.1.5(@types/node@24.6.2)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.5(@types/node@22.18.8)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) '@vitejs/plugin-react@5.0.4(vite@7.1.9(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: @@ -12097,13 +12150,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.9(@types/node@24.6.2)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@7.1.9(@types/node@22.18.8)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: - vite: 7.1.9(@types/node@24.6.2)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.9(@types/node@22.18.8)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) optional: true '@vitest/mocker@3.2.4(vite@7.1.9(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1))': @@ -16759,13 +16812,13 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@24.6.2)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1): + vite-node@3.2.4(@types/node@22.18.8)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.9(@types/node@24.6.2)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.9(@types/node@22.18.8)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -16894,7 +16947,7 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 - vite@7.1.5(@types/node@24.6.2)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1): + vite@7.1.5(@types/node@22.18.8)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -16903,7 +16956,7 @@ snapshots: rollup: 4.52.3 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 fsevents: 2.3.3 jiti: 1.21.7 lightningcss: 1.30.1 @@ -16911,7 +16964,7 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 - vite@7.1.9(@types/node@24.6.2)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1): + vite@7.1.9(@types/node@22.18.8)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.10 fdir: 6.5.0(picomatch@4.0.3) @@ -16920,7 +16973,7 @@ snapshots: rollup: 4.52.4 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.6.2 + '@types/node': 22.18.8 fsevents: 2.3.3 jiti: 1.21.7 lightningcss: 1.30.1 @@ -16954,11 +17007,11 @@ snapshots: optionalDependencies: vite: 7.1.9(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.6.2)(jiti@1.21.7)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.8)(jiti@1.21.7)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.9(@types/node@24.6.2)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.1.9(@types/node@22.18.8)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -16976,12 +17029,12 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.9(@types/node@24.6.2)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.6.2)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.1.9(@types/node@22.18.8)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@22.18.8)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.90.0)(tsx@4.20.6)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 24.6.2 + '@types/node': 22.18.8 jsdom: 27.0.0(postcss@8.5.6) transitivePeerDependencies: - jiti From ebf0bb8bbd90384dc10afe16856332ad6c24118d Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 6 Oct 2025 14:36:38 +0100 Subject: [PATCH 2/5] add tests --- .../tests/electric.test.ts | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index 617416759..ba21e5c6d 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -1031,6 +1031,222 @@ describe(`Electric Integration`, () => { await expect(testCollection.utils.awaitTxId(400)).resolves.toBe(true) }) + it(`should handle snapshot-end messages and match txids via snapshot metadata`, async () => { + const config = { + id: `snapshot-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send snapshot-end message with PostgresSnapshot metadata + // xmin=100, xmax=150, xip_list=[120, 130] + // Visible: txid < 100 (committed before snapshot) OR (100 <= txid < 150 AND txid NOT IN [120, 130]) + // Not visible: txid >= 150 (not yet assigned) OR txid IN [120, 130] (in-progress) + subscriber([ + { + key: `1`, + value: { id: 1, name: `Test User` }, + headers: { operation: `insert` }, + }, + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `150`, + xip_list: [`120`, `130`], + }, + }, + { + headers: { control: `up-to-date` }, + }, + ]) + + // Txids that are visible in the snapshot should resolve + // Txids < xmin are committed and visible + await expect(testCollection.utils.awaitTxId(50)).resolves.toBe(true) + await expect(testCollection.utils.awaitTxId(99)).resolves.toBe(true) + + // Txids in range [xmin, xmax) not in xip_list are visible + await expect(testCollection.utils.awaitTxId(100)).resolves.toBe(true) + await expect(testCollection.utils.awaitTxId(110)).resolves.toBe(true) + await expect(testCollection.utils.awaitTxId(121)).resolves.toBe(true) + await expect(testCollection.utils.awaitTxId(125)).resolves.toBe(true) + await expect(testCollection.utils.awaitTxId(131)).resolves.toBe(true) + await expect(testCollection.utils.awaitTxId(149)).resolves.toBe(true) + + // Txids in xip_list (in-progress transactions) should NOT resolve + await expect(testCollection.utils.awaitTxId(120, 100)).rejects.toThrow( + `Timeout waiting for txId: 120` + ) + await expect(testCollection.utils.awaitTxId(130, 100)).rejects.toThrow( + `Timeout waiting for txId: 130` + ) + + // Txids >= xmax should NOT resolve (not yet assigned) + await expect(testCollection.utils.awaitTxId(150, 100)).rejects.toThrow( + `Timeout waiting for txId: 150` + ) + await expect(testCollection.utils.awaitTxId(200, 100)).rejects.toThrow( + `Timeout waiting for txId: 200` + ) + }) + + it(`should await for txid that arrives later via snapshot-end`, async () => { + const config = { + id: `snapshot-await-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Start waiting for a txid before snapshot arrives + const txidToAwait = 105 + const promise = testCollection.utils.awaitTxId(txidToAwait, 2000) + + // Send snapshot-end message after a delay + setTimeout(() => { + subscriber([ + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `110`, + xip_list: [], + }, + }, + { + headers: { control: `up-to-date` }, + }, + ]) + }, 50) + + // The promise should resolve when the snapshot arrives + await expect(promise).resolves.toBe(true) + }) + + it(`should handle multiple snapshots and track all of them`, async () => { + const config = { + id: `multiple-snapshots-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send first snapshot: visible txids < 110 + subscriber([ + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `110`, + xip_list: [], + }, + }, + { + headers: { control: `up-to-date` }, + }, + ]) + + // Send second snapshot: visible txids < 210 + subscriber([ + { + headers: { + control: `snapshot-end`, + xmin: `200`, + xmax: `210`, + xip_list: [], + }, + }, + { + headers: { control: `up-to-date` }, + }, + ]) + + // Txids visible in first snapshot + await expect(testCollection.utils.awaitTxId(105)).resolves.toBe(true) + + // Txids visible in second snapshot + await expect(testCollection.utils.awaitTxId(205)).resolves.toBe(true) + + // Txid 150 is visible in second snapshot (< xmin=200 means committed) + await expect(testCollection.utils.awaitTxId(150)).resolves.toBe(true) + + // Txids >= second snapshot's xmax should timeout (not yet assigned) + await expect(testCollection.utils.awaitTxId(210, 100)).rejects.toThrow( + `Timeout waiting for txId: 210` + ) + await expect(testCollection.utils.awaitTxId(300, 100)).rejects.toThrow( + `Timeout waiting for txId: 300` + ) + }) + + it(`should prefer explicit txids over snapshot matching when both are available`, async () => { + const config = { + id: `explicit-txid-priority-test`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + getKey: (item: Row) => item.id as number, + startSync: true, + } + + const testCollection = createCollection(electricCollectionOptions(config)) + + // Send message with explicit txid and snapshot + subscriber([ + { + key: `1`, + value: { id: 1, name: `Test User` }, + headers: { + operation: `insert`, + txids: [500], + }, + }, + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `110`, + xip_list: [], + }, + }, + { + headers: { control: `up-to-date` }, + }, + ]) + + // Explicit txid should resolve + await expect(testCollection.utils.awaitTxId(500)).resolves.toBe(true) + + // Snapshot txid should also resolve + await expect(testCollection.utils.awaitTxId(105)).resolves.toBe(true) + }) + it(`should resync after garbage collection and new subscription`, () => { // Use fake timers for this test vi.useFakeTimers() From e3bb738293b62628e1e5aaec48d3eeba0399a96d Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 6 Oct 2025 14:39:21 +0100 Subject: [PATCH 3/5] fix cleanup --- packages/electric-db-collection/src/electric.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 97910539a..76a8f69ac 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -201,6 +201,7 @@ export function electricCollectionOptions( return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { unsubscribeSeenTxids() + unsubscribeSeenSnapshots() reject(new TimeoutWaitingForTxIdError(txId)) }, timeout) From f9ab8245dd960b62f16d111772488d105b2eb613 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 6 Oct 2025 14:42:11 +0100 Subject: [PATCH 4/5] changeset --- .changeset/eager-wings-jog.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/eager-wings-jog.md diff --git a/.changeset/eager-wings-jog.md b/.changeset/eager-wings-jog.md new file mode 100644 index 000000000..535d59359 --- /dev/null +++ b/.changeset/eager-wings-jog.md @@ -0,0 +1,5 @@ +--- +"@tanstack/electric-db-collection": patch +--- + +The awaitTxId utility now resolves transaction IDs based on snapshot-end message metadata (xmin, xmax, xip_list) in addition to explicit txid arrays, enabling matching on the initial snapshot at the start of a new shape. From 9f9a28c8b82f10880720704b30c04678d6179e05 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 6 Oct 2025 17:30:08 +0100 Subject: [PATCH 5/5] address review --- .../electric-db-collection/src/electric.ts | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 76a8f69ac..3dcb54b64 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -43,13 +43,20 @@ export type Txid = number /** * Type representing the result of an insert, update, or delete handler */ -type HandlerResult = +type MaybeTxId = | { txid?: Txid | Array } | undefined | null +/** + * Type representing a snapshot end message + */ +type SnapshotEndMessage = ControlMessage & { + headers: { control: `snapshot-end` } +} + // The `InferSchemaOutput` and `ResolveType` are copied from the `@tanstack/db` package // but we modified `InferSchemaOutput` slightly to restrict the schema output to `Row` // This is needed in order for `GetExtensions` to be able to infer the parser extensions type from the schema @@ -94,13 +101,11 @@ function isMustRefetchMessage>( function isSnapshotEndMessage>( message: Message -): message is ControlMessage & { headers: { control: `snapshot-end` } } { +): message is SnapshotEndMessage { return isControlMessage(message) && message.headers.control === `snapshot-end` } -function parseSnapshotMessage( - message: ControlMessage & { headers: { control: `snapshot-end` } } -): PostgresSnapshot { +function parseSnapshotMessage(message: SnapshotEndMessage): PostgresSnapshot { return { xmin: message.headers.xmin, xmax: message.headers.xmax, @@ -240,7 +245,7 @@ export function electricCollectionOptions( // Runtime check (that doesn't follow type) const handlerResult = - ((await config.onInsert!(params)) as HandlerResult) ?? {} + ((await config.onInsert!(params)) as MaybeTxId) ?? {} const txid = handlerResult.txid if (!txid) { @@ -263,7 +268,7 @@ export function electricCollectionOptions( // Runtime check (that doesn't follow type) const handlerResult = - ((await config.onUpdate!(params)) as HandlerResult) ?? {} + ((await config.onUpdate!(params)) as MaybeTxId) ?? {} const txid = handlerResult.txid if (!txid) { @@ -479,13 +484,12 @@ function createElectricSync>( // Always commit snapshots when we receive up-to-date, regardless of transaction state seenSnapshots.setState((currentSnapshots) => { - const clonedSeen = currentSnapshots.slice() - newSnapshots.forEach((snapshot) => { + const seen = [...currentSnapshots, ...newSnapshots] + newSnapshots.forEach((snapshot) => debug(`new snapshot synced from pg %o`, snapshot) - clonedSeen.push(snapshot) - }) + ) newSnapshots.length = 0 - return clonedSeen + return seen }) } })