From 70a9b31aad61d840adf36580a5011350e9ad4e4c Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 17 Sep 2026 09:49:46 -0600 Subject: [PATCH 01/13] fix(react-native): decode op-sqlite results losslessly --- .../tests/contracts/sqlite-driver-contract.ts | 120 +++- .../src/op-sqlite-driver.ts | 164 ++++- .../tests/helpers/op-sqlite-test-db.ts | 42 +- .../tests/op-sqlite-driver.test.ts | 636 +++++++++++++++++- .../tests/react-native-persistence.test.ts | 220 ++++-- 5 files changed, 1068 insertions(+), 114 deletions(-) diff --git a/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts b/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts index d45b9ef613..6dd6e89016 100644 --- a/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts +++ b/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts @@ -15,11 +15,36 @@ async function withHarness( fn: (harness: SQLiteDriverContractHarness) => Promise, ): Promise { const harness = createHarness() + let outcome: { ok: true; value: T } | { ok: false; error: unknown } + try { + outcome = { ok: true, value: await fn(harness) } + } catch (error) { + outcome = { ok: false, error } + } + + let cleanupOutcome: { ok: true } | { ok: false; error: unknown } = { + ok: true, + } try { - return await fn(harness) - } finally { await Promise.resolve(harness.cleanup()) + } catch (error) { + cleanupOutcome = { ok: false, error } } + + if (!outcome.ok) { + if (!cleanupOutcome.ok) { + throw new AggregateError( + [outcome.error, cleanupOutcome.error], + `SQLite driver contract and cleanup failed`, + { cause: outcome.error }, + ) + } + throw outcome.error + } + if (!cleanupOutcome.ok) { + throw cleanupOutcome.error + } + return outcome.value } export function runSQLiteDriverContractSuite( @@ -55,6 +80,89 @@ export function runSQLiteDriverContractSuite( }) }) + it(`preserves exact query rows after exec and run write results`, async () => { + await withHarness(createHarness, async ({ driver }) => { + await driver.exec( + `CREATE TABLE write_then_read ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + score INTEGER NOT NULL + )`, + ) + await driver.run( + `INSERT INTO write_then_read (id, title, score) VALUES (?, ?, ?)`, + [`run-row`, `Inserted by run`, 17], + ) + await driver.exec( + `INSERT INTO write_then_read (id, title, score) + VALUES ('exec-row', 'Inserted by exec', 29)`, + ) + + expect( + await driver.query<{ id: string; title: string; score: number }>( + `SELECT title, id, score + FROM write_then_read + ORDER BY score DESC`, + ), + ).toEqual([ + { title: `Inserted by exec`, id: `exec-row`, score: 29 }, + { title: `Inserted by run`, id: `run-row`, score: 17 }, + ]) + }) + }) + + it(`preserves SQL aliases that match statement-result field names`, async () => { + await withHarness(createHarness, async ({ driver }) => { + const rows = await driver.query<{ + rows: string + resultRows: string + rawRows: string + columnNames: string + results: string + rowsAffected: number + changes: number + insertId: number + lastInsertRowId: number + }>( + `SELECT + 'rows-value' AS "rows", + 'resultRows-value' AS "resultRows", + 'rawRows-value' AS "rawRows", + 'columnNames-value' AS "columnNames", + 'results-value' AS "results", + 7 AS "rowsAffected", + 8 AS "changes", + 9 AS "insertId", + 10 AS "lastInsertRowId"`, + ) + + expect(rows).toEqual([ + { + rows: `rows-value`, + resultRows: `resultRows-value`, + rawRows: `rawRows-value`, + columnNames: `columnNames-value`, + results: `results-value`, + rowsAffected: 7, + changes: 8, + insertId: 9, + lastInsertRowId: 10, + }, + ]) + expect(Object.keys(rows[0] ?? {})).toEqual([ + `rows`, + `resultRows`, + `rawRows`, + `columnNames`, + `results`, + `rowsAffected`, + `changes`, + `insertId`, + `lastInsertRowId`, + ]) + }) + }) + it(`rolls back transaction when callback throws`, async () => { await withHarness(createHarness, async ({ driver }) => { await driver.exec( @@ -234,11 +342,9 @@ export function runSQLiteDriverContractSuite( it(`requires transaction callbacks to accept a driver argument`, async () => { await withHarness(createHarness, async ({ driver }) => { await expect( - driver.transaction( - (async () => undefined) as unknown as ( - transactionDriver: SQLiteDriver, - ) => Promise, - ), + driver.transaction((() => Promise.resolve()) as unknown as ( + transactionDriver: SQLiteDriver, + ) => Promise), ).rejects.toThrow(`transaction driver argument`) }) }) diff --git a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts index 3fae4aaf86..ca87efcbb4 100644 --- a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts +++ b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts @@ -15,6 +15,9 @@ type OpSQLiteRowListLike = { type OpSQLiteStatementResultLike = { rows?: unknown resultRows?: unknown + rawRows?: unknown + columnNames?: unknown + results?: unknown rowsAffected?: unknown changes?: unknown insertId?: unknown @@ -112,15 +115,27 @@ function isObjectRecord(value: unknown): value is Record { return typeof value === `object` && value !== null } +function hasOwnKey(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key) +} + function hasWriteResultMarker(value: Record): boolean { for (const key of WRITE_RESULT_KEYS) { - if (key in value) { + if (hasOwnKey(value, key)) { return true } } return false } +function unsupportedQueryResult(sql: string, details?: string): never { + throw new InvalidPersistedCollectionConfigError( + `Unsupported op-sqlite query result shape for SQL "${sql}"${ + details ? `: ${details}` : `` + }`, + ) +} + function toRowArray(rowsValue: unknown): Array | null { if (Array.isArray(rowsValue)) { return rowsValue @@ -150,24 +165,120 @@ function toRowArray(rowsValue: unknown): Array | null { return null } +function isStatementResultEnvelope(value: Record): boolean { + return ( + toRowArray(value.rows) !== null || + toRowArray(value.resultRows) !== null || + Array.isArray(value.rawRows) || + Array.isArray(value.columnNames) || + Array.isArray(value.results) + ) +} + +function decodeColumnarRows( + value: Record, + sql: string, +): Array> | null { + const hasRawRows = hasOwnKey(value, `rawRows`) + const hasColumnNames = hasOwnKey(value, `columnNames`) + if (!hasRawRows && !hasColumnNames) { + return null + } + + if (!hasRawRows || !hasColumnNames) { + unsupportedQueryResult( + sql, + `columnar results require both rawRows and columnNames`, + ) + } + if ( + hasOwnKey(value, `rows`) || + hasOwnKey(value, `resultRows`) || + hasOwnKey(value, `results`) + ) { + unsupportedQueryResult(sql, `columnar results contain conflicting carriers`) + } + + const rawRows = value.rawRows + const columnNames = value.columnNames + if ( + !Array.isArray(rawRows) || + !Array.isArray(columnNames) || + !columnNames.every((columnName) => typeof columnName === `string`) + ) { + unsupportedQueryResult(sql, `invalid columnar row or column metadata`) + } + + const uniqueColumnNames = new Set(columnNames) + if (uniqueColumnNames.size !== columnNames.length) { + unsupportedQueryResult( + sql, + `columnar results contain duplicate column names`, + ) + } + + return rawRows.map((rawRow) => { + if (!Array.isArray(rawRow) || rawRow.length !== columnNames.length) { + unsupportedQueryResult( + sql, + `columnar row width does not match columnNames`, + ) + } + + return Object.fromEntries( + columnNames.map((columnName, index) => [columnName, rawRow[index]]), + ) + }) +} + function extractRowsFromStatementResult( value: OpSQLiteStatementResultLike, -): Array | null { - const rowsFromRows = toRowArray(value.rows) - if (rowsFromRows) { - return rowsFromRows + sql: string, +): Array { + const record = value as Record + const columnarRows = decodeColumnarRows(record, sql) + if (columnarRows) { + return columnarRows + } + + const rowCarrierKeys = [`rows`, `resultRows`].filter((key) => + hasOwnKey(record, key), + ) + if ( + rowCarrierKeys.length > 1 || + (rowCarrierKeys.length > 0 && hasOwnKey(record, `results`)) + ) { + unsupportedQueryResult(sql, `query result contains conflicting carriers`) } - const rowsFromResultRows = toRowArray(value.resultRows) - if (rowsFromResultRows) { - return rowsFromResultRows + if (rowCarrierKeys.length === 1) { + const rows = toRowArray(record[rowCarrierKeys[0]!]) + if (!rows) { + unsupportedQueryResult(sql, `invalid ${rowCarrierKeys[0]} carrier`) + } + return rows + } + + if (hasOwnKey(record, `results`)) { + const nestedResults = record.results + if ( + !Array.isArray(nestedResults) || + nestedResults.length !== 1 || + !isObjectRecord(nestedResults[0]) + ) { + unsupportedQueryResult(sql, `invalid nested results carrier`) + } + return extractRowsFromStatementResult(nestedResults[0], sql) } - if (hasWriteResultMarker(value as Record)) { + if ( + hasWriteResultMarker(record) && + Object.keys(record).every((key) => WRITE_RESULT_KEYS.has(key)) + ) { return [] } - return null + return unsupportedQueryResult(sql) } function extractRowsFromExecuteResult( @@ -175,7 +286,7 @@ function extractRowsFromExecuteResult( sql: string, ): Array { if (result == null) { - return [] + return unsupportedQueryResult(sql) } if (Array.isArray(result)) { @@ -184,37 +295,24 @@ function extractRowsFromExecuteResult( } const firstEntry = result[0] - if (isObjectRecord(firstEntry)) { - const rowsFromStatement = extractRowsFromStatementResult(firstEntry) - if (rowsFromStatement) { - return rowsFromStatement + if (isObjectRecord(firstEntry) && isStatementResultEnvelope(firstEntry)) { + if (result.length !== 1) { + return unsupportedQueryResult( + sql, + `statement-result arrays must contain exactly one result`, + ) } + return extractRowsFromStatementResult(firstEntry, sql) } return result } if (isObjectRecord(result)) { - const rowsFromStatement = extractRowsFromStatementResult(result) - if (rowsFromStatement) { - return rowsFromStatement - } - - const nestedResults = result.results - if (Array.isArray(nestedResults) && nestedResults.length > 0) { - const firstResult = nestedResults[0] - if (isObjectRecord(firstResult)) { - const rowsFromNested = extractRowsFromStatementResult(firstResult) - if (rowsFromNested) { - return rowsFromNested - } - } - } + return extractRowsFromStatementResult(result, sql) } - throw new InvalidPersistedCollectionConfigError( - `Unsupported op-sqlite query result shape for SQL "${sql}"`, - ) + return unsupportedQueryResult(sql) } function hasExistingDatabase( diff --git a/packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts b/packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts index 532b2a88f2..268b8358d4 100644 --- a/packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts +++ b/packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts @@ -8,6 +8,7 @@ export type OpSQLiteTestResultShape = | `rows-object` | `rows-list` | `statement-array` + | `execute-async-columnar` type OpSQLiteRowsListLike = { length: number @@ -54,6 +55,8 @@ function formatQueryRows( } case `statement-array`: return [{ rows }] + case `execute-async-columnar`: + throw new Error(`Columnar query rows require statement column metadata`) default: return { rows } } @@ -62,6 +65,7 @@ function formatQueryRows( function formatWriteResult( rowsAffected: number, resultShape: OpSQLiteTestResultShape, + insertId?: number, ): unknown { switch (resultShape) { case `rows-array`: @@ -83,6 +87,11 @@ function formatWriteResult( rowsAffected, }, ] + case `execute-async-columnar`: + return { + rowsAffected, + insertId, + } default: return { rows: [], @@ -109,6 +118,21 @@ export function createOpSQLiteTestDatabase(options: { const parameterValues = [...params] if (QUERY_SQL_PATTERN.test(sql)) { + if (resultShape === `execute-async-columnar`) { + const columnNames = statement + .columns() + .map((column: { name: string }) => column.name) + const rawRows = + parameterValues.length > 0 + ? statement.raw(true).all(...parameterValues) + : statement.raw(true).all() + return { + rowsAffected: 0, + rawRows, + columnNames, + } + } + const rows = parameterValues.length > 0 ? statement.all(...parameterValues) @@ -120,14 +144,26 @@ export function createOpSQLiteTestDatabase(options: { parameterValues.length > 0 ? statement.run(...parameterValues) : statement.run() - return formatWriteResult(runResult.changes, resultShape) + return formatWriteResult( + runResult.changes, + resultShape, + Number(runResult.lastInsertRowid), + ) } - return { - execute, + const database: OpSQLiteTestDatabase = { close: () => { nativeDatabase.close() }, getNativeDatabase: () => nativeDatabase, } + + if (resultShape === `execute-async-columnar`) { + database.executeAsync = (sql, params) => + Promise.resolve(execute(sql, params)) + } else { + database.execute = execute + } + + return database } diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts index 8c482af1c2..52cffc7185 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts @@ -1,17 +1,34 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { fc } from '@fast-check/vitest' import { afterEach, expect, it } from 'vitest' import { OpSQLiteDriver } from '../src/op-sqlite-driver' import { InvalidPersistedCollectionConfigError } from '../../db-sqlite-persistence-core/src' +import { runSQLiteDriverContractSuite } from '../../db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract' import { createOpSQLiteTestDatabase } from './helpers/op-sqlite-test-db' +import type { OpSQLiteDatabaseLike } from '../src/op-sqlite-driver' +import type { SQLiteDriverContractHarnessFactory } from '../../db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract' const activeCleanupFns: Array<() => void | Promise> = [] afterEach(async () => { + const cleanupErrors: Array = [] while (activeCleanupFns.length > 0) { const cleanupFn = activeCleanupFns.pop() - await Promise.resolve(cleanupFn?.()) + try { + await Promise.resolve(cleanupFn?.()) + } catch (error) { + cleanupErrors.push(error) + } + } + if (cleanupErrors.length === 1) { + throw cleanupErrors[0] + } + if (cleanupErrors.length > 1) { + throw new AggregateError(cleanupErrors, `op-sqlite test cleanup failed`, { + cause: cleanupErrors[0], + }) } }) @@ -24,41 +41,565 @@ function createTempSqlitePath(): string { return dbPath } -it.each([`rows-array`, `rows-object`, `rows-list`, `statement-array`] as const)( - `reads query rows across result shape: %s`, - async (resultShape) => { - const dbPath = createTempSqlitePath() - const database = createOpSQLiteTestDatabase({ - filename: dbPath, - resultShape, - }) - activeCleanupFns.push(() => Promise.resolve(database.close())) +type ColumnarDriverHarness = { + database: ReturnType + driver: OpSQLiteDriver + queryExecutions: () => number +} +async function withColumnarDriver( + fn: (harness: ColumnarDriverHarness) => Promise, +): Promise { + const tempDirectory = mkdtempSync( + join(tmpdir(), `db-rn-op-sqlite-alias-contract-`), + ) + let database: ReturnType | undefined + let outcome: { ok: true; value: T } | { ok: false; error: unknown } + try { + database = createOpSQLiteTestDatabase({ + filename: join(tempDirectory, `state.sqlite`), + resultShape: `execute-async-columnar`, + }) + const queryExecutions = trackQueryExecutions(database) const driver = new OpSQLiteDriver({ database }) - await driver.exec( - `CREATE TABLE todos (id TEXT PRIMARY KEY, title TEXT NOT NULL, score INTEGER NOT NULL)`, - ) - await driver.run(`INSERT INTO todos (id, title, score) VALUES (?, ?, ?)`, [ - `1`, - `From test`, - 7, - ]) + outcome = { + ok: true, + value: await fn({ database, driver, queryExecutions }), + } + } catch (error) { + outcome = { ok: false, error } + } + + const cleanupErrors: Array = [] + if (database) { + try { + await Promise.resolve(database.close()) + } catch (error) { + cleanupErrors.push(error) + } + } + try { + rmSync(tempDirectory, { recursive: true, force: true }) + } catch (error) { + cleanupErrors.push(error) + } + + if (!outcome.ok) { + if (cleanupErrors.length > 0) { + throw new AggregateError( + [outcome.error, ...cleanupErrors], + `op-sqlite alias law and cleanup failed`, + { cause: outcome.error }, + ) + } + throw outcome.error + } + if (cleanupErrors.length === 1) throw cleanupErrors[0] + if (cleanupErrors.length > 1) { + throw new AggregateError(cleanupErrors, `op-sqlite alias cleanup failed`, { + cause: cleanupErrors[0], + }) + } + return outcome.value +} + +/** + * Law: SQLiteDriver.query returns the complete ordered object rows supplied by + * the database after exec/run writes. SQL aliases remain row data even when + * their names match envelope fields; malformed result envelopes reject. + * Source: SQLiteDriver's public query contract and op-sqlite v14's documented + * executeAsync `{ rawRows, columnNames, rowsAffected }` envelope. + * Domain: deterministic object-row wrappers and v14 columnar rows, including + * empty, asymmetric/reordered multirow, legal reserved-looking SQL aliases, + * and malformed or conflicting envelopes. + * Path/checkpoint: OpSQLiteDriver over a real better-sqlite3 shim, observed + * when each query Promise settles. Exact row values, keys, order, and count are + * compared. + * Limit: this shim establishes adapter normalization, not native device/host + * execution; native op-sqlite v14 still needs a separate runtime receipt. + */ +it.each([ + `rows-array`, + `rows-object`, + `rows-list`, + `statement-array`, + `execute-async-columnar`, +] as const)(`reads query rows across result shape: %s`, async (resultShape) => { + const dbPath = createTempSqlitePath() + const database = createOpSQLiteTestDatabase({ + filename: dbPath, + resultShape, + }) + activeCleanupFns.push(() => Promise.resolve(database.close())) + + const driver = new OpSQLiteDriver({ database }) + await driver.exec( + `CREATE TABLE todos (id TEXT PRIMARY KEY, title TEXT NOT NULL, score INTEGER NOT NULL)`, + ) + await driver.run(`INSERT INTO todos (id, title, score) VALUES (?, ?, ?)`, [ + `1`, + `Lower score`, + 7, + ]) + await driver.run(`INSERT INTO todos (id, title, score) VALUES (?, ?, ?)`, [ + `2`, + `Higher score`, + 41, + ]) + + const rows = await driver.query<{ + id: string + title: string + score: number + }>(`SELECT title, score, id FROM todos ORDER BY score DESC`) + expectExactRows(rows, [ + { + title: `Higher score`, + score: 41, + id: `2`, + }, + { + title: `Lower score`, + score: 7, + id: `1`, + }, + ]) +}) - const rows = await driver.query<{ - id: string - title: string - score: number - }>(`SELECT id, title, score FROM todos ORDER BY id ASC`) - expect(rows).toEqual([ +function expectExactRows( + actual: ReadonlyArray, + expected: ReadonlyArray, +): void { + expect(actual).toEqual(expected) +} + +function expectExactAliasRows( + actual: ReadonlyArray>, + expected: ReadonlyArray>, +): void { + try { + expect(actual).toHaveLength(expected.length) + expected.forEach((row, rowIndex) => { + expect(Object.keys(actual[rowIndex] ?? {})).toEqual(Object.keys(row)) + expect(actual[rowIndex]).toEqual(row) + }) + } catch (cause) { + throw new Error(`op-sqlite reserved-alias exact-row law violated`, { + cause, + }) + } +} + +const statementResultAliasNames = [ + `rows`, + `resultRows`, + `rawRows`, + `columnNames`, + `results`, + `rowsAffected`, + `changes`, + `insertId`, + `lastInsertRowId`, + `ordinary_name`, + `another_value`, +] as const + +const aliasOracleSeed = Number( + process.env.TANSTACK_DB_OP_SQLITE_ORACLE_SEED ?? 165903, +) +const aliasOracleRuns = Number( + process.env.TANSTACK_DB_OP_SQLITE_ORACLE_RUNS ?? 50, +) +const aliasOraclePath = process.env.TANSTACK_DB_OP_SQLITE_ORACLE_PATH +if (!Number.isSafeInteger(aliasOracleSeed)) { + throw new Error(`Invalid TANSTACK_DB_OP_SQLITE_ORACLE_SEED`) +} +if (!Number.isSafeInteger(aliasOracleRuns) || aliasOracleRuns < 1) { + throw new Error(`Invalid TANSTACK_DB_OP_SQLITE_ORACLE_RUNS`) +} +if (aliasOraclePath !== undefined && !/^\d+(?::\d+)*$/.test(aliasOraclePath)) { + throw new Error( + `TANSTACK_DB_OP_SQLITE_ORACLE_PATH requires a numeric shrink path`, + ) +} + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll(`"`, `""`)}"` +} + +function aliasValue(alias: string, rowIndex: number, columnIndex: number) { + return `${rowIndex === 0 ? `left` : `right`}:${columnIndex}:${alias}` +} + +function aliasQueryCase(aliases: ReadonlyArray): { + sql: string + params: Array + expected: Array> +} { + const rowValues = [0, 1].map((rowIndex) => + aliases.map((alias, columnIndex) => + aliasValue(alias, rowIndex, columnIndex), + ), + ) + const selectList = aliases + .map((alias) => `? AS ${quoteIdentifier(alias)}`) + .join(`, `) + return { + sql: `SELECT ${selectList} UNION ALL SELECT ${selectList}`, + params: [...rowValues[0]!, ...rowValues[1]!], + expected: rowValues.map((values) => + Object.fromEntries(aliases.map((alias, index) => [alias, values[index]])), + ), + } +} + +function trackQueryExecutions( + database: ReturnType, +): () => number { + const executeAsync = database.executeAsync + if (!executeAsync) { + throw new Error(`columnar fixture must expose executeAsync`) + } + let queryExecutions = 0 + database.executeAsync = (sql, params) => { + if (/^\s*SELECT\b/i.test(sql)) queryExecutions++ + return executeAsync.call(database, sql, params) + } + return () => queryExecutions +} + +it(`preserves every statement-result field name when used as a SQL alias`, async () => { + await withColumnarDriver(async ({ driver, queryExecutions }) => { + const aliases = statementResultAliasNames.slice(0, 9) + const { sql, params, expected } = aliasQueryCase(aliases) + + const actual = await driver.query>(sql, params) + expect(queryExecutions()).toBe(1) + expectExactAliasRows(actual, expected) + }) +}) + +it(`preserves statement-result field names in direct row arrays`, async () => { + const dbPath = createTempSqlitePath() + const database = createOpSQLiteTestDatabase({ + filename: dbPath, + resultShape: `rows-array`, + }) + activeCleanupFns.push(() => Promise.resolve(database.close())) + const aliases = statementResultAliasNames.slice(0, 9) + const { sql, params, expected } = aliasQueryCase(aliases) + + expectExactAliasRows( + await new OpSQLiteDriver({ database }).query>( + sql, + params, + ), + expected, + ) +}) + +it(`preserves generated legal SQL aliases through v14 columnar rows`, async () => { + await withColumnarDriver(async ({ driver, queryExecutions }) => { + await fc.assert( + fc.asyncProperty( + fc.uniqueArray(fc.constantFrom(...statementResultAliasNames), { + minLength: 1, + maxLength: statementResultAliasNames.length, + }), + async (aliases) => { + const executionsBefore = queryExecutions() + const { sql, params, expected } = aliasQueryCase(aliases) + let actual: ReadonlyArray> + try { + actual = await driver.query>(sql, params) + } catch (error) { + expect(queryExecutions()).toBe(executionsBefore + 1) + throw error + } + expect(queryExecutions()).toBe(executionsBefore + 1) + expectExactAliasRows(actual, expected) + }, + ), { - id: `1`, - title: `From test`, - score: 7, + seed: aliasOracleSeed, + numRuns: aliasOracleRuns, + ...(aliasOraclePath ? { path: aliasOraclePath } : {}), + examples: [[statementResultAliasNames.slice(0, 9)]], + }, + ) + }) +}) + +it(`returns an exact empty row set for an empty columnar SELECT`, async () => { + const dbPath = createTempSqlitePath() + const database = createOpSQLiteTestDatabase({ + filename: dbPath, + resultShape: `execute-async-columnar`, + }) + activeCleanupFns.push(() => Promise.resolve(database.close())) + const executeAsync = database.executeAsync + if (!executeAsync) { + throw new Error(`columnar fixture must expose executeAsync`) + } + let emptySelectExecutions = 0 + database.executeAsync = (sql, params) => { + if (/^\s*SELECT\b/i.test(sql)) emptySelectExecutions++ + return executeAsync.call(database, sql, params) + } + const driver = new OpSQLiteDriver({ database }) + await driver.exec(`CREATE TABLE empty_rows (id TEXT PRIMARY KEY)`) + + expectExactRows( + await driver.query<{ id: string }>(`SELECT id FROM empty_rows`), + [], + ) + expect(emptySelectExecutions).toBe(1) +}) + +const malformedColumnarResults: ReadonlyArray<{ + name: string + result: unknown +}> = [ + { + name: `null result`, + result: null, + }, + { + name: `undefined result`, + result: undefined, + }, + { + name: `empty object`, + result: {}, + }, + { + name: `primitive result`, + result: 42, + }, + { + name: `rawRows without columnNames`, + result: { rowsAffected: 0, rawRows: [[`1`]] }, + }, + { + name: `columnNames without rawRows`, + result: { rowsAffected: 0, columnNames: [`id`] }, + }, + { + name: `wrapped rawRows without columnNames`, + result: [{ rowsAffected: 0, rawRows: [[`1`]] }], + }, + { + name: `wrapped columnNames without rawRows`, + result: [{ rowsAffected: 0, columnNames: [`id`] }], + }, + { + name: `row shorter than columnNames`, + result: { + rowsAffected: 0, + rawRows: [[`1`]], + columnNames: [`id`, `title`], + }, + }, + { + name: `row wider than columnNames`, + result: { + rowsAffected: 0, + rawRows: [[`1`, `extra`]], + columnNames: [`id`], + }, + }, + { + name: `non-array raw row`, + result: { + rowsAffected: 0, + rawRows: [{ id: `1` }], + columnNames: [`id`], + }, + }, + { + name: `duplicate column names`, + result: { + rowsAffected: 0, + rawRows: [[`left`, `right`]], + columnNames: [`id`, `id`], + }, + }, + { + name: `unknown row carrier beside a write marker`, + result: { rowsAffected: 0, mysteryRows: [[`1`]] }, + }, + { + name: `unknown envelope without a write marker`, + result: { mysteryRows: [[`1`]] }, + }, + { + name: `columnar rows with a conflicting rows carrier`, + result: { + rowsAffected: 0, + rawRows: [[`columnar`]], + columnNames: [`id`], + rows: [{ id: `conflict` }], + }, + }, + { + name: `columnar rows with a conflicting resultRows carrier`, + result: { + rowsAffected: 0, + rawRows: [[`columnar`]], + columnNames: [`id`], + resultRows: [{ id: `conflict` }], + }, + }, + { + name: `columnar rows with a conflicting nested results carrier`, + result: { + rowsAffected: 0, + rawRows: [[`columnar`]], + columnNames: [`id`], + results: [{ rows: [{ id: `conflict` }] }], + }, + }, +] + +function expectMalformedQueryRejected( + outcome: PromiseSettledResult>, +): void { + expect(outcome.status).toBe(`rejected`) + if (outcome.status === `rejected`) { + expect(outcome.reason).toBeInstanceOf(InvalidPersistedCollectionConfigError) + } +} + +it.each(malformedColumnarResults)( + `throws for malformed or unknown SELECT result: $name`, + async ({ result }) => { + let queryExecutions = 0 + const database: OpSQLiteDatabaseLike = { + executeAsync: () => { + queryExecutions++ + return Promise.resolve(result) }, + } + const driver = new OpSQLiteDriver({ database }) + + const [outcome] = await Promise.allSettled([ + driver.query(`SELECT id FROM malformed_result`), ]) + expect(queryExecutions).toBe(1) + expectMalformedQueryRejected(outcome) }, ) +it(`reserved-alias checker rejects exact-row mutants`, () => { + const aliases = [`rows`, `rowsAffected`, `ordinary_name`] + const { expected } = aliasQueryCase(aliases) + const first = expected[0]! + const second = expected[1]! + const mutants: Array>> = [ + [], + [ + Object.fromEntries( + Object.entries(first).filter(([alias]) => alias !== `rowsAffected`), + ), + second, + ], + [{ ...first, rows: first.rowsAffected, rowsAffected: first.rows }, second], + [first, first], + ] + + mutants.forEach((mutant) => { + expect(() => expectExactAliasRows(mutant, expected)).toThrow( + `op-sqlite reserved-alias exact-row law violated`, + ) + }) + expectExactAliasRows(expected, expected) +}) + +it(`reserved-alias checker shrinks and replays the same row-law violation`, () => { + const challenge = (aliases: ReadonlyArray) => { + const { expected } = aliasQueryCase(aliases) + expectExactAliasRows([], expected) + } + const originalAliases = statementResultAliasNames.slice(0, 9) + expect(() => challenge(originalAliases)).toThrow( + `op-sqlite reserved-alias exact-row law violated`, + ) + + const property = fc.property( + fc.uniqueArray(fc.constantFrom(...statementResultAliasNames), { + minLength: 1, + maxLength: statementResultAliasNames.length, + }), + challenge, + ) + const original = fc.check(property, { + seed: 165903, + numRuns: 20, + }) + expect(original.failed).toBe(true) + expect(original.error).toContain( + `op-sqlite reserved-alias exact-row law violated`, + ) + if (original.counterexamplePath === null) { + throw new Error(`Missing reserved-alias calibration replay path`) + } + const reduced = fc.check(property, { + seed: original.seed, + path: original.counterexamplePath, + numRuns: 1, + endOnFailure: true, + }) + expect(reduced.failed).toBe(true) + expect(reduced.error).toContain( + `op-sqlite reserved-alias exact-row law violated`, + ) + expect(reduced.counterexample).toEqual(original.counterexample) + expect(reduced.counterexample?.[0].length).toBeLessThan( + originalAliases.length, + ) +}) + +it(`malformed-envelope checker rejects an accepted conflicting carrier`, () => { + expect(() => + expectMalformedQueryRejected({ + status: `fulfilled`, + value: [{ id: `conflict` }], + }), + ).toThrow() + expectMalformedQueryRejected({ + status: `rejected`, + reason: new InvalidPersistedCollectionConfigError(`invalid result`), + }) +}) + +it.each([ + { + name: `silently empty`, + actual: [], + }, + { + name: `rows swapped`, + actual: [ + { title: `Lower score`, score: 7, id: `1` }, + { title: `Higher score`, score: 41, id: `2` }, + ], + }, + { + name: `first row copied into the second`, + actual: [ + { title: `Higher score`, score: 41, id: `2` }, + { title: `Higher score`, score: 41, id: `2` }, + ], + }, +])(`exact-row oracle rejects hostile output: $name`, ({ actual }) => { + const expected = [ + { title: `Higher score`, score: 41, id: `2` }, + { title: `Lower score`, score: 7, id: `1` }, + ] + expect(() => expectExactRows(actual, expected)).toThrow() + expectExactRows(expected, expected) +}) + it(`rolls back transaction on failure`, async () => { const dbPath = createTempSqlitePath() const database = createOpSQLiteTestDatabase({ filename: dbPath }) @@ -195,7 +736,7 @@ it(`throws when transaction callback omits transaction driver argument`, async ( const driver = new OpSQLiteDriver({ database }) await expect( - driver.transaction((async () => undefined) as never), + driver.transaction((() => Promise.resolve()) as never), ).rejects.toThrow(`transaction driver argument`) }) @@ -261,3 +802,42 @@ it(`throws config error when db execute methods are missing`, () => { InvalidPersistedCollectionConfigError, ) }) + +const createColumnarDriverHarness: SQLiteDriverContractHarnessFactory = () => { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-rn-op-sqlite-contract-`)) + const database = createOpSQLiteTestDatabase({ + filename: join(tempDirectory, `state.sqlite`), + resultShape: `execute-async-columnar`, + }) + const driver = new OpSQLiteDriver({ database }) + + return { + driver, + cleanup: async () => { + const cleanupErrors: Array = [] + try { + await Promise.resolve(database.close()) + } catch (error) { + cleanupErrors.push(error) + } + try { + rmSync(tempDirectory, { recursive: true, force: true }) + } catch (error) { + cleanupErrors.push(error) + } + if (cleanupErrors.length === 1) throw cleanupErrors[0] + if (cleanupErrors.length > 1) { + throw new AggregateError( + cleanupErrors, + `op-sqlite contract cleanup failed`, + { cause: cleanupErrors[0] }, + ) + } + }, + } +} + +runSQLiteDriverContractSuite( + `op-sqlite executeAsync columnar driver`, + createColumnarDriverHarness, +) diff --git a/packages/react-native-db-sqlite-persistence/tests/react-native-persistence.test.ts b/packages/react-native-db-sqlite-persistence/tests/react-native-persistence.test.ts index 65269b814a..6f94e79d2f 100644 --- a/packages/react-native-db-sqlite-persistence/tests/react-native-persistence.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/react-native-persistence.test.ts @@ -17,6 +17,58 @@ type Todo = { const activeCleanupFns: Array<() => void | Promise> = [] +type Cleanup = () => void | Promise + +async function withFailurePreservingCleanup( + run: (cleanups: Array) => Promise, + initialCleanups: Array, +): Promise { + let outcome: { ok: true; value: T } | { ok: false; error: unknown } + try { + outcome = { ok: true, value: await run(initialCleanups) } + } catch (error) { + outcome = { ok: false, error } + } + + const cleanupErrors: Array = [] + for (const cleanup of initialCleanups) { + try { + await cleanup() + } catch (error) { + cleanupErrors.push(error) + } + } + + if (!outcome.ok) { + if (cleanupErrors.length > 0) { + throw new AggregateError( + [outcome.error, ...cleanupErrors], + `react-native restart oracle and cleanup failed`, + { cause: outcome.error }, + ) + } + throw outcome.error + } + if (cleanupErrors.length === 1) throw cleanupErrors[0] + if (cleanupErrors.length > 1) { + throw new AggregateError( + cleanupErrors, + `react-native restart oracle cleanup failed`, + { cause: cleanupErrors[0] }, + ) + } + return outcome.value +} + +function once(cleanup: Cleanup): Cleanup { + let complete = false + return async () => { + if (complete) return + complete = true + await cleanup() + } +} + afterEach(async () => { while (activeCleanupFns.length > 0) { const cleanupFn = activeCleanupFns.pop() @@ -33,53 +85,135 @@ function createTempSqlitePath(): string { return dbPath } -it(`persists data across app restart (close and reopen)`, async () => { - const dbPath = createTempSqlitePath() +/** + * Restart law: an existing collection_registry mapping is authoritative after + * close/reopen, and its exact stream position and stored rows remain readable. + * The first ordinary-row connection seeds the fixed witness; the reopened + * executeAsync columnar connection exercises the production adapter boundary. + * This better-sqlite3 shim cannot close native op-sqlite device coverage. + */ +it(`reuses a pre-populated registry with exact stream and rows after close and reopen`, async () => { + const tempDirectory = mkdtempSync( + join(tmpdir(), `db-rn-persistence-restart-oracle-`), + ) + const dbPath = join(tempDirectory, `state.sqlite`) const collectionId = `todos-restart` - const firstDatabase = createOpSQLiteTestDatabase({ filename: dbPath }) - const firstPersistence = createReactNativeSQLitePersistence({ - database: firstDatabase, - }) - const firstAdapter = firstPersistence.adapter - - await firstAdapter.applyCommittedTx(collectionId, { - txId: `tx-restart-1`, - term: 1, - seq: 1, - rowVersion: 1, - mutations: [ - { - type: `insert`, - key: `1`, - value: { - id: `1`, - title: `Survives restart`, - score: 10, + await withFailurePreservingCleanup( + async (cleanups) => { + const firstDatabase = createOpSQLiteTestDatabase({ filename: dbPath }) + const closeFirstDatabase = once(() => + Promise.resolve(firstDatabase.close()), + ) + cleanups.unshift(closeFirstDatabase) + const firstAdapter = createReactNativeSQLitePersistence({ + database: firstDatabase, + }).adapter + + await firstAdapter.applyCommittedTx(collectionId, { + txId: `tx-restart-1`, + term: 5, + seq: 8, + rowVersion: 13, + mutations: [ + { + type: `insert`, + key: `1`, + value: { + id: `1`, + title: `Survives restart`, + score: 10, + }, + }, + ], + }) + + const firstNativeDatabase = firstDatabase.getNativeDatabase?.() + if (!firstNativeDatabase) { + throw new Error(`restart oracle requires the local SQLite shim`) + } + const registryBeforeRestart = firstNativeDatabase + .prepare( + `SELECT collection_id, table_name, tombstone_table_name, schema_version + FROM collection_registry + WHERE collection_id = ?`, + ) + .all(collectionId) + expect(registryBeforeRestart).toEqual([ + { + collection_id: collectionId, + table_name: expect.stringMatching(/^c_[a-z2-7]+_[0-9a-z]+$/), + tombstone_table_name: expect.stringMatching( + /^t_[a-z2-7]+_[0-9a-z]+$/, + ), + schema_version: 1, }, - }, - ], - }) - await Promise.resolve(firstDatabase.close()) - - const secondDatabase = createOpSQLiteTestDatabase({ filename: dbPath }) - activeCleanupFns.push(() => Promise.resolve(secondDatabase.close())) - const secondPersistence = createReactNativeSQLitePersistence({ - database: secondDatabase, - }) - const secondAdapter = secondPersistence.adapter - - const rows = await secondAdapter.loadSubset(collectionId, {}) - expect(rows).toEqual([ - { - key: `1`, - value: { - id: `1`, - title: `Survives restart`, - score: 10, - }, + ]) + if (!firstAdapter.getStreamPosition) { + throw new Error(`restart oracle requires stream-position support`) + } + expect(await firstAdapter.getStreamPosition(collectionId)).toEqual({ + latestTerm: 5, + latestSeq: 8, + latestRowVersion: 13, + }) + + await closeFirstDatabase() + + const secondDatabase = createOpSQLiteTestDatabase({ + filename: dbPath, + resultShape: `execute-async-columnar`, + }) + cleanups.unshift(() => Promise.resolve(secondDatabase.close())) + const secondNativeDatabase = secondDatabase.getNativeDatabase?.() + if (!secondNativeDatabase) { + throw new Error(`restart oracle requires the reopened SQLite shim`) + } + expect( + secondNativeDatabase + .prepare( + `SELECT collection_id, table_name, tombstone_table_name, schema_version + FROM collection_registry + WHERE collection_id = ?`, + ) + .all(collectionId), + ).toEqual(registryBeforeRestart) + + const secondAdapter = createReactNativeSQLitePersistence({ + database: secondDatabase, + }).adapter + if (!secondAdapter.getStreamPosition) { + throw new Error(`restart oracle requires stream-position support`) + } + expect(await secondAdapter.getStreamPosition(collectionId)).toEqual({ + latestTerm: 5, + latestSeq: 8, + latestRowVersion: 13, + }) + await expect(secondAdapter.loadSubset(collectionId, {})).resolves.toEqual( + [ + { + key: `1`, + value: { + id: `1`, + title: `Survives restart`, + score: 10, + }, + }, + ], + ) + expect( + secondNativeDatabase + .prepare( + `SELECT collection_id, table_name, tombstone_table_name, schema_version + FROM collection_registry + WHERE collection_id = ?`, + ) + .all(collectionId), + ).toEqual(registryBeforeRestart) }, - ]) + [() => rmSync(tempDirectory, { recursive: true, force: true })], + ) }) it(`shared react-native api persists across expo-style restart`, async () => { From adc9ed0c60b009eb3495495ec3b4f0c5827a85a9 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 18 Sep 2026 06:58:43 -0600 Subject: [PATCH 02/13] Apply WS3 review fixes --- .../tests/contracts/sqlite-driver-contract.ts | 2 +- .../src/op-sqlite-driver.ts | 31 +++-- .../tests/op-sqlite-driver.test.ts | 111 +++++++++++++----- .../tests/react-native-persistence.test.ts | 64 +++++----- 4 files changed, 123 insertions(+), 85 deletions(-) diff --git a/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts b/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts index 6dd6e89016..26fd9d0ba5 100644 --- a/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts +++ b/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts @@ -26,7 +26,7 @@ async function withHarness( ok: true, } try { - await Promise.resolve(harness.cleanup()) + await harness.cleanup() } catch (error) { cleanupOutcome = { ok: false, error } } diff --git a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts index ca87efcbb4..c30458b1fe 100644 --- a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts +++ b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts @@ -12,18 +12,6 @@ type OpSQLiteRowListLike = { _array?: unknown } -type OpSQLiteStatementResultLike = { - rows?: unknown - resultRows?: unknown - rawRows?: unknown - columnNames?: unknown - results?: unknown - rowsAffected?: unknown - changes?: unknown - insertId?: unknown - lastInsertRowId?: unknown -} - const WRITE_RESULT_KEYS = new Set([ `rowsAffected`, `changes`, @@ -216,6 +204,12 @@ function decodeColumnarRows( `columnar results contain duplicate column names`, ) } + if (rawRows.length > 0 && columnNames.length === 0) { + unsupportedQueryResult( + sql, + `nonempty columnar results require at least one column name`, + ) + } return rawRows.map((rawRow) => { if (!Array.isArray(rawRow) || rawRow.length !== columnNames.length) { @@ -232,10 +226,10 @@ function decodeColumnarRows( } function extractRowsFromStatementResult( - value: OpSQLiteStatementResultLike, + record: Record, sql: string, + allowResultsWrapper: boolean, ): Array { - const record = value as Record const columnarRows = decodeColumnarRows(record, sql) if (columnarRows) { return columnarRows @@ -260,6 +254,9 @@ function extractRowsFromStatementResult( } if (hasOwnKey(record, `results`)) { + if (!allowResultsWrapper) { + unsupportedQueryResult(sql, `unsupported nested results depth`) + } const nestedResults = record.results if ( !Array.isArray(nestedResults) || @@ -268,7 +265,7 @@ function extractRowsFromStatementResult( ) { unsupportedQueryResult(sql, `invalid nested results carrier`) } - return extractRowsFromStatementResult(nestedResults[0], sql) + return extractRowsFromStatementResult(nestedResults[0], sql, false) } if ( @@ -302,14 +299,14 @@ function extractRowsFromExecuteResult( `statement-result arrays must contain exactly one result`, ) } - return extractRowsFromStatementResult(firstEntry, sql) + return extractRowsFromStatementResult(firstEntry, sql, false) } return result } if (isObjectRecord(result)) { - return extractRowsFromStatementResult(result, sql) + return extractRowsFromStatementResult(result, sql, true) } return unsupportedQueryResult(sql) diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts index 52cffc7185..2eec9a3cf8 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts @@ -42,7 +42,6 @@ function createTempSqlitePath(): string { } type ColumnarDriverHarness = { - database: ReturnType driver: OpSQLiteDriver queryExecutions: () => number } @@ -64,7 +63,7 @@ async function withColumnarDriver( const driver = new OpSQLiteDriver({ database }) outcome = { ok: true, - value: await fn({ database, driver, queryExecutions }), + value: await fn({ driver, queryExecutions }), } } catch (error) { outcome = { ok: false, error } @@ -190,7 +189,7 @@ function expectExactAliasRows( } } -const statementResultAliasNames = [ +const statementResultFieldNames = [ `rows`, `resultRows`, `rawRows`, @@ -200,6 +199,10 @@ const statementResultAliasNames = [ `changes`, `insertId`, `lastInsertRowId`, +] as const + +const statementResultAliasNames = [ + ...statementResultFieldNames, `ordinary_name`, `another_value`, ] as const @@ -227,7 +230,11 @@ function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll(`"`, `""`)}"` } -function aliasValue(alias: string, rowIndex: number, columnIndex: number) { +function aliasValue( + alias: string, + rowIndex: number, + columnIndex: number, +): string { return `${rowIndex === 0 ? `left` : `right`}:${columnIndex}:${alias}` } @@ -270,7 +277,7 @@ function trackQueryExecutions( it(`preserves every statement-result field name when used as a SQL alias`, async () => { await withColumnarDriver(async ({ driver, queryExecutions }) => { - const aliases = statementResultAliasNames.slice(0, 9) + const aliases = statementResultFieldNames const { sql, params, expected } = aliasQueryCase(aliases) const actual = await driver.query>(sql, params) @@ -286,7 +293,7 @@ it(`preserves statement-result field names in direct row arrays`, async () => { resultShape: `rows-array`, }) activeCleanupFns.push(() => Promise.resolve(database.close())) - const aliases = statementResultAliasNames.slice(0, 9) + const aliases = statementResultFieldNames const { sql, params, expected } = aliasQueryCase(aliases) expectExactAliasRows( @@ -324,36 +331,22 @@ it(`preserves generated legal SQL aliases through v14 columnar rows`, async () = seed: aliasOracleSeed, numRuns: aliasOracleRuns, ...(aliasOraclePath ? { path: aliasOraclePath } : {}), - examples: [[statementResultAliasNames.slice(0, 9)]], + examples: [[[...statementResultFieldNames]]], }, ) }) }) it(`returns an exact empty row set for an empty columnar SELECT`, async () => { - const dbPath = createTempSqlitePath() - const database = createOpSQLiteTestDatabase({ - filename: dbPath, - resultShape: `execute-async-columnar`, - }) - activeCleanupFns.push(() => Promise.resolve(database.close())) - const executeAsync = database.executeAsync - if (!executeAsync) { - throw new Error(`columnar fixture must expose executeAsync`) - } - let emptySelectExecutions = 0 - database.executeAsync = (sql, params) => { - if (/^\s*SELECT\b/i.test(sql)) emptySelectExecutions++ - return executeAsync.call(database, sql, params) - } - const driver = new OpSQLiteDriver({ database }) - await driver.exec(`CREATE TABLE empty_rows (id TEXT PRIMARY KEY)`) + await withColumnarDriver(async ({ driver, queryExecutions }) => { + await driver.exec(`CREATE TABLE empty_rows (id TEXT PRIMARY KEY)`) - expectExactRows( - await driver.query<{ id: string }>(`SELECT id FROM empty_rows`), - [], - ) - expect(emptySelectExecutions).toBe(1) + expectExactRows( + await driver.query<{ id: string }>(`SELECT id FROM empty_rows`), + [], + ) + expect(queryExecutions()).toBe(1) + }) }) const malformedColumnarResults: ReadonlyArray<{ @@ -416,6 +409,14 @@ const malformedColumnarResults: ReadonlyArray<{ columnNames: [`id`], }, }, + { + name: `nonempty rawRows with no column names`, + result: { + rowsAffected: 0, + rawRows: [[]], + columnNames: [], + }, + }, { name: `duplicate column names`, result: { @@ -490,6 +491,56 @@ it.each(malformedColumnarResults)( }, ) +it(`supports exactly one results wrapper`, async () => { + let queryExecutions = 0 + const database: OpSQLiteDatabaseLike = { + executeAsync: () => { + queryExecutions++ + return Promise.resolve({ + results: [{ rows: [{ id: `one-level` }] }], + }) + }, + } + + await expect( + new OpSQLiteDriver({ database }).query(`SELECT id FROM wrapped_result`), + ).resolves.toEqual([{ id: `one-level` }]) + expect(queryExecutions).toBe(1) +}) + +it.each([ + { + name: `second results wrapper`, + createResult: () => ({ + results: [{ results: [{ rows: [{ id: `too-deep` }] }] }], + }), + }, + { + name: `cyclic second-level results wrapper`, + createResult: () => { + const cyclicResult: Record = {} + cyclicResult.results = [cyclicResult] + return cyclicResult + }, + }, +])(`rejects unsupported results depth: $name`, async ({ createResult }) => { + let queryExecutions = 0 + const database: OpSQLiteDatabaseLike = { + executeAsync: () => { + queryExecutions++ + return Promise.resolve(createResult()) + }, + } + const [outcome] = await Promise.allSettled([ + new OpSQLiteDriver({ database }).query( + `SELECT id FROM unsupported_results_depth`, + ), + ]) + + expect(queryExecutions).toBe(1) + expectMalformedQueryRejected(outcome) +}) + it(`reserved-alias checker rejects exact-row mutants`, () => { const aliases = [`rows`, `rowsAffected`, `ordinary_name`] const { expected } = aliasQueryCase(aliases) @@ -520,7 +571,7 @@ it(`reserved-alias checker shrinks and replays the same row-law violation`, () = const { expected } = aliasQueryCase(aliases) expectExactAliasRows([], expected) } - const originalAliases = statementResultAliasNames.slice(0, 9) + const originalAliases = statementResultFieldNames expect(() => challenge(originalAliases)).toThrow( `op-sqlite reserved-alias exact-row law violated`, ) diff --git a/packages/react-native-db-sqlite-persistence/tests/react-native-persistence.test.ts b/packages/react-native-db-sqlite-persistence/tests/react-native-persistence.test.ts index 6f94e79d2f..61698fafe3 100644 --- a/packages/react-native-db-sqlite-persistence/tests/react-native-persistence.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/react-native-persistence.test.ts @@ -15,23 +15,23 @@ type Todo = { score: number } -const activeCleanupFns: Array<() => void | Promise> = [] - type Cleanup = () => void | Promise +const activeCleanupFns: Array = [] + async function withFailurePreservingCleanup( run: (cleanups: Array) => Promise, - initialCleanups: Array, + cleanups: Array, ): Promise { let outcome: { ok: true; value: T } | { ok: false; error: unknown } try { - outcome = { ok: true, value: await run(initialCleanups) } + outcome = { ok: true, value: await run(cleanups) } } catch (error) { outcome = { ok: false, error } } const cleanupErrors: Array = [] - for (const cleanup of initialCleanups) { + for (const cleanup of cleanups) { try { await cleanup() } catch (error) { @@ -61,10 +61,10 @@ async function withFailurePreservingCleanup( } function once(cleanup: Cleanup): Cleanup { - let complete = false + let attempted = false return async () => { - if (complete) return - complete = true + if (attempted) return + attempted = true await cleanup() } } @@ -85,6 +85,16 @@ function createTempSqlitePath(): string { return dbPath } +const collectionRegistryQuery = `SELECT collection_id, table_name, tombstone_table_name, schema_version + FROM collection_registry + WHERE collection_id = ?` + +const restartStreamPosition = { + latestTerm: 5, + latestSeq: 8, + latestRowVersion: 13, +} as const + /** * Restart law: an existing collection_registry mapping is authoritative after * close/reopen, and its exact stream position and stored rows remain readable. @@ -133,11 +143,7 @@ it(`reuses a pre-populated registry with exact stream and rows after close and r throw new Error(`restart oracle requires the local SQLite shim`) } const registryBeforeRestart = firstNativeDatabase - .prepare( - `SELECT collection_id, table_name, tombstone_table_name, schema_version - FROM collection_registry - WHERE collection_id = ?`, - ) + .prepare(collectionRegistryQuery) .all(collectionId) expect(registryBeforeRestart).toEqual([ { @@ -152,11 +158,9 @@ it(`reuses a pre-populated registry with exact stream and rows after close and r if (!firstAdapter.getStreamPosition) { throw new Error(`restart oracle requires stream-position support`) } - expect(await firstAdapter.getStreamPosition(collectionId)).toEqual({ - latestTerm: 5, - latestSeq: 8, - latestRowVersion: 13, - }) + expect(await firstAdapter.getStreamPosition(collectionId)).toEqual( + restartStreamPosition, + ) await closeFirstDatabase() @@ -170,13 +174,7 @@ it(`reuses a pre-populated registry with exact stream and rows after close and r throw new Error(`restart oracle requires the reopened SQLite shim`) } expect( - secondNativeDatabase - .prepare( - `SELECT collection_id, table_name, tombstone_table_name, schema_version - FROM collection_registry - WHERE collection_id = ?`, - ) - .all(collectionId), + secondNativeDatabase.prepare(collectionRegistryQuery).all(collectionId), ).toEqual(registryBeforeRestart) const secondAdapter = createReactNativeSQLitePersistence({ @@ -185,11 +183,9 @@ it(`reuses a pre-populated registry with exact stream and rows after close and r if (!secondAdapter.getStreamPosition) { throw new Error(`restart oracle requires stream-position support`) } - expect(await secondAdapter.getStreamPosition(collectionId)).toEqual({ - latestTerm: 5, - latestSeq: 8, - latestRowVersion: 13, - }) + expect(await secondAdapter.getStreamPosition(collectionId)).toEqual( + restartStreamPosition, + ) await expect(secondAdapter.loadSubset(collectionId, {})).resolves.toEqual( [ { @@ -203,13 +199,7 @@ it(`reuses a pre-populated registry with exact stream and rows after close and r ], ) expect( - secondNativeDatabase - .prepare( - `SELECT collection_id, table_name, tombstone_table_name, schema_version - FROM collection_registry - WHERE collection_id = ?`, - ) - .all(collectionId), + secondNativeDatabase.prepare(collectionRegistryQuery).all(collectionId), ).toEqual(registryBeforeRestart) }, [() => rmSync(tempDirectory, { recursive: true, force: true })], From 6a79dcdb282c823c837c0eed5078d0bfe0eb5bd1 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 18 Sep 2026 07:03:00 -0600 Subject: [PATCH 03/13] docs: add op-sqlite decoding changeset --- .changeset/fix-op-sqlite-result-decoding.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-op-sqlite-result-decoding.md diff --git a/.changeset/fix-op-sqlite-result-decoding.md b/.changeset/fix-op-sqlite-result-decoding.md new file mode 100644 index 0000000000..6a460c7b55 --- /dev/null +++ b/.changeset/fix-op-sqlite-result-decoding.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-native-db-sqlite-persistence': patch +--- + +Decode op-sqlite row arrays and columnar results losslessly, and reject malformed or unknown result envelopes instead of treating them as empty results. From 3afe2df714a602b6d8af3d839767a2617d457f44 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 21 Sep 2026 09:51:05 +0100 Subject: [PATCH 04/13] docs(test): explain op-sqlite driver oracles --- .../tests/contracts/sqlite-driver-contract.ts | 15 +++++++++++++++ .../tests/op-sqlite-driver.test.ts | 9 +++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts b/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts index 26fd9d0ba5..f15d25652f 100644 --- a/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts +++ b/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest' import { expectAdmissionHistory } from './driver-admission-laws' import type { SQLiteDriver } from '../../src' +/** + * # What must every SQLite persistence driver mean? + * + * Drivers bind parameters, return exact query rows, roll back failed + * transactions, serialize outside work behind an active transaction, admit it + * after rollback, and use savepoints for nesting. The callback must receive the + * transaction-scoped driver so implementations cannot silently run nested work + * on the outer connection. + * + * Each platform supplies only a harness. This shared suite executes the same + * SQL histories and compares both values and rowid admission order. A passing + * shim proves the TypeScript driver boundary; it does not replace native-device + * evidence for the host runtime. + */ + export type SQLiteDriverContractHarness = { driver: SQLiteDriver cleanup: () => void | Promise diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts index 2eec9a3cf8..aeb6cdabf1 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts @@ -111,9 +111,18 @@ async function withColumnarDriver( * Domain: deterministic object-row wrappers and v14 columnar rows, including * empty, asymmetric/reordered multirow, legal reserved-looking SQL aliases, * and malformed or conflicting envelopes. + * Reference/history grammar: `aliasQueryCase` builds two ordered object rows + * directly from unique legal aliases and distinct scalar values. Histories + * choose wrapper shape or columnar aliases, execute real writes or one SELECT, + * and then either return the exact rows or reject an invalid envelope; the + * reference never calls the production decoder. * Path/checkpoint: OpSQLiteDriver over a real better-sqlite3 shim, observed * when each query Promise settles. Exact row values, keys, order, and count are * compared. + * Refinement evidence: query execution counts prove the SELECT reached the + * adapter. Empty, reordered, duplicated, missing-key, and swapped-value mutants + * challenge the checker; the alias property records a seed and shrink path and + * verifies replay of the same exact-row violation. * Limit: this shim establishes adapter normalization, not native device/host * execution; native op-sqlite v14 still needs a separate runtime receipt. */ From 2cefc4748ac7c999639953a60d5b8e006be9e5e6 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 22 Sep 2026 12:29:48 +0100 Subject: [PATCH 05/13] fix(react-native): decode op-sqlite result envelopes --- .../src/op-sqlite-driver.ts | 69 +++--- .../tests/helpers/op-sqlite-test-db.ts | 16 +- .../tests/op-sqlite-driver.test.ts | 198 ++++++++++++++---- 3 files changed, 213 insertions(+), 70 deletions(-) diff --git a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts index c30458b1fe..160de3e6c9 100644 --- a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts +++ b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts @@ -19,6 +19,17 @@ const WRITE_RESULT_KEYS = new Set([ `lastInsertRowId`, ]) +const STATEMENT_RESULT_KEYS = new Set([ + ...WRITE_RESULT_KEYS, + `rows`, + `resultRows`, + `rawRows`, + `columnNames`, + `results`, + `metadata`, + `res`, +]) + export type OpSQLiteDatabaseLike = { execute?: OpSQLiteExecuteFn executeAsync?: OpSQLiteExecuteFn @@ -116,6 +127,13 @@ function hasWriteResultMarker(value: Record): boolean { return false } +function isWriteResultEnvelope(value: Record): boolean { + return ( + hasWriteResultMarker(value) && + Object.keys(value).every((key) => WRITE_RESULT_KEYS.has(key)) + ) +} + function unsupportedQueryResult(sql: string, details?: string): never { throw new InvalidPersistedCollectionConfigError( `Unsupported op-sqlite query result shape for SQL "${sql}"${ @@ -154,12 +172,21 @@ function toRowArray(rowsValue: unknown): Array | null { } function isStatementResultEnvelope(value: Record): boolean { - return ( + if (!Object.keys(value).every((key) => STATEMENT_RESULT_KEYS.has(key))) { + return false + } + + const hasStructuralCarrier = toRowArray(value.rows) !== null || toRowArray(value.resultRows) !== null || Array.isArray(value.rawRows) || Array.isArray(value.columnNames) || Array.isArray(value.results) + + return ( + isWriteResultEnvelope(value) || + (hasWriteResultMarker(value) && hasStructuralCarrier) || + (Array.isArray(value.rawRows) && Array.isArray(value.columnNames)) ) } @@ -179,14 +206,6 @@ function decodeColumnarRows( `columnar results require both rawRows and columnNames`, ) } - if ( - hasOwnKey(value, `rows`) || - hasOwnKey(value, `resultRows`) || - hasOwnKey(value, `results`) - ) { - unsupportedQueryResult(sql, `columnar results contain conflicting carriers`) - } - const rawRows = value.rawRows const columnNames = value.columnNames if ( @@ -197,13 +216,6 @@ function decodeColumnarRows( unsupportedQueryResult(sql, `invalid columnar row or column metadata`) } - const uniqueColumnNames = new Set(columnNames) - if (uniqueColumnNames.size !== columnNames.length) { - unsupportedQueryResult( - sql, - `columnar results contain duplicate column names`, - ) - } if (rawRows.length > 0 && columnNames.length === 0) { unsupportedQueryResult( sql, @@ -230,17 +242,13 @@ function extractRowsFromStatementResult( sql: string, allowResultsWrapper: boolean, ): Array { - const columnarRows = decodeColumnarRows(record, sql) - if (columnarRows) { - return columnarRows - } - const rowCarrierKeys = [`rows`, `resultRows`].filter((key) => hasOwnKey(record, key), ) if ( rowCarrierKeys.length > 1 || - (rowCarrierKeys.length > 0 && hasOwnKey(record, `results`)) + (rowCarrierKeys.length > 0 && + (hasOwnKey(record, `rawRows`) || hasOwnKey(record, `results`))) ) { unsupportedQueryResult(sql, `query result contains conflicting carriers`) } @@ -253,6 +261,18 @@ function extractRowsFromStatementResult( return rows } + if ( + (hasOwnKey(record, `rawRows`) || hasOwnKey(record, `columnNames`)) && + hasOwnKey(record, `results`) + ) { + unsupportedQueryResult(sql, `query result contains conflicting carriers`) + } + + const columnarRows = decodeColumnarRows(record, sql) + if (columnarRows) { + return columnarRows + } + if (hasOwnKey(record, `results`)) { if (!allowResultsWrapper) { unsupportedQueryResult(sql, `unsupported nested results depth`) @@ -268,10 +288,7 @@ function extractRowsFromStatementResult( return extractRowsFromStatementResult(nestedResults[0], sql, false) } - if ( - hasWriteResultMarker(record) && - Object.keys(record).every((key) => WRITE_RESULT_KEYS.has(key)) - ) { + if (isWriteResultEnvelope(record)) { return [] } diff --git a/packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts b/packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts index 268b8358d4..a41b01ec51 100644 --- a/packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts +++ b/packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts @@ -8,6 +8,7 @@ export type OpSQLiteTestResultShape = | `rows-object` | `rows-list` | `statement-array` + | `execute-rows-with-column-names` | `execute-async-columnar` type OpSQLiteRowsListLike = { @@ -54,7 +55,9 @@ function formatQueryRows( rows: createRowsList(rows), } case `statement-array`: - return [{ rows }] + return [{ rows, rowsAffected: 0 }] + case `execute-rows-with-column-names`: + throw new Error(`Rows with metadata require statement column metadata`) case `execute-async-columnar`: throw new Error(`Columnar query rows require statement column metadata`) default: @@ -133,6 +136,17 @@ export function createOpSQLiteTestDatabase(options: { } } + if (resultShape === `execute-rows-with-column-names`) { + const columnNames = statement + .columns() + .map((column: { name: string }) => column.name) + const rows = + parameterValues.length > 0 + ? statement.all(...parameterValues) + : statement.all() + return { rowsAffected: 0, rows, columnNames } + } + const rows = parameterValues.length > 0 ? statement.all(...parameterValues) diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts index aeb6cdabf1..c5700c2241 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts @@ -1,6 +1,6 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { fc } from '@fast-check/vitest' import { afterEach, expect, it } from 'vitest' import { OpSQLiteDriver } from '../src/op-sqlite-driver' @@ -106,9 +106,10 @@ async function withColumnarDriver( * Law: SQLiteDriver.query returns the complete ordered object rows supplied by * the database after exec/run writes. SQL aliases remain row data even when * their names match envelope fields; malformed result envelopes reject. - * Source: SQLiteDriver's public query contract and op-sqlite v14's documented - * executeAsync `{ rawRows, columnNames, rowsAffected }` envelope. - * Domain: deterministic object-row wrappers and v14 columnar rows, including + * Source: SQLiteDriver's public query contract and op-sqlite's documented + * execute `{ rows, columnNames }` and executeAsync + * `{ rawRows, columnNames, rowsAffected }` envelopes. + * Domain: deterministic object-row wrappers and columnar rows, including * empty, asymmetric/reordered multirow, legal reserved-looking SQL aliases, * and malformed or conflicting envelopes. * Reference/history grammar: `aliasQueryCase` builds two ordered object rows @@ -131,6 +132,7 @@ it.each([ `rows-object`, `rows-list`, `statement-array`, + `execute-rows-with-column-names`, `execute-async-columnar`, ] as const)(`reads query rows across result shape: %s`, async (resultShape) => { const dbPath = createTempSqlitePath() @@ -174,11 +176,70 @@ it.each([ ]) }) +async function queryInjectedResult( + result: unknown, +): Promise> { + return new OpSQLiteDriver({ + database: { + executeAsync: () => Promise.resolve(result), + }, + }).query(`SELECT * FROM injected_result`) +} + +it(`reads op-sqlite execute rows when columnNames metadata is also present`, async () => { + await expect( + queryInjectedResult({ + rowsAffected: 0, + rows: [{ id: `node-or-web-row` }], + columnNames: [`id`], + }), + ).resolves.toEqual([{ id: `node-or-web-row` }]) +}) + +it.each([`rows`, `resultRows`, `rawRows`, `columnNames`, `results`] as const)( + `preserves a direct data row with non-scalar %s alias`, + async (alias) => { + const row = { + [alias]: [`nested`, `value`], + ...(alias === `rowsAffected` ? {} : { rowsAffected: 17 }), + ordinary_name: `ordinary-value`, + } + await expect(queryInjectedResult([row])).resolves.toEqual([row]) + }, +) + +it(`decodes legal duplicate SQLite column names with the last-value law`, async () => { + await withColumnarDriver(async ({ driver }) => { + await expect( + driver.query(`SELECT 'left-id' AS id, 'right-id' AS id`), + ).resolves.toEqual([{ id: `right-id` }]) + }) +}) + +it(`treats array and object write envelopes symmetrically`, async () => { + const writeResult = { rowsAffected: 1, insertId: 17 } + await expect(queryInjectedResult(writeResult)).resolves.toEqual([]) + await expect(queryInjectedResult([writeResult])).resolves.toEqual([]) +}) + function expectExactRows( actual: ReadonlyArray, expected: ReadonlyArray, ): void { - expect(actual).toEqual(expected) + expect(actual).toHaveLength(expected.length) + expected.forEach((row, rowIndex) => { + if (isRecord(row)) { + const actualRow = actual[rowIndex] + expect(Object.keys(isRecord(actualRow) ? actualRow : {})).toEqual( + Object.keys(row), + ) + } + expect(actual[rowIndex]).toEqual(row) + }) +} + +function isRecord(value: unknown): value is Record { + return typeof value === `object` && value !== null && !Array.isArray(value) } function expectExactAliasRows( @@ -426,14 +487,6 @@ const malformedColumnarResults: ReadonlyArray<{ columnNames: [], }, }, - { - name: `duplicate column names`, - result: { - rowsAffected: 0, - rawRows: [[`left`, `right`]], - columnNames: [`id`, `id`], - }, - }, { name: `unknown row carrier beside a write marker`, result: { rowsAffected: 0, mysteryRows: [[`1`]] }, @@ -863,40 +916,99 @@ it(`throws config error when db execute methods are missing`, () => { ) }) -const createColumnarDriverHarness: SQLiteDriverContractHarnessFactory = () => { +function createColumnarDriverHarness( + createDatabase: typeof createOpSQLiteTestDatabase = createOpSQLiteTestDatabase, +): ReturnType { const tempDirectory = mkdtempSync(join(tmpdir(), `db-rn-op-sqlite-contract-`)) - const database = createOpSQLiteTestDatabase({ - filename: join(tempDirectory, `state.sqlite`), - resultShape: `execute-async-columnar`, - }) - const driver = new OpSQLiteDriver({ database }) + let database: ReturnType | undefined + try { + database = createDatabase({ + filename: join(tempDirectory, `state.sqlite`), + resultShape: `execute-async-columnar`, + }) + const driver = new OpSQLiteDriver({ database }) - return { - driver, - cleanup: async () => { - const cleanupErrors: Array = [] - try { - await Promise.resolve(database.close()) - } catch (error) { - cleanupErrors.push(error) - } - try { - rmSync(tempDirectory, { recursive: true, force: true }) - } catch (error) { - cleanupErrors.push(error) - } - if (cleanupErrors.length === 1) throw cleanupErrors[0] - if (cleanupErrors.length > 1) { - throw new AggregateError( - cleanupErrors, - `op-sqlite contract cleanup failed`, - { cause: cleanupErrors[0] }, - ) - } - }, + return { + driver, + cleanup: async () => { + const cleanupErrors: Array = [] + try { + await Promise.resolve(database.close()) + } catch (error) { + cleanupErrors.push(error) + } + try { + rmSync(tempDirectory, { recursive: true, force: true }) + } catch (error) { + cleanupErrors.push(error) + } + if (cleanupErrors.length === 1) throw cleanupErrors[0] + if (cleanupErrors.length > 1) { + throw new AggregateError( + cleanupErrors, + `op-sqlite contract cleanup failed`, + { cause: cleanupErrors[0] }, + ) + } + }, + } + } catch (error) { + const cleanupErrors: Array = [] + try { + database?.close() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + } + try { + rmSync(tempDirectory, { recursive: true, force: true }) + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + } + if (cleanupErrors.length > 0) { + throw new AggregateError( + [error, ...cleanupErrors], + `op-sqlite contract construction and cleanup failed`, + { cause: error }, + ) + } + throw error } } +it(`removes the contract directory when database construction fails`, () => { + let tempDirectory: string | undefined + expect(() => + createColumnarDriverHarness(({ filename }) => { + const directory = dirname(filename) + tempDirectory = directory + activeCleanupFns.push(() => { + rmSync(directory, { recursive: true, force: true }) + }) + throw new Error(`construction failed`) + }), + ).toThrow(`construction failed`) + expect(tempDirectory).toBeDefined() + expect(existsSync(tempDirectory!)).toBe(false) +}) + +it(`closes the database and removes the contract directory when driver construction fails`, () => { + let tempDirectory: string | undefined + let closed = false + expect(() => + createColumnarDriverHarness(({ filename }) => { + tempDirectory = dirname(filename) + return { + close: () => { + closed = true + }, + } + }), + ).toThrow(`execute/executeAsync/executeRaw/execAsync`) + expect(closed).toBe(true) + expect(tempDirectory).toBeDefined() + expect(existsSync(tempDirectory!)).toBe(false) +}) + runSQLiteDriverContractSuite( `op-sqlite executeAsync columnar driver`, createColumnarDriverHarness, From 7aa912d3dc1df17984a651b284b4325bd832be6d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 22 Sep 2026 14:58:14 +0100 Subject: [PATCH 06/13] fix(react-native): preserve write-marker query rows --- .../src/op-sqlite-driver.ts | 13 ++- .../tests/op-sqlite-driver.test.ts | 93 ++++++++++++++++--- 2 files changed, 87 insertions(+), 19 deletions(-) diff --git a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts index 160de3e6c9..2491c66d3e 100644 --- a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts +++ b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts @@ -27,7 +27,6 @@ const STATEMENT_RESULT_KEYS = new Set([ `columnNames`, `results`, `metadata`, - `res`, ]) export type OpSQLiteDatabaseLike = { @@ -176,6 +175,9 @@ function isStatementResultEnvelope(value: Record): boolean { return false } + // Bare arrays are also a supported row carrier. A legitimate row can contain + // only write-marker aliases, so markers alone cannot prove that an array is a + // statement wrapper; wrappers need an actual row structure. const hasStructuralCarrier = toRowArray(value.rows) !== null || toRowArray(value.resultRows) !== null || @@ -184,7 +186,6 @@ function isStatementResultEnvelope(value: Record): boolean { Array.isArray(value.results) return ( - isWriteResultEnvelope(value) || (hasWriteResultMarker(value) && hasStructuralCarrier) || (Array.isArray(value.rawRows) && Array.isArray(value.columnNames)) ) @@ -245,18 +246,20 @@ function extractRowsFromStatementResult( const rowCarrierKeys = [`rows`, `resultRows`].filter((key) => hasOwnKey(record, key), ) + const rowCarrierKey = rowCarrierKeys[0] if ( rowCarrierKeys.length > 1 || (rowCarrierKeys.length > 0 && - (hasOwnKey(record, `rawRows`) || hasOwnKey(record, `results`))) + (hasOwnKey(record, `results`) || + (rowCarrierKey === `resultRows` && hasOwnKey(record, `rawRows`)))) ) { unsupportedQueryResult(sql, `query result contains conflicting carriers`) } if (rowCarrierKeys.length === 1) { - const rows = toRowArray(record[rowCarrierKeys[0]!]) + const rows = toRowArray(record[rowCarrierKey!]) if (!rows) { - unsupportedQueryResult(sql, `invalid ${rowCarrierKeys[0]} carrier`) + unsupportedQueryResult(sql, `invalid ${rowCarrierKey} carrier`) } return rows } diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts index c5700c2241..3734ddfcf9 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts @@ -105,7 +105,10 @@ async function withColumnarDriver( /** * Law: SQLiteDriver.query returns the complete ordered object rows supplied by * the database after exec/run writes. SQL aliases remain row data even when - * their names match envelope fields; malformed result envelopes reject. + * their names match envelope fields, including direct rows made only of write + * marker aliases. When OP-SQLite supplies object rows and raw columnar rows + * together, the already-decoded object rows are authoritative. Malformed or + * undocumented result carriers reject. * Source: SQLiteDriver's public query contract and op-sqlite's documented * execute `{ rows, columnNames }` and executeAsync * `{ rawRows, columnNames, rowsAffected }` envelopes. @@ -176,14 +179,14 @@ it.each([ ]) }) -async function queryInjectedResult( +async function queryInjectedResult( result: unknown, -): Promise> { +): Promise> { return new OpSQLiteDriver({ database: { executeAsync: () => Promise.resolve(result), }, - }).query(`SELECT * FROM injected_result`) + }).query(`SELECT * FROM injected_result`) } it(`reads op-sqlite execute rows when columnNames metadata is also present`, async () => { @@ -196,7 +199,25 @@ it(`reads op-sqlite execute rows when columnNames metadata is also present`, asy ).resolves.toEqual([{ id: `node-or-web-row` }]) }) -it.each([`rows`, `resultRows`, `rawRows`, `columnNames`, `results`] as const)( +it(`prefers object rows when op-sqlite also supplies raw columnar rows`, async () => { + await expect( + queryInjectedResult({ + rowsAffected: 0, + rows: [{ id: `object-row` }], + rawRows: [[`raw-row`]], + columnNames: [`id`], + }), + ).resolves.toEqual([{ id: `object-row` }]) +}) + +it.each([ + `rows`, + `resultRows`, + `rawRows`, + `columnNames`, + `results`, + `res`, +] as const)( `preserves a direct data row with non-scalar %s alias`, async (alias) => { const row = { @@ -216,12 +237,30 @@ it(`decodes legal duplicate SQLite column names with the last-value law`, async }) }) -it(`treats array and object write envelopes symmetrically`, async () => { +it(`reads object and structural statement-array write envelopes as empty`, async () => { const writeResult = { rowsAffected: 1, insertId: 17 } await expect(queryInjectedResult(writeResult)).resolves.toEqual([]) - await expect(queryInjectedResult([writeResult])).resolves.toEqual([]) + await expect( + queryInjectedResult([{ ...writeResult, rows: [] }]), + ).resolves.toEqual([]) }) +it.each([ + { + name: `one row with a rowsAffected alias`, + rows: [{ rowsAffected: 42 }], + }, + { + name: `multiple rows with a changes alias`, + rows: [{ changes: 1 }, { changes: 2 }], + }, +])( + `preserves direct data rows containing only write aliases: $name`, + async ({ rows }) => { + await expect(queryInjectedResult(rows)).resolves.toEqual(rows) + }, +) + function expectExactRows( actual: ReadonlyArray, expected: ReadonlyArray, @@ -269,6 +308,7 @@ const statementResultFieldNames = [ `changes`, `insertId`, `lastInsertRowId`, + `res`, ] as const const statementResultAliasNames = [ @@ -277,6 +317,13 @@ const statementResultAliasNames = [ `another_value`, ] as const +const writeResultFieldNames = [ + `rowsAffected`, + `changes`, + `insertId`, + `lastInsertRowId`, +] as const + const aliasOracleSeed = Number( process.env.TANSTACK_DB_OP_SQLITE_ORACLE_SEED ?? 165903, ) @@ -296,6 +343,29 @@ if (aliasOraclePath !== undefined && !/^\d+(?::\d+)*$/.test(aliasOraclePath)) { ) } +it(`preserves generated single-row write-marker aliases in direct row arrays`, async () => { + await fc.assert( + fc.asyncProperty( + fc.uniqueArray(fc.constantFrom(...writeResultFieldNames), { + minLength: 1, + maxLength: writeResultFieldNames.length, + }), + async (aliases) => { + const row = Object.fromEntries( + aliases.map((alias, index) => [alias, `${index}:${alias}`]), + ) + const actual = await queryInjectedResult>([row]) + expectExactAliasRows(actual, [row]) + }, + ), + { + seed: aliasOracleSeed, + numRuns: aliasOracleRuns, + examples: [[[`rowsAffected`]], [[...writeResultFieldNames]]], + }, + ) +}) + function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll(`"`, `""`)}"` } @@ -496,13 +566,8 @@ const malformedColumnarResults: ReadonlyArray<{ result: { mysteryRows: [[`1`]] }, }, { - name: `columnar rows with a conflicting rows carrier`, - result: { - rowsAffected: 0, - rawRows: [[`columnar`]], - columnNames: [`id`], - rows: [{ id: `conflict` }], - }, + name: `undocumented res carrier`, + result: { rowsAffected: 0, res: [{ id: `legacy` }] }, }, { name: `columnar rows with a conflicting resultRows carrier`, From 0fb27a466f2c168016013a7ff3d8dc22f411f1af Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 22 Sep 2026 16:19:38 +0100 Subject: [PATCH 07/13] test(react-native): add result carrier refinement law --- .../tests/op-sqlite-driver.test.ts | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts index 3734ddfcf9..68e79eab55 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts @@ -127,8 +127,9 @@ async function withColumnarDriver( * adapter. Empty, reordered, duplicated, missing-key, and swapped-value mutants * challenge the checker; the alias property records a seed and shrink path and * verifies replay of the same exact-row violation. - * Limit: this shim establishes adapter normalization, not native device/host - * execution; native op-sqlite v14 still needs a separate runtime receipt. + * Known omissions: this shim establishes adapter normalization, not native + * device/host execution. Native op-sqlite still needs a separate runtime + * receipt for every supported major result contract. */ it.each([ `rows-array`, @@ -222,7 +223,7 @@ it.each([ async (alias) => { const row = { [alias]: [`nested`, `value`], - ...(alias === `rowsAffected` ? {} : { rowsAffected: 17 }), + rowsAffected: 17, ordinary_name: `ordinary-value`, } await expect(queryInjectedResult([row])).resolves.toEqual([row]) @@ -366,6 +367,52 @@ it(`preserves generated single-row write-marker aliases in direct row arrays`, a ) }) +it(`returns the same rows across equivalent documented result carriers`, async () => { + await fc.assert( + fc.asyncProperty( + fc.uniqueArray(fc.constantFrom(...statementResultAliasNames), { + minLength: 1, + maxLength: statementResultAliasNames.length, + }), + async (aliases) => { + const { expected } = aliasQueryCase(aliases) + const rawRows = expected.map((row) => + aliases.map((alias) => row[alias]), + ) + const equivalentResults: ReadonlyArray = [ + expected, + { rows: expected }, + { resultRows: expected }, + [{ rowsAffected: 0, rows: expected }], + { rowsAffected: 0, rows: expected, columnNames: aliases }, + { rowsAffected: 0, rawRows, columnNames: aliases }, + { + rowsAffected: 0, + rows: expected, + rawRows, + columnNames: aliases, + }, + { results: [{ rows: expected }] }, + ] + + for (const result of equivalentResults) { + const actual = + await queryInjectedResult>(result) + expectExactAliasRows(actual, expected) + } + }, + ), + { + seed: aliasOracleSeed, + numRuns: aliasOracleRuns, + examples: [ + [[`rowsAffected`]], + [[`rows`, `rawRows`, `columnNames`, `ordinary_name`]], + ], + }, + ) +}) + function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll(`"`, `""`)}"` } @@ -987,18 +1034,19 @@ function createColumnarDriverHarness( const tempDirectory = mkdtempSync(join(tmpdir(), `db-rn-op-sqlite-contract-`)) let database: ReturnType | undefined try { - database = createDatabase({ + const createdDatabase = createDatabase({ filename: join(tempDirectory, `state.sqlite`), resultShape: `execute-async-columnar`, }) - const driver = new OpSQLiteDriver({ database }) + database = createdDatabase + const driver = new OpSQLiteDriver({ database: createdDatabase }) return { driver, cleanup: async () => { const cleanupErrors: Array = [] try { - await Promise.resolve(database.close()) + await Promise.resolve(createdDatabase.close()) } catch (error) { cleanupErrors.push(error) } From a9a60dd7e28e3ec25648426d17b029b10e980414 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 22 Sep 2026 16:49:41 +0100 Subject: [PATCH 08/13] fix(react-native): require ambiguous array result mode --- .changeset/fix-op-sqlite-result-decoding.md | 1 + .../src/mobile-persistence.ts | 12 ++- .../src/op-sqlite-driver.ts | 55 +++++++++++-- .../expo-sqlite-core-adapter-contract.test.ts | 5 +- ...obile-runtime-persistence-contract.test.ts | 17 +++- .../tests/op-sqlite-driver.test.ts | 82 ++++++++++++++----- ...ative-sqlite-core-adapter-contract.test.ts | 5 +- 7 files changed, 145 insertions(+), 32 deletions(-) diff --git a/.changeset/fix-op-sqlite-result-decoding.md b/.changeset/fix-op-sqlite-result-decoding.md index 6a460c7b55..5e35c7c3ab 100644 --- a/.changeset/fix-op-sqlite-result-decoding.md +++ b/.changeset/fix-op-sqlite-result-decoding.md @@ -3,3 +3,4 @@ --- Decode op-sqlite row arrays and columnar results losslessly, and reject malformed or unknown result envelopes instead of treating them as empty results. +Custom wrappers that return a genuinely ambiguous bare array can set `arrayResultMode` to `rows` or `statement-results`; without that authoritative mode, the driver rejects instead of silently reshaping data. diff --git a/packages/react-native-db-sqlite-persistence/src/mobile-persistence.ts b/packages/react-native-db-sqlite-persistence/src/mobile-persistence.ts index 7fce7b4369..7df5c3d6bf 100644 --- a/packages/react-native-db-sqlite-persistence/src/mobile-persistence.ts +++ b/packages/react-native-db-sqlite-persistence/src/mobile-persistence.ts @@ -12,9 +12,15 @@ import type { SQLiteCoreAdapterOptions, SQLiteDriver, } from '@tanstack/db-sqlite-persistence-core' -import type { OpSQLiteDatabaseLike } from './op-sqlite-driver' +import type { + OpSQLiteArrayResultMode, + OpSQLiteDatabaseLike, +} from './op-sqlite-driver' -export type { OpSQLiteDatabaseLike } from './op-sqlite-driver' +export type { + OpSQLiteArrayResultMode, + OpSQLiteDatabaseLike, +} from './op-sqlite-driver' type MobileSQLiteCoreSchemaMismatchPolicy = | `sync-present-reset` @@ -30,6 +36,7 @@ type MobileSQLitePersistenceBaseOptions = Omit< `driver` | `schemaVersion` | `schemaMismatchPolicy` > & { database: OpSQLiteDatabaseLike + arrayResultMode?: OpSQLiteArrayResultMode coordinator?: PersistedCollectionCoordinator schemaMismatchPolicy?: MobileSQLiteSchemaMismatchPolicy } @@ -71,6 +78,7 @@ function createInternalSQLiteDriver( ): SQLiteDriver { return new OpSQLiteDriver({ database: options.database, + arrayResultMode: options.arrayResultMode, }) } diff --git a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts index 2491c66d3e..3e31cf2ccc 100644 --- a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts +++ b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts @@ -37,11 +37,22 @@ export type OpSQLiteDatabaseLike = { close?: () => Promise | void } -type OpSQLiteExistingDatabaseOptions = { +export type OpSQLiteArrayResultMode = `rows` | `statement-results` + +type OpSQLiteResultOptions = { + /** + * Declares how to interpret a bare array when its first entry could be either + * a data row or a statement-result envelope. Published op-sqlite methods + * return object envelopes and do not need this option. + */ + arrayResultMode?: OpSQLiteArrayResultMode +} + +type OpSQLiteExistingDatabaseOptions = OpSQLiteResultOptions & { database: OpSQLiteDatabaseLike } -type OpSQLiteOpenDatabaseOptions = { +type OpSQLiteOpenDatabaseOptions = OpSQLiteResultOptions & { openDatabase: () => OpSQLiteDatabaseLike } @@ -301,6 +312,7 @@ function extractRowsFromStatementResult( function extractRowsFromExecuteResult( result: unknown, sql: string, + arrayResultMode?: OpSQLiteArrayResultMode, ): Array { if (result == null) { return unsupportedQueryResult(sql) @@ -311,8 +323,24 @@ function extractRowsFromExecuteResult( return [] } + if (arrayResultMode === `rows`) { + return result + } + const firstEntry = result[0] - if (isObjectRecord(firstEntry) && isStatementResultEnvelope(firstEntry)) { + const isStructuralStatementResult = + isObjectRecord(firstEntry) && isStatementResultEnvelope(firstEntry) + const isStatementResult = + isStructuralStatementResult || + (isObjectRecord(firstEntry) && isWriteResultEnvelope(firstEntry)) + + if (arrayResultMode === `statement-results`) { + if (!isStatementResult) { + return unsupportedQueryResult( + sql, + `statement-results mode requires a recognized statement envelope`, + ) + } if (result.length !== 1) { return unsupportedQueryResult( sql, @@ -322,6 +350,13 @@ function extractRowsFromExecuteResult( return extractRowsFromStatementResult(firstEntry, sql, false) } + if (isStructuralStatementResult) { + return unsupportedQueryResult( + sql, + `ambiguous bare result array; set arrayResultMode to "rows" or "statement-results"`, + ) + } + return result } @@ -375,6 +410,7 @@ function resolveExecuteMethod( export class OpSQLiteDriver implements SQLiteDriver { private readonly database: OpSQLiteDatabaseLike private readonly executeMethod: OpSQLiteExecuteFn + private readonly arrayResultMode: OpSQLiteArrayResultMode | undefined private readonly ownsDatabase: boolean private queue: Promise = Promise.resolve() private nextSavepointId = 1 @@ -391,6 +427,7 @@ export class OpSQLiteDriver implements SQLiteDriver { } this.executeMethod = resolveExecuteMethod(this.database) + this.arrayResultMode = options.arrayResultMode } async exec(sql: string): Promise { @@ -416,7 +453,11 @@ export class OpSQLiteDriver implements SQLiteDriver { return this.enqueue(async () => { const result = await this.execute(sql, params) - return extractRowsFromExecuteResult(result, sql) as ReadonlyArray + return extractRowsFromExecuteResult( + result, + sql, + this.arrayResultMode, + ) as ReadonlyArray }) } @@ -557,7 +598,11 @@ export class OpSQLiteDriver implements SQLiteDriver { params: ReadonlyArray = [], ): Promise> => { const result = await this.execute(sql, params) - return extractRowsFromExecuteResult(result, sql) as ReadonlyArray + return extractRowsFromExecuteResult( + result, + sql, + this.arrayResultMode, + ) as ReadonlyArray }, run: async (sql, params = []) => { await this.execute(sql, params) diff --git a/packages/react-native-db-sqlite-persistence/tests/expo-sqlite-core-adapter-contract.test.ts b/packages/react-native-db-sqlite-persistence/tests/expo-sqlite-core-adapter-contract.test.ts index 613d168288..33eb723c0d 100644 --- a/packages/react-native-db-sqlite-persistence/tests/expo-sqlite-core-adapter-contract.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/expo-sqlite-core-adapter-contract.test.ts @@ -14,7 +14,10 @@ const createHarness: SQLiteCoreAdapterHarnessFactory = (options) => { filename: dbPath, resultShape: `statement-array`, }) - const driver = new OpSQLiteDriver({ database }) + const driver = new OpSQLiteDriver({ + database, + arrayResultMode: `statement-results`, + }) const adapter = new SQLiteCorePersistenceAdapter({ driver, diff --git a/packages/react-native-db-sqlite-persistence/tests/mobile-runtime-persistence-contract.test.ts b/packages/react-native-db-sqlite-persistence/tests/mobile-runtime-persistence-contract.test.ts index ebfedef630..33fc77a5e2 100644 --- a/packages/react-native-db-sqlite-persistence/tests/mobile-runtime-persistence-contract.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/mobile-runtime-persistence-contract.test.ts @@ -14,7 +14,10 @@ import type { PersistedCollectionCoordinator, PersistedCollectionPersistence, } from '@tanstack/db-sqlite-persistence-core' -import type { OpSQLiteDatabaseLike } from '../src/op-sqlite-driver' +import type { + OpSQLiteArrayResultMode, + OpSQLiteDatabaseLike, +} from '../src/op-sqlite-driver' import type { RuntimePersistenceContractTodo, RuntimePersistenceDatabaseHarness, @@ -22,6 +25,7 @@ import type { type RuntimePersistenceFactory = (options: { database: OpSQLiteDatabaseLike + arrayResultMode?: OpSQLiteArrayResultMode coordinator?: PersistedCollectionCoordinator }) => PersistedCollectionPersistence @@ -37,7 +41,10 @@ function createRuntimeDatabaseHarness(): RuntimePersistenceDatabaseHarness { filename: dbPath, resultShape: `statement-array`, }) - const driver = new OpSQLiteDriver({ database }) + const driver = new OpSQLiteDriver({ + database, + arrayResultMode: `statement-results`, + }) databases.add(database) drivers.add(driver) return driver @@ -80,10 +87,12 @@ for (const suite of runtimePersistenceSuites) { createAdapter: (driver) => suite.createPersistence({ database: (driver as OpSQLiteDriver).getDatabase(), + arrayResultMode: `statement-results`, }).adapter, createPersistence: (driver, coordinator) => suite.createPersistence({ database: (driver as OpSQLiteDriver).getDatabase(), + arrayResultMode: `statement-results`, coordinator, }), createCoordinator: () => new SingleProcessCoordinator(), @@ -99,6 +108,7 @@ for (const suite of runtimePersistenceSuites) { try { const persistence = suite.createPersistence({ database: (driver as OpSQLiteDriver).getDatabase(), + arrayResultMode: `statement-results`, }) expect(persistence.coordinator).toBeInstanceOf(SingleProcessCoordinator) } finally { @@ -113,6 +123,7 @@ for (const suite of runtimePersistenceSuites) { const coordinator = new SingleProcessCoordinator() const persistence = suite.createPersistence({ database: (driver as OpSQLiteDriver).getDatabase(), + arrayResultMode: `statement-results`, coordinator, }) expect(persistence.coordinator).toBe(coordinator) @@ -135,6 +146,7 @@ for (const suite of runtimePersistenceSuites) { try { const firstPersistence = suite.createPersistence({ database: firstDatabase, + arrayResultMode: `statement-results`, }) const firstCollectionOptions = persistedCollectionOptions< RuntimePersistenceContractTodo, @@ -176,6 +188,7 @@ for (const suite of runtimePersistenceSuites) { try { const secondPersistence = suite.createPersistence({ database: secondDatabase, + arrayResultMode: `statement-results`, }) const syncAbsentOptions = persistedCollectionOptions< diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts index 68e79eab55..5d424f248a 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts @@ -108,7 +108,9 @@ async function withColumnarDriver( * their names match envelope fields, including direct rows made only of write * marker aliases. When OP-SQLite supplies object rows and raw columnar rows * together, the already-decoded object rows are authoritative. Malformed or - * undocumented result carriers reject. + * undocumented result carriers reject. A bare array whose first entry can be + * both a row and a statement envelope has no self-describing interpretation; + * its driver must declare `rows` or `statement-results` mode. * Source: SQLiteDriver's public query contract and op-sqlite's documented * execute `{ rows, columnNames }` and executeAsync * `{ rawRows, columnNames, rowsAffected }` envelopes. @@ -117,9 +119,10 @@ async function withColumnarDriver( * and malformed or conflicting envelopes. * Reference/history grammar: `aliasQueryCase` builds two ordered object rows * directly from unique legal aliases and distinct scalar values. Histories - * choose wrapper shape or columnar aliases, execute real writes or one SELECT, - * and then either return the exact rows or reject an invalid envelope; the - * reference never calls the production decoder. + * choose wrapper shape, explicit mode for ambiguous arrays, or columnar + * aliases, execute real writes or one SELECT, and then either return the exact + * rows or reject an invalid envelope; the reference never calls the production + * decoder. * Path/checkpoint: OpSQLiteDriver over a real better-sqlite3 shim, observed * when each query Promise settles. Exact row values, keys, order, and count are * compared. @@ -146,7 +149,12 @@ it.each([ }) activeCleanupFns.push(() => Promise.resolve(database.close())) - const driver = new OpSQLiteDriver({ database }) + const driver = new OpSQLiteDriver({ + database, + ...(resultShape === `statement-array` + ? { arrayResultMode: `statement-results` as const } + : {}), + }) await driver.exec( `CREATE TABLE todos (id TEXT PRIMARY KEY, title TEXT NOT NULL, score INTEGER NOT NULL)`, ) @@ -182,14 +190,30 @@ it.each([ async function queryInjectedResult( result: unknown, + arrayResultMode?: `rows` | `statement-results`, ): Promise> { return new OpSQLiteDriver({ database: { executeAsync: () => Promise.resolve(result), }, + ...(arrayResultMode ? { arrayResultMode } : {}), }).query(`SELECT * FROM injected_result`) } +it(`requires an explicit mode for an ambiguous bare result array`, async () => { + const ambiguous = [{ rowsAffected: 17, rows: [`nested`] }] + + await expect(queryInjectedResult(ambiguous)).rejects.toThrow( + `ambiguous bare result array`, + ) + await expect(queryInjectedResult(ambiguous, `rows`)).resolves.toEqual( + ambiguous, + ) + await expect( + queryInjectedResult(ambiguous, `statement-results`), + ).resolves.toEqual([`nested`]) +}) + it(`reads op-sqlite execute rows when columnNames metadata is also present`, async () => { await expect( queryInjectedResult({ @@ -242,7 +266,7 @@ it(`reads object and structural statement-array write envelopes as empty`, async const writeResult = { rowsAffected: 1, insertId: 17 } await expect(queryInjectedResult(writeResult)).resolves.toEqual([]) await expect( - queryInjectedResult([{ ...writeResult, rows: [] }]), + queryInjectedResult([{ ...writeResult, rows: [] }], `statement-results`), ).resolves.toEqual([]) }) @@ -379,25 +403,41 @@ it(`returns the same rows across equivalent documented result carriers`, async ( const rawRows = expected.map((row) => aliases.map((alias) => row[alias]), ) - const equivalentResults: ReadonlyArray = [ - expected, - { rows: expected }, - { resultRows: expected }, - [{ rowsAffected: 0, rows: expected }], - { rowsAffected: 0, rows: expected, columnNames: aliases }, - { rowsAffected: 0, rawRows, columnNames: aliases }, + const equivalentResults: ReadonlyArray<{ + result: unknown + arrayResultMode?: `rows` | `statement-results` + }> = [ + { result: expected, arrayResultMode: `rows` }, + { result: { rows: expected } }, + { result: { resultRows: expected } }, + { + result: [{ rowsAffected: 0, rows: expected }], + arrayResultMode: `statement-results`, + }, { - rowsAffected: 0, - rows: expected, - rawRows, - columnNames: aliases, + result: { + rowsAffected: 0, + rows: expected, + columnNames: aliases, + }, }, - { results: [{ rows: expected }] }, + { result: { rowsAffected: 0, rawRows, columnNames: aliases } }, + { + result: { + rowsAffected: 0, + rows: expected, + rawRows, + columnNames: aliases, + }, + }, + { result: { results: [{ rows: expected }] } }, ] - for (const result of equivalentResults) { - const actual = - await queryInjectedResult>(result) + for (const { result, arrayResultMode } of equivalentResults) { + const actual = await queryInjectedResult>( + result, + arrayResultMode, + ) expectExactAliasRows(actual, expected) } }, diff --git a/packages/react-native-db-sqlite-persistence/tests/react-native-sqlite-core-adapter-contract.test.ts b/packages/react-native-db-sqlite-persistence/tests/react-native-sqlite-core-adapter-contract.test.ts index cb27349462..010016611c 100644 --- a/packages/react-native-db-sqlite-persistence/tests/react-native-sqlite-core-adapter-contract.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/react-native-sqlite-core-adapter-contract.test.ts @@ -14,7 +14,10 @@ const createHarness: SQLiteCoreAdapterHarnessFactory = (options) => { filename: dbPath, resultShape: `statement-array`, }) - const driver = new OpSQLiteDriver({ database }) + const driver = new OpSQLiteDriver({ + database, + arrayResultMode: `statement-results`, + }) const adapter = new SQLiteCorePersistenceAdapter({ driver, From c4572bc4688dd4fbf513573e12840084365f7300 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 22 Sep 2026 17:01:58 +0100 Subject: [PATCH 09/13] test(react-native): declare statement-array result mode --- .../e2e/expo-persisted-collection.e2e.test.ts | 6 +++++- .../e2e/react-native-persisted-collection.e2e.test.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/react-native-db-sqlite-persistence/e2e/expo-persisted-collection.e2e.test.ts b/packages/react-native-db-sqlite-persistence/e2e/expo-persisted-collection.e2e.test.ts index 5d2c709473..5a113127aa 100644 --- a/packages/react-native-db-sqlite-persistence/e2e/expo-persisted-collection.e2e.test.ts +++ b/packages/react-native-db-sqlite-persistence/e2e/expo-persisted-collection.e2e.test.ts @@ -3,5 +3,9 @@ import { runMobilePersistedCollectionConformanceSuite } from './mobile-persisted runMobilePersistedCollectionConformanceSuite( `expo persisted collection conformance`, - (database) => createReactNativeSQLitePersistence({ database }), + (database) => + createReactNativeSQLitePersistence({ + database, + arrayResultMode: `statement-results`, + }), ) diff --git a/packages/react-native-db-sqlite-persistence/e2e/react-native-persisted-collection.e2e.test.ts b/packages/react-native-db-sqlite-persistence/e2e/react-native-persisted-collection.e2e.test.ts index 1a62d94744..81ae91d714 100644 --- a/packages/react-native-db-sqlite-persistence/e2e/react-native-persisted-collection.e2e.test.ts +++ b/packages/react-native-db-sqlite-persistence/e2e/react-native-persisted-collection.e2e.test.ts @@ -3,5 +3,9 @@ import { runMobilePersistedCollectionConformanceSuite } from './mobile-persisted runMobilePersistedCollectionConformanceSuite( `react-native persisted collection conformance`, - (database) => createReactNativeSQLitePersistence({ database }), + (database) => + createReactNativeSQLitePersistence({ + database, + arrayResultMode: `statement-results`, + }), ) From 8c8d818eb29642ed63df5fa9e0cac43a7fa9cc4d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 22 Sep 2026 17:41:30 +0100 Subject: [PATCH 10/13] test(react-native): freeze OP-SQLite provider receipts --- .../fixtures/op-sqlite-provider-results.ts | 113 ++++++++++++++++++ .../tests/op-sqlite-driver.test.ts | 54 ++++++++- 2 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 packages/react-native-db-sqlite-persistence/tests/fixtures/op-sqlite-provider-results.ts diff --git a/packages/react-native-db-sqlite-persistence/tests/fixtures/op-sqlite-provider-results.ts b/packages/react-native-db-sqlite-persistence/tests/fixtures/op-sqlite-provider-results.ts new file mode 100644 index 0000000000..95e7c07585 --- /dev/null +++ b/packages/react-native-db-sqlite-persistence/tests/fixtures/op-sqlite-provider-results.ts @@ -0,0 +1,113 @@ +/** + * Frozen query-result receipts from published @op-engineering/op-sqlite + * packages. These are provider data, not a second decoder implementation. + * + * Provenance: + * - 15.2.7 npm tarball SHA-256 + * c800ff4388c1e689fa01468efeaa4756e540ad05e3e3ef7e947af9b1d9c42765 + * (`src/functions.ts`, `src/types.ts`, `node/dist/database.js`). This is the + * package's current supported peer major and the repository lockfile version. + * - 18.2.1 npm tarball SHA-256 + * 54ee28fc481371b3015155f7542c56f34f6b207da0014d92761da6a47a7143a5 + * (`src/functions.ts`, `src/functions.web.ts`, `src/types.ts`, and + * `node/dist/database.js`). These forward receipts preserve the Node/browser + * carrier coexistence that motivated the decoder review. + * + * Native `executeAsync` returns the bridge's object-row result. Node adds + * `columnNames` and `metadata`; the browser adds `columnNames` and retains an + * undefined `insertId` property. Values are representative SELECT receipts, + * with distinct rows and field order so dropping, copying, or reordering a row + * cannot pass an exact-row comparison. + */ + +export type OpSQLiteProviderQueryFixture = { + label: string + providerVersion: `15.2.7` | `18.2.1` + runtime: `react-native` | `node` | `browser` + method: `execute` | `executeAsync` + support: `current-peer` | `upstream-forward` + result: unknown + expectedRows: ReadonlyArray> +} + +const expectedRows = [ + { title: `Higher score`, score: 41, id: `2` }, + { title: `Lower score`, score: 7, id: `1` }, +] as const + +const columnNames = [`title`, `score`, `id`] as const + +const metadata = [ + { name: `title`, type: `TEXT`, index: 0 }, + { name: `score`, type: `INTEGER`, index: 1 }, + { name: `id`, type: `TEXT`, index: 2 }, +] as const + +export const opSQLiteProviderQueryFixtures = [ + { + label: `15.2.7 React Native executeAsync`, + providerVersion: `15.2.7`, + runtime: `react-native`, + method: `executeAsync`, + support: `current-peer`, + result: { + rowsAffected: 0, + rows: expectedRows, + }, + expectedRows, + }, + { + label: `15.2.7 Node executeAsync`, + providerVersion: `15.2.7`, + runtime: `node`, + method: `executeAsync`, + support: `current-peer`, + result: { + rowsAffected: 0, + rows: expectedRows, + columnNames, + metadata, + }, + expectedRows, + }, + { + label: `18.2.1 React Native executeAsync`, + providerVersion: `18.2.1`, + runtime: `react-native`, + method: `executeAsync`, + support: `upstream-forward`, + result: { + rowsAffected: 0, + rows: expectedRows, + }, + expectedRows, + }, + { + label: `18.2.1 Node executeAsync`, + providerVersion: `18.2.1`, + runtime: `node`, + method: `executeAsync`, + support: `upstream-forward`, + result: { + rowsAffected: 0, + rows: expectedRows, + columnNames, + metadata, + }, + expectedRows, + }, + { + label: `18.2.1 browser execute`, + providerVersion: `18.2.1`, + runtime: `browser`, + method: `execute`, + support: `upstream-forward`, + result: { + rowsAffected: 0, + insertId: undefined, + rows: expectedRows, + columnNames, + }, + expectedRows, + }, +] as const satisfies ReadonlyArray diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts index 5d424f248a..137ade00bf 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts @@ -7,6 +7,7 @@ import { OpSQLiteDriver } from '../src/op-sqlite-driver' import { InvalidPersistedCollectionConfigError } from '../../db-sqlite-persistence-core/src' import { runSQLiteDriverContractSuite } from '../../db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract' import { createOpSQLiteTestDatabase } from './helpers/op-sqlite-test-db' +import { opSQLiteProviderQueryFixtures } from './fixtures/op-sqlite-provider-results' import type { OpSQLiteDatabaseLike } from '../src/op-sqlite-driver' import type { SQLiteDriverContractHarnessFactory } from '../../db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract' @@ -130,9 +131,12 @@ async function withColumnarDriver( * adapter. Empty, reordered, duplicated, missing-key, and swapped-value mutants * challenge the checker; the alias property records a seed and shrink path and * verifies replay of the same exact-row violation. - * Known omissions: this shim establishes adapter normalization, not native - * device/host execution. Native op-sqlite still needs a separate runtime - * receipt for every supported major result contract. + * Real-provider refinement: frozen receipts from the published 15.2.7 Native + * and Node sources, plus forward receipts from 18.2.1 Native, Node, and browser + * sources, prove the shim's accepted envelopes match provider output. + * Known omissions: frozen provider values establish result-shape conformance, + * not native device/host execution. A real runtime campaign still owns JSI, + * worker, and platform delivery. */ it.each([ `rows-array`, @@ -188,6 +192,50 @@ it.each([ ]) }) +it.each(opSQLiteProviderQueryFixtures)( + `decodes frozen provider receipt: $label`, + async ({ result, expectedRows }) => { + const receiptBeforeDecode = structuredClone(result) + const actual = await queryInjectedResult>(result) + + expectExactRows(actual, expectedRows) + expect(result).toEqual(receiptBeforeDecode) + }, +) + +it(`keeps a receipt for every runtime in the current peer major`, () => { + expect( + opSQLiteProviderQueryFixtures + .filter(({ support }) => support === `current-peer`) + .map(({ providerVersion, runtime, method }) => ({ + providerVersion, + runtime, + method, + })), + ).toEqual([ + { + providerVersion: `15.2.7`, + runtime: `react-native`, + method: `executeAsync`, + }, + { + providerVersion: `15.2.7`, + runtime: `node`, + method: `executeAsync`, + }, + ]) +}) + +it(`provider-receipt checker rejects a shim that drops provider rows`, async () => { + for (const { result, expectedRows } of opSQLiteProviderQueryFixtures) { + const decoded = await queryInjectedResult>({ + ...(result as Record), + rows: [], + }) + expect(() => expectExactRows(decoded, expectedRows)).toThrow() + } +}) + async function queryInjectedResult( result: unknown, arrayResultMode?: `rows` | `statement-results`, From fc67799dafac72717ef3fde4d811cd18a6325850 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 22 Sep 2026 18:52:38 +0100 Subject: [PATCH 11/13] fix(react-native): honor declared OP-SQLite envelopes --- .../src/op-sqlite-driver.ts | 28 ++++++-- .../tests/helpers/op-sqlite-test-db.ts | 1 + .../tests/op-sqlite-driver.test.ts | 70 +++++++++++++++++-- 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts index 3e31cf2ccc..bce6fe5061 100644 --- a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts +++ b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts @@ -181,6 +181,23 @@ function toRowArray(rowsValue: unknown): Array | null { return null } +function isRowCarrier(rowsValue: unknown): boolean { + if (Array.isArray(rowsValue)) { + return true + } + + if (!isObjectRecord(rowsValue)) { + return false + } + + const rowsObject = rowsValue as OpSQLiteRowListLike + return ( + Array.isArray(rowsObject._array) || + (typeof rowsObject.length === `number` && + typeof rowsObject.item === `function`) + ) +} + function isStatementResultEnvelope(value: Record): boolean { if (!Object.keys(value).every((key) => STATEMENT_RESULT_KEYS.has(key))) { return false @@ -189,14 +206,17 @@ function isStatementResultEnvelope(value: Record): boolean { // Bare arrays are also a supported row carrier. A legitimate row can contain // only write-marker aliases, so markers alone cannot prove that an array is a // statement wrapper; wrappers need an actual row structure. + const hasRowCarrier = + isRowCarrier(value.rows) || + isRowCarrier(value.resultRows) || + Array.isArray(value.results) const hasStructuralCarrier = - toRowArray(value.rows) !== null || - toRowArray(value.resultRows) !== null || + hasRowCarrier || Array.isArray(value.rawRows) || - Array.isArray(value.columnNames) || - Array.isArray(value.results) + Array.isArray(value.columnNames) return ( + hasRowCarrier || (hasWriteResultMarker(value) && hasStructuralCarrier) || (Array.isArray(value.rawRows) && Array.isArray(value.columnNames)) ) diff --git a/packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts b/packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts index a41b01ec51..38e9eb15c7 100644 --- a/packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts +++ b/packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts @@ -94,6 +94,7 @@ function formatWriteResult( return { rowsAffected, insertId, + rows: [], } default: return { diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts index 137ade00bf..14d302336b 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts @@ -109,11 +109,13 @@ async function withColumnarDriver( * their names match envelope fields, including direct rows made only of write * marker aliases. When OP-SQLite supplies object rows and raw columnar rows * together, the already-decoded object rows are authoritative. Malformed or - * undocumented result carriers reject. A bare array whose first entry can be + * incomplete result carriers reject. A bare array whose first entry can be * both a row and a statement envelope has no self-describing interpretation; - * its driver must declare `rows` or `statement-results` mode. + * its driver must declare `rows` or `statement-results` mode. The declared + * mode, rather than reserved field names, decides markerless `{ rows }` + * wrappers. * Source: SQLiteDriver's public query contract and op-sqlite's documented - * execute `{ rows, columnNames }` and executeAsync + * required `QueryResult.rows`, execute `{ rows, columnNames }`, and executeAsync * `{ rawRows, columnNames, rowsAffected }` envelopes. * Domain: deterministic object-row wrappers and columnar rows, including * empty, asymmetric/reordered multirow, legal reserved-looking SQL aliases, @@ -262,6 +264,37 @@ it(`requires an explicit mode for an ambiguous bare result array`, async () => { ).resolves.toEqual([`nested`]) }) +it(`uses the declared mode for a markerless statement-array envelope`, async () => { + const ambiguous = [{ rows: [{ id: `nested-row` }] }] + + await expect(queryInjectedResult(ambiguous)).rejects.toThrow( + `ambiguous bare result array`, + ) + await expect(queryInjectedResult(ambiguous, `rows`)).resolves.toEqual( + ambiguous, + ) + await expect( + queryInjectedResult(ambiguous, `statement-results`), + ).resolves.toEqual([{ id: `nested-row` }]) +}) + +it(`materializes a declared statement row list exactly once`, async () => { + const expected = [{ id: `first` }, { id: `second` }] + const requestedIndexes: Array = [] + const rows = { + length: expected.length, + item: (index: number) => { + requestedIndexes.push(index) + return expected[index] + }, + } + + await expect( + queryInjectedResult([{ rowsAffected: 0, rows }], `statement-results`), + ).resolves.toEqual(expected) + expect(requestedIndexes).toEqual([0, 1]) +}) + it(`reads op-sqlite execute rows when columnNames metadata is also present`, async () => { await expect( queryInjectedResult({ @@ -701,7 +734,7 @@ const malformedColumnarResults: ReadonlyArray<{ result: { mysteryRows: [[`1`]] }, }, { - name: `undocumented res carrier`, + name: `optional res field without the required rows carrier`, result: { rowsAffected: 0, res: [{ id: `legacy` }] }, }, { @@ -770,6 +803,35 @@ it(`supports exactly one results wrapper`, async () => { expect(queryExecutions).toBe(1) }) +it(`rejects a multi-statement results wrapper instead of dropping results`, async () => { + await expect( + queryInjectedResult({ + results: [ + { rows: [{ id: `first-statement` }] }, + { rows: [{ id: `second-statement` }] }, + ], + }), + ).rejects.toThrow(`invalid nested results carrier`) +}) + +it(`models OP-SQLite writes with an empty rows carrier`, async () => { + const dbPath = createTempSqlitePath() + const database = createOpSQLiteTestDatabase({ + filename: dbPath, + resultShape: `execute-async-columnar`, + }) + activeCleanupFns.push(() => Promise.resolve(database.close())) + const executeAsync = database.executeAsync + if (!executeAsync) { + throw new Error(`columnar fixture must expose executeAsync`) + } + + await executeAsync(`CREATE TABLE write_receipt (id INTEGER PRIMARY KEY)`) + await expect( + executeAsync(`INSERT INTO write_receipt (id) VALUES (?)`, [1]), + ).resolves.toMatchObject({ rowsAffected: 1, rows: [] }) +}) + it.each([ { name: `second results wrapper`, From 6997f35ad9677b06ae3be8098d381ded7218cf64 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 22 Sep 2026 19:24:34 +0100 Subject: [PATCH 12/13] fix(react-native): validate op-sqlite row carriers --- .../src/op-sqlite-driver.ts | 34 +++++++++++++------ .../tests/op-sqlite-driver.test.ts | 21 ++++++++++++ 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts index bce6fe5061..3e3999e029 100644 --- a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts +++ b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts @@ -152,6 +152,20 @@ function unsupportedQueryResult(sql: string, details?: string): never { ) } +function isValidRowList( + rowsObject: OpSQLiteRowListLike, +): rowsObject is OpSQLiteRowListLike & { + length: number + item: (index: number) => unknown +} { + return ( + typeof rowsObject.length === `number` && + Number.isSafeInteger(rowsObject.length) && + rowsObject.length >= 0 && + typeof rowsObject.item === `function` + ) +} + function toRowArray(rowsValue: unknown): Array | null { if (Array.isArray(rowsValue)) { return rowsValue @@ -166,14 +180,10 @@ function toRowArray(rowsValue: unknown): Array | null { return rowsObject._array } - if ( - typeof rowsObject.length === `number` && - typeof rowsObject.item === `function` - ) { - const item = rowsObject.item as (index: number) => unknown + if (isValidRowList(rowsObject)) { const rows: Array = [] for (let index = 0; index < rowsObject.length; index++) { - rows.push(item(index)) + rows.push(rowsObject.item(index)) } return rows } @@ -191,11 +201,7 @@ function isRowCarrier(rowsValue: unknown): boolean { } const rowsObject = rowsValue as OpSQLiteRowListLike - return ( - Array.isArray(rowsObject._array) || - (typeof rowsObject.length === `number` && - typeof rowsObject.item === `function`) - ) + return Array.isArray(rowsObject._array) || isValidRowList(rowsObject) } function isStatementResultEnvelope(value: Record): boolean { @@ -340,6 +346,12 @@ function extractRowsFromExecuteResult( if (Array.isArray(result)) { if (result.length === 0) { + if (arrayResultMode === `statement-results`) { + return unsupportedQueryResult( + sql, + `statement-result arrays must contain exactly one result`, + ) + } return [] } diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts index 14d302336b..eddb83ba20 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts @@ -295,6 +295,27 @@ it(`materializes a declared statement row list exactly once`, async () => { expect(requestedIndexes).toEqual([0, 1]) }) +it.each([ + { name: `NaN`, length: Number.NaN }, + { name: `negative`, length: -1 }, + { name: `fractional`, length: 1.5 }, +])(`rejects a $name statement row-list length`, async ({ length }) => { + await expect( + queryInjectedResult( + [{ rowsAffected: 0, rows: { length, item: () => ({ id: `row` }) } }], + `statement-results`, + ), + ).rejects.toThrow(`recognized statement envelope`) +}) + +it(`rejects an empty array in statement-results mode`, async () => { + await expect(queryInjectedResult([], `statement-results`)).rejects.toThrow( + `statement-result arrays must contain exactly one result`, + ) + await expect(queryInjectedResult([], `rows`)).resolves.toEqual([]) + await expect(queryInjectedResult([])).resolves.toEqual([]) +}) + it(`reads op-sqlite execute rows when columnNames metadata is also present`, async () => { await expect( queryInjectedResult({ From 6a8955627774abd59af669b5f2bb532d1f7c653c Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 23 Sep 2026 10:00:57 +0100 Subject: [PATCH 13/13] fix(react-native): harden op-sqlite result contracts --- .../README.md | 6 ++ .../src/index.ts | 1 + .../src/op-sqlite-driver.ts | 60 +++++++++++++---- .../src/react-native.ts | 5 +- ...obile-runtime-persistence-contract.test.ts | 8 +-- .../tests/op-sqlite-driver.test.ts | 65 ++++++++++++++++++- 6 files changed, 126 insertions(+), 19 deletions(-) diff --git a/packages/react-native-db-sqlite-persistence/README.md b/packages/react-native-db-sqlite-persistence/README.md index 84f5597e34..0b6a5574eb 100644 --- a/packages/react-native-db-sqlite-persistence/README.md +++ b/packages/react-native-db-sqlite-persistence/README.md @@ -49,3 +49,9 @@ export const todosCollection = createCollection( - `createReactNativeSQLitePersistence` is shared across collections. - Mode defaults (`sync-present` vs `sync-absent`) are inferred from whether a `sync` config is present in `persistedCollectionOptions`. +- Published OP-SQLite methods return self-describing object envelopes and need + no result-shape option. If a custom database wrapper instead returns a bare + array, set `arrayResultMode: 'rows'` for an array of data rows or + `arrayResultMode: 'statement-results'` for an array containing one statement + result. Undeclared arrays that also look like statement envelopes are rejected + rather than being reshaped heuristically. diff --git a/packages/react-native-db-sqlite-persistence/src/index.ts b/packages/react-native-db-sqlite-persistence/src/index.ts index 8dc1b4aeb0..cfd096e5a0 100644 --- a/packages/react-native-db-sqlite-persistence/src/index.ts +++ b/packages/react-native-db-sqlite-persistence/src/index.ts @@ -1,5 +1,6 @@ export { createReactNativeSQLitePersistence } from './react-native' export type { + OpSQLiteArrayResultMode, OpSQLiteDatabaseLike, ReactNativeSQLitePersistenceOptions, ReactNativeSQLiteSchemaMismatchPolicy, diff --git a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts index 3e3999e029..37ece5e34f 100644 --- a/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts +++ b/packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts @@ -128,6 +128,23 @@ function hasOwnKey(value: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key) } +function rejectInheritedCarrierKeys( + value: Record, + sql: string, +): void { + for (const key of [ + `rows`, + `resultRows`, + `rawRows`, + `columnNames`, + `results`, + ]) { + if (!hasOwnKey(value, key) && key in value) { + unsupportedQueryResult(sql, `inherited ${key} carrier`) + } + } +} + function hasWriteResultMarker(value: Record): boolean { for (const key of WRITE_RESULT_KEYS) { if (hasOwnKey(value, key)) { @@ -269,17 +286,40 @@ function decodeColumnarRows( ) } - return Object.fromEntries( - columnNames.map((columnName, index) => [columnName, rawRow[index]]), - ) + const row: Record = {} + for (let index = 0; index < columnNames.length; index++) { + const columnName = columnNames[index]! + if (columnName === `__proto__`) { + Object.defineProperty(row, columnName, { + configurable: true, + enumerable: true, + value: rawRow[index], + writable: true, + }) + } else { + row[columnName] = rawRow[index] + } + } + return row }) } +function rejectPositionalRows( + rows: Array, + sql: string, +): Array { + if (rows.some((row) => Array.isArray(row))) { + unsupportedQueryResult(sql, `positional rows require column metadata`) + } + return rows +} + function extractRowsFromStatementResult( record: Record, sql: string, allowResultsWrapper: boolean, ): Array { + rejectInheritedCarrierKeys(record, sql) const rowCarrierKeys = [`rows`, `resultRows`].filter((key) => hasOwnKey(record, key), ) @@ -298,7 +338,7 @@ function extractRowsFromStatementResult( if (!rows) { unsupportedQueryResult(sql, `invalid ${rowCarrierKey} carrier`) } - return rows + return rejectPositionalRows(rows, sql) } if ( @@ -356,21 +396,17 @@ function extractRowsFromExecuteResult( } if (arrayResultMode === `rows`) { - return result + return rejectPositionalRows(result, sql) } const firstEntry = result[0] const isStructuralStatementResult = isObjectRecord(firstEntry) && isStatementResultEnvelope(firstEntry) - const isStatementResult = - isStructuralStatementResult || - (isObjectRecord(firstEntry) && isWriteResultEnvelope(firstEntry)) - if (arrayResultMode === `statement-results`) { - if (!isStatementResult) { + if (!isObjectRecord(firstEntry)) { return unsupportedQueryResult( sql, - `statement-results mode requires a recognized statement envelope`, + `statement-results mode requires an object statement envelope`, ) } if (result.length !== 1) { @@ -389,7 +425,7 @@ function extractRowsFromExecuteResult( ) } - return result + return rejectPositionalRows(result, sql) } if (isObjectRecord(result)) { diff --git a/packages/react-native-db-sqlite-persistence/src/react-native.ts b/packages/react-native-db-sqlite-persistence/src/react-native.ts index 7a274aeee4..aec456387e 100644 --- a/packages/react-native-db-sqlite-persistence/src/react-native.ts +++ b/packages/react-native-db-sqlite-persistence/src/react-native.ts @@ -8,7 +8,10 @@ import type { PersistedCollectionPersistence } from '@tanstack/db-sqlite-persist export type ReactNativeSQLitePersistenceOptions = MobileSQLitePersistenceOptions export type ReactNativeSQLiteSchemaMismatchPolicy = MobileSQLiteSchemaMismatchPolicy -export type { OpSQLiteDatabaseLike } from './mobile-persistence' +export type { + OpSQLiteArrayResultMode, + OpSQLiteDatabaseLike, +} from './mobile-persistence' export function createReactNativeSQLitePersistence( options: ReactNativeSQLitePersistenceOptions, diff --git a/packages/react-native-db-sqlite-persistence/tests/mobile-runtime-persistence-contract.test.ts b/packages/react-native-db-sqlite-persistence/tests/mobile-runtime-persistence-contract.test.ts index 33fc77a5e2..4972ad6e4f 100644 --- a/packages/react-native-db-sqlite-persistence/tests/mobile-runtime-persistence-contract.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/mobile-runtime-persistence-contract.test.ts @@ -10,14 +10,12 @@ import { OpSQLiteDriver } from '../src/op-sqlite-driver' import { runRuntimePersistenceContractSuite } from '../../db-sqlite-persistence-core/tests/contracts/runtime-persistence-contract' import { SingleProcessCoordinator } from '../../db-sqlite-persistence-core/src' import { createOpSQLiteTestDatabase } from './helpers/op-sqlite-test-db' +import type { OpSQLiteArrayResultMode, OpSQLiteDatabaseLike } from '../src' +import type { OpSQLiteArrayResultMode as ReactNativeOpSQLiteArrayResultMode } from '../src/react-native' import type { PersistedCollectionCoordinator, PersistedCollectionPersistence, } from '@tanstack/db-sqlite-persistence-core' -import type { - OpSQLiteArrayResultMode, - OpSQLiteDatabaseLike, -} from '../src/op-sqlite-driver' import type { RuntimePersistenceContractTodo, RuntimePersistenceDatabaseHarness, @@ -25,7 +23,7 @@ import type { type RuntimePersistenceFactory = (options: { database: OpSQLiteDatabaseLike - arrayResultMode?: OpSQLiteArrayResultMode + arrayResultMode?: OpSQLiteArrayResultMode & ReactNativeOpSQLiteArrayResultMode coordinator?: PersistedCollectionCoordinator }) => PersistedCollectionPersistence diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts index eddb83ba20..6bf6e92bd7 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts @@ -278,6 +278,57 @@ it(`uses the declared mode for a markerless statement-array envelope`, async () ).resolves.toEqual([{ id: `nested-row` }]) }) +it(`applies the same row-carrier policy to object and declared statement envelopes`, async () => { + const rows = [{ id: `row-with-provider-extension` }] + const envelope = { + rows, + res: [{ id: `ignored-provider-res` }], + error: undefined, + providerExtension: `retained`, + } + + await expect(queryInjectedResult(envelope)).resolves.toEqual(rows) + await expect( + queryInjectedResult([envelope], `statement-results`), + ).resolves.toEqual(rows) +}) + +it(`rejects positional rows without column metadata`, async () => { + const positionalRows = [[`1`, `title`, 7]] + + await expect(queryInjectedResult(positionalRows)).rejects.toThrow( + `positional rows require column metadata`, + ) + await expect(queryInjectedResult(positionalRows, `rows`)).rejects.toThrow( + `positional rows require column metadata`, + ) +}) + +it(`rejects inherited result carriers instead of treating them as writes`, async () => { + let getterReads = 0 + const prototype = Object.defineProperty({}, `rows`, { + get: () => { + getterReads++ + return [{ id: `inherited-row` }] + }, + }) + const result = Object.assign(Object.create(prototype), { rowsAffected: 0 }) + + await expect(queryInjectedResult(result)).rejects.toThrow( + `inherited rows carrier`, + ) + expect(getterReads).toBe(0) +}) + +it.each([undefined, null])( + `rejects a nullish rows carrier beside a write marker: %s`, + async (rows) => { + await expect( + queryInjectedResult({ rowsAffected: 0, rows }), + ).rejects.toThrow(`invalid rows carrier`) + }, +) + it(`materializes a declared statement row list exactly once`, async () => { const expected = [{ id: `first` }, { id: `second` }] const requestedIndexes: Array = [] @@ -305,7 +356,7 @@ it.each([ [{ rowsAffected: 0, rows: { length, item: () => ({ id: `row` }) } }], `statement-results`, ), - ).rejects.toThrow(`recognized statement envelope`) + ).rejects.toThrow(`invalid rows carrier`) }) it(`rejects an empty array in statement-results mode`, async () => { @@ -678,6 +729,18 @@ it(`returns an exact empty row set for an empty columnar SELECT`, async () => { }) }) +it(`preserves a __proto__ column as own data without changing the row prototype`, async () => { + const [row] = await queryInjectedResult>({ + rowsAffected: 0, + rawRows: [[`ordinary-data`]], + columnNames: [`__proto__`], + }) + + expect(Object.getPrototypeOf(row)).toBe(Object.prototype) + expect(Object.prototype.hasOwnProperty.call(row, `__proto__`)).toBe(true) + expect(row?.[`__proto__`]).toBe(`ordinary-data`) +}) + const malformedColumnarResults: ReadonlyArray<{ name: string result: unknown