Skip to content

Commit 64dcfec

Browse files
KyleAMathewsclaude
andauthored
feat: Add client-side cache buster for expired shapes to prevent 409s (#3103)
## Summary - Implements client-side caching of 409'd shapes using localStorage - Adds cache_buster parameter to prevent redundant 409 responses - Reduces app loading latency when shapes expire ## Changes - Add `SHAPE_CACHE_BUSTER_QUERY_PARAM` constant for expired shape requests - Store expired shape handles in localStorage when 409 responses occur - Check for expired shapes during URL construction and add cache buster - Gracefully handle localStorage availability and errors ## Test plan - [ ] Test 409 response handling stores shape in localStorage - [ ] Verify subsequent requests include cache_buster parameter - [ ] Confirm behavior works correctly when localStorage unavailable - [ ] Test multiple expired shapes don't interfere with each other Fixes #3100 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3baf67a commit 64dcfec

8 files changed

Lines changed: 377 additions & 1 deletion

File tree

‎.changeset/wicked-moments-study.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@electric-sql/client": patch
3+
---
4+
5+
Add client-side cache buster for expired shapes to prevent 409s
6+
7+
When a shape 409s, the client now stores this information in localStorage and adds a `expired_handle` parameter to future requests for that shape, preventing redundant 409 responses and reducing app loading latency.

‎packages/typescript-client/package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@
2121
"eslint-config-prettier": "^9.1.0",
2222
"eslint-plugin-prettier": "^5.1.3",
2323
"glob": "^10.3.10",
24+
"jsdom": "^26.1.0",
2425
"pg": "^8.12.0",
2526
"prettier": "^3.3.2",
2627
"shx": "^0.3.4",
2728
"tsup": "^8.0.1",
2829
"typescript": "^5.5.2",
2930
"uuid": "^10.0.0",
30-
"vitest": "^3.0.0"
31+
"vitest": "^3.0.0",
32+
"vitest-localstorage-mock": "^0.1.2"
3133
},
3234
"type": "module",
3335
"exports": {

‎packages/typescript-client/src/client.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
CHUNK_LAST_OFFSET_HEADER,
2929
LIVE_CACHE_BUSTER_HEADER,
3030
LIVE_CACHE_BUSTER_QUERY_PARAM,
31+
EXPIRED_HANDLE_QUERY_PARAM,
3132
COLUMNS_QUERY_PARAM,
3233
LIVE_QUERY_PARAM,
3334
OFFSET_QUERY_PARAM,
@@ -41,11 +42,13 @@ import {
4142
FORCE_DISCONNECT_AND_REFRESH,
4243
PAUSE_STREAM,
4344
EXPERIMENTAL_LIVE_SSE_QUERY_PARAM,
45+
ELECTRIC_PROTOCOL_QUERY_PARAMS,
4446
} from './constants'
4547
import {
4648
EventSourceMessage,
4749
fetchEventSource,
4850
} from '@microsoft/fetch-event-source'
51+
import { expiredShapesCache } from './expired-shapes-cache'
4952

5053
const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
5154
LIVE_CACHE_BUSTER_QUERY_PARAM,
@@ -294,6 +297,23 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
294297
forceDisconnectAndRefresh(): Promise<void>
295298
}
296299

