Skip to content

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

@vitorcamachoo

Description

@vitorcamachoo
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions