TanStack / TanStack/db

OpSQLiteDriver: executeAsync result format not handled by extractRowsFromStatementResult, causing silent data loss on SELECT queries

Open
#1,499 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
3.9k
Forks
266
Avg merge
1d 4h
Merged PRs (30d)
55

Description

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

Describe the bug

OpSQLiteDriver in @tanstack/react-native-db-sqlite-persistence silently returns empty arrays for SELECT queries when op-sqlite's executeAsync method is used. This causes the persistence layer to think collections don't exist in collection_registry on app restart, leading to UNIQUE constraint violations.

Root cause

resolveExecuteMethod picks the first available method from [executeAsync, execute, executeRaw, execAsync]. On op-sqlite v14, executeAsync is available and gets selected.

The problem is that op-sqlite's executeAsync returns a different result format than execute:

  • execute returns: { rows: Array<Record<string, Scalar>> } (object rows)
  • executeAsync returns: { rowsAffected: number, rawRows: unknown[][], columnNames: string[] } (raw columnar format)

extractRowsFromStatementResult only handles the { rows } and { resultRows } shapes. When it receives an executeAsync result:

  1. toRowArray(value.rows) -> value.rows is undefined -> returns null
  2. toRowArray(value.resultRows) -> value.resultRows is undefined -> returns null
  3. hasWriteResultMarker(value) -> "rowsAffected" in value -> true -> returns []

The SELECT result is silently treated as a write result with zero rows, even though the data is present in rawRows.

Impact

This causes a cascade of failures on app restart when the database already has data:

  1. ensureCollectionReadyInternal SELECTs from collection_registry -> gets [] instead of the existing row
  2. Code takes the INSERT branch -> fails with UNIQUE constraint failed: collection_registry.tombstone_table_name
  3. getStreamPosition rejects -> the persistence runtime's ensureStartupMetadataLoaded fails
  4. The Electric sync function is never called -> the collection stays in loading state forever with no data

The ALTER TABLE ADD COLUMN errors in ensureInitialized are a separate but related symptom — executeAsync is used for those DDL statements too, and the existing error handling (isDuplicateColumnAddError) works but still logs errors.

To Reproduce

  1. Create a collection with persistedCollectionOptions and createReactNativeSQLitePersistence using op-sqlite v14+
  2. Let it sync data from an Electric shape
  3. Kill the app (full process kill, not hot reload)
  4. Relaunch the app
  5. The persistence layer crashes on startup — the collection never reaches ready status

Expected behavior

extractRowsFromStatementResult should handle the { rawRows, columnNames } format returned by executeAsync, converting it into the expected Array<Record<string, unknown>> shape. Alternatively, resolveExecuteMethod should prefer execute over executeAsync.

Smartphone (please complete the following information):

  • Device: iOS Simulator & physical Android device
  • OS: iOS 18, Android 14
  • Version: N/A (React Native app, not browser)

Additional context

Package versions:

  • @tanstack/db-sqlite-persistence-core: 0.1.9
  • @tanstack/react-native-db-sqlite-persistence: 0.1.9
  • @op-engineering/op-sqlite: 14.1.4
  • React Native (Expo)

Current workaround:

Remove executeAsync from the database handle before passing it to createReactNativeSQLitePersistence, forcing the driver to fall back to execute:

const database = open({ name: 'my-db.sqlite', location: 'default' });
delete (database as any).executeAsync;

const persistence = createReactNativeSQLitePersistence({ database });

Suggested fix:

Either:

  1. Add rawRows + columnNames handling to extractRowsFromStatementResult:
function extractRowsFromStatementResult(value) {
  // Handle op-sqlite executeAsync format: { rawRows, columnNames }
  if (Array.isArray(value.rawRows) && Array.isArray(value.columnNames)) {
    return value.rawRows.map((row) =>
      Object.fromEntries(value.columnNames.map((col, i) => [col, row[i]]))
    );
  }
  // ... existing logic
}
  1. Or change resolveExecuteMethod to prefer execute over executeAsync, since execute returns the { rows } format the driver already handles.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Locate extractRowsFromStatementResult and resolveExecuteMethod in the React Native SQLite persistence package, then inspect how statement results are normalized. Reproduce the restart scenario with op-sqlite 14.1.4 and verify that SELECT results preserve their rows and collection startup no longer reaches the UNIQUE constraint failure.

Written by the indexing model from the issue text.

Assessment

Tech stack
react-native, typescript
Domain
database, mobile-dev
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.