diff --git a/.github/ci-paths.json b/.github/ci-paths.json index 9ce172e1..24111def 100644 --- a/.github/ci-paths.json +++ b/.github/ci-paths.json @@ -100,6 +100,7 @@ "packages/react-native-nitro-sqlite/package.json", "packages/react-native-nitro-sqlite-vec/package.json", "patches/react-native-nitro-modules*", + "patches/@react-native-harness*", "example/react-native.config.js", "packages/react-native-nitro-sqlite/react-native.config.js", "packages/react-native-nitro-sqlite-vec/react-native.config.js", @@ -137,6 +138,7 @@ "packages/react-native-nitro-sqlite/package.json", "packages/react-native-nitro-sqlite-vec/package.json", "patches/react-native-nitro-modules*", + "patches/@react-native-harness*", "example/react-native.config.js", "packages/react-native-nitro-sqlite/react-native.config.js", "packages/react-native-nitro-sqlite-vec/react-native.config.js", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6459e165..07745bd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,9 @@ jobs: run: | node --test scripts/ci-dependencies.test.ts bun run test:release + - name: Test Harness Metro cache handoff + if: steps.select.outputs.dependencies == 'true' + run: node --test scripts/ci-harness-cache.test.ts - name: Compile TypeScript if: steps.select.outputs.typescript == 'true' run: bun typecheck diff --git a/docs/content/docs/guides/parameters-and-results.mdx b/docs/content/docs/guides/parameters-and-results.mdx index 1e1d763f..fd615fee 100644 --- a/docs/content/docs/guides/parameters-and-results.mdx +++ b/docs/content/docs/guides/parameters-and-results.mdx @@ -51,12 +51,18 @@ Be especially careful with nullable columns and expressions when specifying a ro The connection's JavaScript helpers normalize database failures to `NitroSQLiteError`. Async methods reject; sync methods throw. An empty SQL string or one containing only comments also fails with the native category `SqlExecutionError`, because SQLite produces no statement to execute. Catch the error around the operation you can recover from: ```ts -import { NitroSQLiteError } from 'react-native-nitro-sqlite' +import { + NitroSQLiteError, + NitroSQLiteExceptionType, +} from 'react-native-nitro-sqlite/errors' try { await db.executeAsync('SELECT * FROM missing_table') } catch (error) { - if (error instanceof NitroSQLiteError) { + if ( + error instanceof NitroSQLiteError && + error.type === NitroSQLiteExceptionType.SqlExecutionError + ) { console.error(error.message) } else { throw error @@ -68,4 +74,8 @@ try { When a native `NitroSQLiteException` reaches a managed helper, `error.type` holds a [`NitroSQLiteExceptionType`](/api/react-native-nitro-sqlite/type-aliases/NitroSQLiteExceptionType) such as `SqlExecutionError`. It is `undefined` for JavaScript errors, including connection queue errors, and unrecognized native categories. These categories are not SQLite's numeric error codes. The native categories are `UnknownError`, `DatabaseCannotBeOpened`, `EncryptionNotEnabled`, `DatabaseCannotBeDecrypted`, `DatabaseNotOpen`, `UnableToAttachToDatabase`, `SqlExecutionError`, `CouldNotLoadFile`, and `NoBatchCommandsProvided`. See [encrypt a database](/docs/guides/encryption#handle-open-failures) for the encryption errors. The current native `DatabaseNotOpen` helper emits `UnableToAttachToDatabase`, and a missing database file emits `SqlExecutionError`. Calls through `NitroSQLite.native` throw raw native errors. +The [`NitroSQLiteExceptionType` constants](/api/react-native-nitro-sqlite/variables/NitroSQLiteExceptionType) expose these categories as runtime strings. The object and the TypeScript union share the same name, so existing type-only imports still work. Both the constants and `NitroSQLiteError` are available from the package root and `react-native-nitro-sqlite/errors`. The `/errors` entry point does not import React Native or initialize Nitro's native module, so shared classifiers and Node tests can use it without a native runtime. `error.name` remains `NitroSQLiteError` for every managed failure; use `error.type` to distinguish categories. + +`DatabaseCannotBeOpened` covers several causes, including invalid paths, an already-open default connection, and filesystem failures. It does not by itself establish that the device is low on storage. + For exact types, see [`QueryResult`](/api/react-native-nitro-sqlite/type-aliases/QueryResult), [`NitroSQLiteQueryResult`](/api/react-native-nitro-sqlite/hybrid-objects/NitroSQLiteQueryResult), and [`NitroSQLiteQueryResultRows`](/api/react-native-nitro-sqlite/type-aliases/NitroSQLiteQueryResultRows). The row generic narrows `rows._array` and `rows.item()`, but not the raw `results` array. Query results also inherit Nitro hybrid object members; see [native access](/docs/guides/sync-and-async#global-helpers-and-native-access). diff --git a/packages/react-native-nitro-sqlite/package.json b/packages/react-native-nitro-sqlite/package.json index 2d7ff759..446745b7 100644 --- a/packages/react-native-nitro-sqlite/package.json +++ b/packages/react-native-nitro-sqlite/package.json @@ -19,6 +19,17 @@ "default": "./lib/commonjs/index.js" } }, + "./errors": { + "react-native": "./src/errors.ts", + "import": { + "types": "./lib/typescript/module/errors.d.ts", + "default": "./lib/module/errors.js" + }, + "require": { + "types": "./lib/typescript/commonjs/errors.d.ts", + "default": "./lib/commonjs/errors.js" + } + }, "./mock": { "import": { "types": "./lib/typescript/module/mock.d.ts", diff --git a/packages/react-native-nitro-sqlite/src/NitroSQLiteError.ts b/packages/react-native-nitro-sqlite/src/NitroSQLiteError.ts index db9219c8..0d120228 100644 --- a/packages/react-native-nitro-sqlite/src/NitroSQLiteError.ts +++ b/packages/react-native-nitro-sqlite/src/NitroSQLiteError.ts @@ -1,21 +1,49 @@ const NITRO_SQLITE_ERROR_NAME = 'NitroSQLiteError' as const const NATIVE_EXCEPTION_PREFIX = '[NativeNitroSQLiteException][' -/** Category attached to an error thrown by NitroSQLite's native implementation. */ +/** + * Native error categories exposed by {@linkcode NitroSQLiteError.type}. + * Compare against these constants when handling a specific failure. The values + * are categories, not SQLite's numeric result codes. Import them from + * `react-native-nitro-sqlite/errors` to avoid initializing the native module. + * @see {@linkcode NitroSQLiteError} + */ +export const NitroSQLiteExceptionType = { + /** Native failure without a more specific category. */ + UnknownError: 'UnknownError', + /** Open failure, including invalid paths and an already-open default connection. */ + DatabaseCannotBeOpened: 'DatabaseCannotBeOpened', + /** An encryption key was supplied to a build without SQLite SEE. */ + EncryptionNotEnabled: 'EncryptionNotEnabled', + /** Invalid encryption key, unreadable encrypted database, or existing plaintext file. */ + DatabaseCannotBeDecrypted: 'DatabaseCannotBeDecrypted', + /** Category for a database connection that is not open. */ + DatabaseNotOpen: 'DatabaseNotOpen', + /** Database attachment failure or an unopened native connection. */ + UnableToAttachToDatabase: 'UnableToAttachToDatabase', + /** SQL execution or parameter binding failure, including a missing database file. */ + SqlExecutionError: 'SqlExecutionError', + /** A SQL file could not be read or executed. */ + CouldNotLoadFile: 'CouldNotLoadFile', + /** An empty batch was submitted. */ + NoBatchCommandsProvided: 'NoBatchCommandsProvided', +} as const + +/** + * Category attached to {@linkcode NitroSQLiteError.type} for a recognized native + * failure. The corresponding {@linkcode NitroSQLiteExceptionType} object exposes + * the same values at runtime. + * @see {@linkcode NitroSQLiteError.type} + */ export type NitroSQLiteExceptionType = - | 'UnknownError' - | 'DatabaseCannotBeOpened' - | 'EncryptionNotEnabled' - | 'DatabaseCannotBeDecrypted' - | 'DatabaseNotOpen' - | 'UnableToAttachToDatabase' - | 'SqlExecutionError' - | 'CouldNotLoadFile' - | 'NoBatchCommandsProvided' + (typeof NitroSQLiteExceptionType)[keyof typeof NitroSQLiteExceptionType] /** Error thrown by managed NitroSQLite operations. Native errors are wrapped with this class. */ export default class NitroSQLiteError extends Error { - /** Native exception category, if this error originated in NitroSQLite's C++ code. */ + /** + * Recognized native category from {@linkcode NitroSQLiteExceptionType}, or + * `undefined` for JavaScript failures and unrecognized native categories. + */ readonly type: NitroSQLiteExceptionType | undefined /** Create an error with a message and optional cause. */ @@ -71,18 +99,5 @@ function getNativeExceptionType( if (typeEnd === -1) return undefined const type = message.slice(typeStart, typeEnd) - switch (type) { - case 'UnknownError': - case 'DatabaseCannotBeOpened': - case 'EncryptionNotEnabled': - case 'DatabaseCannotBeDecrypted': - case 'DatabaseNotOpen': - case 'UnableToAttachToDatabase': - case 'SqlExecutionError': - case 'CouldNotLoadFile': - case 'NoBatchCommandsProvided': - return type - default: - return undefined - } + return Object.values(NitroSQLiteExceptionType).find((value) => value === type) } diff --git a/packages/react-native-nitro-sqlite/src/__tests__/NitroSQLiteError.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/NitroSQLiteError.test.ts index a6234cda..82d41367 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/NitroSQLiteError.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/NitroSQLiteError.test.ts @@ -1,4 +1,8 @@ -import NitroSQLiteError from '../NitroSQLiteError' +jest.mock('react-native-nitro-modules', () => { + throw new Error('The errors entry point must not load the native runtime') +}) + +import { NitroSQLiteError, NitroSQLiteExceptionType } from '../errors' describe('NitroSQLiteError', () => { it('keeps its name, cause, and prototype', () => { @@ -12,6 +16,16 @@ describe('NitroSQLiteError', () => { expect(error.type).toBeUndefined() }) + it('supports comparing a native failure against the exported category', () => { + const error = NitroSQLiteError.fromError( + '[NativeNitroSQLiteException][DatabaseCannotBeOpened] failed', + ) + const category: NitroSQLiteExceptionType = + NitroSQLiteExceptionType.DatabaseCannotBeOpened + + expect(error.type).toBe(category) + }) + it('returns an existing NitroSQLiteError unchanged', () => { const error = new NitroSQLiteError('existing') expect(NitroSQLiteError.fromError(error)).toBe(error) diff --git a/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts index c636c8f3..1cfe6048 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts @@ -7,12 +7,26 @@ jest.mock('react-native-nitro-modules', () => ({ })) import { NitroModules } from 'react-native-nitro-modules' -import { NitroSQLite, open } from '../index' +import { + NitroSQLite, + NitroSQLiteError, + NitroSQLiteExceptionType, + open, +} from '../index' +import { + NitroSQLiteError as ErrorFromErrors, + NitroSQLiteExceptionType as ExceptionTypeFromErrors, +} from '../errors' import { HybridNitroSQLite } from '../nitro' import { init as androidInit } from '../OnLoad.android' import { init as iosInit } from '../OnLoad' describe('entrypoints', () => { + it('shares the error class and category constants with the errors entry point', () => { + expect(NitroSQLiteError).toBe(ErrorFromErrors) + expect(NitroSQLiteExceptionType).toBe(ExceptionTypeFromErrors) + }) + it('creates the native object and exposes its native and managed methods', () => { expect(NitroModules.createHybridObject).toHaveBeenCalledWith('NitroSQLite') expect(NitroSQLite.native).toBe(HybridNitroSQLite) diff --git a/packages/react-native-nitro-sqlite/src/errors.ts b/packages/react-native-nitro-sqlite/src/errors.ts new file mode 100644 index 00000000..5ed13cf8 --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/errors.ts @@ -0,0 +1,4 @@ +export { + default as NitroSQLiteError, + NitroSQLiteExceptionType, +} from './NitroSQLiteError' diff --git a/packages/react-native-nitro-sqlite/src/index.ts b/packages/react-native-nitro-sqlite/src/index.ts index 1985c928..add9fd39 100644 --- a/packages/react-native-nitro-sqlite/src/index.ts +++ b/packages/react-native-nitro-sqlite/src/index.ts @@ -26,8 +26,7 @@ export const NitroSQLite = { } export { open } from './operations/session' -export { default as NitroSQLiteError } from './NitroSQLiteError' -export type { NitroSQLiteExceptionType } from './NitroSQLiteError' +export { NitroSQLiteError, NitroSQLiteExceptionType } from './errors' export type { DatabaseQueueKey } from './DatabaseQueue' export type { NitroSQLite as NitroSQLiteNative } from './specs/NitroSQLite.nitro' export type { NitroSQLitePreparedStatement } from './specs/NitroSQLitePreparedStatement.nitro' diff --git a/patches/@react-native-harness+cli+1.5.0.patch b/patches/@react-native-harness+cli+1.5.0.patch new file mode 100644 index 00000000..c7b1e055 --- /dev/null +++ b/patches/@react-native-harness+cli+1.5.0.patch @@ -0,0 +1,66 @@ +diff --git a/node_modules/@react-native-harness/cli/dist/ci/plan-metro-save.js b/node_modules/@react-native-harness/cli/dist/ci/plan-metro-save.js +index 55a6fed..d20c85c 100644 +--- a/node_modules/@react-native-harness/cli/dist/ci/plan-metro-save.js ++++ b/node_modules/@react-native-harness/cli/dist/ci/plan-metro-save.js +@@ -9,7 +9,10 @@ const parseSnapshot = (raw) => { + return {}; + } + try { +- return JSON.parse(raw); ++ const json = raw.trimStart().startsWith('{') ++ ? raw ++ : getFs().readFileSync(raw, 'utf8'); ++ return JSON.parse(json); + } + catch (error) { + console.warn('Failed to parse the metroSnapshot input. Treating the pre-run cache state as empty.', error); +diff --git a/node_modules/@react-native-harness/cli/dist/ci/snapshot-metro.js b/node_modules/@react-native-harness/cli/dist/ci/snapshot-metro.js +index 24965c3..6786315 100644 +--- a/node_modules/@react-native-harness/cli/dist/ci/snapshot-metro.js ++++ b/node_modules/@react-native-harness/cli/dist/ci/snapshot-metro.js +@@ -36,7 +36,11 @@ export const runSnapshotMetro = async () => { + else { + console.info('Metro cache: no existing cache entry matched — starting cold.'); + } +- const output = `metroSnapshot=${JSON.stringify(snapshotAfterRestore)}\n`; ++ // Keep large snapshots out of step environment variables. This file stays ++ // beside GITHUB_OUTPUT for the later cache-save step in the same job. ++ const snapshotPath = `${githubOutput}.metro-snapshot.json`; ++ getFs().writeFileSync(snapshotPath, JSON.stringify(snapshotAfterRestore)); ++ const output = `metroSnapshot=${snapshotPath}\n`; + getFs().appendFileSync(githubOutput, output); + } + catch (error) { +diff --git a/node_modules/@react-native-harness/cli/src/ci/plan-metro-save.ts b/node_modules/@react-native-harness/cli/src/ci/plan-metro-save.ts +index 959420f..aa28677 100644 +--- a/node_modules/@react-native-harness/cli/src/ci/plan-metro-save.ts ++++ b/node_modules/@react-native-harness/cli/src/ci/plan-metro-save.ts +@@ -16,7 +16,10 @@ const parseSnapshot = (raw: string | undefined): CacheSnapshot => { + } + + try { +- return JSON.parse(raw) as CacheSnapshot; ++ const json = raw.trimStart().startsWith('{') ++ ? raw ++ : getFs().readFileSync(raw, 'utf8'); ++ return JSON.parse(json) as CacheSnapshot; + } catch (error) { + console.warn( + 'Failed to parse the metroSnapshot input. Treating the pre-run cache state as empty.', +diff --git a/node_modules/@react-native-harness/cli/src/ci/snapshot-metro.ts b/node_modules/@react-native-harness/cli/src/ci/snapshot-metro.ts +index b24379f..1faae3f 100644 +--- a/node_modules/@react-native-harness/cli/src/ci/snapshot-metro.ts ++++ b/node_modules/@react-native-harness/cli/src/ci/snapshot-metro.ts +@@ -50,7 +50,11 @@ export const runSnapshotMetro = async (): Promise => { + ); + } + +- const output = `metroSnapshot=${JSON.stringify(snapshotAfterRestore)}\n`; ++ // Keep large snapshots out of step environment variables. This file stays ++ // beside GITHUB_OUTPUT for the later cache-save step in the same job. ++ const snapshotPath = `${githubOutput}.metro-snapshot.json`; ++ getFs().writeFileSync(snapshotPath, JSON.stringify(snapshotAfterRestore)); ++ const output = `metroSnapshot=${snapshotPath}\n`; + + getFs().appendFileSync(githubOutput, output); + } catch (error) { diff --git a/scripts/ci-harness-cache.test.ts b/scripts/ci-harness-cache.test.ts new file mode 100644 index 00000000..6df41e34 --- /dev/null +++ b/scripts/ci-harness-cache.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import type { CacheSnapshot, SavePlan } from '@react-native-harness/cache' + +const cli = fileURLToPath(import.meta.resolve('@react-native-harness/cli')) +const nodeModules = fileURLToPath(new URL('../node_modules', import.meta.url)) + +test('large Metro snapshots pass between steps without exceeding Linux environment limits', () => { + const root = cacheFixture(1400) + try { + const snapshotOutput = join(root, 'snapshot-output') + runCi(root, 'snapshot-metro', { GITHUB_OUTPUT: snapshotOutput }) + const snapshotPath = readOutput(snapshotOutput, 'metroSnapshot') + assert.ok(Buffer.byteLength(snapshotPath) < 4096) + const snapshot = readFileSync(snapshotPath, 'utf8') + assert.ok(Buffer.byteLength(snapshot) > 128 * 1024) + assert.equal((JSON.parse(snapshot) as CacheSnapshot).metro?.length, 1400) + + const env = { INPUT_METRO_SNAPSHOT: snapshotPath } + const unchanged = planSave(root, env) + assert.equal(unchanged.shouldSave, 'false') + assert.match(unchanged.stdout, /Metro cache: unchanged/) + assert.deepEqual(unchanged.delta, { + addedEntries: 0, + removedEntries: 0, + addedBytes: 0, + }) + + writeFileSync(join(root, '.harness/cache/metro/new-transform'), 'new') + const changed = planSave(root, env) + assert.equal(changed.shouldSave, 'true') + assert.deepEqual(changed.delta, { + addedEntries: 1, + removedEntries: 0, + addedBytes: 3, + }) + + assert.equal( + planSave(root, { + ...env, + INPUT_CACHESAVEPOLICY: 'default-branch', + IS_DEFAULT_BRANCH: 'false', + }).shouldSave, + 'false', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('the cache planner still accepts inline JSON snapshots', () => { + const root = cacheFixture(2) + try { + const output = join(root, 'snapshot-output') + runCi(root, 'snapshot-metro', { GITHUB_OUTPUT: output }) + const snapshot = readFileSync(readOutput(output, 'metroSnapshot'), 'utf8') + assert.equal( + planSave(root, { INPUT_METRO_SNAPSHOT: snapshot }).shouldSave, + 'false', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +function planSave(root: string, env: Record) { + const output = join(root, 'save-output') + writeFileSync(output, '') + const stdout = runCi(root, 'plan-metro-save', { + INPUT_CACHESAVEPOLICY: 'always', + ...env, + GITHUB_OUTPUT: output, + }) + const plan = JSON.parse( + readFileSync(join(root, '.harness/cache/keys.json'), 'utf8'), + ) as SavePlan + assert.ok(plan.domains.metro) + return { + stdout, + shouldSave: readOutput(output, 'metroShouldSave'), + delta: plan.domains.metro.delta, + } +} + +function runCi(root: string, command: string, env: Record) { + const result = spawnSync(process.execPath, [cli, 'ci', command], { + cwd: root, + env: { ...process.env, INPUT_PROJECTROOT: root, ...env }, + encoding: 'utf8', + timeout: 30000, + }) + assert.equal( + result.status, + 0, + `${result.error ?? ''}\n${result.stdout}\n${result.stderr}`, + ) + assert.doesNotMatch(result.stderr, /Failed to parse the metroSnapshot input/) + return result.stdout +} + +function readOutput(path: string, key: string): string { + const line = readFileSync(path, 'utf8') + .split('\n') + .find((value) => value.startsWith(`${key}=`)) + assert.ok(line, `Missing ${key} output`) + return line.slice(key.length + 1) +} + +function cacheFixture(entries: number): string { + const root = mkdtempSync(join(tmpdir(), 'nitro harness cache ')) + symlinkSync(nodeModules, join(root, 'node_modules'), 'dir') + writeFileSync( + join(root, 'rn-harness.config.json'), + JSON.stringify({ + entryPoint: 'index.js', + appRegistryComponentName: 'CacheTest', + runners: [ + { + name: 'android', + platformId: 'android', + runner: 'unused', + config: {}, + }, + ], + }), + ) + for (const domain of ['metro', 'metro-file-map']) { + mkdirSync(join(root, '.harness/cache', domain), { recursive: true }) + } + for (let index = 0; index < entries; index += 1) { + const domain = index % 2 === 0 ? 'metro' : 'metro-file-map' + writeFileSync( + join(root, '.harness/cache', domain, `${index}-${'a'.repeat(80)}`), + 'cache', + ) + } + return root +} diff --git a/scripts/ci.test.ts b/scripts/ci.test.ts index d222afa6..43a8007f 100644 --- a/scripts/ci.test.ts +++ b/scripts/ci.test.ts @@ -84,6 +84,16 @@ test('dependency lockfile changes retain both mobile and desktop coverage', () = assert.equal(checks.macos, true) }) +test('Harness patches retain both mobile and desktop coverage', () => { + const checks = selectChecks( + ['patches/@react-native-harness+cli+1.5.0.patch'], + false, + ) + assert.equal(checks.android, true) + assert.equal(checks.ios, true) + assert.equal(checks.macos, true) +}) + test('Ruby dependency changes retain both Apple platforms', () => { const checks = selectChecks(['example/Gemfile'], false) assert.equal(checks.ios, true)