|
| 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