From 5e2df8468edb4aa1a7d7235d40921838bfa1ccf6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 18 Sep 2026 13:28:39 +0200 Subject: [PATCH 1/4] feat: support independent SQLite connections --- .github/workflows/test-cpp.yml | 49 ++- .github/workflows/test-harness-android.yml | 6 +- .github/workflows/test-harness-ios.yml | 6 +- README.md | 6 +- docs/multiple-connections.md | 89 +++++ example/tests/unit/index.ts | 2 + example/tests/unit/specs/Connections.spec.ts | 376 ++++++++++++++++++ .../cpp/databaseConnections.cpp | 306 ++++++++++++++ .../cpp/databaseConnections.hpp | 62 +++ .../cpp/hybridObjects/HybridNitroSQLite.cpp | 70 +++- .../cpp/hybridObjects/HybridNitroSQLite.hpp | 8 +- .../cpp/operations.cpp | 129 +----- .../cpp/operations.hpp | 25 +- .../shared/c++/HybridNitroSQLiteSpec.cpp | 2 + .../shared/c++/HybridNitroSQLiteSpec.hpp | 6 +- .../src/DatabaseQueue.ts | 26 +- .../src/__mocks__/nitro.ts | 2 + .../src/__tests__/session.test.ts | 230 ++++++++++- .../src/operations/execute.ts | 9 +- .../src/operations/executeBatch.ts | 11 +- .../src/operations/session.ts | 161 ++++++-- .../src/operations/transaction.ts | 6 +- .../src/specs/NitroSQLite.nitro.ts | 6 +- .../react-native-nitro-sqlite/src/types.ts | 4 + .../tests/cpp/databaseConnections.test.cpp | 220 ++++++++++ .../databaseConnectionsThreadsafe.test.cpp | 23 ++ 26 files changed, 1629 insertions(+), 211 deletions(-) create mode 100644 docs/multiple-connections.md create mode 100644 example/tests/unit/specs/Connections.spec.ts create mode 100644 packages/react-native-nitro-sqlite/cpp/databaseConnections.cpp create mode 100644 packages/react-native-nitro-sqlite/cpp/databaseConnections.hpp create mode 100644 packages/react-native-nitro-sqlite/tests/cpp/databaseConnections.test.cpp create mode 100644 packages/react-native-nitro-sqlite/tests/cpp/databaseConnectionsThreadsafe.test.cpp diff --git a/.github/workflows/test-cpp.yml b/.github/workflows/test-cpp.yml index fded1fce..2473f733 100644 --- a/.github/workflows/test-cpp.yml +++ b/.github/workflows/test-cpp.yml @@ -7,6 +7,7 @@ on: paths: - ".github/workflows/test-cpp.yml" - "packages/react-native-nitro-sqlite/cpp/databaseMigration.*" + - "packages/react-native-nitro-sqlite/cpp/databaseConnections.*" - "packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp" - "packages/react-native-nitro-sqlite/cpp/sqlite/sqlite3.*" - "packages/react-native-nitro-sqlite/tests/cpp/**" @@ -14,13 +15,14 @@ on: paths: - ".github/workflows/test-cpp.yml" - "packages/react-native-nitro-sqlite/cpp/databaseMigration.*" + - "packages/react-native-nitro-sqlite/cpp/databaseConnections.*" - "packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp" - "packages/react-native-nitro-sqlite/cpp/sqlite/sqlite3.*" - "packages/react-native-nitro-sqlite/tests/cpp/**" jobs: test: - name: Database migration tests + name: Database lifecycle tests runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 @@ -52,3 +54,48 @@ jobs: - name: Run migration tests run: /tmp/databaseMigrationTests + + - name: Build connection tests + run: | + clang++ \ + -std=c++20 \ + -Wall \ + -Wextra \ + -Werror \ + -Ipackages/react-native-nitro-sqlite/cpp \ + -Ipackages/react-native-nitro-sqlite/cpp/sqlite \ + packages/react-native-nitro-sqlite/cpp/databaseConnections.cpp \ + packages/react-native-nitro-sqlite/cpp/databaseMigration.cpp \ + packages/react-native-nitro-sqlite/tests/cpp/databaseConnections.test.cpp \ + /tmp/sqlite3.o \ + -ldl \ + -lm \ + -pthread \ + -o /tmp/databaseConnectionsTests + + - name: Run connection tests + run: /tmp/databaseConnectionsTests + + - name: Build and run thread safety gate test + run: | + clang \ + -std=c11 \ + -DSQLITE_THREADSAFE=0 \ + -c packages/react-native-nitro-sqlite/cpp/sqlite/sqlite3.c \ + -o /tmp/sqlite3-threadsafe-0.o + clang++ \ + -std=c++20 \ + -Wall \ + -Wextra \ + -Werror \ + -Ipackages/react-native-nitro-sqlite/cpp \ + -Ipackages/react-native-nitro-sqlite/cpp/sqlite \ + packages/react-native-nitro-sqlite/cpp/databaseConnections.cpp \ + packages/react-native-nitro-sqlite/cpp/databaseMigration.cpp \ + packages/react-native-nitro-sqlite/tests/cpp/databaseConnectionsThreadsafe.test.cpp \ + /tmp/sqlite3-threadsafe-0.o \ + -ldl \ + -lm \ + -pthread \ + -o /tmp/databaseConnectionsThreadsafeTests + /tmp/databaseConnectionsThreadsafeTests diff --git a/.github/workflows/test-harness-android.yml b/.github/workflows/test-harness-android.yml index 9840255a..55e27953 100644 --- a/.github/workflows/test-harness-android.yml +++ b/.github/workflows/test-harness-android.yml @@ -9,8 +9,10 @@ on: - "example/tests/**" - "example/rn-harness.config.mjs" - "example/jest.config.js" - - "package/cpp/**" - - "package/android/**" + - "packages/react-native-nitro-sqlite/cpp/**" + - "packages/react-native-nitro-sqlite/android/**" + - "packages/react-native-nitro-sqlite/src/**" + - "packages/react-native-nitro-sqlite/nitrogen/**" - "**/bun.lock" - "**/react-native.config.js" - "**/nitro.json" diff --git a/.github/workflows/test-harness-ios.yml b/.github/workflows/test-harness-ios.yml index b015c10b..f8a86c0b 100644 --- a/.github/workflows/test-harness-ios.yml +++ b/.github/workflows/test-harness-ios.yml @@ -9,8 +9,10 @@ on: - "example/tests/**" - "example/rn-harness.config.mjs" - "example/jest.config.js" - - "package/cpp/**" - - "package/ios/**" + - "packages/react-native-nitro-sqlite/cpp/**" + - "packages/react-native-nitro-sqlite/ios/**" + - "packages/react-native-nitro-sqlite/src/**" + - "packages/react-native-nitro-sqlite/nitrogen/**" - "**/Podfile.lock" - "**/*.podspec" - "**/react-native.config.js" diff --git a/README.md b/README.md index 51528ae4..afe7ec10 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ const db = open({ name: 'myDb.sqlite' }) // open({ name: 'myDb.sqlite', location: 'databases' }) ``` +To open another connection to the same file, use `open({ name: 'myDb.sqlite', connection: 'independent' })`. Add `readOnly: true` for a reader connection. Each connection has its own queue and transaction state. See [multiple connections](docs/multiple-connections.md) for WAL setup, app migration guidance, and concurrency limits. + | Method | Sync | Async | Description | |--------|------|-------|-------------| | **Execute** | `db.execute(query, params?)` | `db.executeAsync(query, params?)` | Run a single SQL statement. | @@ -186,7 +188,7 @@ const { rowsAffected, commands } = db.loadFile('/absolute/path/to/file.sql') Databases are created under the app documents directory (iOS) or files directory (Android). `location` is a directory path relative to that root, not an absolute file path. For example, `open({ name: 'myDb.sqlite', location: 'databases' })` opens `myDb.sqlite` under the `databases` directory. To use a database from another app-accessible location, copy or move it into this directory first. On iOS, files outside the app sandbox are inaccessible. -Close a connection before deleting its database. A connection must not be used after `close()` or `delete()`. +Close connections and detach the database from other connections before deleting it. Deletion fails while another connection still uses the file. A read-only connection cannot delete its database. A connection must not be used after `close()` or `delete()`. ```ts db.close() @@ -414,6 +416,8 @@ import type { `open()` is the recommended API. `NitroSQLite` exposes the underlying database-name-based methods for advanced integrations; prefer the connection returned by `open()` because it binds the database name and adds the JavaScript transaction and result helpers. +Name-based methods address the default connection only. Use the returned connection object for operations on an independent connection. + --- # Community diff --git a/docs/multiple-connections.md b/docs/multiple-connections.md new file mode 100644 index 00000000..e25e0ae2 --- /dev/null +++ b/docs/multiple-connections.md @@ -0,0 +1,89 @@ +# Multiple connections + +NitroSQLite can open several independent connections to the same database file. Each connection owns a native SQLite handle, an operation queue, and its transactions. Closing one connection leaves the others open. + +Existing apps need no changes. `open({ name })` still opens the default connection for that name, and opening another default connection with that name throws. Set `connection: 'independent'` to open another handle. Connections can share a filename and location, or use the same filename in different locations. + +## Set up one writer and one reader + +Initialize the database and finish schema migrations before opening readers. Enable [WAL mode](https://www.sqlite.org/wal.html) on the writer to allow readers to run while another connection writes. + +```ts +import { open } from 'react-native-nitro-sqlite' + +const writer = open({ name: 'app.sqlite' }) +await writer.executeAsync('PRAGMA journal_mode = WAL') +await writer.executeAsync('PRAGMA busy_timeout = 1000') +await writer.executeAsync( + 'CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, value TEXT)', +) + +const reader = open({ + name: 'app.sqlite', + connection: 'independent', + readOnly: true, +}) +await reader.executeAsync('PRAGMA busy_timeout = 1000') + +const [_, items] = await Promise.all([ + writer.executeAsync('INSERT INTO items (value) VALUES (?)', ['new item']), + reader.executeAsync('SELECT * FROM items'), +]) +``` + +The read in this example may run before the insert commits. If it must include that insert, await the write first, then start the read. A read-only connection requires an existing database and rejects writes to it. It cannot delete the database or attach other databases, including through raw SQL `ATTACH`. + +WAL mode persists in the database file. Settings such as `busy_timeout` and `foreign_keys` belong to each connection, so configure them on every connection that needs them. NitroSQLite does not enable WAL or change SQLite's default busy timeout automatically. + +## Adapt existing app code + +After updating NitroSQLite, rebuild the iOS and Android apps so the new native connection methods are included. An update that replaces only JavaScript cannot add these methods to an older app binary. + +Keep your existing connection as the writer. Add a shared reader connection in the database service, then pass the appropriate connection to each repository or query helper. Open a small, bounded number of connections and reuse them across screens and services. + +Route operations explicitly. Code that fetches data can use the reader. Inserts, updates, deletes, schema migrations, imports, and write batches should use the writer. NitroSQLite does not inspect SQL keywords or distribute operations across connections. A statement beginning with `WITH`, for example, can perform a write, and a statement returning rows can still modify data. + +Inside a transaction, pass the callback's `tx` object into every helper that belongs to that transaction. This includes reads that must see uncommitted writes. Work sent to another connection has a separate transaction and cannot see those writes. + +```ts +await writer.transaction(async (tx) => { + await tx.executeAsync('UPDATE items SET value = ? WHERE id = ?', ['edited', 1]) + const updated = await tx.executeAsync('SELECT * FROM items WHERE id = ?', [1]) + // updated includes this transaction's uncommitted change. +}) + +// This read starts after the writer commits. +const committed = await reader.executeAsync('SELECT * FROM items WHERE id = ?', [1]) +``` + +Do not await a queued operation on the same connection from inside its transaction callback. That operation waits for the callback to finish. Use `tx.execute()` or `tx.executeAsync()` instead. A read transaction on a separate connection keeps its [snapshot](https://www.sqlite.org/isolation.html) until that transaction ends, even if a writer commits in the meantime. + +Name-based calls such as `NitroSQLite.executeAsync('app.sqlite', ...)` always address the default connection. Use the object returned by `open()` for an independent connection. The existing TypeORM driver continues to use its default connection; using independent connections requires an adapter that retains the returned connection object. + +## What can run in parallel + +Operations on different connections can run on native worker threads concurrently. Each connection still serializes its own queued operations and protects the full lifetime of a transaction callback. `Promise.all()` on a single connection continues to queue work in call order. + +SQLite allows one writer at a time per database file. Creating several writer connections does not make simultaneous writes execute in parallel. Prefer `executeBatchAsync()` for groups of writes that can share a transaction. Separate connections help when independent reads need to run alongside writes or other reads. + +Competing connections can produce SQLite busy or locked errors. A bounded `busy_timeout` lets SQLite wait for some locks, but it does not eliminate every conflict. Catch failures at the operation or transaction boundary. If a transaction needs to be retried, retry the whole transaction only when the application can safely repeat its effects. Avoid synchronous calls with long timeouts because they block the JavaScript thread. + +Keep read transactions short. In WAL mode, a long-lived reader can prevent checkpoints from advancing and allow the WAL file to grow. Choose connection counts and timeouts based on your app's workload, and measure latency on the devices you support. + +## Close and delete + +Await pending work before closing each connection. Closing a busy managed connection throws. Deletion fails while another connection has the file open, including through an attachment. Detach or close those connections first. + +```ts +reader.close() +writer.close() +writer.delete() +``` + +Do not reuse a closed connection object. Open a new connection instead. Calls through `NitroSQLite.native` bypass the JavaScript queue and must not be mixed with managed transactions. + +## Thread safety + +Independent connections require SQLite's mutex support. NitroSQLite rejects `connection: 'independent'` when SQLite was built with `SQLITE_THREADSAFE=0`. Keep `nitroSQLite.threadSafe` enabled on iOS and avoid disabling SQLite thread safety through Android compiler flags. `performanceMode` is a separate setting and can remain enabled. + +Per-connection queues cannot protect SQLite's process-wide state in a build without mutex support. `SQLITE_OPEN_FULLMUTEX` cannot restore mutex code removed at compile time. See [SQLite's threading modes](https://www.sqlite.org/threadsafe.html). diff --git a/example/tests/unit/index.ts b/example/tests/unit/index.ts index 890d4356..07ea92a1 100644 --- a/example/tests/unit/index.ts +++ b/example/tests/unit/index.ts @@ -6,6 +6,7 @@ import registerExecuteBatchUnitTests from './specs/operations/executeBatch.spec' import registerTypeORMUnitTestsSpecs from './specs/typeorm.spec' import registerDatabaseQueueUnitTests from './specs/DatabaseQueue.spec' import registerSqliteVecUnitTestsSpecs from './specs/sqlite-vec.spec' +import registerConnectionUnitTests from './specs/Connections.spec' export function registerUnitTests() { beforeEach(setupTestDb) @@ -14,6 +15,7 @@ export function registerUnitTests() { describe('operations/transaction.spec.ts', registerTransactionUnitTests) describe('operations/executeBatch.spec.ts', registerExecuteBatchUnitTests) describe('DatabaseQueue.spec.ts', registerDatabaseQueueUnitTests) + describe('Connections.spec.ts', registerConnectionUnitTests) } export function registerTypeORMUnitTests() { diff --git a/example/tests/unit/specs/Connections.spec.ts b/example/tests/unit/specs/Connections.spec.ts new file mode 100644 index 00000000..1eac5665 --- /dev/null +++ b/example/tests/unit/specs/Connections.spec.ts @@ -0,0 +1,376 @@ +import { describe, it } from '@tests/TestApi' +import { expect } from '@tests/unit/common' +import { + NitroSQLite, + NitroSQLiteError, + open, + type NitroSQLiteConnection, +} from 'react-native-nitro-sqlite' + +const TIMEOUT_MS = 5000 + +export default function registerConnectionUnitTests() { + describe('independent connections', () => { + it('keeps the default name lookup separate from independent handles', () => { + const name = 'connections-default-lookup' + const writer = open({ name }) + const other = open({ name, connection: 'independent' }) + let writerOpen = true + + try { + writer.execute('CREATE TABLE Item (value TEXT)') + other.execute('CREATE TEMP TABLE PrivateItem (value TEXT)') + other.execute('INSERT INTO PrivateItem VALUES (?)', ['private']) + NitroSQLite.execute(name, 'INSERT INTO Item VALUES (?)', ['default']) + + expect(writer.execute('SELECT value FROM Item').results).toEqual([ + { value: 'default' }, + ]) + expect(other.execute('SELECT value FROM Item').results).toEqual([ + { value: 'default' }, + ]) + expect(other.execute('SELECT value FROM PrivateItem').results).toEqual([ + { value: 'private' }, + ]) + expectThrows(() => + NitroSQLite.execute(name, 'SELECT * FROM PrivateItem'), + ) + expectThrows(() => open({ name })) + + writer.close() + writerOpen = false + expectThrows(() => NitroSQLite.execute(name, 'SELECT 1')) + expect(other.execute('SELECT value FROM Item').results).toEqual([ + { value: 'default' }, + ]) + } finally { + other.close() + if (writerOpen) writer.close() + writer.delete() + } + }) + + it('opens the same filename in different locations as different files', () => { + const name = 'connections-location' + const first = open({ name }) + const second = open({ name, location: '..', connection: 'independent' }) + + try { + first.execute('CREATE TABLE Item (value TEXT)') + second.execute('CREATE TABLE Item (value TEXT)') + first.execute('INSERT INTO Item VALUES (?)', ['first']) + second.execute('INSERT INTO Item VALUES (?)', ['second']) + + expect(first.execute('SELECT value FROM Item').results).toEqual([ + { value: 'first' }, + ]) + expect(second.execute('SELECT value FROM Item').results).toEqual([ + { value: 'second' }, + ]) + expect(mainPath(first)).not.toBe(mainPath(second)) + } finally { + second.close() + first.close() + second.delete() + first.delete() + } + }) + + it('reads committed data while a WAL writer callback is held open', async () => { + const writer = open({ name: 'connections-wal-reader' }) + writer.execute('PRAGMA journal_mode = WAL') + writer.execute('CREATE TABLE Item (value TEXT)') + writer.execute('INSERT INTO Item VALUES (?)', ['committed']) + const reader = open({ + name: 'connections-wal-reader', + connection: 'independent', + readOnly: true, + }) + const started = gate() + const release = gate() + const write = writer.transaction(async (tx) => { + tx.execute('INSERT INTO Item VALUES (?)', ['uncommitted']) + started.resolve() + await release.promise + }) + let pendingRead: ReturnType | undefined + + try { + await bounded(Promise.race([started.promise, rejectIfSettled(write)])) + pendingRead = reader.executeAsync( + 'SELECT value FROM Item ORDER BY rowid', + ) + const rows = await bounded(pendingRead) + expect(rows.results).toEqual([{ value: 'committed' }]) + + release.resolve() + await bounded(write) + expect( + reader.execute('SELECT value FROM Item ORDER BY rowid').results, + ).toEqual([{ value: 'committed' }, { value: 'uncommitted' }]) + } finally { + release.resolve() + try { + await Promise.allSettled([ + bounded(write), + ...(pendingRead ? [bounded(pendingRead)] : []), + ]) + } finally { + reader.close() + writer.close() + writer.delete() + } + } + }) + + it('keeps a reader transaction snapshot across a writer commit', async () => { + const writer = open({ name: 'connections-snapshot' }) + writer.execute('PRAGMA journal_mode = WAL') + writer.execute('CREATE TABLE Item (value TEXT)') + writer.execute('INSERT INTO Item VALUES (?)', ['before']) + const reader = open({ + name: 'connections-snapshot', + connection: 'independent', + readOnly: true, + }) + const snapshotReady = gate() + const release = gate() + const read = reader.transaction(async (tx) => { + const initial = tx.execute( + 'SELECT value FROM Item ORDER BY rowid', + ).results + snapshotReady.resolve() + await release.promise + const afterCommit = tx.execute( + 'SELECT value FROM Item ORDER BY rowid', + ).results + return { initial, afterCommit } + }) + let pendingWrite: Promise | undefined + + try { + await bounded( + Promise.race([snapshotReady.promise, rejectIfSettled(read)]), + ) + pendingWrite = writer.executeAsync('INSERT INTO Item VALUES (?)', [ + 'after', + ]) + await bounded(pendingWrite) + release.resolve() + + const snapshot = await bounded(read) + expect(snapshot.initial).toEqual([{ value: 'before' }]) + expect(snapshot.afterCommit).toEqual([{ value: 'before' }]) + expect( + reader.execute('SELECT value FROM Item ORDER BY rowid').results, + ).toEqual([{ value: 'before' }, { value: 'after' }]) + } finally { + release.resolve() + try { + await Promise.allSettled([ + bounded(read), + ...(pendingWrite ? [bounded(pendingWrite)] : []), + ]) + } finally { + reader.close() + writer.close() + writer.delete() + } + } + }) + + it('keeps temporary tables, pragmas, and attachments on their own handles', () => { + const name = 'connections-local-state' + const auxiliaryName = 'connections-local-state-aux' + const first = open({ name }) + const second = open({ name, connection: 'independent' }) + const auxiliary = open({ name: auxiliaryName }) + let attached = false + + try { + first.execute('CREATE TEMP TABLE PrivateItem (value TEXT)') + first.execute('PRAGMA foreign_keys = ON') + second.execute('PRAGMA foreign_keys = OFF') + first.attach(auxiliaryName, 'extra') + attached = true + + expect(first.execute('PRAGMA foreign_keys').results).toEqual([ + { foreign_keys: 1 }, + ]) + expect(second.execute('PRAGMA foreign_keys').results).toEqual([ + { foreign_keys: 0 }, + ]) + expectThrows(() => second.execute('SELECT * FROM PrivateItem')) + expect(databaseNames(first)).toContain('extra') + expect(databaseNames(second)).toEqual(['main']) + } finally { + if (attached) first.detach('extra') + auxiliary.close() + auxiliary.delete() + second.close() + first.close() + first.delete() + } + }) + + it('enforces read-only handles without limiting a writable peer', () => { + const name = 'connections-read-only' + const writer = open({ name }) + writer.execute('CREATE TABLE Item (value TEXT)') + const reader = open({ name, connection: 'independent', readOnly: true }) + + try { + expectThrows(() => + reader.execute('INSERT INTO Item VALUES (?)', ['blocked']), + ) + expectThrows(() => reader.delete()) + writer.execute('INSERT INTO Item VALUES (?)', ['allowed']) + expect(reader.execute('SELECT value FROM Item').results).toEqual([ + { value: 'allowed' }, + ]) + } finally { + reader.close() + writer.close() + writer.delete() + } + }) + + it('allows only one writer at a time', async () => { + const name = 'connections-writer-contention' + const first = open({ name }) + first.execute('CREATE TABLE Item (value TEXT)') + const second = open({ name, connection: 'independent' }) + second.execute('PRAGMA busy_timeout = 0') + const started = gate() + const release = gate() + const write = first.transaction(async (tx) => { + tx.execute('INSERT INTO Item VALUES (?)', ['first']) + started.resolve() + await release.promise + }) + + try { + await bounded(Promise.race([started.promise, rejectIfSettled(write)])) + expectThrows(() => + second.execute('INSERT INTO Item VALUES (?)', ['blocked']), + ) + release.resolve() + await bounded(write) + second.execute('INSERT INTO Item VALUES (?)', ['second']) + expect( + first.execute('SELECT value FROM Item ORDER BY rowid').results, + ).toEqual([{ value: 'first' }, { value: 'second' }]) + } finally { + release.resolve() + try { + await bounded(write) + } finally { + second.close() + first.close() + first.delete() + } + } + }) + + it('rejects deletion while a peer handle is open and preserves both handles', () => { + const name = 'connections-delete-peer' + const first = open({ name }) + first.execute('CREATE TABLE Item (value TEXT)') + const second = open({ name, connection: 'independent' }) + + try { + expectThrows(() => first.delete()) + expectThrows(() => second.delete()) + first.execute('INSERT INTO Item VALUES (?)', ['still open']) + expect(second.execute('SELECT value FROM Item').results).toEqual([ + { value: 'still open' }, + ]) + } finally { + second.close() + first.close() + first.delete() + } + }) + + it('rejects deletion while raw SQL ATTACH holds the file', () => { + const name = 'connections-delete-attached' + const target = open({ name }) + const holder = open({ name: 'connections-attachment-holder' }) + let attached = false + + try { + target.execute('CREATE TABLE Item (value TEXT)') + holder.execute('ATTACH DATABASE ? AS held', [mainPath(target)]) + attached = true + expectThrows(() => target.delete()) + target.execute('INSERT INTO Item VALUES (?)', ['still open']) + expect(holder.execute('SELECT value FROM held.Item').results).toEqual([ + { value: 'still open' }, + ]) + } finally { + if (attached) holder.execute('DETACH DATABASE held') + holder.close() + holder.delete() + target.close() + target.delete() + } + }) + }) +} + +function gate() { + let resolve!: () => void + const promise = new Promise((finish) => { + resolve = finish + }) + return { promise, resolve } +} + +async function bounded(promise: Promise): Promise { + let timeout: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error('Connection test timed out')), + TIMEOUT_MS, + ) + }), + ]) + } finally { + if (timeout != null) clearTimeout(timeout) + } +} + +function rejectIfSettled(promise: Promise): Promise { + return promise.then(() => { + throw new Error('Transaction finished before its callback reached the gate') + }) +} + +function expectThrows(action: () => unknown) { + let error: unknown + try { + action() + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(NitroSQLiteError) +} + +function mainPath(connection: NitroSQLiteConnection): string { + const main = connection + .execute<{ name: string; file: string }>('PRAGMA database_list') + .results.find((row) => row.name === 'main') + if (typeof main?.file !== 'string') + throw new Error('Missing main database path') + return main.file +} + +function databaseNames(connection: NitroSQLiteConnection): string[] { + return connection + .execute<{ name: string }>('PRAGMA database_list') + .results.map((row) => row.name) + .filter((name): name is string => typeof name === 'string') +} diff --git a/packages/react-native-nitro-sqlite/cpp/databaseConnections.cpp b/packages/react-native-nitro-sqlite/cpp/databaseConnections.cpp new file mode 100644 index 00000000..fc025794 --- /dev/null +++ b/packages/react-native-nitro-sqlite/cpp/databaseConnections.cpp @@ -0,0 +1,306 @@ +#include "databaseConnections.hpp" +#include "NitroSQLiteException.hpp" +#include "databaseMigration.hpp" +#include +#include +#include +#include +#include + +namespace margelo::rnnitrosqlite { + +namespace fs = std::filesystem; + +namespace { + + constexpr const char* kIndependentPrefix = "nitro-sqlite:"; + + int readOnlyAuthorizer(void*, int action, const char*, const char*, const char*, const char*) { + return action == SQLITE_ATTACH ? SQLITE_DENY : SQLITE_OK; + } + + struct IndependentId { + fs::path physicalPath; + bool readOnly; + }; + + std::optional parseIndependentId(const std::string& key) { + if (key.empty() || key.front() != '\0' || key.compare(1, std::char_traits::length(kIndependentPrefix), kIndependentPrefix) != 0) { + return std::nullopt; + } + const auto separator = key.find('\0', 1); + if (separator == std::string::npos || separator + 2 >= key.size()) { + return std::nullopt; + } + const char mode = key[separator + 1]; + if (mode != 'r' && mode != 'w') { + return std::nullopt; + } + return IndependentId{fs::path(key.substr(separator + 2)), mode == 'r'}; + } + + std::string connectionLabel(const std::string& key) { + const auto independent = parseIndependentId(key); + if (independent) { + return independent->physicalPath.string(); + } + return key.find('\0') == std::string::npos ? key : "invalid connection ID"; + } + + template + bool anyDatabasePath(sqlite3* database, Predicate&& predicate) { + sqlite3_stmt* rawStatement = nullptr; + const int prepared = sqlite3_prepare_v2(database, "PRAGMA database_list", -1, &rawStatement, nullptr); + std::unique_ptr statement(rawStatement, sqlite3_finalize); + if (prepared != SQLITE_OK) { + throw NitroSQLiteException::SqlExecution(sqlite3_errmsg(database)); + } + int result; + while ((result = sqlite3_step(statement.get())) == SQLITE_ROW) { + const auto* rawPath = reinterpret_cast(sqlite3_column_text(statement.get(), 2)); + if (rawPath != nullptr && rawPath[0] != '\0' && predicate(fs::path(rawPath))) { + return true; + } + } + if (result != SQLITE_DONE) { + throw NitroSQLiteException::SqlExecution(sqlite3_errmsg(database)); + } + return false; + } + + bool sameFile(const fs::path& first, const fs::path& second) { + std::error_code error; + if (fs::equivalent(first, second, error) && !error) { + return true; + } + return canonicalDatabasePath(first) == canonicalDatabasePath(second); + } + +} // namespace + +SQLiteConnection::SQLiteConnection(std::string connectionName, fs::path path, bool isReadOnly, sqlite3* handle) + : name(std::move(connectionName)), physicalPath(canonicalDatabasePath(path)), readOnly(isReadOnly), database(handle) {} + +SQLiteConnection::~SQLiteConnection() { + close(); +} + +void SQLiteConnection::close() noexcept { + std::lock_guard lock(mutex); + if (database == nullptr) { + return; + } + sqlite3_close_v2(database); + database = nullptr; +} + +void DatabaseConnections::open(const std::string& key, const fs::path& path, bool readOnly) { + std::lock_guard lock(lifecycleMutex); + validateDatabaseName(key); + if (connections.contains(key)) { + throw NitroSQLiteException::DatabaseAlreadyOpen(key); + } + openKey(key, path, readOnly); +} + +std::string DatabaseConnections::openIndependent(const fs::path& path, bool readOnly) { + std::lock_guard lock(lifecycleMutex); + if (path.string().find('\0') != std::string::npos) { + throw NitroSQLiteException(NitroSQLiteExceptionType::DatabaseCannotBeOpened, "Database path contains a NUL byte"); + } + if (sqlite3_threadsafe() == 0) { + throw NitroSQLiteException(NitroSQLiteExceptionType::DatabaseCannotBeOpened, + "Independent connections require a thread-safe SQLite build"); + } + // NUL cannot occur in a filesystem name. The ID cannot collide with a legacy database key. + const auto physicalPath = canonicalDatabasePath(path); + const std::string key = std::string(1, '\0') + kIndependentPrefix + std::to_string(++nextConnectionId) + std::string(1, '\0') + + (readOnly ? "r" : "w") + physicalPath.string(); + openKey(key, path, readOnly); + return key; +} + +void DatabaseConnections::openKey(const std::string& key, const fs::path& path, bool readOnly) { + if (path.string().find('\0') != std::string::npos) { + throw NitroSQLiteException(NitroSQLiteExceptionType::DatabaseCannotBeOpened, "Database path contains a NUL byte"); + } + const auto physicalPath = canonicalDatabasePath(path); + const int flags = (readOnly ? SQLITE_OPEN_READONLY : SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE) | SQLITE_OPEN_FULLMUTEX; + sqlite3* rawDatabase = nullptr; + const int result = sqlite3_open_v2(physicalPath.string().c_str(), &rawDatabase, flags, nullptr); + std::unique_ptr database(rawDatabase, sqlite3_close_v2); + if (result != SQLITE_OK) { + const std::string message = rawDatabase == nullptr ? sqlite3_errstr(result) : sqlite3_errmsg(rawDatabase); + throw NitroSQLiteException(NitroSQLiteExceptionType::DatabaseCannotBeOpened, message); + } + if (readOnly) { + sqlite3_set_authorizer(database.get(), readOnlyAuthorizer, nullptr); + } + auto connection = std::make_shared(connectionLabel(key), physicalPath, readOnly, database.get()); + database.release(); + connections.emplace(key, std::move(connection)); +} + +void DatabaseConnections::close(const std::string& key) { + std::lock_guard lock(lifecycleMutex); + const auto found = connections.find(key); + if (found == connections.end()) { + throw NitroSQLiteException::DatabaseNotOpen(connectionLabel(key)); + } + auto connection = std::move(found->second); + connections.erase(found); + connection->close(); +} + +void DatabaseConnections::closeAll() { + std::lock_guard lock(lifecycleMutex); + for (auto& [key, connection] : connections) { + connection->close(); + } + connections.clear(); +} + +SQLiteConnectionPtr DatabaseConnections::get(const std::string& key) { + std::lock_guard lock(lifecycleMutex); + const auto found = connections.find(key); + if (found == connections.end()) { + throw NitroSQLiteException::DatabaseNotOpen(connectionLabel(key)); + } + return found->second; +} + +bool DatabaseConnections::isOpen(const std::string& key) { + std::lock_guard lock(lifecycleMutex); + return connections.contains(key); +} + +std::optional DatabaseConnections::physicalPathForKey(const std::string& key) { + std::lock_guard lock(lifecycleMutex); + const auto live = connections.find(key); + if (live != connections.end()) { + return live->second->physicalPath; + } + const auto independent = parseIndependentId(key); + return independent ? std::optional(independent->physicalPath) : std::nullopt; +} + +std::optional DatabaseConnections::findLivePath(const fs::path& first, const fs::path& second) { + std::optional found; + withConnectionsLocked([&]() { + for (const auto& [_, connection] : connections) { + if (connection->database == nullptr) { + continue; + } + anyDatabasePath(connection->database, [&](const fs::path& candidate) { + if (sameFile(candidate, first)) { + found = first; + return true; + } + if (sameFile(candidate, second)) { + found = second; + return true; + } + return false; + }); + if (found) { + return; + } + } + }); + return found; +} + +void DatabaseConnections::withConnectionsLocked(const std::function& action) { + std::lock_guard lifecycleLock(lifecycleMutex); + std::vector> locks; + locks.reserve(connections.size()); + for (const auto& [_, connection] : connections) { + locks.emplace_back(connection->mutex); + } + action(); +} + +void DatabaseConnections::drop(const std::string& dbName, const fs::path& path, const std::optional& connectionId, + const std::optional& otherPath) { + std::lock_guard lifecycleLock(lifecycleMutex); + validateDatabaseName(dbName); + const auto target = canonicalDatabasePath(path); + if (!fs::exists(target)) { + throw NitroSQLiteException::DatabaseFileNotFound(target.string()); + } + const auto key = connectionId.value_or(dbName); + const auto live = connections.find(key); + const auto independent = connectionId ? parseIndependentId(*connectionId) : std::nullopt; + if (connectionId && !independent) { + throw NitroSQLiteException::DatabaseNotOpen(connectionLabel(key)); + } + if (independent && (!sameFile(independent->physicalPath, target) || independent->readOnly)) { + throw NitroSQLiteException(NitroSQLiteExceptionType::SqlExecutionError, independent->readOnly + ? "A read-only connection cannot delete a database" + : "Connection targets a different database file"); + } + if (live != connections.end()) { + if (!sameFile(live->second->physicalPath, target)) { + throw NitroSQLiteException(NitroSQLiteExceptionType::SqlExecutionError, "Connection targets a different database file"); + } + if (live->second->readOnly) { + throw NitroSQLiteException(NitroSQLiteExceptionType::SqlExecutionError, "A read-only connection cannot delete a database"); + } + } + + const SQLiteConnectionPtr connectionToClose = live == connections.end() ? nullptr : live->second; + withConnectionsLocked([&]() { + if (isPathInUse(target, key) || (otherPath && isPathInUse(*otherPath, key))) { + throw NitroSQLiteException(NitroSQLiteExceptionType::SqlExecutionError, "Database is in use by another connection"); + } + if (!fs::exists(target)) { + throw NitroSQLiteException::DatabaseFileNotFound(target.string()); + } + if (connectionToClose) { + close(key); + } + if (!nitro::rnnitrosqlite::removeDatabaseFiles(target.filename().string(), target.parent_path())) { + throw NitroSQLiteException(NitroSQLiteExceptionType::SqlExecutionError, "Could not remove database files"); + } + if (otherPath) { + nitro::rnnitrosqlite::removeDatabaseFiles(otherPath->filename().string(), otherPath->parent_path()); + } + }); +} + +bool DatabaseConnections::isPathInUse(const fs::path& path, const std::string& excludedKey) const { + for (const auto& [key, connection] : connections) { + if (key == excludedKey) { + continue; + } + if (connection->database == nullptr) { + continue; + } + if (anyDatabasePath(connection->database, [&](const fs::path& candidate) { return sameFile(candidate, path); })) { + return true; + } + } + return false; +} + +DatabaseConnections& databaseConnections() { + static DatabaseConnections registry; + return registry; +} + +void validateDatabaseName(const std::string& dbName) { + if (dbName.find('\0') != std::string::npos) { + throw NitroSQLiteException(NitroSQLiteExceptionType::DatabaseCannotBeOpened, "Database name contains a NUL byte"); + } +} + +fs::path canonicalDatabasePath(const fs::path& path) { + std::error_code error; + auto canonical = fs::weakly_canonical(path, error); + if (!error) { + return canonical; + } + return fs::absolute(path).lexically_normal(); +} + +} // namespace margelo::rnnitrosqlite diff --git a/packages/react-native-nitro-sqlite/cpp/databaseConnections.hpp b/packages/react-native-nitro-sqlite/cpp/databaseConnections.hpp new file mode 100644 index 00000000..e89d81ab --- /dev/null +++ b/packages/react-native-nitro-sqlite/cpp/databaseConnections.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace margelo::rnnitrosqlite { + +struct SQLiteConnection final { + SQLiteConnection(std::string name, std::filesystem::path physicalPath, bool readOnly, sqlite3* database); + ~SQLiteConnection(); + + SQLiteConnection(const SQLiteConnection&) = delete; + SQLiteConnection& operator=(const SQLiteConnection&) = delete; + + void close() noexcept; + + const std::string name; + const std::filesystem::path physicalPath; + const bool readOnly; + sqlite3* database; + std::recursive_mutex mutex; +}; + +using SQLiteConnectionPtr = std::shared_ptr; + +class DatabaseConnections final { +public: + // Callers hold this while resolving or migrating a database path. It is recursive because + // open, attach and drop take it again after path resolution. + std::recursive_mutex lifecycleMutex; + + void open(const std::string& key, const std::filesystem::path& path, bool readOnly); + std::string openIndependent(const std::filesystem::path& path, bool readOnly); + void close(const std::string& key); + void closeAll(); + SQLiteConnectionPtr get(const std::string& key); + bool isOpen(const std::string& key); + std::optional physicalPathForKey(const std::string& key); + std::optional findLivePath(const std::filesystem::path& first, const std::filesystem::path& second); + void withConnectionsLocked(const std::function& action); + void drop(const std::string& dbName, const std::filesystem::path& path, const std::optional& connectionId, + const std::optional& otherPath = std::nullopt); + +private: + void openKey(const std::string& key, const std::filesystem::path& path, bool readOnly); + bool isPathInUse(const std::filesystem::path& path, const std::string& excludedKey) const; + + std::map connections; + unsigned long long nextConnectionId = 0; +}; + +DatabaseConnections& databaseConnections(); +std::filesystem::path canonicalDatabasePath(const std::filesystem::path& path); +void validateDatabaseName(const std::string& dbName); + +} // namespace margelo::rnnitrosqlite diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp index 94c576dd..0127153d 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp @@ -60,6 +60,9 @@ static std::vector copyArrayBufferParamsForBackground(const std::vec } const std::string getDocPath(const std::optional& location) { + if (location && location->find('\0') != std::string::npos) { + throw NitroSQLiteException(NitroSQLiteExceptionType::DatabaseCannotBeOpened, "Database location contains a NUL byte"); + } std::string tempDocPath = std::string(HybridNitroSQLite::docPath); if (location) { tempDocPath = tempDocPath + "/" + *location; @@ -77,45 +80,92 @@ const std::string getOldDocPath(const std::optional& location) { return oldDocPath; } -const std::string getMigratedDocPath(const std::string& dbName, const std::optional& location) { +const std::string getMigratedDocPath(const std::string& dbName, const std::optional& location, bool readOnly = false) { const auto currentDocPath = getDocPath(location); if (HybridNitroSQLite::migrationDocPath.empty()) { return currentDocPath; } + const auto oldDocPath = getOldDocPath(location); + std::string selectedPath; + databaseConnections().withConnectionsLocked([&]() { + const auto oldPath = std::filesystem::path(oldDocPath) / dbName; + const auto livePath = databaseConnections().findLivePath(oldPath, std::filesystem::path(currentDocPath) / dbName); + if (livePath) { + selectedPath = *livePath == oldPath ? oldDocPath : currentDocPath; + } else if (readOnly) { + selectedPath = std::filesystem::exists(std::filesystem::path(oldDocPath) / dbName) ? oldDocPath : currentDocPath; + } else { + selectedPath = migrateDatabase(dbName, oldDocPath, currentDocPath).string(); + } + }); + return selectedPath; +} - return migrateDatabase(dbName, getOldDocPath(location), currentDocPath).string(); +void HybridNitroSQLite::open(const std::string& dbName, const std::optional& location, std::optional readOnly) { + validateDatabaseName(dbName); + std::lock_guard lock(databaseConnections().lifecycleMutex); + if (databaseConnections().isOpen(dbName)) { + throw NitroSQLiteException::DatabaseAlreadyOpen(dbName); + } + const auto docPath = getMigratedDocPath(dbName, location, readOnly.value_or(false)); + sqliteOpenDb(dbName, docPath, readOnly.value_or(false)); } -void HybridNitroSQLite::open(const std::string& dbName, const std::optional& location) { - const auto docPath = getMigratedDocPath(dbName, location); - sqliteOpenDb(dbName, docPath); +std::string HybridNitroSQLite::openConnection(const std::string& dbName, const std::optional& location, + std::optional readOnly) { + validateDatabaseName(dbName); + if (sqlite3_threadsafe() == 0) { + throw NitroSQLiteException(NitroSQLiteExceptionType::DatabaseCannotBeOpened, + "Independent connections require a thread-safe SQLite build"); + } + std::lock_guard lock(databaseConnections().lifecycleMutex); + const auto docPath = getMigratedDocPath(dbName, location, readOnly.value_or(false)); + return sqliteOpenConnection(dbName, docPath, readOnly.value_or(false)); } void HybridNitroSQLite::close(const std::string& dbName) { sqliteCloseDb(dbName); }; -void HybridNitroSQLite::drop(const std::string& dbName, const std::optional& location) { +bool HybridNitroSQLite::isConnectionOpen(const std::string& connectionId) { + return databaseConnections().isOpen(connectionId); +} + +void HybridNitroSQLite::drop(const std::string& dbName, const std::optional& location, + const std::optional& connectionId) { + validateDatabaseName(dbName); + std::lock_guard lock(databaseConnections().lifecycleMutex); const auto currentDocPath = getDocPath(location); if (migrationDocPath.empty()) { - sqliteRemoveDb(dbName, currentDocPath); + sqliteRemoveDb(dbName, currentDocPath, connectionId); return; } const auto oldDocPath = getOldDocPath(location); + const auto preferredPath = databaseConnections().physicalPathForKey(connectionId.value_or(dbName)); + const auto currentPath = std::filesystem::path(currentDocPath) / dbName; std::error_code ec; const bool oldDatabaseExists = std::filesystem::exists(std::filesystem::path(oldDocPath) / dbName, ec); if (ec) { LOGW("Failed to inspect database %s in its old location: %s", dbName.c_str(), ec.message().c_str()); } - sqliteRemoveDb(dbName, oldDatabaseExists || ec ? oldDocPath : currentDocPath); - removeDatabaseFiles(dbName, oldDocPath); - removeDatabaseFiles(dbName, currentDocPath); + // A stale copy in the old directory must not override the actual target of a live or + // recently closed independent connection. + std::error_code equivalentError; + const bool prefersCurrent = preferredPath && (std::filesystem::equivalent(*preferredPath, currentPath, equivalentError) || + canonicalDatabasePath(*preferredPath) == canonicalDatabasePath(currentPath)); + const bool useOldPath = !prefersCurrent && (preferredPath || oldDatabaseExists || ec); + sqliteRemoveDb(dbName, useOldPath ? oldDocPath : currentDocPath, connectionId, useOldPath ? currentDocPath : oldDocPath); }; void HybridNitroSQLite::attach(const std::string& mainDbName, const std::string& dbNameToAttach, const std::string& alias, const std::optional& location) { + validateDatabaseName(dbNameToAttach); + std::lock_guard lock(databaseConnections().lifecycleMutex); + if (databaseConnections().get(mainDbName)->readOnly) { + throw NitroSQLiteException(NitroSQLiteExceptionType::UnableToAttachToDatabase, "Cannot attach a database to a read-only connection"); + } const auto attachedDocPath = getMigratedDocPath(dbNameToAttach, location); sqliteAttachDb(mainDbName, attachedDocPath, dbNameToAttach, alias); }; diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp index 0c29298a..ba62ea38 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp @@ -21,11 +21,15 @@ class HybridNitroSQLite : public HybridNitroSQLiteSpec { public: // Methods - void open(const std::string& dbName, const std::optional& location) override; + void open(const std::string& dbName, const std::optional& location, std::optional readOnly) override; + + std::string openConnection(const std::string& dbName, const std::optional& location, std::optional readOnly) override; void close(const std::string& dbName) override; - void drop(const std::string& dbName, const std::optional& location) override; + bool isConnectionOpen(const std::string& connectionId) override; + + void drop(const std::string& dbName, const std::optional& location, const std::optional& connectionId) override; void attach(const std::string& mainDbName, const std::string& dbNameToAttach, const std::string& alias, const std::optional& location) override; diff --git a/packages/react-native-nitro-sqlite/cpp/operations.cpp b/packages/react-native-nitro-sqlite/cpp/operations.cpp index 8c99db01..fbafe4af 100644 --- a/packages/react-native-nitro-sqlite/cpp/operations.cpp +++ b/packages/react-native-nitro-sqlite/cpp/operations.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include #include #include @@ -30,96 +29,33 @@ namespace margelo::rnnitrosqlite { static constexpr double kInt64MinAsDouble = static_cast(std::numeric_limits::min()); static constexpr double kInt64UpperBoundAsDouble = -kInt64MinAsDouble; -namespace { - - std::map dbMap; - std::mutex dbMapMutex; - std::mutex dbLifecycleMutex; - -} // namespace - -SQLiteConnection::SQLiteConnection(std::string connectionName, sqlite3* database) : name(std::move(connectionName)), database(database) {} - -SQLiteConnection::~SQLiteConnection() { - close(); -} - -void SQLiteConnection::close() noexcept { - std::lock_guard lock(mutex); - if (database == nullptr) { - return; - } - - sqlite3_close_v2(database); - database = nullptr; -} - -void sqliteOpenDb(const std::string& dbName, const std::string& docPath) { - std::lock_guard lifecycleLock(dbLifecycleMutex); - { - std::lock_guard lock(dbMapMutex); - if (dbMap.contains(dbName)) { - throw NitroSQLiteException::DatabaseAlreadyOpen(dbName); - } - } - +void sqliteOpenDb(const std::string& dbName, const std::string& docPath, bool readOnly) { #ifdef NITRO_SQLITE_VEC // Register before opening so the connection exposes vec0 + vec_*. margelo::rnnitrosqlitevec::registerVectorExtensions(); #endif + const std::string dbPath = readOnly ? docPath + "/" + dbName : get_db_path(dbName, docPath); + databaseConnections().open(dbName, dbPath, readOnly); +} - std::string dbPath = get_db_path(dbName, docPath); - - int sqlOpenFlags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX; - - sqlite3* rawDatabase = nullptr; - const int openStatus = sqlite3_open_v2(dbPath.c_str(), &rawDatabase, sqlOpenFlags, nullptr); - std::unique_ptr database(rawDatabase, sqlite3_close_v2); - - if (openStatus != SQLITE_OK) { - const std::string errorMessage = rawDatabase == nullptr ? sqlite3_errstr(openStatus) : sqlite3_errmsg(rawDatabase); - throw NitroSQLiteException(NitroSQLiteExceptionType::DatabaseCannotBeOpened, errorMessage); - } - - auto connection = std::make_shared(dbName, database.get()); - database.release(); - { - std::lock_guard lock(dbMapMutex); - const bool inserted = dbMap.emplace(dbName, connection).second; - if (!inserted) { - throw NitroSQLiteException::DatabaseAlreadyOpen(dbName); - } +std::string sqliteOpenConnection(const std::string& dbName, const std::string& docPath, bool readOnly) { + if (sqlite3_threadsafe() == 0) { + throw NitroSQLiteException(NitroSQLiteExceptionType::DatabaseCannotBeOpened, + "Independent connections require a thread-safe SQLite build"); } +#ifdef NITRO_SQLITE_VEC + margelo::rnnitrosqlitevec::registerVectorExtensions(); +#endif + const std::string dbPath = readOnly ? docPath + "/" + dbName : get_db_path(dbName, docPath); + return databaseConnections().openIndependent(dbPath, readOnly); } void sqliteCloseDb(const std::string& dbName) { - std::lock_guard lifecycleLock(dbLifecycleMutex); - SQLiteConnectionPtr connection; - { - std::lock_guard lock(dbMapMutex); - auto iterator = dbMap.find(dbName); - if (iterator == dbMap.end()) { - throw NitroSQLiteException::DatabaseNotOpen(dbName); - } - - connection = std::move(iterator->second); - dbMap.erase(iterator); - } - - connection->close(); + databaseConnections().close(dbName); } void sqliteCloseAll() { - std::lock_guard lifecycleLock(dbLifecycleMutex); - std::map connections; - { - std::lock_guard lock(dbMapMutex); - connections.swap(dbMap); - } - - for (const auto& [_, connection] : connections) { - connection->close(); - } + databaseConnections().closeAll(); } void sqliteAttachDb(const std::string& mainDBName, const std::string& docPath, const std::string& databaseToAttach, @@ -152,28 +88,13 @@ void sqliteDetachDb(const std::string& mainDBName, const std::string& alias) { } } -void sqliteRemoveDb(const std::string& dbName, const std::string& docPath) { - std::lock_guard lifecycleLock(dbLifecycleMutex); - const std::string dbFilePath = get_db_path(dbName, docPath); - if (!file_exists(dbFilePath)) { - throw NitroSQLiteException::DatabaseFileNotFound(dbFilePath); - } - - SQLiteConnectionPtr connection; - { - std::lock_guard lock(dbMapMutex); - auto iterator = dbMap.find(dbName); - if (iterator != dbMap.end()) { - connection = std::move(iterator->second); - dbMap.erase(iterator); - } - } - - if (connection) { - connection->close(); +void sqliteRemoveDb(const std::string& dbName, const std::string& docPath, const std::optional& connectionId, + const std::optional& otherDocPath) { + std::optional otherPath; + if (otherDocPath) { + otherPath = std::filesystem::path(*otherDocPath) / dbName; } - - remove(dbFilePath.c_str()); + databaseConnections().drop(dbName, std::filesystem::path(docPath) / dbName, connectionId, otherPath); } void bindStatement(sqlite3_stmt* statement, const SQLiteQueryParams& values) { @@ -251,13 +172,7 @@ namespace { } // namespace SQLiteConnectionPtr sqliteGetOpenDatabase(const std::string& dbName) { - std::lock_guard lock(dbMapMutex); - auto iterator = dbMap.find(dbName); - if (iterator == dbMap.end()) { - throw NitroSQLiteException::DatabaseNotOpen(dbName); - } - - return iterator->second; + return databaseConnections().get(dbName); } std::shared_ptr sqliteExecute(const std::string& dbName, const std::string& query, diff --git a/packages/react-native-nitro-sqlite/cpp/operations.hpp b/packages/react-native-nitro-sqlite/cpp/operations.hpp index 549d68e5..3aabf8f5 100644 --- a/packages/react-native-nitro-sqlite/cpp/operations.hpp +++ b/packages/react-native-nitro-sqlite/cpp/operations.hpp @@ -1,5 +1,6 @@ #pragma once +#include "databaseConnections.hpp" #include "hybridObjects/HybridNitroSQLiteQueryResult.hpp" #include "types.hpp" #include @@ -9,30 +10,14 @@ namespace margelo::rnnitrosqlite { -// Calls against one connection are serialized by `mutex`. Separate connections -// intentionally remain independent, so SQLITE_THREADSAFE=0 still requires the -// caller to serialize SQLite calls globally. -struct SQLiteConnection final { - SQLiteConnection(std::string name, sqlite3* database); - ~SQLiteConnection(); +void sqliteOpenDb(const std::string& dbName, const std::string& docPath, bool readOnly = false); - SQLiteConnection(const SQLiteConnection&) = delete; - SQLiteConnection& operator=(const SQLiteConnection&) = delete; - - void close() noexcept; - - const std::string name; - sqlite3* database; - std::recursive_mutex mutex; -}; - -using SQLiteConnectionPtr = std::shared_ptr; - -void sqliteOpenDb(const std::string& dbName, const std::string& docPath); +std::string sqliteOpenConnection(const std::string& dbName, const std::string& docPath, bool readOnly = false); void sqliteCloseDb(const std::string& dbName); -void sqliteRemoveDb(const std::string& dbName, const std::string& docPath); +void sqliteRemoveDb(const std::string& dbName, const std::string& docPath, const std::optional& connectionId = std::nullopt, + const std::optional& otherDocPath = std::nullopt); void sqliteAttachDb(const std::string& mainDBName, const std::string& docPath, const std::string& databaseToAttach, const std::string& alias); diff --git a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.cpp b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.cpp index aea61f16..305201f2 100644 --- a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.cpp +++ b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.cpp @@ -15,6 +15,8 @@ namespace margelo::nitro::rnnitrosqlite { // load custom methods/properties registerHybrids(this, [](Prototype& prototype) { prototype.registerHybridMethod("open", &HybridNitroSQLiteSpec::open); + prototype.registerHybridMethod("openConnection", &HybridNitroSQLiteSpec::openConnection); + prototype.registerHybridMethod("isConnectionOpen", &HybridNitroSQLiteSpec::isConnectionOpen); prototype.registerHybridMethod("close", &HybridNitroSQLiteSpec::close); prototype.registerHybridMethod("drop", &HybridNitroSQLiteSpec::drop); prototype.registerHybridMethod("attach", &HybridNitroSQLiteSpec::attach); diff --git a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.hpp b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.hpp index a773422e..ed91df3d 100644 --- a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.hpp +++ b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLiteSpec.hpp @@ -66,9 +66,11 @@ namespace margelo::nitro::rnnitrosqlite { public: // Methods - virtual void open(const std::string& dbName, const std::optional& location) = 0; + virtual void open(const std::string& dbName, const std::optional& location, std::optional readOnly) = 0; + virtual std::string openConnection(const std::string& dbName, const std::optional& location, std::optional readOnly) = 0; + virtual bool isConnectionOpen(const std::string& connectionId) = 0; virtual void close(const std::string& dbName) = 0; - virtual void drop(const std::string& dbName, const std::optional& location) = 0; + virtual void drop(const std::string& dbName, const std::optional& location, const std::optional& connectionId) = 0; virtual void attach(const std::string& mainDbName, const std::string& dbNameToAttach, const std::string& alias, const std::optional& location) = 0; virtual void detach(const std::string& mainDbName, const std::string& alias) = 0; virtual std::shared_ptr execute(const std::string& dbName, const std::string& query, const std::optional, std::string, double>>>& params) = 0; diff --git a/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts b/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts index 1ad4d258..26ab100a 100644 --- a/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts +++ b/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts @@ -12,42 +12,44 @@ export type DatabaseQueue = { inProgress: boolean } -const databaseQueues = new Map() +export type DatabaseQueueKey = string | symbol -export function openDatabaseQueue(dbName: string) { +const databaseQueues = new Map() + +export function openDatabaseQueue(dbName: DatabaseQueueKey) { if (isDatabaseOpen(dbName)) { throw new NitroSQLiteError( - `Database ${dbName} is already open. There is already a connection to the database.`, + `Database ${String(dbName)} is already open. There is already a connection to the database.`, ) } databaseQueues.set(dbName, { queue: [], inProgress: false }) } -export function closeDatabaseQueue(dbName: string) { +export function closeDatabaseQueue(dbName: DatabaseQueueKey) { const databaseQueue = getDatabaseQueue(dbName) if (databaseQueue.inProgress || databaseQueue.queue.length > 0) { throw new NitroSQLiteError( - `Cannot close database ${dbName}. The database is busy with another operation.`, + `Cannot close database ${String(dbName)}. The database is busy with another operation.`, ) } databaseQueues.delete(dbName) } -export function isDatabaseOpen(dbName: string) { +export function isDatabaseOpen(dbName: DatabaseQueueKey) { return databaseQueues.has(dbName) } -export function throwIfDatabaseIsNotOpen(dbName: string) { +export function throwIfDatabaseIsNotOpen(dbName: DatabaseQueueKey) { if (!isDatabaseOpen(dbName)) throw new NitroSQLiteError( - `Database ${dbName} is not open. There is no connection to the database.`, + `Database ${String(dbName)} is not open. There is no connection to the database.`, ) } -export function getDatabaseQueue(dbName: string) { +export function getDatabaseQueue(dbName: DatabaseQueueKey) { throwIfDatabaseIsNotOpen(dbName) const queue = databaseQueues.get(dbName)! @@ -55,7 +57,7 @@ export function getDatabaseQueue(dbName: string) { } export function queueOperationAsync( - dbName: string, + dbName: DatabaseQueueKey, callback: () => Promise, ) { const databaseQueue = getDatabaseQueue(dbName) @@ -97,7 +99,7 @@ function startOperationAsync(queue: DatabaseQueue) { } export function startOperationSync( - dbName: string, + dbName: DatabaseQueueKey, callback: () => Result, ): Result { const databaseQueue = getDatabaseQueue(dbName) @@ -105,7 +107,7 @@ export function startOperationSync( // Database is busy - cannot execute synchronously if (databaseQueue.inProgress || databaseQueue.queue.length > 0) { throw new NitroSQLiteError( - `Cannot run synchronous operation on database. Database ${dbName} is busy with another operation.`, + `Cannot run synchronous operation on database. Database ${String(dbName)} is busy with another operation.`, ) } diff --git a/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts b/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts index 717519c9..22a9ccfb 100644 --- a/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts +++ b/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts @@ -1,5 +1,7 @@ export const HybridNitroSQLite = { open: jest.fn(), + openConnection: jest.fn(), + isConnectionOpen: jest.fn(), close: jest.fn(), drop: jest.fn(), attach: jest.fn(), diff --git a/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts index e44b0be6..daeca277 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts @@ -3,7 +3,8 @@ jest.mock('../nitro') import { HybridNitroSQLite } from '../nitro' import { closeDatabaseQueue, isDatabaseOpen } from '../DatabaseQueue' import { open } from '../operations/session' -import { nativeResult } from './testUtils' +import { execute } from '../operations/execute' +import { deferred, nativeResult } from './testUtils' const dbName = 'session-test' const options = { name: dbName, location: 'data' } @@ -12,6 +13,7 @@ beforeEach(() => { jest.clearAllMocks() jest.mocked(HybridNitroSQLite.execute).mockReturnValue(nativeResult()) jest.mocked(HybridNitroSQLite.executeAsync).mockResolvedValue(nativeResult()) + jest.mocked(HybridNitroSQLite.isConnectionOpen).mockReturnValue(true) }) afterEach(() => { @@ -122,10 +124,45 @@ describe('open', () => { expect(() => db.delete()).toThrow('cannot delete') expect(isDatabaseOpen(dbName)).toBe(true) + expect(HybridNitroSQLite.isConnectionOpen).toHaveBeenCalledWith(dbName) db.delete() expect(isDatabaseOpen(dbName)).toBe(false) }) + it('releases the queue when deletion closes native before unlink fails', () => { + const stale = open(options) + jest.mocked(HybridNitroSQLite.drop).mockImplementationOnce(() => { + throw new Error('unlink failed') + }) + jest.mocked(HybridNitroSQLite.isConnectionOpen).mockReturnValueOnce(false) + + expect(() => stale.delete()).toThrow('unlink failed') + expect(HybridNitroSQLite.isConnectionOpen).toHaveBeenCalledWith(dbName) + expect(isDatabaseOpen(dbName)).toBe(false) + + const current = open(options) + expect(() => stale.delete()).toThrow('reopened') + current.close() + }) + + it('does not delete or inspect a connection while its queue is busy', async () => { + const db = open(options) + const gate = deferred>() + jest + .mocked(HybridNitroSQLite.executeAsync) + .mockReturnValueOnce(gate.promise) + const pending = db.executeAsync('SELECT waiting') + + expect(() => db.delete()).toThrow('busy') + expect(HybridNitroSQLite.drop).not.toHaveBeenCalled() + expect(HybridNitroSQLite.isConnectionOpen).not.toHaveBeenCalled() + expect(isDatabaseOpen(dbName)).toBe(true) + + gate.resolve(nativeResult()) + await pending + db.close() + }) + it('passes attach, detach, and file loading through the native connection', async () => { const db = open(options) jest.mocked(HybridNitroSQLite.loadFile).mockReturnValue({ commands: 2 }) @@ -175,4 +212,195 @@ describe('open', () => { commands: 1, }) }) + + it('opens independent sessions for the same file with separate native IDs', () => { + jest + .mocked(HybridNitroSQLite.openConnection) + .mockReturnValueOnce('connection-1') + .mockReturnValueOnce('connection-2') + const first = open({ ...options, connection: 'independent' }) + const second = open({ ...options, connection: 'independent' }) + + expect(HybridNitroSQLite.openConnection).toHaveBeenNthCalledWith( + 1, + dbName, + 'data', + ) + expect(HybridNitroSQLite.openConnection).toHaveBeenNthCalledWith( + 2, + dbName, + 'data', + ) + first.execute('SELECT 1') + second.execute('SELECT 2') + expect(HybridNitroSQLite.execute).toHaveBeenNthCalledWith( + 1, + 'connection-1', + 'SELECT 1', + undefined, + ) + expect(HybridNitroSQLite.execute).toHaveBeenNthCalledWith( + 2, + 'connection-2', + 'SELECT 2', + undefined, + ) + + first.close() + expect(HybridNitroSQLite.close).toHaveBeenCalledWith('connection-1') + second.execute('SELECT 3') + second.close() + }) + + it('keeps independent queues separate during an entire transaction callback', async () => { + jest + .mocked(HybridNitroSQLite.openConnection) + .mockReturnValueOnce('transaction-1') + .mockReturnValueOnce('transaction-2') + const first = open({ ...options, connection: 'independent' }) + const second = open({ ...options, connection: 'independent' }) + const gate = deferred() + const entered = deferred() + const transactionPromise = first.transaction(async (tx) => { + entered.resolve() + await gate.promise + tx.execute('SELECT 1') + }) + + await entered.promise + const pendingOnFirst = first.executeAsync('SELECT 2') + await expect(second.executeAsync('SELECT 3')).resolves.toBeDefined() + expect(HybridNitroSQLite.executeAsync).toHaveBeenCalledWith( + 'transaction-2', + 'SELECT 3', + undefined, + ) + expect(HybridNitroSQLite.executeAsync).not.toHaveBeenCalledWith( + 'transaction-1', + 'SELECT 2', + undefined, + ) + + gate.resolve() + await transactionPromise + await pendingOnFirst + first.close() + second.close() + }) + + it('cleans up failed independent opens and forwards readOnly', () => { + jest + .mocked(HybridNitroSQLite.openConnection) + .mockImplementationOnce(() => { + throw new Error('independent open failed') + }) + .mockReturnValueOnce('readonly-1') + + expect(() => open({ ...options, connection: 'independent' })).toThrow( + 'independent open failed', + ) + const readOnly = open({ + ...options, + connection: 'independent', + readOnly: true, + }) + expect(HybridNitroSQLite.openConnection).toHaveBeenLastCalledWith( + dbName, + 'data', + true, + ) + expect(() => readOnly.delete()).toThrow('read-only') + expect(HybridNitroSQLite.drop).not.toHaveBeenCalled() + readOnly.close() + + const defaultReadOnly = open({ ...options, readOnly: true }) + expect(HybridNitroSQLite.open).toHaveBeenCalledWith(dbName, 'data', true) + expect(() => defaultReadOnly.delete()).toThrow('read-only') + defaultReadOnly.close() + }) + + it('passes an independent ID to delete, including after close', () => { + jest + .mocked(HybridNitroSQLite.openConnection) + .mockReturnValueOnce('delete-1') + .mockReturnValueOnce('delete-2') + const first = open({ ...options, connection: 'independent' }) + const second = open({ ...options, connection: 'independent' }) + first.close() + first.delete() + expect(HybridNitroSQLite.drop).toHaveBeenCalledWith( + dbName, + 'data', + 'delete-1', + ) + expect(second.execute('SELECT 1')).toBeDefined() + second.delete() + expect(HybridNitroSQLite.drop).toHaveBeenCalledWith( + dbName, + 'data', + 'delete-2', + ) + }) + + it('rejects stale default wrappers after the database is reopened', () => { + const stale = open(options) + stale.close() + const current = open(options) + + expect(() => stale.execute('SELECT 1')).toThrow('not open') + expect(() => stale.close()).toThrow('not open') + expect(() => stale.delete()).toThrow('reopened') + expect(HybridNitroSQLite.drop).not.toHaveBeenCalled() + current.execute('SELECT 2') + current.close() + }) + + it('isolates an independent queue when its native ID equals a default name', () => { + jest.mocked(HybridNitroSQLite.openConnection).mockReturnValueOnce(dbName) + const independent = open({ ...options, connection: 'independent' }) + const defaultConnection = open(options) + + independent.execute('SELECT independent') + defaultConnection.execute('SELECT default') + expect(HybridNitroSQLite.execute).toHaveBeenCalledTimes(2) + + independent.close() + defaultConnection.execute('SELECT still open') + defaultConnection.close() + }) + + it('keeps name-based calls on the default connection', () => { + jest + .mocked(HybridNitroSQLite.openConnection) + .mockReturnValueOnce('other-id') + const independent = open({ ...options, connection: 'independent' }) + const defaultConnection = open(options) + + execute(dbName, 'SELECT global') + independent.execute('SELECT independent') + expect(HybridNitroSQLite.execute).toHaveBeenNthCalledWith( + 1, + dbName, + 'SELECT global', + undefined, + ) + expect(HybridNitroSQLite.execute).toHaveBeenNthCalledWith( + 2, + 'other-id', + 'SELECT independent', + undefined, + ) + + defaultConnection.close() + independent.close() + }) + + it('keeps readOnly protection if the options object changes later', () => { + const readOnlyOptions = { ...options, readOnly: true } + const db = open(readOnlyOptions) + readOnlyOptions.readOnly = false + + expect(() => db.delete()).toThrow('read-only') + db.close() + }) }) diff --git a/packages/react-native-nitro-sqlite/src/operations/execute.ts b/packages/react-native-nitro-sqlite/src/operations/execute.ts index bc75a84a..96c2e107 100644 --- a/packages/react-native-nitro-sqlite/src/operations/execute.ts +++ b/packages/react-native-nitro-sqlite/src/operations/execute.ts @@ -7,6 +7,7 @@ import { queueOperationAsync, startOperationSync, } from '../DatabaseQueue' +import type { DatabaseQueueKey } from '../DatabaseQueue' export function execute( dbName: string, @@ -24,8 +25,11 @@ export function executeManaged( dbName: string, query: string, params?: SQLiteQueryParams, + queueKey: DatabaseQueueKey = dbName, ): QueryResult { - return startOperationSync(dbName, () => executeNative(dbName, query, params)) + return startOperationSync(queueKey, () => + executeNative(dbName, query, params), + ) } export function executeNative( @@ -57,8 +61,9 @@ export async function executeAsyncManaged( dbName: string, query: string, params?: SQLiteQueryParams, + queueKey: DatabaseQueueKey = dbName, ): Promise> { - return queueOperationAsync(dbName, () => + return queueOperationAsync(queueKey, () => executeAsyncNative(dbName, query, params), ) } diff --git a/packages/react-native-nitro-sqlite/src/operations/executeBatch.ts b/packages/react-native-nitro-sqlite/src/operations/executeBatch.ts index 983e40e7..f54f61f8 100644 --- a/packages/react-native-nitro-sqlite/src/operations/executeBatch.ts +++ b/packages/react-native-nitro-sqlite/src/operations/executeBatch.ts @@ -6,15 +6,17 @@ import { } from '../DatabaseQueue' import NitroSQLiteError from '../NitroSQLiteError' import type { BatchQueryCommand, BatchQueryResult } from '../types' +import type { DatabaseQueueKey } from '../DatabaseQueue' export function executeBatch( dbName: string, commands: BatchQueryCommand[], + queueKey: DatabaseQueueKey = dbName, ): BatchQueryResult { - throwIfDatabaseIsNotOpen(dbName) + throwIfDatabaseIsNotOpen(queueKey) try { - return startOperationSync(dbName, () => + return startOperationSync(queueKey, () => HybridNitroSQLite.executeBatch(dbName, commands), ) } catch (error) { @@ -25,10 +27,11 @@ export function executeBatch( export async function executeBatchAsync( dbName: string, commands: BatchQueryCommand[], + queueKey: DatabaseQueueKey = dbName, ): Promise { - throwIfDatabaseIsNotOpen(dbName) + throwIfDatabaseIsNotOpen(queueKey) - return queueOperationAsync(dbName, async () => { + return queueOperationAsync(queueKey, async () => { try { return await HybridNitroSQLite.executeBatchAsync(dbName, commands) } catch (error) { diff --git a/packages/react-native-nitro-sqlite/src/operations/session.ts b/packages/react-native-nitro-sqlite/src/operations/session.ts index 6b4d5a6d..d3d9e9d8 100644 --- a/packages/react-native-nitro-sqlite/src/operations/session.ts +++ b/packages/react-native-nitro-sqlite/src/operations/session.ts @@ -14,92 +14,171 @@ import { executeBatch, executeBatchAsync } from './executeBatch' import NitroSQLiteError from '../NitroSQLiteError' import { closeDatabaseQueue, + getDatabaseQueue, isDatabaseOpen, openDatabaseQueue, queueOperationAsync, startOperationSync, } from '../DatabaseQueue' +import type { DatabaseQueueKey } from '../DatabaseQueue' export function open( options: NitroSQLiteConnectionOptions, ): NitroSQLiteConnection { - openDatabaseQueue(options.name) + const { name, location: databaseLocation, connection, readOnly } = options + const { connectionId, queueKey } = openNativeConnection(options) + const connectionQueue = getDatabaseQueue(queueKey) - try { - HybridNitroSQLite.open(options.name, options.location) - } catch (error) { - closeDatabaseQueue(options.name) - throw NitroSQLiteError.fromError(error) + const assertCurrentConnection = () => { + if ( + !isDatabaseOpen(queueKey) || + getDatabaseQueue(queueKey) !== connectionQueue + ) { + throw new NitroSQLiteError( + `Database ${name} is not open. There is no connection to the database.`, + ) + } + } + + const assertNoReplacement = () => { + if ( + isDatabaseOpen(queueKey) && + getDatabaseQueue(queueKey) !== connectionQueue + ) { + throw new NitroSQLiteError( + `Database ${name} has been reopened with another connection.`, + ) + } + } + + const runSyncOperation = (callback: () => Result): Result => { + try { + assertCurrentConnection() + return startOperationSync(queueKey, callback) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } + } + + const runOperation = (callback: () => Result): Result => { + assertCurrentConnection() + return callback() } return { close: () => { - try { - startOperationSync(options.name, () => - HybridNitroSQLite.close(options.name), - ) - closeDatabaseQueue(options.name) - } catch (error) { - throw NitroSQLiteError.fromError(error) - } + runSyncOperation(() => HybridNitroSQLite.close(connectionId)) + closeDatabaseQueue(queueKey) }, delete: () => { + if (readOnly) { + throw new NitroSQLiteError(`Cannot delete read-only database ${name}.`) + } + + let dropFailed = false try { - if (!isDatabaseOpen(options.name)) { - HybridNitroSQLite.drop(options.name, options.location) + assertNoReplacement() + const drop = () => { + try { + if (connection === 'independent') { + HybridNitroSQLite.drop(name, databaseLocation, connectionId) + } else { + HybridNitroSQLite.drop(name, databaseLocation) + } + } catch (error) { + dropFailed = true + throw error + } + } + + if (!isDatabaseOpen(queueKey)) { + drop() return } - startOperationSync(options.name, () => - HybridNitroSQLite.drop(options.name, options.location), - ) - closeDatabaseQueue(options.name) + startOperationSync(queueKey, drop) + closeDatabaseQueue(queueKey) } catch (error) { + if ( + dropFailed && + isDatabaseOpen(queueKey) && + getDatabaseQueue(queueKey) === connectionQueue && + !HybridNitroSQLite.isConnectionOpen(connectionId) + ) { + closeDatabaseQueue(queueKey) + } throw NitroSQLiteError.fromError(error) } }, attach: (dbNameToAttach: string, alias: string, location?: string) => - runSyncOperation(options.name, () => - HybridNitroSQLite.attach(options.name, dbNameToAttach, alias, location), + runSyncOperation(() => + HybridNitroSQLite.attach(connectionId, dbNameToAttach, alias, location), ), detach: (alias: string) => - runSyncOperation(options.name, () => - HybridNitroSQLite.detach(options.name, alias), - ), + runSyncOperation(() => HybridNitroSQLite.detach(connectionId, alias)), transaction: (fn: (tx: Transaction) => Promise) => - transaction(options.name, fn), + runOperation(() => transaction(connectionId, fn, false, queueKey)), execute: ( query: string, params?: SQLiteQueryParams, - ): QueryResult => executeManaged(options.name, query, params), + ): QueryResult => + runOperation(() => executeManaged(connectionId, query, params, queueKey)), executeAsync: ( query: string, params?: SQLiteQueryParams, ): Promise> => - executeAsyncManaged(options.name, query, params), + runOperation(() => + executeAsyncManaged(connectionId, query, params, queueKey), + ), executeBatch: (commands: BatchQueryCommand[]) => - executeBatch(options.name, commands), + runOperation(() => executeBatch(connectionId, commands, queueKey)), executeBatchAsync: (commands: BatchQueryCommand[]) => - executeBatchAsync(options.name, commands), + runOperation(() => executeBatchAsync(connectionId, commands, queueKey)), loadFile: (location: string) => - runSyncOperation(options.name, () => - HybridNitroSQLite.loadFile(options.name, location), + runSyncOperation(() => + HybridNitroSQLite.loadFile(connectionId, location), ), loadFileAsync: (location: string) => - queueOperationAsync(options.name, async () => { - try { - return await HybridNitroSQLite.loadFileAsync(options.name, location) - } catch (error) { - throw NitroSQLiteError.fromError(error) - } - }), + runOperation(() => + queueOperationAsync(queueKey, async () => { + try { + return await HybridNitroSQLite.loadFileAsync(connectionId, location) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } + }), + ), } } -function runSyncOperation(dbName: string, callback: () => Result) { +function openNativeConnection(options: NitroSQLiteConnectionOptions): { + connectionId: string + queueKey: DatabaseQueueKey +} { + if (options.connection === 'independent') { + let connectionId: string + try { + connectionId = options.readOnly + ? HybridNitroSQLite.openConnection(options.name, options.location, true) + : HybridNitroSQLite.openConnection(options.name, options.location) + const queueKey = Symbol(options.name) + openDatabaseQueue(queueKey) + return { connectionId, queueKey } + } catch (error) { + throw NitroSQLiteError.fromError(error) + } + } + + openDatabaseQueue(options.name) try { - return startOperationSync(dbName, callback) + if (options.readOnly) { + HybridNitroSQLite.open(options.name, options.location, true) + } else { + HybridNitroSQLite.open(options.name, options.location) + } } catch (error) { + closeDatabaseQueue(options.name) throw NitroSQLiteError.fromError(error) } + return { connectionId: options.name, queueKey: options.name } } diff --git a/packages/react-native-nitro-sqlite/src/operations/transaction.ts b/packages/react-native-nitro-sqlite/src/operations/transaction.ts index 25f68b2d..955e4957 100644 --- a/packages/react-native-nitro-sqlite/src/operations/transaction.ts +++ b/packages/react-native-nitro-sqlite/src/operations/transaction.ts @@ -7,13 +7,15 @@ import type { } from '../types' import { executeAsyncNative, executeNative } from './execute' import NitroSQLiteError from '../NitroSQLiteError' +import type { DatabaseQueueKey } from '../DatabaseQueue' export const transaction = async ( dbName: string, transactionCallback: (tx: Transaction) => Promise, isExclusive = false, + queueKey: DatabaseQueueKey = dbName, ) => { - throwIfDatabaseIsNotOpen(dbName) + throwIfDatabaseIsNotOpen(queueKey) let isFinished = false @@ -61,7 +63,7 @@ export const transaction = async ( return executeNative(dbName, 'ROLLBACK') } - return await queueOperationAsync(dbName, async () => { + return await queueOperationAsync(queueKey, async () => { try { await executeAsyncNative( dbName, diff --git a/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts b/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts index 650493db..868e23ae 100644 --- a/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts +++ b/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts @@ -12,9 +12,11 @@ export interface NitroSQLite ios: 'c++' android: 'c++' }> { - open(dbName: string, location?: string): void + open(dbName: string, location?: string, readOnly?: boolean): void + openConnection(dbName: string, location?: string, readOnly?: boolean): string + isConnectionOpen(connectionId: string): boolean close(dbName: string): void - drop(dbName: string, location?: string): void + drop(dbName: string, location?: string, connectionId?: string): void attach( mainDbName: string, dbNameToAttach: string, diff --git a/packages/react-native-nitro-sqlite/src/types.ts b/packages/react-native-nitro-sqlite/src/types.ts index 517bcca3..0a9b20b8 100644 --- a/packages/react-native-nitro-sqlite/src/types.ts +++ b/packages/react-native-nitro-sqlite/src/types.ts @@ -3,6 +3,10 @@ import type { NitroSQLiteQueryResult } from './specs/NitroSQLiteQueryResult.nitr export interface NitroSQLiteConnectionOptions { name: string location?: string + /** Reuse the name-based default connection, or open a separate connection to the same file. Defaults to 'default'. */ + connection?: 'default' | 'independent' + /** Open an existing database without write access. A read-only connection cannot delete the database. */ + readOnly?: boolean } export interface NitroSQLiteConnection { diff --git a/packages/react-native-nitro-sqlite/tests/cpp/databaseConnections.test.cpp b/packages/react-native-nitro-sqlite/tests/cpp/databaseConnections.test.cpp new file mode 100644 index 00000000..9dfb51cb --- /dev/null +++ b/packages/react-native-nitro-sqlite/tests/cpp/databaseConnections.test.cpp @@ -0,0 +1,220 @@ +#include "databaseConnections.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using margelo::rnnitrosqlite::DatabaseConnections; + +namespace { + +struct QueryGate { + std::mutex mutex; + std::condition_variable condition; + bool entered = false; + bool release = false; +}; + +void waitInStatement(sqlite3_context* context, int, sqlite3_value**) { + auto* gate = static_cast(sqlite3_user_data(context)); + std::unique_lock lock(gate->mutex); + gate->entered = true; + gate->condition.notify_all(); + gate->condition.wait(lock, [&]() { return gate->release; }); + sqlite3_result_int(context, 1); +} + +void expect(bool condition, const std::string& message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +void execute(const margelo::rnnitrosqlite::SQLiteConnectionPtr& connection, const std::string& sql) { + std::lock_guard lock(connection->mutex); + char* error = nullptr; + const int result = sqlite3_exec(connection->database, sql.c_str(), nullptr, nullptr, &error); + if (result == SQLITE_OK) { + return; + } + const std::string message = error == nullptr ? sqlite3_errmsg(connection->database) : error; + sqlite3_free(error); + throw std::runtime_error(message); +} + +int scalar(const margelo::rnnitrosqlite::SQLiteConnectionPtr& connection, const std::string& sql) { + std::lock_guard lock(connection->mutex); + sqlite3_stmt* statement = nullptr; + if (sqlite3_prepare_v2(connection->database, sql.c_str(), -1, &statement, nullptr) != SQLITE_OK) { + throw std::runtime_error(sqlite3_errmsg(connection->database)); + } + if (sqlite3_step(statement) != SQLITE_ROW) { + sqlite3_finalize(statement); + throw std::runtime_error("query returned no row"); + } + const int value = sqlite3_column_int(statement, 0); + sqlite3_finalize(statement); + return value; +} + +void expectThrows(const std::function& action, const std::string& message) { + try { + action(); + } catch (const std::exception&) { + return; + } + throw std::runtime_error(message); +} + +} // namespace + +int main() { + const auto root = fs::temp_directory_path() / + ("nitro-sqlite-connections-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + fs::create_directories(root); + try { + DatabaseConnections registry; + const auto path = root / "shared.sqlite"; + registry.open("shared.sqlite", path, false); + const auto first = registry.get("shared.sqlite"); + try { + registry.drop("shared.sqlite", root / "missing" / "shared.sqlite", std::nullopt); + throw std::runtime_error("deleting a missing target must fail"); + } catch (const std::exception& error) { + expect(std::string(error.what()).find("Database file not found") != std::string::npos, + "a missing target must preserve the existing file-not-found error"); + } + expect(registry.get("shared.sqlite") == first, "deleting a missing target must preserve the live handle"); + execute(first, "CREATE TABLE values_table (value INTEGER)"); + execute(first, "CREATE TEMP TABLE private_table (value INTEGER)"); + + const auto secondId = registry.openIndependent(path, false); + const auto second = registry.get(secondId); + expect(secondId.front() == '\0', "independent IDs must not be valid filenames"); + expect(first->database != second->database, "independent opens must use distinct SQLite handles"); + expect(registry.findLivePath(path, root / "migrated.sqlite") == path, "live old path must be reused during migration"); + expectThrows([&]() { execute(second, "SELECT * FROM private_table"); }, "temporary tables must be connection local"); + execute(second, "INSERT INTO values_table VALUES (1)"); + expect(scalar(first, "SELECT COUNT(*) FROM values_table") == 1, "connections should share file data"); + expectThrows([&]() { registry.drop("shared.sqlite", path, secondId); }, "drop must reject a live peer"); + expect(second->database != nullptr, "rejected drop must preserve its own connection"); + + registry.close("shared.sqlite"); + expect(first->database == nullptr && second->database != nullptr, "closing one handle must preserve another"); + registry.close(secondId); + registry.drop("shared.sqlite", path, secondId); + expect(!fs::exists(path), "closed independent ID should delete its own file"); + + registry.open("shared.sqlite", path, false); + registry.close("shared.sqlite"); + const auto readOnlyId = registry.openIndependent(path, true); + expectThrows([&]() { execute(registry.get(readOnlyId), "CREATE TABLE forbidden (id INTEGER)"); }, + "read-only handles must reject writes"); + registry.close(readOnlyId); + expectThrows([&]() { registry.drop("shared.sqlite", path, readOnlyId); }, "read-only handles must not delete after close"); + expect(fs::exists(path), "read-only drop must preserve the file"); + registry.open("shared.sqlite", path, true); + expectThrows([&]() { execute(registry.get("shared.sqlite"), "ATTACH DATABASE '" + path.string() + "' AS forbidden"); }, + "read-only handles must reject writable attachments"); + expectThrows([&]() { registry.drop("shared.sqlite", path, std::nullopt); }, "read-only default handle must not delete its file"); + expect(registry.get("shared.sqlite")->database != nullptr, "rejected read-only drop must preserve its handle"); + registry.close("shared.sqlite"); + + const auto attachedPath = root / "attached.sqlite"; + registry.open("attached.sqlite", attachedPath, false); + registry.close("attached.sqlite"); + registry.open("shared.sqlite", path, false); + execute(registry.get("shared.sqlite"), "ATTACH DATABASE '" + attachedPath.string() + "' AS other"); + expectThrows([&]() { registry.drop("attached.sqlite", attachedPath, std::nullopt); }, "raw SQL attachment must block deletion"); + expect(fs::exists(attachedPath), "attached database file must survive rejected drop"); + execute(registry.get("shared.sqlite"), "DETACH DATABASE other"); + registry.drop("attached.sqlite", attachedPath, std::nullopt); + const auto aliasPath = root / "alias.sqlite"; + fs::create_symlink(path, aliasPath); + const auto aliasId = registry.openIndependent(aliasPath, false); + expectThrows([&]() { registry.drop("shared.sqlite", path, std::nullopt); }, "a path alias must block deletion of the open file"); + registry.close(aliasId); + registry.drop("shared.sqlite", path, std::nullopt); + + const auto nestedPath = root / "old" / "nested" / "shared.sqlite"; + fs::create_directories(nestedPath.parent_path()); + const auto nestedId = registry.openIndependent(nestedPath, false); + expect(registry.findLivePath(nestedPath, root / "new" / "nested" / "shared.sqlite") == nestedPath, + "live path resolution must retain nested database names"); + registry.drop("shared.sqlite", nestedPath, nestedId); + + const auto concurrentPath = root / "concurrent.sqlite"; + const auto leftId = registry.openIndependent(concurrentPath, false); + const auto rightId = registry.openIndependent(concurrentPath, false); + const auto left = registry.get(leftId); + const auto right = registry.get(rightId); + execute(left, "CREATE TABLE writes (value INTEGER)"); + sqlite3_busy_timeout(left->database, 5000); + sqlite3_busy_timeout(right->database, 5000); + std::vector threads; + for (const auto& connection : {left, right}) { + threads.emplace_back([connection]() { + for (int i = 0; i < 100; i++) { + execute(connection, "INSERT INTO writes VALUES (1)"); + } + }); + } + for (auto& thread : threads) { + thread.join(); + } + expect(scalar(left, "SELECT COUNT(*) FROM writes") == 200, "concurrent handles should preserve all writes"); + QueryGate gate; + expect(sqlite3_create_function_v2(left->database, "block_here", 0, SQLITE_UTF8, &gate, waitInStatement, nullptr, nullptr, nullptr) == + SQLITE_OK, + "failed to register blocking SQLite function"); + auto blocked = std::async(std::launch::async, [&]() { return scalar(left, "SELECT block_here()"); }); + { + std::unique_lock lock(gate.mutex); + gate.condition.wait(lock, [&]() { return gate.entered; }); + } + auto independentQuery = std::async(std::launch::async, [&]() { return scalar(right, "SELECT 42"); }); + const bool ranWhileBlocked = independentQuery.wait_for(std::chrono::seconds(2)) == std::future_status::ready; + { + std::lock_guard lock(gate.mutex); + gate.release = true; + } + gate.condition.notify_all(); + expect(blocked.get() == 1, "blocked query returned wrong value"); + expect(independentQuery.get() == 42 && ranWhileBlocked, "a blocked statement must not serialize a second handle"); + + execute(left, "PRAGMA journal_mode=WAL"); + execute(right, "BEGIN"); + expect(scalar(right, "SELECT COUNT(*) FROM writes") == 200, "reader snapshot should start at committed data"); + execute(left, "INSERT INTO writes VALUES (1)"); + expect(scalar(right, "SELECT COUNT(*) FROM writes") == 200, "WAL reader should keep its snapshot during another write"); + execute(right, "COMMIT"); + expect(scalar(right, "SELECT COUNT(*) FROM writes") == 201, "reader should see committed data after its transaction"); + registry.close(leftId); + registry.drop("logical-name.sqlite", concurrentPath, rightId); + expect(!fs::exists(concurrentPath), "drop must remove the physical filename rather than the logical name"); + const auto missingReadOnlyPath = root / "missing-directory" / "missing.sqlite"; + expectThrows([&]() { registry.openIndependent(missingReadOnlyPath, true); }, "read-only open must reject a missing database"); + expect(!fs::exists(missingReadOnlyPath.parent_path()), "read-only open must not create a directory"); + const std::string invalidName("bad\0name", 8); + expectThrows([&]() { registry.open(invalidName, root / "bad.sqlite", false); }, + "default database names containing NUL must be rejected"); + expect(!fs::exists(root / "bad.sqlite"), "invalid default name must not create a database"); + expectThrows([&]() { registry.openIndependent(fs::path(invalidName), false); }, + "independent database paths containing NUL must be rejected"); + registry.closeAll(); + fs::remove_all(root); + std::cout << "[PASS] independent connections, lifecycle, attachments, read-only and concurrent writes\n"; + return 0; + } catch (const std::exception& error) { + fs::remove_all(root); + std::cerr << "[FAIL] " << error.what() << '\n'; + return 1; + } +} diff --git a/packages/react-native-nitro-sqlite/tests/cpp/databaseConnectionsThreadsafe.test.cpp b/packages/react-native-nitro-sqlite/tests/cpp/databaseConnectionsThreadsafe.test.cpp new file mode 100644 index 00000000..8daf74d5 --- /dev/null +++ b/packages/react-native-nitro-sqlite/tests/cpp/databaseConnectionsThreadsafe.test.cpp @@ -0,0 +1,23 @@ +#include "databaseConnections.hpp" +#include +#include +#include + +int main() { + if (sqlite3_threadsafe() != 0) { + std::cerr << "[FAIL] test SQLite build is thread-safe\n"; + return 1; + } + margelo::rnnitrosqlite::DatabaseConnections registry; + const auto path = std::filesystem::temp_directory_path() / "nitro-sqlite-threadsafe-gate.sqlite"; + try { + registry.openIndependent(path, false); + } catch (const std::exception&) { + if (!std::filesystem::exists(path)) { + std::cout << "[PASS] independent connections reject SQLITE_THREADSAFE=0\n"; + return 0; + } + } + std::cerr << "[FAIL] independent connection opened with SQLITE_THREADSAFE=0\n"; + return 1; +} From dfbe5baac87215702c280de12aa09302a5080b06 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 18 Sep 2026 13:35:18 +0200 Subject: [PATCH 2/4] fix: preserve asynchronous session error behavior --- .../src/__tests__/session.test.ts | 23 +++++++++++++++++++ .../src/operations/session.ts | 9 ++++---- .../react-native-nitro-sqlite/src/types.ts | 2 +- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts index daeca277..062986d2 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts @@ -355,6 +355,29 @@ describe('open', () => { current.close() }) + it.each([false, true])( + 'preserves promise rejection for closed async methods, reopened=%s', + async (reopen) => { + const stale = open(options) + stale.close() + const current = reopen ? open(options) : undefined + const callback = jest.fn(async () => {}) + + try { + await expect(stale.executeAsync('SELECT 1')).rejects.toThrow('not open') + await expect( + stale.executeBatchAsync([{ query: 'SELECT 1' }]), + ).rejects.toThrow('not open') + await expect(stale.transaction(callback)).rejects.toThrow('not open') + expect(callback).not.toHaveBeenCalled() + expect(HybridNitroSQLite.executeAsync).not.toHaveBeenCalled() + expect(HybridNitroSQLite.executeBatchAsync).not.toHaveBeenCalled() + } finally { + current?.close() + } + }, + ) + it('isolates an independent queue when its native ID equals a default name', () => { jest.mocked(HybridNitroSQLite.openConnection).mockReturnValueOnce(dbName) const independent = open({ ...options, connection: 'independent' }) diff --git a/packages/react-native-nitro-sqlite/src/operations/session.ts b/packages/react-native-nitro-sqlite/src/operations/session.ts index d3d9e9d8..4912c7e8 100644 --- a/packages/react-native-nitro-sqlite/src/operations/session.ts +++ b/packages/react-native-nitro-sqlite/src/operations/session.ts @@ -116,14 +116,15 @@ export function open( ), detach: (alias: string) => runSyncOperation(() => HybridNitroSQLite.detach(connectionId, alias)), - transaction: (fn: (tx: Transaction) => Promise) => - runOperation(() => transaction(connectionId, fn, false, queueKey)), + transaction: async ( + fn: (tx: Transaction) => Promise, + ) => runOperation(() => transaction(connectionId, fn, false, queueKey)), execute: ( query: string, params?: SQLiteQueryParams, ): QueryResult => runOperation(() => executeManaged(connectionId, query, params, queueKey)), - executeAsync: ( + executeAsync: async ( query: string, params?: SQLiteQueryParams, ): Promise> => @@ -132,7 +133,7 @@ export function open( ), executeBatch: (commands: BatchQueryCommand[]) => runOperation(() => executeBatch(connectionId, commands, queueKey)), - executeBatchAsync: (commands: BatchQueryCommand[]) => + executeBatchAsync: async (commands: BatchQueryCommand[]) => runOperation(() => executeBatchAsync(connectionId, commands, queueKey)), loadFile: (location: string) => runSyncOperation(() => diff --git a/packages/react-native-nitro-sqlite/src/types.ts b/packages/react-native-nitro-sqlite/src/types.ts index 0a9b20b8..26a9ad9e 100644 --- a/packages/react-native-nitro-sqlite/src/types.ts +++ b/packages/react-native-nitro-sqlite/src/types.ts @@ -3,7 +3,7 @@ import type { NitroSQLiteQueryResult } from './specs/NitroSQLiteQueryResult.nitr export interface NitroSQLiteConnectionOptions { name: string location?: string - /** Reuse the name-based default connection, or open a separate connection to the same file. Defaults to 'default'. */ + /** Open the name-based default connection or a separate connection. Duplicate default opens throw. Defaults to 'default'. */ connection?: 'default' | 'independent' /** Open an existing database without write access. A read-only connection cannot delete the database. */ readOnly?: boolean From 05b3ac88478457e7d09452cab1dc7b75ef511ab5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 18 Sep 2026 15:06:41 +0200 Subject: [PATCH 3/4] test: close reopened native connection before cleanup --- example/tests/unit/specs/DatabaseQueue.spec.ts | 3 +++ .../tests/cpp/databaseConnections.test.cpp | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/example/tests/unit/specs/DatabaseQueue.spec.ts b/example/tests/unit/specs/DatabaseQueue.spec.ts index e5822ce2..afcf7835 100644 --- a/example/tests/unit/specs/DatabaseQueue.spec.ts +++ b/example/tests/unit/specs/DatabaseQueue.spec.ts @@ -534,6 +534,9 @@ export default function registerDatabaseQueueUnitTests() { ).results, ).toEqual([{ value: 'replacement' }]) } finally { + if (NitroSQLite.native.isConnectionOpen(dbName)) { + NitroSQLite.native.close(dbName) + } dropDatabaseIfExists(dbName) dropDatabaseIfExists(dbName, '..') } diff --git a/packages/react-native-nitro-sqlite/tests/cpp/databaseConnections.test.cpp b/packages/react-native-nitro-sqlite/tests/cpp/databaseConnections.test.cpp index 9dfb51cb..80e8b358 100644 --- a/packages/react-native-nitro-sqlite/tests/cpp/databaseConnections.test.cpp +++ b/packages/react-native-nitro-sqlite/tests/cpp/databaseConnections.test.cpp @@ -199,6 +199,22 @@ int main() { registry.close(leftId); registry.drop("logical-name.sqlite", concurrentPath, rightId); expect(!fs::exists(concurrentPath), "drop must remove the physical filename rather than the logical name"); + + const auto originalPath = root / "original" / "reopened.sqlite"; + const auto replacementPath = root / "reopened.sqlite"; + fs::create_directories(originalPath.parent_path()); + registry.open("reopened.sqlite", originalPath, false); + registry.close("reopened.sqlite"); + registry.open("reopened.sqlite", replacementPath, false); + expectThrows([&]() { registry.drop("reopened.sqlite", originalPath, std::nullopt); }, + "deleting the original file must not close a replacement in another location"); + expect(registry.isOpen("reopened.sqlite") && fs::exists(originalPath) && fs::exists(replacementPath), + "rejected cleanup must preserve the replacement connection and both files"); + registry.close("reopened.sqlite"); + registry.drop("reopened.sqlite", originalPath, std::nullopt); + registry.drop("reopened.sqlite", replacementPath, std::nullopt); + expect(!fs::exists(originalPath) && !fs::exists(replacementPath), "closing the replacement must allow both files to be deleted"); + const auto missingReadOnlyPath = root / "missing-directory" / "missing.sqlite"; expectThrows([&]() { registry.openIndependent(missingReadOnlyPath, true); }, "read-only open must reject a missing database"); expect(!fs::exists(missingReadOnlyPath.parent_path()), "read-only open must not create a directory"); From 29da940ec715eaedc2e32c2a77c098c963ac12d9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 23 Sep 2026 15:15:53 +0200 Subject: [PATCH 4/4] chore: format merged unit test registry --- example/tests/unit/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/example/tests/unit/index.ts b/example/tests/unit/index.ts index dd62e816..f2735689 100644 --- a/example/tests/unit/index.ts +++ b/example/tests/unit/index.ts @@ -9,7 +9,6 @@ import registerSqliteVecUnitTestsSpecs from './specs/sqlite-vec.spec' import registerConnectionUnitTests from './specs/Connections.spec' import registerBenchmarkDatabaseUnitTests from './specs/benchmarkDatabase.spec' - export function registerUnitTests() { beforeEach(setupTestDb) @@ -19,7 +18,6 @@ export function registerUnitTests() { describe('DatabaseQueue.spec.ts', registerDatabaseQueueUnitTests) describe('Connections.spec.ts', registerConnectionUnitTests) describe('benchmarkDatabase.spec.ts', registerBenchmarkDatabaseUnitTests) - } export function registerTypeORMUnitTests() {