[PowerSync] A synced row that fails deserializationSchema hangs pending writes and silently stops sync for the table

未關閉
#2,358 0 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視

維護者通常 1 天內回覆

還沒有人認領這個 Issue。

評估

難度
4/5
預估耗時
3-5 天
新手友好度
48/100
Issue 類型
缺陷
描述清晰度
描述清楚
活躍度
活躍
技術堆疊
sqlite, typescript
領域
backend, databases

研究方向

Read packages/powersync-db-collection/src/powersync.ts, especially flushDiffRecordsWithContext and its catch near line 568; the issue describes the failure path and a Node reproduction. Trace how pending operations are resolved and how collection failures are reported, then run the provided reproduction with a malformed row. Done when the write settles or rejects, later rows are handled, and reload reports a visible error or becomes ready.

由索引模型根據 Issue 內容生成。

描述

  • I've validated the bug against the latest version of DB packages

Describe the bug

In @tanstack/powersync-db-collection, one synced row that the deserializationSchema rejects stops the table and leaves local writes hanging.

With the collection already ready:

  1. The bad row reaches SQLite. onDeserializationError fires and the adapter logs An error has been detected in the sync handler. So far as documented.
  2. The collection stays ready and health.hasErrors stays false, but it applies nothing more for that table: later changes to other rows never show. onDeserializationError fires again on every later flush for the same row.
  3. A local write through the collection reaches ps_crud, but tx.when('settled') neither resolves nor rejects.
  4. Creating the collection again over the same database (a reload) fails its first load, so it never becomes ready.
  5. Only fixing the row and reloading recovers it.

Cause, read from packages/powersync-db-collection/src/powersync.ts on main (same code in 1.0.0-rc.1.1 and 1.0.0-rc.1.3):

  • flushDiffRecordsWithContext calls deserializeSyncRow() for each tracked diff record, which calls onDeserializationError and throws.
  • The catch at the end of that function (around line 568) only logs. So the DELETE FROM <tracking table>, commit() and pendingOperationStore.resolvePendingFor(pendingOperations) are never reached.
  • The next flush re-reads the same record first and throws again.
  • PowerSyncTransactor.applyTransaction waits on whenComplete, which only resolvePendingFor resolves, so the local write never settles.
  • On startup, the same throw fails the first load.

The docs call a failed deserialization fatal, and onDeserializationError exists to react to it. The part that looks wrong is that the failure isn't surfaced through fail() / reportError() (#2168), and a pending write neither settles nor rejects.

To Reproduce

Versions: @tanstack/db 1.0.0-rc.1.3, @tanstack/powersync-db-collection 1.0.0-rc.1.3, @powersync/node 1.3.0 (@powersync/common 2.5.0), better-sqlite3, zod 4.6.7, Node 25. No sync service is needed: a local SQL write stands in for a synced row.

import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PowerSyncDatabase, Schema, Table, column } from '@powersync/node'
import { createCollection } from '@tanstack/db'
import { powerSyncCollectionOptions } from '@tanstack/powersync-db-collection'
import { z } from 'zod'

const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const race = (p, ms) =>
  Promise.race([
    p.then(() => 'settled', (e) => `rejected: ${e?.message ?? e}`),
    sleep(ms).then(() => `still pending after ${ms / 1000} s`),
  ])

const AppSchema = new Schema({ todos: new Table({ title: column.text, meta: column.text }) })
const db = new PowerSyncDatabase({
  schema: AppSchema,
  database: { dbFilename: 'repro.sqlite', dbLocation: mkdtempSync(join(tmpdir(), 'ps-deser-')) },
})
await db.init()

const schema = z.object({ id: z.string(), title: z.string().nullable(), meta: z.record(z.string(), z.unknown()).nullable() })
const deserializationSchema = z.object({
  id: z.string(),
  title: z.string().nullable(),
  meta: z.string().nullable().transform((v, ctx) => {
    if (v == null) return null
    try {
      return JSON.parse(v)
    } catch {
      ctx.addIssue({ code: 'custom', message: `meta is not JSON: ${v}` })
      return z.NEVER
    }
  }),
})

