diff --git a/.github/workflows/lint-typescript.yml b/.github/workflows/lint-typescript.yml index 8ca2b1a0..69e2eeb2 100644 --- a/.github/workflows/lint-typescript.yml +++ b/.github/workflows/lint-typescript.yml @@ -51,7 +51,7 @@ jobs: REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} lint: - name: Lint TypeScript (eslint, prettier) + name: Lint and test TypeScript runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 @@ -69,3 +69,11 @@ jobs: - name: Verify no files have changed after auto-fix run: git diff --exit-code HEAD -- . ':(exclude)bun.lock' + + - name: Test core JavaScript wrappers + working-directory: packages/react-native-nitro-sqlite + run: ../../node_modules/.bin/jest --coverage --runInBand + + - name: Test sqlite-vec helpers + working-directory: packages/react-native-nitro-sqlite-vec + run: ../../node_modules/.bin/jest --coverage --runInBand diff --git a/.gitignore b/.gitignore index 83c888c2..9287a338 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ example/vendor/bundle # node.js # node_modules/ +coverage/ npm-debug.log # BUCK diff --git a/example/tests/unit/specs/operations/executeBatch.spec.ts b/example/tests/unit/specs/operations/executeBatch.spec.ts index 9ceca983..733c7333 100644 --- a/example/tests/unit/specs/operations/executeBatch.spec.ts +++ b/example/tests/unit/specs/operations/executeBatch.spec.ts @@ -1,5 +1,8 @@ import { chance, expect } from '@tests/unit/common' -import type { BatchQueryCommand } from 'react-native-nitro-sqlite' +import { + NitroSQLiteError, + type BatchQueryCommand, +} from 'react-native-nitro-sqlite' import { describe, it } from '@tests/TestApi' import { testDb } from '@tests/db' @@ -94,5 +97,125 @@ export default function registerExecuteBatchUnitTests() { }, ]) }) + + it('expands nested parameters into separate statements', () => { + const result = testDb.executeBatch([ + { + query: + 'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)', + params: [ + [1, 'first', 10, 100], + [2, 'second', 20, 200], + ], + }, + ]) + + expect(result.rowsAffected).toBe(2) + expect( + testDb.execute('SELECT id, name FROM User ORDER BY id').results, + ).toEqual([ + { id: 1, name: 'first' }, + { id: 2, name: 'second' }, + ]) + }) + + it('expands nested parameters in asynchronous batches', async () => { + const result = await testDb.executeBatchAsync([ + { + query: + 'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)', + params: [ + [1, 'first', 10, 100], + [2, 'second', 20, 200], + ], + }, + ]) + + expect(result.rowsAffected).toBe(2) + expect( + testDb.execute('SELECT id, name FROM User ORDER BY id').results, + ).toEqual([ + { id: 1, name: 'first' }, + { id: 2, name: 'second' }, + ]) + }) + + it('rolls back every statement when a synchronous batch fails', () => { + let batchError: unknown + try { + testDb.executeBatch([ + { + query: + 'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)', + params: [1, 'first', 10, 100], + }, + { query: 'INSERT INTO MissingTable (value) VALUES (1)' }, + ]) + } catch (error) { + batchError = error + } + + expect(batchError).toBeInstanceOf(NitroSQLiteError) + expect(testDb.execute('SELECT id FROM User').results).toEqual([]) + expect( + testDb.executeBatch([ + { + query: + "INSERT INTO User (id, name, age, networth) VALUES (2, 'later', 20, 200)", + }, + ]).rowsAffected, + ).toBe(1) + }) + + it('rolls back every statement when an asynchronous batch fails', async () => { + let batchError: unknown + try { + await testDb.executeBatchAsync([ + { + query: + 'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)', + params: [1, 'first', 10, 100], + }, + { query: 'INSERT INTO MissingTable (value) VALUES (1)' }, + ]) + } catch (error) { + batchError = error + } + + expect(batchError).toBeInstanceOf(NitroSQLiteError) + expect(testDb.execute('SELECT id FROM User').results).toEqual([]) + expect( + ( + await testDb.executeBatchAsync([ + { + query: + "INSERT INTO User (id, name, age, networth) VALUES (2, 'later', 20, 200)", + }, + ]) + ).rowsAffected, + ).toBe(1) + }) + + it('rejects empty batches without leaving a transaction open', async () => { + let syncError: unknown + try { + testDb.executeBatch([]) + } catch (error) { + syncError = error + } + + let asyncError: unknown + try { + await testDb.executeBatchAsync([]) + } catch (error) { + asyncError = error + } + + expect(syncError).toBeInstanceOf(NitroSQLiteError) + expect(asyncError).toBeInstanceOf(NitroSQLiteError) + expect(testDb.execute('SELECT 1 AS value').results).toEqual([ + { value: 1 }, + ]) + }) }) } diff --git a/example/tests/unit/specs/operations/transaction.spec.ts b/example/tests/unit/specs/operations/transaction.spec.ts index f835f786..eeafbe13 100644 --- a/example/tests/unit/specs/operations/transaction.spec.ts +++ b/example/tests/unit/specs/operations/transaction.spec.ts @@ -8,6 +8,7 @@ import { } from '@tests/unit/common' import { describe, it } from '@tests/TestApi' import type { User } from '@/model/User' +import { NitroSQLiteError } from 'react-native-nitro-sqlite' import { testDb } from '@tests/db' export default function registerTransactionUnitTests() { @@ -145,11 +146,21 @@ export default function registerTransactionUnitTests() { tx.commit() + let queryError: unknown try { tx.execute('SELECT * FROM "User"') } catch (e) { - expect(e).not.toBe(undefined) + queryError = e } + expect(queryError).toBeInstanceOf(NitroSQLiteError) + + let asyncQueryError: unknown + try { + await tx.executeAsync('SELECT * FROM "User"') + } catch (e) { + asyncQueryError = e + } + expect(asyncQueryError).toBeInstanceOf(NitroSQLiteError) }) const res = testDb.execute('SELECT * FROM User') diff --git a/packages/react-native-nitro-sqlite-vec/babel.config.js b/packages/react-native-nitro-sqlite-vec/babel.config.js new file mode 100644 index 00000000..3e0218e6 --- /dev/null +++ b/packages/react-native-nitro-sqlite-vec/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], +} diff --git a/packages/react-native-nitro-sqlite-vec/package.json b/packages/react-native-nitro-sqlite-vec/package.json index 63dae783..b5fbd730 100644 --- a/packages/react-native-nitro-sqlite-vec/package.json +++ b/packages/react-native-nitro-sqlite-vec/package.json @@ -8,12 +8,14 @@ "types": "./src/index", "files": [ "src", + "!**/__tests__", "cpp", "RNNitroSqliteVec.podspec", "react-native.config.js", "README.md" ], "scripts": { + "test": "jest", "typecheck": "tsc --build", "lint": "eslint \"**/*.{js,ts,tsx}\" --fix", "release": "release-it", @@ -45,6 +47,20 @@ "react-native-nitro-sqlite": "9.8.0", "typescript": "^5.8.3" }, + "jest": { + "preset": "@react-native/jest-preset", + "watchman": false, + "testMatch": ["/src/__tests__/**/*.test.ts"], + "collectCoverageFrom": ["src/index.ts"], + "coverageThreshold": { + "global": { + "branches": 100, + "functions": 100, + "lines": 100, + "statements": 100 + } + } + }, "release-it": { "npm": { "publish": true, diff --git a/packages/react-native-nitro-sqlite-vec/src/__tests__/index.test.ts b/packages/react-native-nitro-sqlite-vec/src/__tests__/index.test.ts new file mode 100644 index 00000000..70887bb3 --- /dev/null +++ b/packages/react-native-nitro-sqlite-vec/src/__tests__/index.test.ts @@ -0,0 +1,72 @@ +import type { NitroSQLiteConnection } from 'react-native-nitro-sqlite' +import { + createVectorTable, + isVecAvailable, + knnSearch, + vecVersion, +} from '../index' + +const execute = jest.fn() +const db = { execute } as unknown as NitroSQLiteConnection + +beforeEach(() => execute.mockReset()) + +describe('sqlite-vec helpers', () => { + it('returns the linked version and reports availability', () => { + execute.mockReturnValue({ rows: { _array: [{ value: 'v0.1.9' }] } }) + + expect(vecVersion(db)).toBe('v0.1.9') + expect(isVecAvailable(db)).toBe(true) + expect(execute).toHaveBeenCalledWith('SELECT vec_version() AS value') + }) + + it('reports sqlite-vec as unavailable when the native query fails', () => { + execute.mockImplementation(() => { + throw new Error('no such function: vec_version') + }) + + expect(isVecAvailable(db)).toBe(false) + }) + + it('creates a vector table with defaults and optional storage settings', () => { + createVectorTable(db, 'documents', { dimensions: 3 }) + createVectorTable(db, 'binary_documents', { + dimensions: 8, + type: 'bit', + distanceMetric: 'cosine', + column: 'features', + }) + + expect(execute).toHaveBeenNthCalledWith( + 1, + 'CREATE VIRTUAL TABLE IF NOT EXISTS documents USING vec0(embedding float[3]);', + ) + expect(execute).toHaveBeenNthCalledWith( + 2, + 'CREATE VIRTUAL TABLE IF NOT EXISTS binary_documents USING vec0(features bit[8] distance_metric=cosine);', + ) + }) + + it('serializes array queries and returns KNN matches', () => { + const matches = [{ rowid: 4, distance: 0.25 }] + execute.mockReturnValue({ rows: { _array: matches } }) + + expect(knnSearch(db, 'documents', [0.1, 0.2], 5)).toBe(matches) + expect(execute).toHaveBeenCalledWith( + 'SELECT rowid, distance FROM documents WHERE embedding MATCH ? AND k = ? ORDER BY distance', + ['[0.1,0.2]', 5], + ) + }) + + it('passes JSON queries through and supports a custom column', () => { + execute.mockReturnValue({ rows: undefined }) + + expect( + knnSearch(db, 'documents', '[1,2]', 2, { column: 'features' }), + ).toEqual([]) + expect(execute).toHaveBeenCalledWith( + 'SELECT rowid, distance FROM documents WHERE features MATCH ? AND k = ? ORDER BY distance', + ['[1,2]', 2], + ) + }) +}) diff --git a/packages/react-native-nitro-sqlite-vec/tsconfig.json b/packages/react-native-nitro-sqlite-vec/tsconfig.json index a3e0c454..c89bef6f 100644 --- a/packages/react-native-nitro-sqlite-vec/tsconfig.json +++ b/packages/react-native-nitro-sqlite-vec/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src"], "references": [{ "path": "../react-native-nitro-sqlite" }], "compilerOptions": { + "types": ["node", "jest"], "rootDir": "src", "paths": { "react-native-nitro-sqlite": ["../react-native-nitro-sqlite/src/index.ts"] diff --git a/packages/react-native-nitro-sqlite/babel.config.js b/packages/react-native-nitro-sqlite/babel.config.js index a4b40953..2a6fded9 100644 --- a/packages/react-native-nitro-sqlite/babel.config.js +++ b/packages/react-native-nitro-sqlite/babel.config.js @@ -3,6 +3,6 @@ module.exports = { plugins: [ 'babel-plugin-transform-typescript-metadata', ['@babel/plugin-proposal-decorators', { legacy: true }], - ['@babel/plugin-proposal-class-properties', { loose: true }], + ['@babel/plugin-transform-class-properties', { loose: true }], ], } diff --git a/packages/react-native-nitro-sqlite/package.json b/packages/react-native-nitro-sqlite/package.json index 5ac231fc..f725d147 100644 --- a/packages/react-native-nitro-sqlite/package.json +++ b/packages/react-native-nitro-sqlite/package.json @@ -87,6 +87,23 @@ }, "jest": { "preset": "@react-native/jest-preset", + "watchman": false, + "testMatch": ["/src/__tests__/**/*.test.ts"], + "collectCoverageFrom": [ + "src/**/*.ts", + "!src/**/__tests__/**", + "!src/**/__mocks__/**", + "!src/specs/**", + "!src/types.ts" + ], + "coverageThreshold": { + "global": { + "branches": 100, + "functions": 100, + "lines": 100, + "statements": 100 + } + }, "modulePathIgnorePatterns": [ "/example/node_modules", "/lib/" diff --git a/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts b/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts new file mode 100644 index 00000000..717519c9 --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts @@ -0,0 +1,13 @@ +export const HybridNitroSQLite = { + open: jest.fn(), + close: jest.fn(), + drop: jest.fn(), + attach: jest.fn(), + detach: jest.fn(), + execute: jest.fn(), + executeAsync: jest.fn(), + executeBatch: jest.fn(), + executeBatchAsync: jest.fn(), + loadFile: jest.fn(), + loadFileAsync: jest.fn(), +} diff --git a/packages/react-native-nitro-sqlite/src/__tests__/DatabaseQueue.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/DatabaseQueue.test.ts new file mode 100644 index 00000000..fcf15fe1 --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/__tests__/DatabaseQueue.test.ts @@ -0,0 +1,99 @@ +import { + closeDatabaseQueue, + getDatabaseQueue, + isDatabaseOpen, + openDatabaseQueue, + queueOperationAsync, + startOperationSync, + throwIfDatabaseIsNotOpen, +} from '../DatabaseQueue' +import NitroSQLiteError from '../NitroSQLiteError' +import { deferred } from './testUtils' + +const dbName = 'queue-test' + +afterEach(() => { + if (isDatabaseOpen(dbName)) closeDatabaseQueue(dbName) +}) + +describe('DatabaseQueue', () => { + it('tracks open connections and rejects duplicate or missing connections', () => { + expect(isDatabaseOpen(dbName)).toBe(false) + expect(() => throwIfDatabaseIsNotOpen(dbName)).toThrow(NitroSQLiteError) + expect(() => getDatabaseQueue(dbName)).toThrow('not open') + expect(() => closeDatabaseQueue(dbName)).toThrow('not open') + + openDatabaseQueue(dbName) + expect(isDatabaseOpen(dbName)).toBe(true) + expect(getDatabaseQueue(dbName)).toEqual({ queue: [], inProgress: false }) + expect(() => openDatabaseQueue(dbName)).toThrow('already open') + + closeDatabaseQueue(dbName) + expect(isDatabaseOpen(dbName)).toBe(false) + }) + + it('runs synchronous work and releases the queue after a throw', () => { + openDatabaseQueue(dbName) + expect(startOperationSync(dbName, () => 42)).toBe(42) + expect(() => + startOperationSync(dbName, () => { + throw new Error('callback failed') + }), + ).toThrow('callback failed') + expect(getDatabaseQueue(dbName).inProgress).toBe(false) + expect(startOperationSync(dbName, () => 'available')).toBe('available') + }) + + it('rejects nested synchronous operations and closing a busy queue', () => { + openDatabaseQueue(dbName) + + startOperationSync(dbName, () => { + expect(() => startOperationSync(dbName, () => 1)).toThrow('busy') + expect(() => closeDatabaseQueue(dbName)).toThrow('busy') + }) + }) + + it('runs async operations in order, including after a rejection', async () => { + openDatabaseQueue(dbName) + const first = deferred() + const started: number[] = [] + const one = queueOperationAsync(dbName, async () => { + started.push(1) + await first.promise + throw new Error('first failed') + }) + const two = queueOperationAsync(dbName, async () => { + started.push(2) + return 2 + }) + const three = queueOperationAsync(dbName, async () => { + started.push(3) + return 3 + }) + + expect(getDatabaseQueue(dbName).queue).toHaveLength(2) + expect(() => closeDatabaseQueue(dbName)).toThrow('busy') + expect(() => startOperationSync(dbName, () => 0)).toThrow('busy') + first.resolve() + + await expect(one).rejects.toThrow('first failed') + await expect(two).resolves.toBe(2) + await expect(three).resolves.toBe(3) + expect(started).toEqual([1, 2, 3]) + expect(getDatabaseQueue(dbName)).toEqual({ queue: [], inProgress: false }) + }) + + it('keeps queues for different databases independent', async () => { + openDatabaseQueue(dbName) + openDatabaseQueue('other') + try { + const pending = deferred() + const first = queueOperationAsync(dbName, () => pending.promise) + await expect(queueOperationAsync('other', async () => 7)).resolves.toBe(7) + pending.resolve() + await first + } finally { + closeDatabaseQueue('other') + } + }) +}) diff --git a/packages/react-native-nitro-sqlite/src/__tests__/NitroSQLiteError.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/NitroSQLiteError.test.ts new file mode 100644 index 00000000..6e45de09 --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/__tests__/NitroSQLiteError.test.ts @@ -0,0 +1,45 @@ +import NitroSQLiteError from '../NitroSQLiteError' + +describe('NitroSQLiteError', () => { + it('keeps its name, cause, and prototype', () => { + const cause = new Error('underlying') + const error = new NitroSQLiteError('query failed', { cause }) + + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(NitroSQLiteError) + expect(error.name).toBe('NitroSQLiteError') + expect(error.cause).toBe(cause) + }) + + it('returns an existing NitroSQLiteError unchanged', () => { + const error = new NitroSQLiteError('existing') + expect(NitroSQLiteError.fromError(error)).toBe(error) + }) + + it('converts an Error while preserving its cause and stack', () => { + const cause = new Error('root') + const original = new Error('native failure', { cause }) + original.stack = 'original stack' + + const converted = NitroSQLiteError.fromError(original) + + expect(converted).toBeInstanceOf(NitroSQLiteError) + expect(converted.message).toBe('native failure') + expect(converted.cause).toBe(cause) + expect(converted.stack).toBe('original stack') + }) + + it('converts an Error without a stack', () => { + const original = new Error('no stack') + original.stack = undefined + expect(NitroSQLiteError.fromError(original).message).toBe('no stack') + }) + + it('converts strings and retains unknown values as causes', () => { + expect(NitroSQLiteError.fromError('failure').message).toBe('failure') + const value = { code: 42 } + const converted = NitroSQLiteError.fromError(value) + expect(converted.message).toBe('Unknown error occurred') + expect(converted.cause).toBe(value) + }) +}) diff --git a/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts new file mode 100644 index 00000000..c636c8f3 --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts @@ -0,0 +1,35 @@ +jest.mock('react-native-nitro-modules', () => ({ + NitroModules: { + createHybridObject: jest.fn((name: string) => + name === 'NitroSQLiteOnLoad' ? { init: jest.fn() } : { open: jest.fn() }, + ), + }, +})) + +import { NitroModules } from 'react-native-nitro-modules' +import { NitroSQLite, open } from '../index' +import { HybridNitroSQLite } from '../nitro' +import { init as androidInit } from '../OnLoad.android' +import { init as iosInit } from '../OnLoad' + +describe('entrypoints', () => { + it('creates the native object and exposes its native and managed methods', () => { + expect(NitroModules.createHybridObject).toHaveBeenCalledWith('NitroSQLite') + expect(NitroSQLite.native).toBe(HybridNitroSQLite) + expect(NitroSQLite.open).toBe(open) + expect(NitroSQLite.open).not.toBe(HybridNitroSQLite.open) + expect(iosInit()).toBeUndefined() + }) + + it('initializes the Android native loader', () => { + androidInit() + + expect(NitroModules.createHybridObject).toHaveBeenCalledWith( + 'NitroSQLiteOnLoad', + ) + const onLoad = jest + .mocked(NitroModules.createHybridObject) + .mock.results.find((result) => 'init' in result.value)?.value + expect(onLoad?.init).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react-native-nitro-sqlite/src/__tests__/execute.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/execute.test.ts new file mode 100644 index 00000000..6830858c --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/__tests__/execute.test.ts @@ -0,0 +1,123 @@ +jest.mock('../nitro') + +import { HybridNitroSQLite } from '../nitro' +import { closeDatabaseQueue, openDatabaseQueue } from '../DatabaseQueue' +import NitroSQLiteError from '../NitroSQLiteError' +import { + buildJSQueryResult, + execute, + executeAsync, + executeAsyncManaged, + executeManaged, +} from '../operations/execute' +import { nativeResult } from './testUtils' + +const dbName = 'execute-test' +const query = 'SELECT ? AS value' +const params = [7] + +beforeEach(() => jest.clearAllMocks()) + +afterEach(() => { + try { + closeDatabaseQueue(dbName) + } catch (error) { + if ( + !(error instanceof NitroSQLiteError) || + !error.message.includes('not open') + ) { + throw error + } + } +}) + +describe('execute', () => { + it('passes unmanaged synchronous queries to native and builds row access', () => { + const rows = [{ value: 7 }, { value: null }] + const native = nativeResult(rows) + jest.mocked(HybridNitroSQLite.execute).mockReturnValue(native) + + const result = execute(dbName, query, params) + + expect(HybridNitroSQLite.execute).toHaveBeenCalledWith( + dbName, + query, + params, + ) + expect(result).toBe(native) + expect(result.rows._array).toBe(rows) + expect(result.rows.length).toBe(2) + expect(result.rows.item(0)).toBe(rows[0]) + expect(result.rows.item(2)).toBeUndefined() + }) + + it('reads native results only once and supports empty results', () => { + const native = nativeResult() + const read = jest.fn(() => []) + Object.defineProperty(native, 'results', { get: read }) + + const result = buildJSQueryResult(native) + + expect(read).toHaveBeenCalledTimes(1) + expect(result.rows).toEqual({ + _array: [], + length: 0, + item: expect.any(Function), + }) + expect(result.rows.item(0)).toBeUndefined() + }) + + it('routes managed synchronous queries through the queue', () => { + openDatabaseQueue(dbName) + jest.mocked(HybridNitroSQLite.execute).mockReturnValue(nativeResult()) + + expect(executeManaged(dbName, query).rows.length).toBe(0) + expect(execute(dbName, query).rows.length).toBe(0) + expect(HybridNitroSQLite.execute).toHaveBeenCalledTimes(2) + }) + + it('converts native synchronous errors', () => { + jest.mocked(HybridNitroSQLite.execute).mockImplementation(() => { + throw new Error('SQL failed') + }) + + expect(() => execute(dbName, query)).toThrow(NitroSQLiteError) + expect(() => execute(dbName, query)).toThrow('SQL failed') + }) + + it('runs unmanaged and managed asynchronous queries', async () => { + jest + .mocked(HybridNitroSQLite.executeAsync) + .mockResolvedValue(nativeResult([{ value: 7 }])) + + expect((await executeAsync(dbName, query, params)).rows.item(0)).toEqual({ + value: 7, + }) + openDatabaseQueue(dbName) + expect((await executeAsyncManaged(dbName, query)).rows.length).toBe(1) + expect((await executeAsync(dbName, query)).rows.length).toBe(1) + expect(HybridNitroSQLite.executeAsync).toHaveBeenNthCalledWith( + 1, + dbName, + query, + params, + ) + expect(HybridNitroSQLite.executeAsync).toHaveBeenCalledTimes(3) + }) + + it('converts native asynchronous errors and releases a managed queue', async () => { + openDatabaseQueue(dbName) + jest + .mocked(HybridNitroSQLite.executeAsync) + .mockRejectedValueOnce('async failed') + .mockResolvedValueOnce(nativeResult()) + + await expect(executeAsync(dbName, query)).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'async failed', + }) + await expect(executeAsync(dbName, query)).resolves.toMatchObject({ + rows: { length: 0 }, + }) + }) +}) diff --git a/packages/react-native-nitro-sqlite/src/__tests__/executeBatch.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/executeBatch.test.ts new file mode 100644 index 00000000..6a8fa798 --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/__tests__/executeBatch.test.ts @@ -0,0 +1,79 @@ +jest.mock('../nitro') + +import { HybridNitroSQLite } from '../nitro' +import { closeDatabaseQueue, openDatabaseQueue } from '../DatabaseQueue' +import NitroSQLiteError from '../NitroSQLiteError' +import { executeBatch, executeBatchAsync } from '../operations/executeBatch' + +const dbName = 'batch-test' +const commands = [{ query: 'INSERT INTO item VALUES (?)', params: [1] }] + +beforeEach(() => jest.clearAllMocks()) + +afterEach(() => { + try { + closeDatabaseQueue(dbName) + } catch (error) { + if ( + !(error instanceof NitroSQLiteError) || + !error.message.includes('not open') + ) { + throw error + } + } +}) + +describe('executeBatch', () => { + it('requires an open database before calling native code', async () => { + expect(() => executeBatch(dbName, commands)).toThrow('not open') + await expect(executeBatchAsync(dbName, commands)).rejects.toThrow( + 'not open', + ) + expect(HybridNitroSQLite.executeBatch).not.toHaveBeenCalled() + expect(HybridNitroSQLite.executeBatchAsync).not.toHaveBeenCalled() + }) + + it('passes commands and results through synchronous and asynchronous calls', async () => { + openDatabaseQueue(dbName) + jest + .mocked(HybridNitroSQLite.executeBatch) + .mockReturnValue({ rowsAffected: 2 }) + jest.mocked(HybridNitroSQLite.executeBatchAsync).mockResolvedValue({ + rowsAffected: 3, + }) + + expect(executeBatch(dbName, commands)).toEqual({ rowsAffected: 2 }) + await expect(executeBatchAsync(dbName, commands)).resolves.toEqual({ + rowsAffected: 3, + }) + expect(HybridNitroSQLite.executeBatch).toHaveBeenCalledWith( + dbName, + commands, + ) + expect(HybridNitroSQLite.executeBatchAsync).toHaveBeenCalledWith( + dbName, + commands, + ) + }) + + it('converts synchronous and asynchronous errors and releases the queue', async () => { + openDatabaseQueue(dbName) + jest.mocked(HybridNitroSQLite.executeBatch).mockImplementation(() => { + throw new Error('sync failed') + }) + jest + .mocked(HybridNitroSQLite.executeBatchAsync) + .mockRejectedValueOnce('async failed') + .mockResolvedValueOnce({ rowsAffected: 1 }) + + expect(() => executeBatch(dbName, commands)).toThrow(NitroSQLiteError) + expect(() => executeBatch(dbName, commands)).toThrow('sync failed') + await expect(executeBatchAsync(dbName, commands)).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'async failed', + }) + await expect(executeBatchAsync(dbName, commands)).resolves.toEqual({ + rowsAffected: 1, + }) + }) +}) diff --git a/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts new file mode 100644 index 00000000..e44b0be6 --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts @@ -0,0 +1,178 @@ +jest.mock('../nitro') + +import { HybridNitroSQLite } from '../nitro' +import { closeDatabaseQueue, isDatabaseOpen } from '../DatabaseQueue' +import { open } from '../operations/session' +import { nativeResult } from './testUtils' + +const dbName = 'session-test' +const options = { name: dbName, location: 'data' } + +beforeEach(() => { + jest.clearAllMocks() + jest.mocked(HybridNitroSQLite.execute).mockReturnValue(nativeResult()) + jest.mocked(HybridNitroSQLite.executeAsync).mockResolvedValue(nativeResult()) +}) + +afterEach(() => { + if (isDatabaseOpen(dbName)) closeDatabaseQueue(dbName) +}) + +describe('open', () => { + it('opens a native connection and routes its query and batch methods', async () => { + const db = open(options) + jest + .mocked(HybridNitroSQLite.executeBatch) + .mockReturnValue({ rowsAffected: 2 }) + jest.mocked(HybridNitroSQLite.executeBatchAsync).mockResolvedValue({ + rowsAffected: 3, + }) + + expect(HybridNitroSQLite.open).toHaveBeenCalledWith(dbName, 'data') + expect(db.execute('SELECT 1').rows.length).toBe(0) + expect((await db.executeAsync('SELECT 2')).rows.length).toBe(0) + expect(db.executeBatch([{ query: 'INSERT 1' }])).toEqual({ + rowsAffected: 2, + }) + await expect( + db.executeBatchAsync([{ query: 'INSERT 2' }]), + ).resolves.toEqual({ + rowsAffected: 3, + }) + expect(HybridNitroSQLite.execute).toHaveBeenCalledWith( + dbName, + 'SELECT 1', + undefined, + ) + expect(HybridNitroSQLite.executeAsync).toHaveBeenCalledWith( + dbName, + 'SELECT 2', + undefined, + ) + + db.close() + expect(HybridNitroSQLite.close).toHaveBeenCalledWith(dbName) + expect(isDatabaseOpen(dbName)).toBe(false) + }) + + it('delegates transactions through the connection', async () => { + const db = open(options) + await expect( + db.transaction(async (tx) => tx.execute('SELECT 1').rows.length), + ).resolves.toBe(0) + expect(HybridNitroSQLite.executeAsync).toHaveBeenCalledWith( + dbName, + 'BEGIN TRANSACTION', + undefined, + ) + expect(HybridNitroSQLite.execute).toHaveBeenCalledWith( + dbName, + 'COMMIT', + undefined, + ) + }) + + it('rejects duplicate opens without replacing the original connection', () => { + const db = open(options) + + expect(() => open({ name: dbName, location: 'other' })).toThrow( + 'already open', + ) + expect(HybridNitroSQLite.open).toHaveBeenCalledTimes(1) + expect(db.execute('SELECT 1').rows.length).toBe(0) + }) + + it('cleans up the queue when native open fails', () => { + jest.mocked(HybridNitroSQLite.open).mockImplementationOnce(() => { + throw new Error('native open failed') + }) + + expect(() => open(options)).toThrow('native open failed') + expect(isDatabaseOpen(dbName)).toBe(false) + expect(open(options)).toBeDefined() + }) + + it('keeps the queue open if native close fails so close can be retried', () => { + const db = open(options) + jest.mocked(HybridNitroSQLite.close).mockImplementationOnce(() => { + throw new Error('native close failed') + }) + + expect(() => db.close()).toThrow('native close failed') + expect(isDatabaseOpen(dbName)).toBe(true) + db.close() + expect(isDatabaseOpen(dbName)).toBe(false) + }) + + it('deletes an open database and can delete again after closing', () => { + const db = open(options) + db.delete() + + expect(HybridNitroSQLite.drop).toHaveBeenCalledWith(dbName, 'data') + expect(isDatabaseOpen(dbName)).toBe(false) + db.delete() + expect(HybridNitroSQLite.drop).toHaveBeenCalledTimes(2) + }) + + it('preserves the open queue when deletion fails', () => { + const db = open(options) + jest.mocked(HybridNitroSQLite.drop).mockImplementationOnce(() => { + throw new Error('cannot delete') + }) + + expect(() => db.delete()).toThrow('cannot delete') + expect(isDatabaseOpen(dbName)).toBe(true) + db.delete() + expect(isDatabaseOpen(dbName)).toBe(false) + }) + + it('passes attach, detach, and file loading through the native connection', async () => { + const db = open(options) + jest.mocked(HybridNitroSQLite.loadFile).mockReturnValue({ commands: 2 }) + jest + .mocked(HybridNitroSQLite.loadFileAsync) + .mockResolvedValue({ commands: 3 }) + + db.attach('other', 'alias', 'external') + db.detach('alias') + expect(db.loadFile('/tmp/statements.sql')).toEqual({ commands: 2 }) + await expect(db.loadFileAsync('/tmp/statements.sql')).resolves.toEqual({ + commands: 3, + }) + expect(HybridNitroSQLite.attach).toHaveBeenCalledWith( + dbName, + 'other', + 'alias', + 'external', + ) + expect(HybridNitroSQLite.detach).toHaveBeenCalledWith(dbName, 'alias') + expect(HybridNitroSQLite.loadFile).toHaveBeenCalledWith( + dbName, + '/tmp/statements.sql', + ) + expect(HybridNitroSQLite.loadFileAsync).toHaveBeenCalledWith( + dbName, + '/tmp/statements.sql', + ) + }) + + it('converts synchronous operation and asynchronous file errors', async () => { + const db = open(options) + jest.mocked(HybridNitroSQLite.attach).mockImplementationOnce(() => { + throw new Error('attach failed') + }) + jest + .mocked(HybridNitroSQLite.loadFileAsync) + .mockRejectedValueOnce('load failed') + .mockResolvedValueOnce({ commands: 1 }) + + expect(() => db.attach('other', 'alias')).toThrow('attach failed') + await expect(db.loadFileAsync('/tmp/missing.sql')).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'load failed', + }) + await expect(db.loadFileAsync('/tmp/valid.sql')).resolves.toEqual({ + commands: 1, + }) + }) +}) diff --git a/packages/react-native-nitro-sqlite/src/__tests__/testUtils.ts b/packages/react-native-nitro-sqlite/src/__tests__/testUtils.ts new file mode 100644 index 00000000..ae4d24f2 --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/__tests__/testUtils.ts @@ -0,0 +1,18 @@ +import type { NitroSQLiteQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' +import type { SQLiteValue } from '../types' + +export function nativeResult( + results: Record[] = [], +): NitroSQLiteQueryResult { + return { rowsAffected: results.length, results } as NitroSQLiteQueryResult +} + +export function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} diff --git a/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts new file mode 100644 index 00000000..60524814 --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts @@ -0,0 +1,161 @@ +jest.mock('../nitro') + +import { HybridNitroSQLite } from '../nitro' +import { closeDatabaseQueue, openDatabaseQueue } from '../DatabaseQueue' +import { transaction } from '../operations/transaction' +import { deferred, nativeResult } from './testUtils' + +const dbName = 'transaction-test' + +beforeEach(() => { + jest.clearAllMocks() + openDatabaseQueue(dbName) + jest.mocked(HybridNitroSQLite.execute).mockReturnValue(nativeResult()) + jest.mocked(HybridNitroSQLite.executeAsync).mockResolvedValue(nativeResult()) +}) + +afterEach(() => closeDatabaseQueue(dbName)) + +describe('transaction', () => { + it('requires an open database', async () => { + closeDatabaseQueue(dbName) + await expect(transaction(dbName, async () => {})).rejects.toThrow( + 'not open', + ) + openDatabaseQueue(dbName) + }) + + it('begins a normal transaction, runs queries, commits, and returns the callback result', async () => { + const result = await transaction(dbName, async (tx) => { + expect(tx.execute('SELECT ?', [1]).rows.length).toBe(0) + expect((await tx.executeAsync('SELECT ?', [2])).rows.length).toBe(0) + return 'finished' + }) + + expect(result).toBe('finished') + expect(HybridNitroSQLite.executeAsync).toHaveBeenNthCalledWith( + 1, + dbName, + 'BEGIN TRANSACTION', + undefined, + ) + expect(HybridNitroSQLite.execute).toHaveBeenNthCalledWith( + 1, + dbName, + 'SELECT ?', + [1], + ) + expect(HybridNitroSQLite.executeAsync).toHaveBeenNthCalledWith( + 2, + dbName, + 'SELECT ?', + [2], + ) + expect(HybridNitroSQLite.execute).toHaveBeenLastCalledWith( + dbName, + 'COMMIT', + undefined, + ) + }) + + it('starts an exclusive transaction and does not commit after an explicit commit', async () => { + await transaction( + dbName, + async (tx) => { + tx.commit() + expect(() => tx.commit()).toThrow('finalized transaction') + expect(() => tx.rollback()).toThrow('finalized transaction') + expect(() => tx.execute('SELECT 1')).toThrow('finalized transaction') + expect(() => tx.executeAsync('SELECT 1')).toThrow( + 'finalized transaction', + ) + }, + true, + ) + + expect(HybridNitroSQLite.executeAsync).toHaveBeenCalledWith( + dbName, + 'BEGIN EXCLUSIVE TRANSACTION', + undefined, + ) + expect(HybridNitroSQLite.execute).toHaveBeenCalledTimes(1) + expect(HybridNitroSQLite.execute).toHaveBeenCalledWith( + dbName, + 'COMMIT', + undefined, + ) + }) + + it('does not commit after an explicit rollback', async () => { + await transaction(dbName, async (tx) => { + tx.rollback() + expect(() => tx.rollback()).toThrow('finalized transaction') + }) + + expect(HybridNitroSQLite.execute).toHaveBeenCalledTimes(1) + expect(HybridNitroSQLite.execute).toHaveBeenCalledWith( + dbName, + 'ROLLBACK', + undefined, + ) + }) + + it('rolls back when the callback fails', async () => { + await expect( + transaction(dbName, async () => { + throw new Error('callback failed') + }), + ).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'callback failed', + }) + expect(HybridNitroSQLite.execute).toHaveBeenCalledWith( + dbName, + 'ROLLBACK', + undefined, + ) + }) + + it('converts a rollback failure when handling a callback error', async () => { + jest.mocked(HybridNitroSQLite.execute).mockImplementation(() => { + throw new Error('rollback failed') + }) + + await expect( + transaction(dbName, async () => { + throw new Error('callback failed') + }), + ).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'rollback failed', + }) + }) + + it('does not roll back a transaction already committed before a callback error', async () => { + await expect( + transaction(dbName, async (tx) => { + tx.commit() + throw new Error('after commit') + }), + ).rejects.toThrow('after commit') + expect(HybridNitroSQLite.execute).toHaveBeenCalledTimes(1) + }) + + it('serializes transactions behind the same queue', async () => { + const firstMayFinish = deferred() + const order: string[] = [] + const first = transaction(dbName, async () => { + order.push('first') + await firstMayFinish.promise + }) + const second = transaction(dbName, async () => { + order.push('second') + }) + + await new Promise((resolve) => setImmediate(resolve)) + expect(order).toEqual(['first']) + firstMayFinish.resolve() + await Promise.all([first, second]) + expect(order).toEqual(['first', 'second']) + }) +}) diff --git a/packages/react-native-nitro-sqlite/src/__tests__/typeORM.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/typeORM.test.ts new file mode 100644 index 00000000..9f20c521 --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/__tests__/typeORM.test.ts @@ -0,0 +1,128 @@ +jest.mock('../nitro') + +import { HybridNitroSQLite } from '../nitro' +import { closeDatabaseQueue, isDatabaseOpen } from '../DatabaseQueue' +import { typeORMDriver } from '../typeORM' +import { nativeResult } from './testUtils' + +const dbName = 'typeorm-test' + +beforeEach(() => { + jest.clearAllMocks() + jest.mocked(HybridNitroSQLite.execute).mockReturnValue(nativeResult()) + jest + .mocked(HybridNitroSQLite.executeAsync) + .mockResolvedValue(nativeResult([{ id: 1 }])) +}) + +afterEach(() => { + if (isDatabaseOpen(dbName)) closeDatabaseQueue(dbName) +}) + +describe('typeORMDriver', () => { + it('opens a connection and forwards successful query, transaction, attachment, and close callbacks', async () => { + const opened = jest.fn() + const failed = jest.fn() + const connection = typeORMDriver.openDatabase( + { name: dbName }, + opened, + failed, + ) + if (!connection) throw new Error('expected an open connection') + + const querySucceeded = jest.fn() + const queryFailed = jest.fn() + await connection.executeSql( + 'SELECT id FROM item', + [], + querySucceeded, + queryFailed, + ) + expect(querySucceeded).toHaveBeenCalledWith( + expect.objectContaining({ + rows: { _array: [{ id: 1 }], length: 1, item: expect.any(Function) }, + }), + ) + expect(queryFailed).not.toHaveBeenCalled() + + await connection.transaction(async (tx) => { + tx.execute('SELECT 1') + }) + const attached = jest.fn() + const detached = jest.fn() + connection.attach('other', 'alias', undefined, attached) + connection.detach('alias', detached) + expect(attached).toHaveBeenCalledTimes(1) + expect(detached).toHaveBeenCalledTimes(1) + expect(HybridNitroSQLite.attach).toHaveBeenCalledWith( + dbName, + 'other', + 'alias', + undefined, + ) + expect(HybridNitroSQLite.detach).toHaveBeenCalledWith(dbName, 'alias') + + const closed = jest.fn() + const closeFailed = jest.fn() + connection.close(closed, closeFailed) + expect(closed).toHaveBeenCalledTimes(1) + expect(closeFailed).not.toHaveBeenCalled() + expect(opened).toHaveBeenCalledWith(connection) + expect(failed).not.toHaveBeenCalled() + }) + + it('forwards query and close errors to their failure callbacks', async () => { + const connection = typeORMDriver.openDatabase( + { name: dbName }, + jest.fn(), + jest.fn(), + ) + if (!connection) throw new Error('expected an open connection') + jest + .mocked(HybridNitroSQLite.executeAsync) + .mockRejectedValueOnce(new Error('query failed')) + const queryFailed = jest.fn() + + await connection.executeSql('INVALID', undefined, jest.fn(), queryFailed) + expect(queryFailed).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'NitroSQLiteError', + message: 'query failed', + }), + ) + + jest.mocked(HybridNitroSQLite.close).mockImplementationOnce(() => { + throw new Error('close failed') + }) + const closeFailed = jest.fn() + connection.close(jest.fn(), closeFailed) + expect(closeFailed).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'NitroSQLiteError', + message: 'close failed', + }), + ) + expect(isDatabaseOpen(dbName)).toBe(true) + connection.close(jest.fn(), jest.fn()) + }) + + it('reports an open failure and returns null', () => { + jest.mocked(HybridNitroSQLite.open).mockImplementationOnce(() => { + throw new Error('open failed') + }) + const opened = jest.fn() + const failed = jest.fn() + + expect( + typeORMDriver.openDatabase({ name: dbName }, opened, failed), + ).toBeNull() + expect(opened).not.toHaveBeenCalled() + expect(failed).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'NitroSQLiteError', + message: 'open failed', + }), + ) + expect(isDatabaseOpen(dbName)).toBe(false) + }) +}) diff --git a/packages/react-native-nitro-sqlite/tests/cpp/databaseMigration.test.cpp b/packages/react-native-nitro-sqlite/tests/cpp/databaseMigration.test.cpp index f8b717c0..08225df1 100644 --- a/packages/react-native-nitro-sqlite/tests/cpp/databaseMigration.test.cpp +++ b/packages/react-native-nitro-sqlite/tests/cpp/databaseMigration.test.cpp @@ -115,8 +115,10 @@ class SQLiteDatabase { void migratesDatabaseAndEveryJournalType(); void removesStaleDestinationJournalsMissingFromSource(); void fallsBackWithoutChangingSourceFilesWhenDestinationCleanupFails(); +void preservesSourceWhenCopyingAnAuxiliaryFileFails(); void removesOrphanedSourceJournalsAfterAnInterruptedMigration(); void removesEveryDatabaseGenerationFile(); +void reportsCleanupFailureForNonemptyDatabaseDirectory(); void recoversCommittedWalAfterMigration(); void rollsBackHotJournalAfterMigration(); void runWithoutCleanShutdown(const std::function& action); @@ -136,8 +138,10 @@ int main() { {"removes stale destination journals missing from source", removesStaleDestinationJournalsMissingFromSource}, {"falls back without changing source files when destination cleanup fails", fallsBackWithoutChangingSourceFilesWhenDestinationCleanupFails}, + {"preserves source when copying an auxiliary file fails", preservesSourceWhenCopyingAnAuxiliaryFileFails}, {"removes orphaned source journals after an interrupted migration", removesOrphanedSourceJournalsAfterAnInterruptedMigration}, {"removes every database generation file", removesEveryDatabaseGenerationFile}, + {"reports cleanup failure for a nonempty database directory", reportsCleanupFailureForNonemptyDatabaseDirectory}, {"recovers committed WAL content after migration", recoversCommittedWalAfterMigration}, {"rolls back a hot journal after migration", rollsBackHotJournalAfterMigration}, }; @@ -222,6 +226,26 @@ void fallsBackWithoutChangingSourceFilesWhenDestinationCleanupFails() { } } +void preservesSourceWhenCopyingAnAuxiliaryFileFails() { + TemporaryDirectory temporaryDirectory; + const auto source = temporaryDirectory.path / "Documents"; + const auto destination = temporaryDirectory.path / "Application Support"; + const std::string dbName = "database.sqlite"; + + writeFile(source / dbName, "source database"); + fs::create_directories(source / (dbName + "-wal")); + + expect(migrateDatabase(dbName, source, destination) == source, "a failed auxiliary copy should keep the source active"); + expect(readFile(source / dbName) == "source database", "a failed copy should preserve the source database"); + + fs::remove(source / (dbName + "-wal")); + writeFile(source / (dbName + "-wal"), "source WAL"); + + expect(migrateDatabase(dbName, source, destination) == destination, "a later attempt should complete the migration"); + expect(readFile(destination / dbName) == "source database", "the retry should copy the source database"); + expect(readFile(destination / (dbName + "-wal")) == "source WAL", "the retry should copy the source WAL"); +} + void removesOrphanedSourceJournalsAfterAnInterruptedMigration() { TemporaryDirectory temporaryDirectory; const auto source = temporaryDirectory.path / "Documents"; @@ -257,6 +281,18 @@ void removesEveryDatabaseGenerationFile() { } } +void reportsCleanupFailureForNonemptyDatabaseDirectory() { + TemporaryDirectory temporaryDirectory; + const auto directory = temporaryDirectory.path / "Database"; + const std::string dbName = "database.sqlite"; + + writeFile(directory / dbName / "child", "prevents directory removal"); + writeFile(directory / (dbName + "-wal"), "must remain untouched"); + + expect(!removeDatabaseFiles(dbName, directory), "cleanup should fail if a database path is a nonempty directory"); + expect(readFile(directory / (dbName + "-wal")) == "must remain untouched", "cleanup should stop before deleting another generation file"); +} + void recoversCommittedWalAfterMigration() { TemporaryDirectory temporaryDirectory; const auto source = temporaryDirectory.path / "Documents"; diff --git a/packages/react-native-nitro-sqlite/tsconfig.build.json b/packages/react-native-nitro-sqlite/tsconfig.build.json index 01dff735..7bdb9494 100644 --- a/packages/react-native-nitro-sqlite/tsconfig.build.json +++ b/packages/react-native-nitro-sqlite/tsconfig.build.json @@ -1,6 +1,7 @@ { "extends": "../../config/tsconfig.json", "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/__tests__", "src/__mocks__"], "compilerOptions": { "rootDir": "src", "noEmit": false diff --git a/packages/react-native-nitro-sqlite/tsconfig.json b/packages/react-native-nitro-sqlite/tsconfig.json index 1f319653..85e46aaa 100644 --- a/packages/react-native-nitro-sqlite/tsconfig.json +++ b/packages/react-native-nitro-sqlite/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../config/tsconfig.json", "include": ["src"], "compilerOptions": { + "types": ["node", "jest"], "rootDir": "src", "outDir": ".tsbuild", "emitDeclarationOnly": true,