diff --git a/.changeset/fix-op-sqlite-result-decoding.md b/.changeset/fix-op-sqlite-result-decoding.md new file mode 100644 index 0000000000..5e35c7c3ab --- /dev/null +++ b/.changeset/fix-op-sqlite-result-decoding.md @@ -0,0 +1,6 @@ +--- +'@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. +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/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts b/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts index cae17dd008..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 @@ -30,11 +30,36 @@ async function withHarness( fn: (harness: SQLiteDriverContractHarness) => Promise, ): Promise { const harness = createHarness() + let outcome: { ok: true; value: T } | { ok: false; error: unknown } try { - return await fn(harness) - } finally { - await Promise.resolve(harness.cleanup()) + outcome = { ok: true, value: await fn(harness) } + } catch (error) { + outcome = { ok: false, error } } + + let cleanupOutcome: { ok: true } | { ok: false; error: unknown } = { + ok: true, + } + try { + await 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( @@ -70,6 +95,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( @@ -249,11 +357,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/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/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`, + }), ) 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/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 3fae4aaf86..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 @@ -12,15 +12,6 @@ type OpSQLiteRowListLike = { _array?: unknown } -type OpSQLiteStatementResultLike = { - rows?: unknown - resultRows?: unknown - rowsAffected?: unknown - changes?: unknown - insertId?: unknown - lastInsertRowId?: unknown -} - const WRITE_RESULT_KEYS = new Set([ `rowsAffected`, `changes`, @@ -28,6 +19,16 @@ const WRITE_RESULT_KEYS = new Set([ `lastInsertRowId`, ]) +const STATEMENT_RESULT_KEYS = new Set([ + ...WRITE_RESULT_KEYS, + `rows`, + `resultRows`, + `rawRows`, + `columnNames`, + `results`, + `metadata`, +]) + export type OpSQLiteDatabaseLike = { execute?: OpSQLiteExecuteFn executeAsync?: OpSQLiteExecuteFn @@ -36,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 } @@ -112,15 +124,65 @@ 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 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 (key in value) { + if (hasOwnKey(value, key)) { return true } } 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}"${ + details ? `: ${details}` : `` + }`, + ) +} + +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 @@ -135,14 +197,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 } @@ -150,71 +208,231 @@ 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) || isValidRowList(rowsObject) +} + +function isStatementResultEnvelope(value: Record): boolean { + if (!Object.keys(value).every((key) => STATEMENT_RESULT_KEYS.has(key))) { + 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 hasRowCarrier = + isRowCarrier(value.rows) || + isRowCarrier(value.resultRows) || + Array.isArray(value.results) + const hasStructuralCarrier = + hasRowCarrier || + Array.isArray(value.rawRows) || + Array.isArray(value.columnNames) + + return ( + hasRowCarrier || + (hasWriteResultMarker(value) && hasStructuralCarrier) || + (Array.isArray(value.rawRows) && Array.isArray(value.columnNames)) + ) +} + +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`, + ) + } + 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`) + } + + 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) { + unsupportedQueryResult( + sql, + `columnar row width does not match columnNames`, + ) + } + + 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( - value: OpSQLiteStatementResultLike, -): Array | null { - const rowsFromRows = toRowArray(value.rows) - if (rowsFromRows) { - return rowsFromRows + record: Record, + sql: string, + allowResultsWrapper: boolean, +): Array { + rejectInheritedCarrierKeys(record, sql) + const rowCarrierKeys = [`rows`, `resultRows`].filter((key) => + hasOwnKey(record, key), + ) + const rowCarrierKey = rowCarrierKeys[0] + if ( + rowCarrierKeys.length > 1 || + (rowCarrierKeys.length > 0 && + (hasOwnKey(record, `results`) || + (rowCarrierKey === `resultRows` && hasOwnKey(record, `rawRows`)))) + ) { + unsupportedQueryResult(sql, `query result contains conflicting carriers`) + } + + if (rowCarrierKeys.length === 1) { + const rows = toRowArray(record[rowCarrierKey!]) + if (!rows) { + unsupportedQueryResult(sql, `invalid ${rowCarrierKey} carrier`) + } + return rejectPositionalRows(rows, sql) + } + + if ( + (hasOwnKey(record, `rawRows`) || hasOwnKey(record, `columnNames`)) && + hasOwnKey(record, `results`) + ) { + unsupportedQueryResult(sql, `query result contains conflicting carriers`) } - const rowsFromResultRows = toRowArray(value.resultRows) - if (rowsFromResultRows) { - return rowsFromResultRows + const columnarRows = decodeColumnarRows(record, sql) + if (columnarRows) { + return columnarRows + } + + if (hasOwnKey(record, `results`)) { + if (!allowResultsWrapper) { + unsupportedQueryResult(sql, `unsupported nested results depth`) + } + 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, false) } - if (hasWriteResultMarker(value as Record)) { + if (isWriteResultEnvelope(record)) { return [] } - return null + return unsupportedQueryResult(sql) } function extractRowsFromExecuteResult( result: unknown, sql: string, + arrayResultMode?: OpSQLiteArrayResultMode, ): Array { if (result == null) { - return [] + return unsupportedQueryResult(sql) } if (Array.isArray(result)) { if (result.length === 0) { + if (arrayResultMode === `statement-results`) { + return unsupportedQueryResult( + sql, + `statement-result arrays must contain exactly one result`, + ) + } return [] } + if (arrayResultMode === `rows`) { + return rejectPositionalRows(result, sql) + } + const firstEntry = result[0] - if (isObjectRecord(firstEntry)) { - const rowsFromStatement = extractRowsFromStatementResult(firstEntry) - if (rowsFromStatement) { - return rowsFromStatement + const isStructuralStatementResult = + isObjectRecord(firstEntry) && isStatementResultEnvelope(firstEntry) + if (arrayResultMode === `statement-results`) { + if (!isObjectRecord(firstEntry)) { + return unsupportedQueryResult( + sql, + `statement-results mode requires an object statement envelope`, + ) + } + if (result.length !== 1) { + return unsupportedQueryResult( + sql, + `statement-result arrays must contain exactly one result`, + ) } + return extractRowsFromStatementResult(firstEntry, sql, false) } - return result + if (isStructuralStatementResult) { + return unsupportedQueryResult( + sql, + `ambiguous bare result array; set arrayResultMode to "rows" or "statement-results"`, + ) + } + + return rejectPositionalRows(result, sql) } 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, true) } - throw new InvalidPersistedCollectionConfigError( - `Unsupported op-sqlite query result shape for SQL "${sql}"`, - ) + return unsupportedQueryResult(sql) } function hasExistingDatabase( @@ -260,6 +478,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 @@ -276,6 +495,7 @@ export class OpSQLiteDriver implements SQLiteDriver { } this.executeMethod = resolveExecuteMethod(this.database) + this.arrayResultMode = options.arrayResultMode } async exec(sql: string): Promise { @@ -301,7 +521,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 }) } @@ -442,7 +666,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/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/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/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/helpers/op-sqlite-test-db.ts b/packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts index 532b2a88f2..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 @@ -8,6 +8,8 @@ export type OpSQLiteTestResultShape = | `rows-object` | `rows-list` | `statement-array` + | `execute-rows-with-column-names` + | `execute-async-columnar` type OpSQLiteRowsListLike = { length: number @@ -53,7 +55,11 @@ 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: return { rows } } @@ -62,6 +68,7 @@ function formatQueryRows( function formatWriteResult( rowsAffected: number, resultShape: OpSQLiteTestResultShape, + insertId?: number, ): unknown { switch (resultShape) { case `rows-array`: @@ -83,6 +90,12 @@ function formatWriteResult( rowsAffected, }, ] + case `execute-async-columnar`: + return { + rowsAffected, + insertId, + rows: [], + } default: return { rows: [], @@ -109,6 +122,32 @@ 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, + } + } + + 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) @@ -120,14 +159,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/mobile-runtime-persistence-contract.test.ts b/packages/react-native-db-sqlite-persistence/tests/mobile-runtime-persistence-contract.test.ts index ebfedef630..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,11 +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 { OpSQLiteDatabaseLike } from '../src/op-sqlite-driver' import type { RuntimePersistenceContractTodo, RuntimePersistenceDatabaseHarness, @@ -22,6 +23,7 @@ import type { type RuntimePersistenceFactory = (options: { database: OpSQLiteDatabaseLike + arrayResultMode?: OpSQLiteArrayResultMode & ReactNativeOpSQLiteArrayResultMode coordinator?: PersistedCollectionCoordinator }) => PersistedCollectionPersistence @@ -37,7 +39,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 +85,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 +106,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 +121,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 +144,7 @@ for (const suite of runtimePersistenceSuites) { try { const firstPersistence = suite.createPersistence({ database: firstDatabase, + arrayResultMode: `statement-results`, }) const firstCollectionOptions = persistedCollectionOptions< RuntimePersistenceContractTodo, @@ -176,6 +186,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 8c482af1c2..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 @@ -1,17 +1,35 @@ -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' 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' 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 +42,1023 @@ 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 = { + 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({ 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, 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 + * 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. The declared + * mode, rather than reserved field names, decides markerless `{ rows }` + * wrappers. + * Source: SQLiteDriver's public query contract and op-sqlite's documented + * 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, + * 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, 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. + * 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. + * 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`, + `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() + const database = createOpSQLiteTestDatabase({ + filename: dbPath, + resultShape, + }) + activeCleanupFns.push(() => Promise.resolve(database.close())) + + 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)`, + ) + 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`, + }, + ]) +}) + +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`, +): 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(`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` }]) +}) - const rows = await driver.query<{ - id: string - title: string - score: number - }>(`SELECT id, title, score FROM todos ORDER BY id ASC`) - expect(rows).toEqual([ +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 = [] + 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.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(`invalid rows carrier`) +}) + +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({ + rowsAffected: 0, + rows: [{ id: `node-or-web-row` }], + columnNames: [`id`], + }), + ).resolves.toEqual([{ id: `node-or-web-row` }]) +}) + +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 = { + [alias]: [`nested`, `value`], + 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(`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: [] }], `statement-results`), + ).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, +): void { + 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( + 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 statementResultFieldNames = [ + `rows`, + `resultRows`, + `rawRows`, + `columnNames`, + `results`, + `rowsAffected`, + `changes`, + `insertId`, + `lastInsertRowId`, + `res`, +] as const + +const statementResultAliasNames = [ + ...statementResultFieldNames, + `ordinary_name`, + `another_value`, +] as const + +const writeResultFieldNames = [ + `rowsAffected`, + `changes`, + `insertId`, + `lastInsertRowId`, +] 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`, + ) +} + +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]]], + }, + ) +}) + +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<{ + result: unknown + arrayResultMode?: `rows` | `statement-results` + }> = [ + { result: expected, arrayResultMode: `rows` }, + { result: { rows: expected } }, + { result: { resultRows: expected } }, + { + result: [{ rowsAffected: 0, rows: expected }], + arrayResultMode: `statement-results`, + }, + { + result: { + rowsAffected: 0, + rows: expected, + columnNames: aliases, + }, + }, + { result: { rowsAffected: 0, rawRows, columnNames: aliases } }, + { + result: { + rowsAffected: 0, + rows: expected, + rawRows, + columnNames: aliases, + }, + }, + { result: { results: [{ rows: expected }] } }, + ] + + for (const { result, arrayResultMode } of equivalentResults) { + const actual = await queryInjectedResult>( + result, + arrayResultMode, + ) + expectExactAliasRows(actual, expected) + } + }, + ), + { + seed: aliasOracleSeed, + numRuns: aliasOracleRuns, + examples: [ + [[`rowsAffected`]], + [[`rows`, `rawRows`, `columnNames`, `ordinary_name`]], + ], + }, + ) +}) + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll(`"`, `""`)}"` +} + +function aliasValue( + alias: string, + rowIndex: number, + columnIndex: number, +): string { + 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 = statementResultFieldNames + 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 = statementResultFieldNames + 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: [[[...statementResultFieldNames]]], }, + ) + }) +}) + +it(`returns an exact empty row set for an empty columnar SELECT`, async () => { + 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(queryExecutions()).toBe(1) + }) +}) + +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 +}> = [ + { + 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: `nonempty rawRows with no column names`, + result: { + rowsAffected: 0, + rawRows: [[]], + columnNames: [], + }, + }, + { + name: `unknown row carrier beside a write marker`, + result: { rowsAffected: 0, mysteryRows: [[`1`]] }, + }, + { + name: `unknown envelope without a write marker`, + result: { mysteryRows: [[`1`]] }, + }, + { + name: `optional res field without the required rows carrier`, + result: { rowsAffected: 0, res: [{ id: `legacy` }] }, + }, + { + 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(`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(`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`, + 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) + 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 = statementResultFieldNames + 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 +1195,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 +1261,102 @@ it(`throws config error when db execute methods are missing`, () => { InvalidPersistedCollectionConfigError, ) }) + +function createColumnarDriverHarness( + createDatabase: typeof createOpSQLiteTestDatabase = createOpSQLiteTestDatabase, +): ReturnType { + const tempDirectory = mkdtempSync(join(tmpdir(), `db-rn-op-sqlite-contract-`)) + let database: ReturnType | undefined + try { + const createdDatabase = createDatabase({ + filename: join(tempDirectory, `state.sqlite`), + resultShape: `execute-async-columnar`, + }) + database = createdDatabase + const driver = new OpSQLiteDriver({ database: createdDatabase }) + + return { + driver, + cleanup: async () => { + const cleanupErrors: Array = [] + try { + await Promise.resolve(createdDatabase.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, +) 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..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,7 +15,59 @@ type Todo = { score: number } -const activeCleanupFns: Array<() => void | Promise> = [] +type Cleanup = () => void | Promise + +const activeCleanupFns: Array = [] + +async function withFailurePreservingCleanup( + run: (cleanups: Array) => Promise, + cleanups: Array, +): Promise { + let outcome: { ok: true; value: T } | { ok: false; error: unknown } + try { + outcome = { ok: true, value: await run(cleanups) } + } catch (error) { + outcome = { ok: false, error } + } + + const cleanupErrors: Array = [] + for (const cleanup of cleanups) { + 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 attempted = false + return async () => { + if (attempted) return + attempted = true + await cleanup() + } +} afterEach(async () => { while (activeCleanupFns.length > 0) { @@ -33,53 +85,125 @@ function createTempSqlitePath(): string { return dbPath } -it(`persists data across app restart (close and reopen)`, async () => { - const dbPath = createTempSqlitePath() +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. + * 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(collectionRegistryQuery) + .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( + restartStreamPosition, + ) + + 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(collectionRegistryQuery).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( + restartStreamPosition, + ) + await expect(secondAdapter.loadSubset(collectionId, {})).resolves.toEqual( + [ + { + key: `1`, + value: { + id: `1`, + title: `Survives restart`, + score: 10, + }, + }, + ], + ) + expect( + secondNativeDatabase.prepare(collectionRegistryQuery).all(collectionId), + ).toEqual(registryBeforeRestart) }, - ]) + [() => rmSync(tempDirectory, { recursive: true, force: true })], + ) }) it(`shared react-native api persists across expo-style restart`, async () => { 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,