From c7ff610fc700d5ddba0f9c050e4a050526c8e803 Mon Sep 17 00:00:00 2001 From: Oleksii Sholik Date: Wed, 26 Aug 2026 16:26:38 +0200 Subject: [PATCH 1/4] test: add regression test for duplicate snapshot prefetch --- packages/typescript-client/src/constants.ts | 1 + packages/typescript-client/test/fetch.test.ts | 29 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/typescript-client/src/constants.ts b/packages/typescript-client/src/constants.ts index 3744152423..b929186f3c 100644 --- a/packages/typescript-client/src/constants.ts +++ b/packages/typescript-client/src/constants.ts @@ -3,6 +3,7 @@ export const SHAPE_HANDLE_HEADER = `electric-handle` export const CHUNK_LAST_OFFSET_HEADER = `electric-offset` export const SHAPE_SCHEMA_HEADER = `electric-schema` export const CHUNK_UP_TO_DATE_HEADER = `electric-up-to-date` +export const SNAPSHOT_HEADER = `electric-snapshot` export const COLUMNS_QUERY_PARAM = `columns` export const LIVE_CACHE_BUSTER_QUERY_PARAM = `cursor` export const EXPIRED_HANDLE_QUERY_PARAM = `expired_handle` diff --git a/packages/typescript-client/test/fetch.test.ts b/packages/typescript-client/test/fetch.test.ts index 6a44652b65..6dea9a452b 100644 --- a/packages/typescript-client/test/fetch.test.ts +++ b/packages/typescript-client/test/fetch.test.ts @@ -16,7 +16,11 @@ import { createFetchWithConsumedMessages, parseRetryAfterHeader, } from '../src/fetch' -import { CHUNK_LAST_OFFSET_HEADER, SHAPE_HANDLE_HEADER } from '../src/constants' +import { + CHUNK_LAST_OFFSET_HEADER, + SHAPE_HANDLE_HEADER, + SNAPSHOT_HEADER, +} from '../src/constants' describe(`createFetchWithBackoff`, () => { const initialDelay = 10 @@ -392,6 +396,29 @@ describe(`createFetchWithChunkBuffer`, () => { ) }) + it(`should not prefetch a snapshot (subset) GET response`, async () => { + const fetchWrapper = createFetchWithChunkBuffer(mockFetch) + const snapshotUrl = `https://example.com/v1/shape?table=foo&subset__limit=50` + const snapshotResponse = new Response(`[]`, { + status: 200, + headers: responseHeaders({ + [SHAPE_HANDLE_HEADER]: `123`, + [CHUNK_LAST_OFFSET_HEADER]: `456`, + [SNAPSHOT_HEADER]: `true`, + }), + }) + + mockFetch.mockResolvedValueOnce(snapshotResponse) + + const result = await fetchWrapper(snapshotUrl) + await sleep() + + expect(result).toBe(snapshotResponse) + // Snapshot responses must not trigger a speculative prefetch of the + // same subset filters — the client only ever issues one such request. + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + it(`should not prefetch for non-GET requests`, async () => { const fetchWrapper = createFetchWithChunkBuffer(mockFetch) const postResponse = new Response(`snapshot chunk`, { From 7ae8ad3152820aaae039c54ce19b86abc984a660 Mon Sep 17 00:00:00 2001 From: Oleksii Sholik Date: Wed, 26 Aug 2026 16:26:52 +0200 Subject: [PATCH 2/4] fix: skip chunk prefetch for subset snapshot responses Snapshot (subset) responses carry a shape handle and offset just like normal chunks, so the prefetcher mistook them for a chunk stream and speculatively re-issued the same subset request, downloading the snapshot twice. Skip prefetch whenever the response is marked with the electric-snapshot header. Fixes #4753 --- .changeset/quiet-actors-argue.md | 5 +++++ packages/typescript-client/src/fetch.ts | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-actors-argue.md diff --git a/.changeset/quiet-actors-argue.md b/.changeset/quiet-actors-argue.md new file mode 100644 index 0000000000..f922b81b89 --- /dev/null +++ b/.changeset/quiet-actors-argue.md @@ -0,0 +1,5 @@ +--- +"@electric-sql/client": patch +--- + +Fix duplicate download of subset snapshot responses caused by the chunk prefetcher mistaking them for a normal chunk to prefetch. diff --git a/packages/typescript-client/src/fetch.ts b/packages/typescript-client/src/fetch.ts index cfd6ef5ac6..06ba5b42c0 100644 --- a/packages/typescript-client/src/fetch.ts +++ b/packages/typescript-client/src/fetch.ts @@ -8,6 +8,7 @@ import { SHAPE_SCHEMA_HEADER, SHAPE_HANDLE_HEADER, SHAPE_HANDLE_QUERY_PARAM, + SNAPSHOT_HEADER, SUBSET_PARAM_LIMIT, SUBSET_PARAM_OFFSET, SUBSET_PARAM_ORDER_BY, @@ -475,10 +476,13 @@ function getNextChunkUrl(url: string, res: Response): string | void { const shapeHandle = res.headers.get(SHAPE_HANDLE_HEADER) const lastOffset = res.headers.get(CHUNK_LAST_OFFSET_HEADER) const isUpToDate = res.headers.has(CHUNK_UP_TO_DATE_HEADER) + const isSnapshot = res.headers.has(SNAPSHOT_HEADER) // only prefetch if shape handle and offset for next chunk are available, and // response is not already up-to-date - if (!shapeHandle || !lastOffset || isUpToDate) return + // snapshot (subset) responses are one-shot and must never be prefetched: + // there is no "next chunk" URL for them, only a repeat of the same request + if (!shapeHandle || !lastOffset || isUpToDate || isSnapshot) return const nextUrl = new URL(url) From a1ea9cd70857e7a3a2df1e543d8150a4006d9707 Mon Sep 17 00:00:00 2001 From: Oleksii Sholik Date: Thu, 27 Aug 2026 11:24:50 +0200 Subject: [PATCH 3/4] fix(client): require true snapshot header to skip prefetch --- packages/typescript-client/src/fetch.ts | 2 +- packages/typescript-client/test/fetch.test.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/typescript-client/src/fetch.ts b/packages/typescript-client/src/fetch.ts index 06ba5b42c0..accde8273f 100644 --- a/packages/typescript-client/src/fetch.ts +++ b/packages/typescript-client/src/fetch.ts @@ -476,7 +476,7 @@ function getNextChunkUrl(url: string, res: Response): string | void { const shapeHandle = res.headers.get(SHAPE_HANDLE_HEADER) const lastOffset = res.headers.get(CHUNK_LAST_OFFSET_HEADER) const isUpToDate = res.headers.has(CHUNK_UP_TO_DATE_HEADER) - const isSnapshot = res.headers.has(SNAPSHOT_HEADER) + const isSnapshot = res.headers.get(SNAPSHOT_HEADER) === `true` // only prefetch if shape handle and offset for next chunk are available, and // response is not already up-to-date diff --git a/packages/typescript-client/test/fetch.test.ts b/packages/typescript-client/test/fetch.test.ts index 6dea9a452b..91cd353900 100644 --- a/packages/typescript-client/test/fetch.test.ts +++ b/packages/typescript-client/test/fetch.test.ts @@ -419,6 +419,28 @@ describe(`createFetchWithChunkBuffer`, () => { expect(mockFetch).toHaveBeenCalledTimes(1) }) + it(`should prefetch when the snapshot header is not true`, async () => { + const fetchWrapper = createFetchWithChunkBuffer(mockFetch) + const initialResponse = new Response(`initial chunk`, { + status: 200, + headers: responseHeaders({ + [SHAPE_HANDLE_HEADER]: `123`, + [CHUNK_LAST_OFFSET_HEADER]: `456`, + [SNAPSHOT_HEADER]: `false`, + }), + }) + const nextResponse = new Response(`next chunk`, { status: 200 }) + + mockFetch.mockResolvedValueOnce(initialResponse) + mockFetch.mockResolvedValueOnce(nextResponse) + + const result = await fetchWrapper(baseUrl) + + expect(result).toBe(initialResponse) + const nextUrl = sortUrlParams(`${baseUrl}&handle=123&offset=456`) + expect(mockFetch).toHaveBeenCalledWith(nextUrl, expect.anything()) + }) + it(`should not prefetch for non-GET requests`, async () => { const fetchWrapper = createFetchWithChunkBuffer(mockFetch) const postResponse = new Response(`snapshot chunk`, { From 5234cd52d8e0c72887f6083c2ef85bc91b1d81a1 Mon Sep 17 00:00:00 2001 From: Oleksii Sholik Date: Tue, 1 Sep 2026 11:22:05 +0200 Subject: [PATCH 4/4] fix: expose electric-snapshot header to cross-origin clients The subset-snapshot response header was set as a raw string outside of the @electric_headers list that CORSHeaderPlug turns into access-control-expose-headers, so browser clients on a different origin could not read it and the client-side prefetch guard was a no-op for them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HFFVFCNmGhPm94KCwK1fg8 --- .changeset/expose-snapshot-header.md | 5 +++++ packages/sync-service/lib/electric/shapes/api/response.ex | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changeset/expose-snapshot-header.md diff --git a/.changeset/expose-snapshot-header.md b/.changeset/expose-snapshot-header.md new file mode 100644 index 0000000000..e3ada927ed --- /dev/null +++ b/.changeset/expose-snapshot-header.md @@ -0,0 +1,5 @@ +--- +"@core/sync-service": patch +--- + +Include the `electric-snapshot` response header in `access-control-expose-headers` so that cross-origin browser clients can read it. diff --git a/packages/sync-service/lib/electric/shapes/api/response.ex b/packages/sync-service/lib/electric/shapes/api/response.ex index 6a99d06335..4eb85417e1 100644 --- a/packages/sync-service/lib/electric/shapes/api/response.ex +++ b/packages/sync-service/lib/electric/shapes/api/response.ex @@ -12,6 +12,7 @@ defmodule Electric.Shapes.Api.Response do @electric_schema_header "electric-schema" @electric_up_to_date_header "electric-up-to-date" @electric_has_data_header "electric-has-data" + @electric_snapshot_header "electric-snapshot" @electric_known_error_header "electric-internal-known-error" @retry_after_header "retry-after" @@ -23,6 +24,7 @@ defmodule Electric.Shapes.Api.Response do @electric_offset_header, @electric_schema_header, @electric_up_to_date_header, + @electric_snapshot_header, @electric_known_error_header, @retry_after_header ] @@ -207,7 +209,7 @@ defmodule Electric.Shapes.Api.Response do defp put_resp_headers(conn, %__MODULE__{response_type: :subset} = response) do conn |> put_cache_header("cache-control", "no-cache", response.api) - |> Plug.Conn.put_resp_header("electric-snapshot", "true") + |> Plug.Conn.put_resp_header(@electric_snapshot_header, "true") |> put_shape_handle_header(response) |> put_schema_header(response) |> put_offset_header(response)