Skip to content

Commit 4244cbd

Browse files
KyleAMathewsclaude
andcommitted
fix(db-sqlite-persistence-core): clear collection_metadata on schema-mismatch reset
A schema-version reset wiped rows, tombstones, and the applied_tx replay log but left collection_metadata behind — including Electric's electric:resume offset/handle. The next sync then resumed past all the wiped data and the collection came up permanently empty and silently ready (#1589). Also adds review-claims.test.ts: red/green verification tests for the persistence-cluster ground-truthing, including it.fails tests documenting three confirmed-but-deferred defects (readiness gated on the remote source, write-behind persistence failures, sync-present mutations never persisted locally) that become acceptance criteria for the RFC's PR 6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a93abf6 commit 4244cbd

3 files changed

Lines changed: 285 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db-sqlite-persistence-core": patch
3+
---
4+
5+
Clear `collection_metadata` in the same transaction as a schema-mismatch reset. Previously the reset wiped rows, tombstones, and the replay log but left collection metadata behind — including Electric's `electric:resume` offset/handle — so the next sync resumed past all of the wiped data and the collection came up permanently empty (#1589).

packages/db-sqlite-persistence-core/src/sqlite-core-adapter.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2018,6 +2018,11 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
20182018
WHERE collection_id = ?`,
20192019
[collectionId],
20202020
)
2021+
await transactionDriver.run(
2022+
`DELETE FROM collection_metadata
2023+
WHERE collection_id = ?`,
2024+
[collectionId],
2025+
)
20212026
await transactionDriver.run(
20222027
`DELETE FROM persisted_index_registry
20232028
WHERE collection_id = ?`,
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/**
2+
* Red/green verification tests for external-review claims about the
3+
* persistence/Electric/SQLite issue cluster.
4+
*
5+
* Each test asserts the DESIRED invariant claimed by the review. A failing
6+
* test (RED) confirms the claimed defect exists in current code.
7+
*/
8+
import { DatabaseSync } from 'node:sqlite'
9+
import { describe, expect, it } from 'vitest'
10+
import { createCollection } from '@tanstack/db'
11+
import { SQLiteCorePersistenceAdapter, persistedCollectionOptions } from '../src'
12+
import type { PersistenceAdapter, SQLiteDriver } from '../src'
13+
import type { SyncConfig } from '@tanstack/db'
14+
15+
type Todo = {
16+
id: string
17+
title: string
18+
}
19+
20+
async function flushAsyncWork(delayMs: number = 0): Promise<void> {
21+
await new Promise((resolve) => setTimeout(resolve, delayMs))
22+
await new Promise((resolve) => setTimeout(resolve, 0))
23+
}
24+
25+
function toBindable(value: unknown): string | number | bigint | null {
26+
if (value === null || value === undefined) return null
27+
if (typeof value === `boolean`) return value ? 1 : 0
28+
if (
29+
typeof value === `string` ||
30+
typeof value === `number` ||
31+
typeof value === `bigint`
32+
) {
33+
return value
34+
}
35+
return String(value)
36+
}
37+
38+
function createNodeSqliteDriver(db: DatabaseSync): SQLiteDriver {
39+
const driver: SQLiteDriver = {
40+
exec: (sql) => {
41+
db.exec(sql)
42+
return Promise.resolve()
43+
},
44+
query: (sql, params = []) => {
45+
const rows = db
46+
.prepare(sql)
47+
.all(...params.map(toBindable))
48+
.map((row) => ({ ...row }))
49+
return Promise.resolve(rows as Array<never>)
50+
},
51+
run: (sql, params = []) => {
52+
db.prepare(sql).run(...params.map(toBindable))
53+
return Promise.resolve()
54+
},
55+
transaction: async (fn) => {
56+
db.exec(`BEGIN IMMEDIATE`)
57+
try {
58+
const result = await fn(driver)
59+
db.exec(`COMMIT`)
60+
return result
61+
} catch (error) {
62+
db.exec(`ROLLBACK`)
63+
throw error
64+
}
65+
},
66+
}
67+
return driver
68+
}
69+
70+
type FakeAdapter = PersistenceAdapter & {
71+
rows: Map<string, Todo>
72+
applyCommittedTxCalls: number
73+
}
74+
75+
function createFakeAdapter(
76+
initialRows: Array<Todo> = [],
77+
options: { failWrites?: boolean } = {},
78+
): FakeAdapter {
79+
const rows = new Map(initialRows.map((row) => [row.id, row]))
80+
const adapter: FakeAdapter = {
81+
rows,
82+
applyCommittedTxCalls: 0,
83+
loadSubset: () =>
84+
Promise.resolve(
85+
Array.from(rows.values()).map((value) => ({ key: value.id, value })),
86+
),
87+
applyCommittedTx: (_collectionId, tx) => {
88+
adapter.applyCommittedTxCalls += 1
89+
if (options.failWrites) {
90+
return Promise.reject(new Error(`disk write failed`))
91+
}
92+
for (const mutation of tx.mutations) {
93+
if (mutation.type === `delete`) {
94+
rows.delete(mutation.key as string)
95+
} else {
96+
rows.set(mutation.key as string, mutation.value as Todo)
97+
}
98+
}
99+
return Promise.resolve()
100+
},
101+
ensureIndex: () => Promise.resolve(),
102+
}
103+
return adapter
104+
}
105+
106+
describe(`review claim: schema reset must not leave a sync resume point behind (#1589)`, () => {
107+
it(`clears collection metadata (electric:resume) when a schema mismatch wipes rows`, async () => {
108+
const db = new DatabaseSync(`:memory:`)
109+
const driver = createNodeSqliteDriver(db)
110+
111+
const adapterV1 = new SQLiteCorePersistenceAdapter({
112+
driver,
113+
schemaVersion: 1,
114+
})
115+
116+
await adapterV1.applyCommittedTx(`todos`, {
117+
txId: `tx-1`,
118+
term: 1,
119+
seq: 1,
120+
rowVersion: 1,
121+
mutations: [
122+
{ type: `insert`, key: `1`, value: { id: `1`, title: `First` } },
123+
],
124+
collectionMetadataMutations: [
125+
{
126+
type: `set`,
127+
key: `electric:resume`,
128+
value: {
129+
kind: `resume`,
130+
offset: `10_0`,
131+
handle: `handle-1`,
132+
shapeId: `shape-1`,
133+
updatedAt: 1,
134+
},
135+
},
136+
],
137+
})
138+
139+
// Sanity: v1 adapter sees the row and the resume point.
140+
expect(await adapterV1.loadSubset(`todos`, {})).toHaveLength(1)
141+
expect(await adapterV1.loadCollectionMetadata(`todos`)).toEqual([
142+
{ key: `electric:resume`, value: expect.objectContaining({ kind: `resume` }) },
143+
])
144+
145+
// Reopen at schemaVersion 2 → schema-mismatch reset wipes the rows.
146+
const adapterV2 = new SQLiteCorePersistenceAdapter({
147+
driver,
148+
schemaVersion: 2,
149+
schemaMismatchPolicy: `sync-present-reset`,
150+
})
151+
152+
const rowsAfterReset = await adapterV2.loadSubset(`todos`, {})
153+
expect(rowsAfterReset).toHaveLength(0)
154+
155+
// DESIRED INVARIANT: a reset that wipes rows must also invalidate the
156+
// sync resume point, or Electric resumes past all the wiped data and the
157+
// collection stays permanently empty.
158+
const metadataAfterReset = await adapterV2.loadCollectionMetadata(`todos`)
159+
expect(
160+
metadataAfterReset.find((entry) => entry.key === `electric:resume`),
161+
).toBeUndefined()
162+
})
163+
})
164+
165+
describe(`review claim: local hydration should make persisted data usable when the remote source is unreachable (#1416/#1443)`, () => {
166+
it.fails(`marks a sync-present collection ready from hydrated local rows when the source never signals`, async () => {
167+
const adapter = createFakeAdapter([{ id: `1`, title: `Persisted locally` }])
168+
169+
// Simulates Electric offline: the client retries forever, never calls
170+
// markReady, never errors.
171+
const silentSource: SyncConfig<Todo, string> = {
172+
sync: () => {},
173+
}
174+
175+
const collection = createCollection(
176+
persistedCollectionOptions<Todo, string>({
177+
id: `offline-electric`,
178+
getKey: (item) => item.id,
179+
sync: silentSource,
180+
startSync: true,
181+
persistence: { adapter },
182+
}),
183+
)
184+
185+
await flushAsyncWork(50)
186+
187+
// Hydration itself works: the persisted row is in the collection.
188+
expect(collection.size).toBe(1)
189+
190+
// DESIRED INVARIANT: locally hydrated data is readable — the collection
191+
// should not stay in "loading" forever just because the remote is down.
192+
expect(collection.status).toBe(`ready`)
193+
})
194+
})
195+
196+
describe(`review claim: sync commits are write-behind; persistence failures are silent and data is lost on restart`, () => {
197+
it.fails(`does not silently drop a committed sync transaction when the disk write fails`, async () => {
198+
const adapter = createFakeAdapter([], { failWrites: true })
199+
200+
const source: SyncConfig<Todo, string> = {
201+
sync: ({ begin, write, commit, markReady }) => {
202+
begin()
203+
write({ type: `insert`, value: { id: `1`, title: `From remote` } })
204+
commit()
205+
markReady()
206+
},
207+
}
208+
209+
const collection = createCollection(
210+
persistedCollectionOptions<Todo, string>({
211+
id: `write-behind`,
212+
getKey: (item) => item.id,
213+
sync: source,
214+
startSync: true,
215+
persistence: { adapter },
216+
}),
217+
)
218+
219+
await collection.stateWhenReady()
220+
await flushAsyncWork(20)
221+
222+
// The row is visible in memory and the collection reports healthy...
223+
expect(collection.get(`1`)).toBeDefined()
224+
expect(adapter.applyCommittedTxCalls).toBeGreaterThan(0)
225+
226+
// ...but the persistence write failed. Simulate an app restart with the
227+
// same (empty) storage and a source that is now unreachable.
228+
const restarted = createCollection(
229+
persistedCollectionOptions<Todo, string>({
230+
id: `write-behind-restarted`,
231+
getKey: (item) => item.id,
232+
sync: { sync: ({ markReady }) => markReady() },
233+
startSync: true,
234+
persistence: { adapter },
235+
}),
236+
)
237+
await restarted.stateWhenReady()
238+
await flushAsyncWork(20)
239+
240+
// DESIRED INVARIANT: data that was visible and "committed" should be
241+
// durable across restart (or the failure must surface as an error state,
242+
// which would also fail this test's premise that status stayed healthy).
243+
expect(restarted.get(`1`)).toBeDefined()
244+
})
245+
})
246+
247+
describe(`review claim: sync-present local mutations are never persisted locally (#1456)`, () => {
248+
it.fails(`persists an accepted optimistic mutation so it survives restart before the sync stream echoes it`, async () => {
249+
const adapter = createFakeAdapter()
250+
251+
const collection = createCollection(
252+
persistedCollectionOptions<Todo, string>({
253+
id: `electric-writes`,
254+
getKey: (item) => item.id,
255+
sync: { sync: ({ markReady }) => markReady() },
256+
startSync: true,
257+
// Server accepts the write; the stream echo has not arrived yet
258+
// (or never will, while offline).
259+
onInsert: () => Promise.resolve({}),
260+
persistence: { adapter },
261+
}),
262+
)
263+
264+
await collection.stateWhenReady()
265+
266+
const tx = collection.insert({ id: `1`, title: `Written offline` })
267+
await tx.isPersisted.promise
268+
await flushAsyncWork(20)
269+
270+
// DESIRED INVARIANT: the locally accepted mutation reaches the local
271+
// store (as pending/outbox state at minimum) instead of existing only in
272+
// memory until the remote round-trip completes.
273+
expect(adapter.applyCommittedTxCalls).toBeGreaterThan(0)
274+
})
275+
})

0 commit comments

Comments
 (0)