300+
/**
301+
* Creates a canonical shape key from a URL excluding only Electric protocol parameters
302+
*/
303+
function canonicalShapeKey(url: URL): string {
304+
const cleanUrl = new URL(url.origin + url.pathname)
305+
306+
// Copy all params except Electric protocol ones that vary between requests
307+
for (const [key, value] of url.searchParams) {
308+
if (!ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
309+
cleanUrl.searchParams.set(key, value)
310+
}
311+
}
312+
313+
cleanUrl.searchParams.sort()
314+
return cleanUrl.toString()
315+
}
316+
297317
/**
298318
* Reads updates to a shape from Electric using HTTP requests and long polling or
299319
* Server-Sent Events (SSE).
@@ -521,6 +541,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
521541
// with the newly provided shape handle, or a fallback
522542
// pseudo-handle based on the current one to act as a
523543
// consistent cache buster
544+
545+
// Store the current shape URL as expired to avoid future 409s
546+
if (this.#shapeHandle) {
547+
const shapeKey = canonicalShapeKey(fetchUrl)
548+
expiredShapesCache.markExpired(shapeKey, this.#shapeHandle)
549+
}
550+
524551
const newShapeHandle =
525552
e.headers[SHAPE_HANDLE_HEADER] || `${this.#shapeHandle!}-next`
526553
this.#reset(newShapeHandle)
@@ -606,6 +633,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
606633
fetchUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, this.#shapeHandle!)
607634
}
608635

636+
// Add cache buster for shapes known to be expired to prevent 409s
637+
const shapeKey = canonicalShapeKey(fetchUrl)
638+
const expiredHandle = expiredShapesCache.getExpiredHandle(shapeKey)
639+
if (expiredHandle) {
640+
fetchUrl.searchParams.set(EXPIRED_HANDLE_QUERY_PARAM, expiredHandle)
641+
}
642+
609643
// sort query params in-place for stable URLs and improved cache hits
610644
fetchUrl.searchParams.sort()
611645

‎packages/typescript-client/src/constants.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const SHAPE_SCHEMA_HEADER = `electric-schema`
55
export const CHUNK_UP_TO_DATE_HEADER = `electric-up-to-date`
66
export const COLUMNS_QUERY_PARAM = `columns`
77
export const LIVE_CACHE_BUSTER_QUERY_PARAM = `cursor`
8+
export const EXPIRED_HANDLE_QUERY_PARAM = `expired_handle`
89
export const SHAPE_HANDLE_QUERY_PARAM = `handle`
910
export const LIVE_QUERY_PARAM = `live`
1011
export const OFFSET_QUERY_PARAM = `offset`
@@ -22,4 +23,5 @@ export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
2223
SHAPE_HANDLE_QUERY_PARAM,
2324
OFFSET_QUERY_PARAM,
2425
LIVE_CACHE_BUSTER_QUERY_PARAM,
26+
EXPIRED_HANDLE_QUERY_PARAM,
2527
]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
interface ExpiredShapeCacheEntry {
2+
expiredHandle: string
3+
lastUsed: number
4+
}
5+
6+
/**
7+
* LRU cache for tracking expired shapes with automatic cleanup
8+
*/
9+
export class ExpiredShapesCache {
10+
private data: Record<string, ExpiredShapeCacheEntry> = {}
11+
private max: number = 250
12+
private readonly storageKey = `electric_expired_shapes`
13+
14+
getExpiredHandle(shapeUrl: string): string | null {
15+
const entry = this.data[shapeUrl]
16+
if (entry) {
17+
// Update last used time when accessed
18+
entry.lastUsed = Date.now()
19+
this.save()
20+
return entry.expiredHandle
21+
}
22+
return null
23+
}
24+
25+
markExpired(shapeUrl: string, handle: string): void {
26+
this.data[shapeUrl] = { expiredHandle: handle, lastUsed: Date.now() }
27+
28+
const keys = Object.keys(this.data)
29+
if (keys.length > this.max) {
30+
const oldest = keys.reduce((min, k) =>
31+
this.data[k].lastUsed < this.data[min].lastUsed ? k : min
32+
)
33+
delete this.data[oldest]
34+
}
35+
36+
this.save()
37+
}
38+
39+
private save(): void {
40+
if (typeof localStorage === `undefined`) return
41+
try {
42+
localStorage.setItem(this.storageKey, JSON.stringify(this.data))
43+
} catch {
44+
// Ignore localStorage errors
45+
}
46+
}
47+
48+
private load(): void {
49+
if (typeof localStorage === `undefined`) return
50+
try {
51+
const stored = localStorage.getItem(this.storageKey)
52+
if (stored) {
53+
this.data = JSON.parse(stored)
54+
}
55+
} catch {
56+
// Ignore localStorage errors, start fresh
57+
this.data = {}
58+
}
59+
}
60+
61+
constructor() {
62+
this.load()
63+
}
64+
65+
clear(): void {
66+
this.data = {}
67+
this.save()
68+
}
69+
}
70+
71+
// Module-level singleton instance
72+
export const expiredShapesCache = new ExpiredShapesCache()

0 commit comments

Comments
 (0)