let deserErrors = 0
const make = (id) =>
  createCollection(
    powerSyncCollectionOptions({
      id,
      database: db,
      table: AppSchema.props.todos,
      schema,
      deserializationSchema,
      onDeserializationError: () => deserErrors++,
    }),
  )

const todos = make('todos-1')
await todos.preload()

// 1. A row with malformed JSON in `meta`, as a synced row would arrive.
await db.execute(`INSERT INTO todos (id, title, meta) VALUES ('bad', 'bad', '{"a":')`)
await sleep(500)

// 2. One local write.
const tx = todos.insert({ id: 'good', title: 'good', meta: { ok: true } })
console.log('2.', await race(tx.when('settled'), 15_000))

// 3. A later change to another row.
await db.execute(`INSERT INTO todos (id, title, meta) VALUES ('later', 'later', '{}')`)
await sleep(500)
console.log('3.', todos.has('later'), todos.status, todos.health.hasErrors, deserErrors)

// 4. A reload: a second collection over the same database.
const again = make('todos-2')
console.log('4.', await race(again.preload(), 10_000), again.status)
process.exit(0)

Output (adapter log lines trimmed):

2. still pending after 15 s
3. false ready false 3
4. rejected: Failed to validate incoming data for todos. Issues: meta - meta is not JSON: {"a": error

Control: the same script without the bad insert prints 2. settled, 3. true ready false 0 and 4. settled ready.

We first hit this in a browser against a real PowerSync service (@powersync/web 2.6.0; Chromium, WebKit, Firefox and Brave under Playwright). The pending write never settled and reloads never became ready in every run: 14 of 14 on rc.1.1, 4 of 4 on rc.1.3.

Expected behavior

  • A pending write settles or rejects. It never hangs.
  • A row that fails deserializationSchema is either reported and skipped (its tracking record deleted, the rest of the batch committed), or the failure is surfaced through the Collection with reportError() / fail(), so health or status shows it. Today status stays ready and health shows nothing while the table stops applying changes.
  • A reload with the bad row in place either becomes ready with the row reported, or fails with a visible error. Today the second preload rejects and the collection stays in error.

Fix pointer: the catch at the end of flushDiffRecordsWithContext. Deserializing each record under its own try/catch, and skipping and reporting the bad one, lets the DELETE, commit() and resolvePendingFor run. The alternative is to reject the pending operations and call fail() / reportError() there.

Workaround we use: a deserializationSchema that never throws. The transform catches its own error and returns a marker value, and the app counts the marked row and leaves it out of the UI. With that, the same browser histories pass every run: the bad row shows as a problem, every write settles, and reloads become ready. It gives up onDeserializationError for those columns.

Desktop

  • OS: macOS
  • Browser: Chromium, WebKit, Firefox, Brave (Playwright), plus the Node repro above
  • Version: @tanstack/powersync-db-collection 1.0.0-rc.1.3 (also 1.0.0-rc.1.1)

Additional context

Related:

  • #2168 (reportError / fail after ready).
  • #2118: no conformance law covers a delivery that the adapter's own schema rejects. The PowerSync driver declares no delivery-error capability and no deserializationSchema, so the oracle never generates this case.
  • #1647: a different cause with the same shape, a pending operation that resolvePendingFor never sees.
主要語言
TypeScript
星號
4k
分支
280
平均合併
1 天 4 小時
30 天內合併 PR
130

環境準備

從這裡開始

  1. 先讀完整個 Issue,再讀專案的貢獻指南。
  2. 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
  3. Fork 儲存庫,在一個分支上完成修改。
  4. 送出 Pull Request,並在描述裡引用這個 Issue 編號。

TanStack/db 的其他 Issue

查看 TanStack/db 的全部 Issue

相似的 Issue

更多 TypeScript Issue

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。