From 75cb2bbfc5313944c98ffe80cfbb8ff0fd3a5e0c Mon Sep 17 00:00:00 2001 From: Dave Mkpa-Eke Date: Fri, 16 Oct 2026 10:42:39 +0100 Subject: [PATCH 01/13] feat: add executeRaw to return rows as arrays --- docs/content/docs/guides/node-test-mock.mdx | 2 +- .../docs/guides/parameters-and-results.mdx | 15 ++ .../docs/guides/prepared-statements.mdx | 2 +- docs/content/docs/guides/sync-and-async.mdx | 2 +- docs/content/docs/guides/transactions.mdx | 6 +- docs/scripts/check-api.mjs | 1 + example/tests/unit/index.ts | 2 + .../unit/specs/operations/executeRaw.spec.ts | 145 ++++++++++++++++++ .../cpp/NitroSQLiteOperations.cpp | 69 +++++++-- .../cpp/NitroSQLiteOperations.hpp | 10 ++ .../cpp/NitroSQLiteQueryResults.hpp | 34 ++++ .../cpp/hybridObjects/HybridNitroSQLite.cpp | 20 +++ .../cpp/hybridObjects/HybridNitroSQLite.hpp | 8 + .../HybridNitroSQLitePreparedStatement.cpp | 13 ++ .../HybridNitroSQLitePreparedStatement.hpp | 4 + ...HybridNitroSQLitePreparedStatementSpec.cpp | 2 + ...HybridNitroSQLitePreparedStatementSpec.hpp | 5 + .../shared/c++/HybridNitroSQLiteSpec.cpp | 2 + .../shared/c++/HybridNitroSQLiteSpec.hpp | 5 + .../shared/c++/NitroSQLiteRawQueryResult.hpp | 98 ++++++++++++ .../src/__mocks__/nitro.ts | 2 + .../src/__tests__/mock.test.ts | 57 +++++++ .../src/__tests__/prepare.test.ts | 31 +++- .../src/__tests__/session.test.ts | 41 ++++- .../src/__tests__/testUtils.ts | 16 +- .../src/__tests__/transaction.test.ts | 41 ++++- .../react-native-nitro-sqlite/src/index.ts | 2 + .../react-native-nitro-sqlite/src/mock.ts | 19 ++- .../src/mock/MockConnection.ts | 2 + .../src/mock/query.ts | 76 +++++++-- .../src/mock/transaction.ts | 30 +++- .../src/operations/execute.ts | 51 +++++- .../src/operations/prepare.ts | 21 +++ .../src/operations/session.ts | 15 +- .../src/operations/transaction.ts | 60 ++++++-- .../src/specs/NitroSQLite.nitro.ts | 23 +++ .../NitroSQLitePreparedStatement.nitro.ts | 17 +- .../src/specs/NitroSQLiteQueryResult.nitro.ts | 21 +++ .../react-native-nitro-sqlite/src/types.ts | 53 ++++++- 39 files changed, 956 insertions(+), 67 deletions(-) create mode 100644 example/tests/unit/specs/operations/executeRaw.spec.ts create mode 100644 packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/NitroSQLiteRawQueryResult.hpp diff --git a/docs/content/docs/guides/node-test-mock.mdx b/docs/content/docs/guides/node-test-mock.mdx index 41a2acbb..cfd1140f 100644 --- a/docs/content/docs/guides/node-test-mock.mdx +++ b/docs/content/docs/guides/node-test-mock.mdx @@ -38,7 +38,7 @@ test('stores a note', () => { }) ``` -The mock returns query rows through both `results` and `rows`, and supports [atomic batches](/docs/guides/batch-operations). Bound `undefined` becomes SQL NULL. A query that fails throws from `execute()` or rejects from `executeAsync()`. +The mock returns query rows through both `results` and `rows`, supports [rows as arrays](/docs/guides/parameters-and-results#rows-as-arrays) through `executeRaw()` and `executeRawAsync()`, and supports [atomic batches](/docs/guides/batch-operations). Bound `undefined` becomes SQL NULL. A query that fails throws from `execute()` or rejects from `executeAsync()`. ## Share a database between connections diff --git a/docs/content/docs/guides/parameters-and-results.mdx b/docs/content/docs/guides/parameters-and-results.mdx index 9bf62830..a95bafd3 100644 --- a/docs/content/docs/guides/parameters-and-results.mdx +++ b/docs/content/docs/guides/parameters-and-results.mdx @@ -31,6 +31,21 @@ for (const person of result.rows._array) { SQLite INTEGER and REAL result values both arrive as JavaScript numbers. BLOB values arrive as `ArrayBuffer`; NULL values arrive as `null`, even if the parameter was `undefined`. A bound boolean is stored through SQLite's integer binding, so read it as a number and convert it in application code if needed. JavaScript numbers cannot represent every 64-bit SQLite integer exactly; choose a storage representation appropriate for identifiers that exceed the safe integer range. +## Rows as arrays + +A row object holds one value per column name, so when two result columns share a name, the later value replaces the earlier one. This happens in joins such as `SELECT users.id, pets.id ...`. `db.executeRaw()` and `db.executeRawAsync()` take the same arguments as `execute()` and `executeAsync()` but return each row as an array of values in column order, so every column is kept. + +```ts +const { columnNames, rows } = db.executeRaw( + 'SELECT users.id, users.name, pets.id, pets.name FROM users LEFT JOIN pets ON pets.owner_id = users.id', +) + +// columnNames: ['id', 'name', 'id', 'name'] +// rows: [[1, 'Ada', 7, 'Rex'], [2, 'Grace', null, null]] +``` + +The result has `columnNames`, `rows`, `rowsAffected`, and `insertId`. It has no `rows._array` container and no `metadata`. `columnNames` is filled even when the query returns no rows. Transactions and prepared statements have the same two methods. The exact shape is in [`NitroSQLiteRawQueryResult`](/api/react-native-nitro-sqlite/interfaces/NitroSQLiteRawQueryResult). + ## Column metadata For a query with result columns, `metadata` maps column names to `{ name, type, index }`. `index` is the zero-based result column position. The [`ColumnType` values](/api/react-native-nitro-sqlite/enumerations/ColumnType) are `BOOLEAN = 0`, `NUMBER = 1`, `INT64 = 2`, `TEXT = 3`, `ARRAY_BUFFER = 4`, and `NULL_VALUE = 5`. The package exports `ColumnType` as a TypeScript type, not a runtime enum object. In the current native implementation, `metadata.type` is unreliable for detecting a column's declared SQL type. Use your schema for that decision. An expression without a declared type maps to `NULL_VALUE`, which does not mean the result itself is null. The exact shape is in [`NitroSQLiteQueryColumnMetadata`](/api/react-native-nitro-sqlite/type-aliases/NitroSQLiteQueryColumnMetadata). diff --git a/docs/content/docs/guides/prepared-statements.mdx b/docs/content/docs/guides/prepared-statements.mdx index 28b20b8d..ed1e8fe0 100644 --- a/docs/content/docs/guides/prepared-statements.mdx +++ b/docs/content/docs/guides/prepared-statements.mdx @@ -3,7 +3,7 @@ title: Prepared statements description: Reuse a compiled SQL statement with different bound values. --- -Call `db.prepare(query)` to reuse SQL with different parameter values. It returns a statement with `execute(params)`, `executeAsync(params)`, `isFinalized`, and `finalize()`. Preparation is synchronous and throws if the connection has work pending or running. For a query that runs only once, use `db.execute()` or `db.executeAsync()`. See [parameters and prepared statements](/docs/concepts/queries-and-indexes#parameters-and-prepared-statements) for the SQLite concept. +Call `db.prepare(query)` to reuse SQL with different parameter values. It returns a statement with `execute(params)`, `executeAsync(params)`, `isFinalized`, and `finalize()`, plus `executeRaw(params)` and `executeRawAsync(params)` for [rows as arrays](/docs/guides/parameters-and-results#rows-as-arrays). Preparation is synchronous and throws if the connection has work pending or running. For a query that runs only once, use `db.execute()` or `db.executeAsync()`. See [parameters and prepared statements](/docs/concepts/queries-and-indexes#parameters-and-prepared-statements) for the SQLite concept. ```ts import { open } from 'react-native-nitro-sqlite' diff --git a/docs/content/docs/guides/sync-and-async.mdx b/docs/content/docs/guides/sync-and-async.mdx index 46e96b6e..4b388bd3 100644 --- a/docs/content/docs/guides/sync-and-async.mdx +++ b/docs/content/docs/guides/sync-and-async.mdx @@ -5,7 +5,7 @@ description: Pick the query form that fits your workload and understand connecti Use `db.execute()` for a small query that needs an immediate result, and `await db.executeAsync()` for work that may take longer. A synchronous call blocks its JavaScript caller until SQLite finishes. An asynchronous call returns a promise and lets JavaScript continue while the database work runs in the background. -`executeBatch` and `loadFile` also have matching `Async` methods. A [prepared statement](/docs/guides/prepared-statements) has `execute()` and `executeAsync()` for repeated SQL. +`executeRaw`, `executeBatch`, and `loadFile` also have matching `Async` methods. A [prepared statement](/docs/guides/prepared-statements) has `execute()` and `executeAsync()` for repeated SQL. ```ts import { open } from 'react-native-nitro-sqlite' diff --git a/docs/content/docs/guides/transactions.mdx b/docs/content/docs/guides/transactions.mdx index a2395503..efd3a9d5 100644 --- a/docs/content/docs/guides/transactions.mdx +++ b/docs/content/docs/guides/transactions.mdx @@ -29,7 +29,7 @@ Throw from the callback when an application check fails. Use `tx` for those chec ## Commit and rollback behavior -When the callback resolves, the wrapper commits unless you already called `tx.commit()` or `tx.rollback()`. When it rejects or throws, the wrapper rolls back unless the transaction has already been finalized. An explicit `tx.rollback()` does not itself reject the callback: it ends the transaction, and the callback can still resolve. Calls to `tx.execute`, `tx.executeAsync`, `tx.commit`, or `tx.rollback` after finalization throw. +When the callback resolves, the wrapper commits unless you already called `tx.commit()` or `tx.rollback()`. When it rejects or throws, the wrapper rolls back unless the transaction has already been finalized. An explicit `tx.rollback()` does not itself reject the callback: it ends the transaction, and the callback can still resolve. Calls to `tx.execute`, `tx.executeAsync`, `tx.executeRaw`, `tx.executeRawAsync`, `tx.commit`, or `tx.rollback` after finalization throw. A failed COMMIT can leave SQLite's transaction active, for example when a deferred foreign-key constraint fails. NitroSQLite marks a commit complete only after SQLite accepts it. A failed manual or automatic commit triggers rollback and rejects with the commit error. This also happens if the callback catches a failed `tx.commit()` and returns without explicitly rolling back. After the failure, only `tx.rollback()` is allowed on that transaction handle. @@ -39,7 +39,7 @@ If rollback also fails, the rejection preserves the original message and appends -The connection queue is occupied for the whole callback. Use `tx.execute` or `tx.executeAsync` for every statement on this database during that callback, including statements in helpers. +The connection queue is occupied for the whole callback. Use the `tx` methods (`execute`, `executeAsync`, `executeRaw`, or `executeRawAsync`) for every statement on this database during that callback, including statements in helpers. > [!TIP] > Pass `tx` to any helper that runs statements during the transaction. @@ -57,7 +57,7 @@ await db.transaction(async (tx) => { ## Await pending statements -Await every `tx.executeAsync()` before the callback returns or before manually finalizing. While an async statement is pending, `tx.execute()`, `tx.commit()`, and `tx.rollback()` throw instead of running out of order. If the callback throws, the wrapper waits for pending async statements before rolling back. A thrown error after an explicit commit or rollback cannot undo that completed transaction. +Await every `tx.executeAsync()` and `tx.executeRawAsync()` before the callback returns or before manually finalizing. While an async statement is pending, `tx.execute()`, `tx.executeRaw()`, `tx.commit()`, and `tx.rollback()` throw instead of running out of order. If the callback throws, the wrapper waits for pending async statements before rolling back. A thrown error after an explicit commit or rollback cannot undo that completed transaction. `tx.commit()` and `tx.rollback()` are synchronous. Their declared return type exposes the native query result fields. The JavaScript helper also adds `rows` at runtime, but that field is not in their declared type. Use them to finalize the transaction, not to retrieve rows. The transaction starts with `BEGIN TRANSACTION`; the global [`NitroSQLite.transaction`](/api/react-native-nitro-sqlite/variables/NitroSQLite) helper also accepts `isExclusive` to use `BEGIN EXCLUSIVE TRANSACTION`. See [transaction modes](/docs/concepts/transactions#transaction-modes) for the SQLite differences. diff --git a/docs/scripts/check-api.mjs b/docs/scripts/check-api.mjs index 96fc4c3e..3203242c 100644 --- a/docs/scripts/check-api.mjs +++ b/docs/scripts/check-api.mjs @@ -22,6 +22,7 @@ const requiredExports = { 'NitroSQLiteNative', 'NitroSQLiteQueryResult', 'NitroSQLiteQueryColumnMetadata', + 'NitroSQLiteRawQueryResult', 'TypeOrmNitroSQLiteConnection', 'typeORMDriver', ], diff --git a/example/tests/unit/index.ts b/example/tests/unit/index.ts index 30d0a01d..977ca3cf 100644 --- a/example/tests/unit/index.ts +++ b/example/tests/unit/index.ts @@ -2,6 +2,7 @@ import { beforeEach, describe } from '../TestApi' import { setupTestDb } from './common' import registerLoadFileUnitTests from './specs/operations/loadFile.spec' import registerExecuteUnitTests from './specs/operations/execute.spec' +import registerExecuteRawUnitTests from './specs/operations/executeRaw.spec' import registerTransactionUnitTests from './specs/operations/transaction.spec' import registerExecuteBatchUnitTests from './specs/operations/executeBatch.spec' import registerPreparedStatementUnitTests from './specs/operations/preparedStatement.spec' @@ -15,6 +16,7 @@ export function registerUnitTests() { beforeEach(setupTestDb) describe('operations/execute.spec.ts', registerExecuteUnitTests) + describe('operations/executeRaw.spec.ts', registerExecuteRawUnitTests) describe('operations/transaction.spec.ts', registerTransactionUnitTests) describe('operations/executeBatch.spec.ts', registerExecuteBatchUnitTests) describe('operations/loadFile.spec.ts', registerLoadFileUnitTests) diff --git a/example/tests/unit/specs/operations/executeRaw.spec.ts b/example/tests/unit/specs/operations/executeRaw.spec.ts new file mode 100644 index 00000000..715feb9f --- /dev/null +++ b/example/tests/unit/specs/operations/executeRaw.spec.ts @@ -0,0 +1,145 @@ +import { + expect, + isNitroSQLiteError, + TEST_ERROR_CODES, +} from '@tests/unit/common' +import { describe, it } from '@tests/TestApi' +import { testDb } from '@tests/db' + +const valuesQuery = + 'SELECT 1 AS duplicate, 2 AS duplicate, 3.5 AS "café", NULL AS nullable, zeroblob(2) AS payload' + +const joinQuery = ` + SELECT owners.id, owners.name, pets.id, pets.name + FROM owners LEFT JOIN pets ON pets.owner_id = owners.id + ORDER BY owners.id +` + +function createJoinTables() { + testDb.execute('CREATE TABLE owners (id INTEGER PRIMARY KEY, name TEXT)') + testDb.execute( + 'CREATE TABLE pets (id INTEGER PRIMARY KEY, name TEXT, owner_id INTEGER)', + ) + testDb.execute("INSERT INTO owners VALUES (1, 'Dave'), (2, 'Ritesh')") + testDb.execute("INSERT INTO pets VALUES (7, 'Rex', 1)") +} + +function expectValuesRow(row: unknown[] | undefined) { + expect(row?.slice(0, 4)).toEqual([1, 2, 3.5, null]) + expect(Array.from(new Uint8Array(row?.[4] as ArrayBuffer))).toEqual([0, 0]) +} + +export default function registerExecuteRawUnitTests() { + describe('executeRaw', () => { + it('returns every value type and keeps repeated column names', async () => { + const result = testDb.executeRaw(valuesQuery) + expect(result.columnNames).toEqual([ + 'duplicate', + 'duplicate', + 'café', + 'nullable', + 'payload', + ]) + expect(result.rows).toHaveLength(1) + expectValuesRow(result.rows[0]) + + const asyncResult = await testDb.executeRawAsync(valuesQuery) + expect(asyncResult.columnNames).toEqual(result.columnNames) + expectValuesRow(asyncResult.rows[0]) + }) + + it('keeps both sides of a join with matching column names', async () => { + createJoinTables() + const expected = [ + [1, 'Dave', 7, 'Rex'], + [2, 'Ritesh', null, null], + ] + + const result = testDb.executeRaw(joinQuery) + expect(result.columnNames).toEqual(['id', 'name', 'id', 'name']) + expect(result.rows).toEqual(expected) + expect((await testDb.executeRawAsync(joinQuery)).rows).toEqual(expected) + }) + + it('binds parameters and reports changes for writes', async () => { + const inserted = testDb.executeRaw( + 'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)', + [1, 'Ada', 36, 1.5], + ) + expect(inserted.rowsAffected).toBe(1) + expect(inserted.insertId).toBe(1) + expect(inserted.columnNames).toEqual([]) + expect(inserted.rows).toEqual([]) + + const selected = await testDb.executeRawAsync( + 'SELECT name, age FROM User WHERE id = ?', + [1], + ) + expect(selected.rows).toEqual([['Ada', 36]]) + }) + + it('returns column names for an empty result', () => { + const result = testDb.executeRaw('SELECT id, name FROM User WHERE 0') + expect(result.columnNames).toEqual(['id', 'name']) + expect(result.rows).toEqual([]) + }) + + it('returns large results in order', async () => { + const query = ` + WITH RECURSIVE counter(value) AS ( + SELECT 1 UNION ALL SELECT value + 1 FROM counter WHERE value < 10000 + ) + SELECT value, 'row-' || value FROM counter + ` + for (const result of [ + testDb.executeRaw(query), + await testDb.executeRawAsync(query), + ]) { + expect(result.rows).toHaveLength(10_000) + expect(result.rows[0]).toEqual([1, 'row-1']) + expect(result.rows[9_999]).toEqual([10_000, 'row-10000']) + } + }) + + it('runs inside a transaction', async () => { + createJoinTables() + await testDb.transaction(async (tx) => { + tx.executeRaw("INSERT INTO pets VALUES (8, 'Tom', 2)") + expect((await tx.executeRawAsync(joinQuery)).rows).toEqual([ + [1, 'Dave', 7, 'Rex'], + [2, 'Ritesh', 8, 'Tom'], + ]) + }) + }) + + it('runs prepared statements and switches between raw and object rows', async () => { + createJoinTables() + const statement = testDb.prepare(`${joinQuery} LIMIT ?`) + try { + expect(statement.executeRaw([1]).rows).toEqual([[1, 'Dave', 7, 'Rex']]) + expect(statement.execute([1]).results).toEqual([{ id: 7, name: 'Rex' }]) + const asyncResult = await statement.executeRawAsync([2]) + expect(asyncResult.columnNames).toEqual(['id', 'name', 'id', 'name']) + expect(asyncResult.rows).toHaveLength(2) + } finally { + statement.finalize() + } + }) + + it('throws NitroSQLiteError for invalid SQL', async () => { + try { + testDb.executeRaw('SELECT * FROM missing_table') + throw new Error(TEST_ERROR_CODES.EXPECT_NITRO_SQLITE_ERROR) + } catch (error) { + expect(isNitroSQLiteError(error)).toBe(true) + } + + try { + await testDb.executeRawAsync('SELECT * FROM missing_table') + throw new Error(TEST_ERROR_CODES.EXPECT_PROMISE_REJECTION) + } catch (error) { + expect(isNitroSQLiteError(error)).toBe(true) + } + }) + }) +} diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp index 2c8c49fd..798bce81 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp @@ -217,7 +217,9 @@ namespace { // and a smaller final batch shortens the conversion left after the statement completes. constexpr size_t kRowBatchSize = 256; - NitroSQLiteQueryResult executeStatement(sqlite3* db, sqlite3_stmt* statement, const SQLiteRowBatchHandler& onRows = nullptr) { + // Reads every row of a bound statement. With onRows, full batches go to it and only the rows + // after the last batch are returned. + SQLiteQueryResultData readStatementRows(sqlite3* db, sqlite3_stmt* statement, const SQLiteRowBatchHandler& onRows) { int columnCount = 0; std::vector columnNames; std::vector rows; @@ -292,8 +294,14 @@ namespace { captureColumns(); } + return {std::move(columnNames), std::move(rows)}; + } + + NitroSQLiteQueryResult executeStatement(sqlite3* db, sqlite3_stmt* statement, const SQLiteRowBatchHandler& onRows = nullptr) { + auto [columnNames, rows] = readStatementRows(db, statement, onRows); + std::optional metadata = std::nullopt; - for (int i = 0; i < columnCount; i++) { + for (int i = 0; i < static_cast(columnNames.size()); i++) { const std::string& columnName = columnNames[i]; ColumnType columnDeclaredType = mapSQLiteTypeToColumnType(sqlite3_column_decltype(statement, i)); auto columnMeta = NitroSQLiteQueryColumnMetadata(columnName, std::move(columnDeclaredType), i); @@ -312,21 +320,45 @@ namespace { return result; } + NitroSQLiteRawQueryResult executeStatementRaw(sqlite3* db, sqlite3_stmt* statement) { + auto [columnNames, rows] = readStatementRows(db, statement, nullptr); + + NitroSQLiteRawQueryResult result; + result.rowsAffected = sqlite3_changes(db); + result.insertId = static_cast(sqlite3_last_insert_rowid(db)); + result.columnNames = columnNames; + result.rows = SQLiteRawQueryResults(SQLiteQueryResults(std::move(columnNames), std::move(rows))); + return result; + } + + // Runs a query on the connection's cached statement for it, bound to params. + template + auto executeCached(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional& params, + Execute&& execute) { + std::lock_guard lock(connection->mutex); + sqlite3* db = connection->database; + if (db == nullptr) { + throw NitroSQLiteException::DatabaseNotOpen(connection->name); + } + + CachedStatement statement(*connection, query); + if (params) { + bindStatement(statement.get(), *params); + } + return execute(db, statement.get()); + } + } // namespace NitroSQLiteQueryResult sqliteExecute(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional& params, const SQLiteRowBatchHandler& onRows) { - std::lock_guard lock(connection->mutex); - sqlite3* db = connection->database; - if (db == nullptr) { - throw NitroSQLiteException::DatabaseNotOpen(connection->name); - } + return executeCached(connection, query, params, + [&](sqlite3* db, sqlite3_stmt* statement) { return executeStatement(db, statement, onRows); }); +} - CachedStatement statement(*connection, query); - if (params) { - bindStatement(statement.get(), *params); - } - return executeStatement(db, statement.get(), onRows); +NitroSQLiteRawQueryResult sqliteExecuteRaw(const SQLiteConnectionPtr& connection, const std::string& query, + const std::optional& params) { + return executeCached(connection, query, params, executeStatementRaw); } SQLiteOperationResult sqliteExecuteCommand(const SQLiteConnectionPtr& connection, const std::string& query, @@ -379,7 +411,8 @@ SQLitePreparedStatement::~SQLitePreparedStatement() { finalize(); } -NitroSQLiteQueryResult SQLitePreparedStatement::execute(const std::optional& params) { +template +auto SQLitePreparedStatement::executeBound(const std::optional& params, Execute&& execute) { std::lock_guard lock(_state->mutex); std::lock_guard connectionLock(_state->connection->mutex); @@ -407,13 +440,21 @@ NitroSQLiteQueryResult SQLitePreparedStatement::execute(const std::optionalstatement.get()); + return execute(database, _state->statement.get()); } catch (...) { sqlite3_reset(_state->statement.get()); throw; } } +NitroSQLiteQueryResult SQLitePreparedStatement::execute(const std::optional& params) { + return executeBound(params, [](sqlite3* database, sqlite3_stmt* statement) { return executeStatement(database, statement); }); +} + +NitroSQLiteRawQueryResult SQLitePreparedStatement::executeRaw(const std::optional& params) { + return executeBound(params, executeStatementRaw); +} + void SQLitePreparedStatement::finalize() { std::lock_guard lock(_state->mutex); std::lock_guard connectionLock(_state->connection->mutex); diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp index 1bbd578a..9631c34e 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp @@ -2,6 +2,7 @@ #include "NitroSQLiteDatabaseConnections.hpp" #include "NitroSQLiteQueryResult.hpp" +#include "NitroSQLiteRawQueryResult.hpp" #include "sqlite/sqlite3.h" #include #include @@ -26,6 +27,8 @@ class SQLitePreparedStatement { /** Reset and execute with new bindings. Throws after finalization or connection closure. */ NitroSQLiteQueryResult execute(const std::optional& params); + /** Reset and execute with new bindings, returning rows as arrays of column values. */ + NitroSQLiteRawQueryResult executeRaw(const std::optional& params); /** Release the native statement. Repeated calls are safe. */ void finalize(); /** Check whether the native statement has been released. */ @@ -38,6 +41,9 @@ class SQLitePreparedStatement { explicit SQLitePreparedStatement(std::shared_ptr state); + template + auto executeBound(const std::optional& params, Execute&& execute); + std::shared_ptr _state; friend std::shared_ptr sqlitePrepare(const SQLiteConnectionPtr& connection, const std::string& query); @@ -59,6 +65,10 @@ void sqliteDetachDb(const SQLiteConnectionPtr& connection, const std::string& al NitroSQLiteQueryResult sqliteExecute(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional& params, const SQLiteRowBatchHandler& onRows = nullptr); +/** Execute one statement, returning rows as arrays of column values. */ +NitroSQLiteRawQueryResult sqliteExecuteRaw(const SQLiteConnectionPtr& connection, const std::string& query, + const std::optional& params); + SQLiteOperationResult sqliteExecuteCommand(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional& params = std::nullopt); diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteQueryResults.hpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteQueryResults.hpp index e12712c9..9d1a0342 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteQueryResults.hpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteQueryResults.hpp @@ -28,6 +28,14 @@ struct SQLiteQueryResults { std::shared_ptr data; }; +// Rows converted to JavaScript arrays of column values, which keep repeated column names apart. +struct SQLiteRawQueryResults { + SQLiteRawQueryResults() : data(std::make_shared()) {} + explicit SQLiteRawQueryResults(SQLiteQueryResults results) : data(std::move(results.data)) {} + + std::shared_ptr data; +}; + /** Receives leading result rows in batches while the statement still runs. */ using SQLiteRowBatchHandler = std::function; @@ -68,4 +76,30 @@ struct JSIConverter final { } }; +template <> +struct JSIConverter final { + static jsi::Value toJSI(jsi::Runtime& runtime, const rnnitrosqlite::SQLiteRawQueryResults& results) { + const auto& data = *results.data; + jsi::Array array(runtime, data.rows.size()); + for (size_t rowIndex = 0; rowIndex < data.rows.size(); rowIndex++) { + const auto& row = data.rows[rowIndex]; + jsi::Array values(runtime, row.size()); + for (size_t columnIndex = 0; columnIndex < row.size(); columnIndex++) { + values.setValueAtIndex(runtime, columnIndex, JSIConverter::toJSI(runtime, row[columnIndex])); + } + array.setValueAtIndex(runtime, rowIndex, std::move(values)); + } + return array; + } + + // Query results only travel from native code to JavaScript. + static rnnitrosqlite::SQLiteRawQueryResults fromJSI(jsi::Runtime&, const jsi::Value&) { + throw std::logic_error("SQLite query results cannot be passed from JavaScript"); + } + + static bool canConvert(jsi::Runtime&, const jsi::Value&) { + return false; + } +}; + } // namespace margelo::nitro diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp index df6f21d9..fd2c3c5d 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp @@ -229,6 +229,26 @@ std::shared_ptr> HybridNitroSQLite::executeAsync }); }; +NitroSQLiteRawQueryResult HybridNitroSQLite::executeRaw(const std::string& dbName, const std::string& query, + const std::optional& params) { + return sqliteExecuteRaw(_connections.get(dbName), query, params); +} + +std::shared_ptr> HybridNitroSQLite::executeRawAsync(const std::string& dbName, const std::string& query, + const std::optional& params) { + const auto copiedParams = copyArrayBufferParamsForBackground(params); + SQLiteConnectionPtr connection; + try { + connection = _connections.get(dbName); + } catch (...) { + return Promise::rejected(std::current_exception()); + } + + return enqueueConnectionOperation( + connection, + [connection, query, copiedParams]() -> NitroSQLiteRawQueryResult { return sqliteExecuteRaw(connection, query, copiedParams); }); +} + std::shared_ptr HybridNitroSQLite::prepare(const std::string& dbName, const std::string& query) { return std::make_shared(sqlitePrepare(_connections.get(dbName), query)); } diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp index aca8e47d..72c9f6f2 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp @@ -73,6 +73,14 @@ class HybridNitroSQLite : public HybridNitroSQLiteSpec { const std::optional& params, const std::optional& onRows) override; + /** Execute one SQL statement on the calling thread, returning rows as arrays of column values. */ + NitroSQLiteRawQueryResult executeRaw(const std::string& dbName, const std::string& query, + const std::optional& params) override; + + /** Execute one SQL statement on a background thread, returning rows as arrays of column values. */ + std::shared_ptr> executeRawAsync(const std::string& dbName, const std::string& query, + const std::optional& params) override; + /** Prepare one SQL statement for repeated execution on an open connection. */ std::shared_ptr prepare(const std::string& dbName, const std::string& query) override; diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.cpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.cpp index 11caae01..70003b71 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.cpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.cpp @@ -42,6 +42,19 @@ HybridNitroSQLitePreparedStatement::executeAsync(const std::optional NitroSQLiteQueryResult { return statement->execute(copiedParams); }); } +NitroSQLiteRawQueryResult HybridNitroSQLitePreparedStatement::executeRaw(const std::optional& params) { + return _statement->executeRaw(params); +} + +std::shared_ptr> +HybridNitroSQLitePreparedStatement::executeRawAsync(const std::optional& params) { + const auto copiedParams = copyArrayBufferParamsForBackground(params); + const auto statement = _statement; + + return Promise::async( + [statement, copiedParams]() -> NitroSQLiteRawQueryResult { return statement->executeRaw(copiedParams); }); +} + void HybridNitroSQLitePreparedStatement::finalize() { _statement->finalize(); } diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.hpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.hpp index 966acfab..c5e65822 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.hpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLitePreparedStatement.hpp @@ -17,6 +17,10 @@ class HybridNitroSQLitePreparedStatement : public HybridNitroSQLitePreparedState NitroSQLiteQueryResult execute(const std::optional& params) override; /** Execute with new bindings on a background thread. */ std::shared_ptr> executeAsync(const std::optional& params) override; + /** Execute with new bindings on the calling thread, returning rows as arrays of column values. */ + NitroSQLiteRawQueryResult executeRaw(const std::optional& params) override; + /** Execute with new bindings on a background thread, returning rows as arrays of column values. */ + std::shared_ptr> executeRawAsync(const std::optional& params) override; /** Release the native statement. */ void finalize() override; /** Check whether the native statement has been released. */ diff --git a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.cpp b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.cpp index 906479d3..fefa6ac9 100644 --- a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.cpp +++ b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.cpp @@ -17,6 +17,8 @@ namespace margelo::nitro::rnnitrosqlite { prototype.registerHybridGetter("isFinalized", &HybridNitroSQLitePreparedStatementSpec::getIsFinalized); prototype.registerHybridMethod("execute", &HybridNitroSQLitePreparedStatementSpec::execute); prototype.registerHybridMethod("executeAsync", &HybridNitroSQLitePreparedStatementSpec::executeAsync); + prototype.registerHybridMethod("executeRaw", &HybridNitroSQLitePreparedStatementSpec::executeRaw); + prototype.registerHybridMethod("executeRawAsync", &HybridNitroSQLitePreparedStatementSpec::executeRawAsync); prototype.registerHybridMethod("finalize", &HybridNitroSQLitePreparedStatementSpec::finalize); }); } diff --git a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.hpp b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.hpp index 5ede22ad..9fde1d9f 100644 --- a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.hpp +++ b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/HybridNitroSQLitePreparedStatementSpec.hpp @@ -15,6 +15,8 @@ // Forward declaration of `NitroSQLiteQueryResult` to properly resolve imports. namespace margelo::nitro::rnnitrosqlite { struct NitroSQLiteQueryResult; } +// Forward declaration of `NitroSQLiteRawQueryResult` to properly resolve imports. +namespace margelo::nitro::rnnitrosqlite { struct NitroSQLiteRawQueryResult; } #include "NitroSQLiteQueryResult.hpp" #include @@ -24,6 +26,7 @@ namespace margelo::nitro::rnnitrosqlite { struct NitroSQLiteQueryResult; } #include #include #include +#include "NitroSQLiteRawQueryResult.hpp" namespace margelo::nitro::rnnitrosqlite { @@ -58,6 +61,8 @@ namespace margelo::nitro::rnnitrosqlite { // Methods virtual NitroSQLiteQueryResult execute(const std::optional, std::string, double>>>>& params) = 0; virtual std::shared_ptr> executeAsync(const std::optional, std::string, double>>>>& params) = 0; + virtual NitroSQLiteRawQueryResult executeRaw(const std::optional, std::string, double>>>>& params) = 0; + virtual std::shared_ptr> executeRawAsync(const std::optional, std::string, double>>>>& params) = 0; virtual void finalize() = 0; protected: 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 1297822c..a5cf0e27 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 @@ -23,6 +23,8 @@ namespace margelo::nitro::rnnitrosqlite { prototype.registerHybridMethod("detach", &HybridNitroSQLiteSpec::detach); prototype.registerHybridMethod("execute", &HybridNitroSQLiteSpec::execute); prototype.registerHybridMethod("executeAsync", &HybridNitroSQLiteSpec::executeAsync); + prototype.registerHybridMethod("executeRaw", &HybridNitroSQLiteSpec::executeRaw); + prototype.registerHybridMethod("executeRawAsync", &HybridNitroSQLiteSpec::executeRawAsync); prototype.registerHybridMethod("prepare", &HybridNitroSQLiteSpec::prepare); prototype.registerHybridMethod("executeBatch", &HybridNitroSQLiteSpec::executeBatch); prototype.registerHybridMethod("executeBatchAsync", &HybridNitroSQLiteSpec::executeBatchAsync); 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 87856cee..b49be09b 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 @@ -15,6 +15,8 @@ // Forward declaration of `NitroSQLiteQueryResult` to properly resolve imports. namespace margelo::nitro::rnnitrosqlite { struct NitroSQLiteQueryResult; } +// Forward declaration of `NitroSQLiteRawQueryResult` to properly resolve imports. +namespace margelo::nitro::rnnitrosqlite { struct NitroSQLiteRawQueryResult; } // Forward declaration of `HybridNitroSQLitePreparedStatementSpec` to properly resolve imports. namespace margelo::nitro::rnnitrosqlite { class HybridNitroSQLitePreparedStatementSpec; } // Forward declaration of `BatchQueryResult` to properly resolve imports. @@ -34,6 +36,7 @@ namespace margelo::nitro::rnnitrosqlite { struct FileLoadResult; } #include #include "NitroSQLiteQueryResults.hpp" #include +#include "NitroSQLiteRawQueryResult.hpp" #include #include "HybridNitroSQLitePreparedStatementSpec.hpp" #include "BatchQueryResult.hpp" @@ -80,6 +83,8 @@ namespace margelo::nitro::rnnitrosqlite { virtual void detach(const std::string& mainDbName, const std::string& alias) = 0; virtual NitroSQLiteQueryResult execute(const std::string& dbName, const std::string& query, const std::optional, std::string, double>>>>& params) = 0; virtual std::shared_ptr> executeAsync(const std::string& dbName, const std::string& query, const std::optional, std::string, double>>>>& params, const std::optional>& onRows) = 0; + virtual NitroSQLiteRawQueryResult executeRaw(const std::string& dbName, const std::string& query, const std::optional, std::string, double>>>>& params) = 0; + virtual std::shared_ptr> executeRawAsync(const std::string& dbName, const std::string& query, const std::optional, std::string, double>>>>& params) = 0; virtual std::shared_ptr prepare(const std::string& dbName, const std::string& query) = 0; virtual BatchQueryResult executeBatch(const std::string& dbName, const std::vector& commands) = 0; virtual std::shared_ptr> executeBatchAsync(const std::string& dbName, const std::vector& commands) = 0; diff --git a/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/NitroSQLiteRawQueryResult.hpp b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/NitroSQLiteRawQueryResult.hpp new file mode 100644 index 00000000..dfed87c6 --- /dev/null +++ b/packages/react-native-nitro-sqlite/nitrogen/generated/shared/c++/NitroSQLiteRawQueryResult.hpp @@ -0,0 +1,98 @@ +/// +/// NitroSQLiteRawQueryResult.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include +#include +#include +#include "NitroSQLiteQueryResults.hpp" + +namespace margelo::nitro::rnnitrosqlite { + + /** + * A struct which can be represented as a JavaScript object (NitroSQLiteRawQueryResult). + */ + struct NitroSQLiteRawQueryResult final { + public: + double rowsAffected SWIFT_PRIVATE; + std::optional insertId SWIFT_PRIVATE; + std::vector columnNames SWIFT_PRIVATE; + margelo::nitro::rnnitrosqlite::SQLiteRawQueryResults rows SWIFT_PRIVATE; + + public: + NitroSQLiteRawQueryResult() = default; + explicit NitroSQLiteRawQueryResult(double rowsAffected, std::optional insertId, std::vector columnNames, margelo::nitro::rnnitrosqlite::SQLiteRawQueryResults rows): rowsAffected(rowsAffected), insertId(insertId), columnNames(columnNames), rows(rows) {} + + public: + // NitroSQLiteRawQueryResult is not equatable because these properties are not equatable: rows + }; + +} // namespace margelo::nitro::rnnitrosqlite + +namespace margelo::nitro { + + // C++ NitroSQLiteRawQueryResult <> JS NitroSQLiteRawQueryResult (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::rnnitrosqlite::NitroSQLiteRawQueryResult fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::rnnitrosqlite::NitroSQLiteRawQueryResult( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rowsAffected"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "insertId"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "columnNames"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rows"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::rnnitrosqlite::NitroSQLiteRawQueryResult& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "rowsAffected"), JSIConverter::toJSI(runtime, arg.rowsAffected)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "insertId"), JSIConverter>::toJSI(runtime, arg.insertId)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "columnNames"), JSIConverter>::toJSI(runtime, arg.columnNames)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "rows"), JSIConverter::toJSI(runtime, arg.rows)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rowsAffected")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "insertId")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "columnNames")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rows")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts b/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts index 3c52ddf1..9309d54f 100644 --- a/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts +++ b/packages/react-native-nitro-sqlite/src/__mocks__/nitro.ts @@ -8,6 +8,8 @@ export const HybridNitroSQLite = { detach: jest.fn(), execute: jest.fn(), executeAsync: jest.fn(), + executeRaw: jest.fn(), + executeRawAsync: jest.fn(), prepare: jest.fn(), executeBatch: jest.fn(), executeBatchAsync: jest.fn(), diff --git a/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts index 24e14d71..ac5f2e3e 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts @@ -29,6 +29,63 @@ describe('Node SQLite mock', () => { expect(NitroSQLite.open).toBe(open) }) + it('returns raw rows that keep repeated column names', async () => { + const database = open({ name: 'raw' }) + database.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)') + database.execute( + 'CREATE TABLE pets (id INTEGER PRIMARY KEY, name TEXT, owner_id INTEGER, photo BLOB)', + ) + database.execute( + "INSERT INTO users (id, name) VALUES (1, 'Dave'), (2, 'Ritesh')", + ) + database.execute( + 'INSERT INTO pets (id, name, owner_id, photo) VALUES (?, ?, ?, ?)', + [7, 'Rex', 1, new Uint8Array([1, 2]).buffer], + ) + const query = + 'SELECT users.id, users.name, pets.id, pets.name, pets.photo FROM users LEFT JOIN pets ON pets.owner_id = users.id ORDER BY users.id' + + const result = database.executeRaw(query) + expect(result.columnNames).toEqual(['id', 'name', 'id', 'name', 'photo']) + expect(result.rows).toEqual([ + [1, 'Dave', 7, 'Rex', expect.any(ArrayBuffer)], + [2, 'Ritesh', null, null, null], + ]) + expect(new Uint8Array(result.rows[0]![4] as ArrayBuffer)).toEqual( + new Uint8Array([1, 2]), + ) + await expect(database.executeRawAsync(query)).resolves.toEqual(result) + + expect( + database.executeRaw("INSERT INTO users (name) VALUES ('Ada')"), + ).toMatchObject({ rowsAffected: 1, insertId: 3, columnNames: [], rows: [] }) + }) + + it('switches a prepared statement between raw and object rows', async () => { + const database = open({ name: 'raw-prepared' }) + const statement = database.prepare('SELECT ? AS a, ? AS b') + + expect(statement.executeRaw([1, 2]).rows).toEqual([[1, 2]]) + expect(statement.execute([3, 4]).results).toEqual([{ a: 3, b: 4 }]) + await expect(statement.executeRawAsync([5, 6])).resolves.toMatchObject({ + columnNames: ['a', 'b'], + rows: [[5, 6]], + }) + statement.finalize() + }) + + it('runs raw queries inside transactions', async () => { + const database = open({ name: 'raw-transaction' }) + database.execute('CREATE TABLE items (value INTEGER)') + + await database.transaction(async (tx) => { + tx.executeRaw('INSERT INTO items (value) VALUES (?)', [1]) + expect( + (await tx.executeRawAsync('SELECT value FROM items')).rows, + ).toEqual([[1]]) + }) + }) + it('binds repeated named parameters in first-occurrence order', () => { const database = open({ name: 'named' }) expect( diff --git a/packages/react-native-nitro-sqlite/src/__tests__/prepare.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/prepare.test.ts index 270d0e1d..f6902e6c 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/prepare.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/prepare.test.ts @@ -9,7 +9,7 @@ import { import type { NitroSQLitePreparedStatement } from '../specs/NitroSQLitePreparedStatement.nitro' import { prepare } from '../operations/prepare' import { open } from '../operations/session' -import { nativeResult } from './testUtils' +import { nativeRawResult, nativeResult } from './testUtils' const dbName = 'prepare-test' const query = 'SELECT ? AS value' @@ -35,6 +35,8 @@ function mockStatement(): NitroSQLitePreparedStatement { }, execute: jest.fn(() => nativeResult([{ value: 7 }])), executeAsync: jest.fn(async () => nativeResult([{ value: 8 }])), + executeRaw: jest.fn(() => nativeRawResult(['value'], [[9]])), + executeRawAsync: jest.fn(async () => nativeRawResult(['value'], [[10]])), finalize: jest.fn(() => { finalized = true }), @@ -95,6 +97,33 @@ it('keeps synchronous calls from overtaking an asynchronous execution', async () statement.finalize() }) +it('runs raw executions through the statement queue', async () => { + const native = mockStatement() + jest.mocked(HybridNitroSQLite.prepare).mockReturnValue(native) + const statement = prepare(dbName, query) + + expect(statement.executeRaw([9]).rows).toEqual([[9]]) + const pending = statement.executeRawAsync([10]) + expect(() => statement.executeRaw()).toThrow('busy with another operation') + await expect(pending).resolves.toMatchObject({ + columnNames: ['value'], + rows: [[10]], + }) + expect(native.executeRaw).toHaveBeenCalledWith([9]) + expect(native.executeRawAsync).toHaveBeenCalledWith([10]) + + jest.mocked(native.executeRaw).mockImplementationOnce(() => { + throw new Error('raw failed') + }) + expect(() => statement.executeRaw()).toThrow('raw failed') + jest.mocked(native.executeRawAsync).mockRejectedValueOnce('async raw failed') + await expect(statement.executeRawAsync()).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'async raw failed', + }) + statement.finalize() +}) + it('converts preparation and execution errors', async () => { jest.mocked(HybridNitroSQLite.prepare).mockImplementationOnce(() => { throw new Error('prepare failed') 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 ab114c76..57af2526 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts @@ -5,7 +5,7 @@ import { closeDatabaseQueue, isDatabaseOpen } from '../DatabaseQueue' import { open } from '../operations/session' import { execute } from '../operations/execute' -import { deferred, nativeResult } from './testUtils' +import { deferred, nativeRawResult, nativeResult } from './testUtils' const dbName = 'session-test' const options = { name: dbName, location: 'data' } @@ -89,6 +89,45 @@ describe('open', () => { expect(isDatabaseOpen(dbName)).toBe(false) }) + it('routes raw queries through the connection queue', async () => { + const db = open(options) + const raw = nativeRawResult(['id', 'id'], [[1, 7]]) + jest.mocked(HybridNitroSQLite.executeRaw).mockReturnValue(raw) + jest.mocked(HybridNitroSQLite.executeRawAsync).mockResolvedValue(raw) + + expect(db.executeRaw('SELECT 1', [1])).toBe(raw) + await expect(db.executeRawAsync('SELECT 2')).resolves.toBe(raw) + expect(HybridNitroSQLite.executeRaw).toHaveBeenCalledWith( + dbName, + 'SELECT 1', + [1], + ) + expect(HybridNitroSQLite.executeRawAsync).toHaveBeenCalledWith( + dbName, + 'SELECT 2', + undefined, + ) + + const pending = db.executeRawAsync('SELECT 3') + expect(() => db.executeRaw('SELECT 4')).toThrow( + 'busy with another operation', + ) + await pending + + jest.mocked(HybridNitroSQLite.executeRaw).mockImplementationOnce(() => { + throw new Error('raw failed') + }) + expect(() => db.executeRaw('SELECT 5')).toThrow('raw failed') + jest + .mocked(HybridNitroSQLite.executeRawAsync) + .mockRejectedValueOnce('async raw failed') + await expect(db.executeRawAsync('SELECT 6')).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'async raw failed', + }) + db.close() + }) + it('delegates transactions through the connection', async () => { const db = open(options) await expect( diff --git a/packages/react-native-nitro-sqlite/src/__tests__/testUtils.ts b/packages/react-native-nitro-sqlite/src/__tests__/testUtils.ts index ae4d24f2..08e14a25 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/testUtils.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/testUtils.ts @@ -1,4 +1,7 @@ -import type { NitroSQLiteQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' +import type { + NitroSQLiteQueryResult, + NitroSQLiteRawQueryResult, +} from '../specs/NitroSQLiteQueryResult.nitro' import type { SQLiteValue } from '../types' export function nativeResult( @@ -7,6 +10,17 @@ export function nativeResult( return { rowsAffected: results.length, results } as NitroSQLiteQueryResult } +export function nativeRawResult( + columnNames: string[] = [], + rows: SQLiteValue[][] = [], +): NitroSQLiteRawQueryResult { + return { + rowsAffected: rows.length, + columnNames, + rows, + } as NitroSQLiteRawQueryResult +} + export function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void diff --git a/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts index 52d1cc6b..6553dd1e 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts @@ -4,7 +4,8 @@ import Database from 'better-sqlite3' import { HybridNitroSQLite } from '../nitro' import { closeDatabaseQueue, openDatabaseQueue } from '../DatabaseQueue' import { transaction } from '../operations/transaction' -import { deferred, nativeResult } from './testUtils' +import type { Transaction } from '../types' +import { deferred, nativeRawResult, nativeResult } from './testUtils' const dbName = 'transaction-test' @@ -61,6 +62,44 @@ describe('transaction', () => { ) }) + it('runs raw queries inside the transaction with the same guards', async () => { + const raw = nativeRawResult(['value'], [[1]]) + const pendingQuery = deferred() + jest.mocked(HybridNitroSQLite.executeRaw).mockReturnValue(raw) + jest + .mocked(HybridNitroSQLite.executeRawAsync) + .mockReturnValue(pendingQuery.promise) + + let handle: Transaction | undefined + await transaction(dbName, async (tx) => { + handle = tx + expect(tx.executeRaw('SELECT ?', [1])).toBe(raw) + const pending = tx.executeRawAsync('SELECT ?', [2]) + expect(() => tx.executeRaw('SELECT sync')).toThrow( + 'Await all tx.executeAsync', + ) + pendingQuery.resolve(raw) + await expect(pending).resolves.toBe(raw) + }) + + expect(HybridNitroSQLite.executeRaw).toHaveBeenCalledWith( + dbName, + 'SELECT ?', + [1], + ) + expect(HybridNitroSQLite.executeRawAsync).toHaveBeenCalledWith( + dbName, + 'SELECT ?', + [2], + ) + expect(() => handle!.executeRaw('SELECT 1')).toThrow( + 'finalized transaction', + ) + expect(() => handle!.executeRawAsync('SELECT 1')).toThrow( + 'finalized transaction', + ) + }) + it('rejects synchronous transaction work until earlier async queries settle', async () => { const pendingQuery = deferred>() jest diff --git a/packages/react-native-nitro-sqlite/src/index.ts b/packages/react-native-nitro-sqlite/src/index.ts index 1ab6af75..b241664e 100644 --- a/packages/react-native-nitro-sqlite/src/index.ts +++ b/packages/react-native-nitro-sqlite/src/index.ts @@ -34,6 +34,8 @@ export type { NitroSQLiteQueryResult, NitroSQLiteQueryRows, NitroSQLiteQueryColumnMetadata, + NitroSQLiteRawQueryResult, + NitroSQLiteRawQueryRows, } from './specs/NitroSQLiteQueryResult.nitro' export type { TypeOrmNitroSQLiteConnection } from './typeORM' export type * from './types' diff --git a/packages/react-native-nitro-sqlite/src/mock.ts b/packages/react-native-nitro-sqlite/src/mock.ts index 0971a4a7..4ff95e89 100644 --- a/packages/react-native-nitro-sqlite/src/mock.ts +++ b/packages/react-native-nitro-sqlite/src/mock.ts @@ -18,7 +18,13 @@ import { import NitroSQLiteError from './NitroSQLiteError' import type { NitroSQLiteConnectionOptions, PreparedStatement } from './types' import type { MockConnection } from './mock/MockConnection' -import { executeBatch, executePrepared, executeQuery } from './mock/query' +import { + executeBatch, + executePrepared, + executePreparedRaw, + executeQuery, + executeRawQuery, +} from './mock/query' import { runTransaction } from './mock/transaction' export type { MockConnection } from './mock/MockConnection' @@ -110,6 +116,10 @@ export function open(options: NitroSQLiteConnectionOptions): MockConnection { runSync(() => executeQuery(database, query, params)), executeAsync: (query, params) => runAsync(() => executeQuery(database, query, params)), + executeRaw: (query, params) => + runSync(() => executeRawQuery(database, query, params)), + executeRawAsync: (query, params) => + runAsync(() => executeRawQuery(database, query, params)), executeBatch: (commands) => runSync(() => executeBatch(database, commands)), executeBatchAsync: (commands) => runAsync(() => executeBatch(database, commands), true), @@ -122,12 +132,19 @@ export function open(options: NitroSQLiteConnectionOptions): MockConnection { throw new NitroSQLiteError('Prepared statement is finalized.') return executePrepared(database, statement, params) } + const executeRaw: PreparedStatement['executeRaw'] = (params) => { + if (!statement) + throw new NitroSQLiteError('Prepared statement is finalized.') + return executePreparedRaw(database, statement, params) + } return { get isFinalized() { return statement === undefined }, execute: (params) => runSync(() => execute(params)), executeAsync: (params) => runAsync(() => execute(params), true), + executeRaw: (params) => runSync(() => executeRaw(params)), + executeRawAsync: (params) => runAsync(() => executeRaw(params), true), finalize: () => runSync(() => { // better-sqlite3 releases statement resources when the object is collected. diff --git a/packages/react-native-nitro-sqlite/src/mock/MockConnection.ts b/packages/react-native-nitro-sqlite/src/mock/MockConnection.ts index fbfc449a..c0c71ef9 100644 --- a/packages/react-native-nitro-sqlite/src/mock/MockConnection.ts +++ b/packages/react-native-nitro-sqlite/src/mock/MockConnection.ts @@ -13,6 +13,8 @@ export type MockConnection = Pick< | 'delete' | 'execute' | 'executeAsync' + | 'executeRaw' + | 'executeRawAsync' | 'executeBatch' | 'executeBatchAsync' | 'prepare' diff --git a/packages/react-native-nitro-sqlite/src/mock/query.ts b/packages/react-native-nitro-sqlite/src/mock/query.ts index 05bf33b6..460d95e5 100644 --- a/packages/react-native-nitro-sqlite/src/mock/query.ts +++ b/packages/react-native-nitro-sqlite/src/mock/query.ts @@ -1,4 +1,5 @@ import type BetterSqlite3 from 'better-sqlite3' +import type { NitroSQLiteRawQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' import type { BatchQueryCommand, QueryResult, @@ -9,6 +10,7 @@ import type { type Database = InstanceType type Statement = BetterSqlite3.Statement> +type RawStatement = BetterSqlite3.Statement type BoundValue = string | number | Buffer | null type ExpandedBatchCommand = { query: string; params?: SQLiteQueryParams } @@ -37,6 +39,48 @@ export function executePrepared( } as QueryResult } +/** @internal Execute an already prepared statement, returning rows as arrays of column values. */ +export function executePreparedRaw( + database: Database, + statement: Statement, + params?: SQLiteQueryParams, +): NitroSQLiteRawQueryResult { + const bindings = getBindings(statement.source, params) + let columnNames: string[] = [] + let rows: SQLiteValue[][] = [] + if (statement.reader) { + columnNames = statement.columns().map((column) => column.name) + try { + // better-sqlite3's types keep the object row type after raw(true). + const rawStatement = statement.raw(true) as unknown as RawStatement + rows = rawStatement + .all(...bindings) + .map((row) => + row.map((value, index) => normalizeValue(value, columnNames[index]!)), + ) + } finally { + // Raw mode stays on the statement, and prepared statements also serve execute(). + statement.raw(false) + } + } else statement.run(...bindings) + const { rowsAffected, insertId } = getDatabaseChanges(database) + return { + rowsAffected, + insertId, + columnNames, + rows, + } as NitroSQLiteRawQueryResult +} + +/** @internal Execute one SQL statement and return rows as arrays of column values. */ +export function executeRawQuery( + database: Database, + query: string, + params?: SQLiteQueryParams, +): NitroSQLiteRawQueryResult { + return executePreparedRaw(database, database.prepare(query), params) +} + /** @internal Execute one SQL statement and return Nitro-shaped data. */ export function executeQuery( database: Database, @@ -178,24 +222,28 @@ function toBoundValue(value: SQLiteValue): BoundValue { function normalizeRow(row: Record): QueryResultRow { const result: QueryResultRow = {} for (const [key, value] of Object.entries(row)) { - if (Buffer.isBuffer(value)) { - const bytes = new ArrayBuffer(value.byteLength) - new Uint8Array(bytes).set(value) - result[key] = bytes - } else if ( - value === null || - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - result[key] = value - } else { - throw new Error(`Unsupported SQLite value in column ${key}.`) - } + result[key] = normalizeValue(value, key) } return result } +function normalizeValue(value: unknown, column: string): SQLiteValue { + if (Buffer.isBuffer(value)) { + const bytes = new ArrayBuffer(value.byteLength) + new Uint8Array(bytes).set(value) + return bytes + } + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value + } + throw new Error(`Unsupported SQLite value in column ${column}.`) +} + function getChangeCount(database: Database): number { return getDatabaseChanges(database).rowsAffected } diff --git a/packages/react-native-nitro-sqlite/src/mock/transaction.ts b/packages/react-native-nitro-sqlite/src/mock/transaction.ts index a9099b62..c425cc0d 100644 --- a/packages/react-native-nitro-sqlite/src/mock/transaction.ts +++ b/packages/react-native-nitro-sqlite/src/mock/transaction.ts @@ -1,7 +1,7 @@ import type BetterSqlite3 from 'better-sqlite3' import NitroSQLiteError from '../NitroSQLiteError' import type { QueryResultRow, SQLiteQueryParams, Transaction } from '../types' -import { executeQuery } from './query' +import { executeQuery, executeRawQuery } from './query' /** @internal Run a callback while the connection's exclusive queue item owns it. */ export async function runTransaction( @@ -21,6 +21,14 @@ export async function runTransaction( ) } } + const track = (operation: Promise): Promise => { + pending.add(operation) + operation.then( + () => pending.delete(operation), + () => pending.delete(operation), + ) + return operation + } const finish = (query: 'COMMIT' | 'ROLLBACK') => { assertSync() const result = executeQuery(database, query) @@ -37,15 +45,21 @@ export async function runTransaction( params?: SQLiteQueryParams, ) => { assertActive() - const operation = Promise.resolve().then(() => - executeQuery(database, query, params), + return track( + Promise.resolve().then(() => + executeQuery(database, query, params), + ), ) - pending.add(operation) - operation.then( - () => pending.delete(operation), - () => pending.delete(operation), + }, + executeRaw: (query, params) => { + assertSync() + return executeRawQuery(database, query, params) + }, + executeRawAsync: async (query, params) => { + assertActive() + return track( + Promise.resolve().then(() => executeRawQuery(database, query, params)), ) - return operation }, commit: () => finish('COMMIT'), rollback: () => finish('ROLLBACK'), diff --git a/packages/react-native-nitro-sqlite/src/operations/execute.ts b/packages/react-native-nitro-sqlite/src/operations/execute.ts index 47e0f86c..61a707a7 100644 --- a/packages/react-native-nitro-sqlite/src/operations/execute.ts +++ b/packages/react-native-nitro-sqlite/src/operations/execute.ts @@ -1,7 +1,10 @@ import { HybridNitroSQLite } from '../nitro' import type { QueryResult, QueryResultRow, SQLiteQueryParams } from '../types' import NitroSQLiteError from '../NitroSQLiteError' -import type { NitroSQLiteQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' +import type { + NitroSQLiteQueryResult, + NitroSQLiteRawQueryResult, +} from '../specs/NitroSQLiteQueryResult.nitro' import { isDatabaseOpen, queueStatementAsync, @@ -108,6 +111,52 @@ export async function executeAsyncNative( } } +export function executeRawManaged( + dbName: string, + query: string, + params?: SQLiteQueryParams, + queueKey: DatabaseQueueKey = dbName, +): NitroSQLiteRawQueryResult { + return startOperationSync(queueKey, () => + executeRawNative(dbName, query, params), + ) +} + +export function executeRawNative( + dbName: string, + query: string, + params?: SQLiteQueryParams, +): NitroSQLiteRawQueryResult { + try { + return HybridNitroSQLite.executeRaw(dbName, query, params) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } +} + +export async function executeRawAsyncManaged( + dbName: string, + query: string, + params?: SQLiteQueryParams, + queueKey: DatabaseQueueKey = dbName, +): Promise { + return queueStatementAsync(queueKey, () => + executeRawAsyncNative(dbName, query, params), + ) +} + +export async function executeRawAsyncNative( + dbName: string, + query: string, + params?: SQLiteQueryParams, +): Promise { + try { + return await HybridNitroSQLite.executeRawAsync(dbName, query, params) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } +} + export function buildJSQueryResult( result: NitroSQLiteQueryResult, leadingRows?: Row[], diff --git a/packages/react-native-nitro-sqlite/src/operations/prepare.ts b/packages/react-native-nitro-sqlite/src/operations/prepare.ts index 3451a8df..ce0e5de7 100644 --- a/packages/react-native-nitro-sqlite/src/operations/prepare.ts +++ b/packages/react-native-nitro-sqlite/src/operations/prepare.ts @@ -6,6 +6,7 @@ import type { QueryResultRow, SQLiteQueryParams, } from '../types' +import type { NitroSQLiteRawQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' import { buildJSQueryResult } from './execute' import { queueOperationAsync, startOperationSync } from '../DatabaseQueue' import type { DatabaseQueueKey } from '../DatabaseQueue' @@ -55,6 +56,26 @@ export function prepare( throw NitroSQLiteError.fromError(error) } }, + executeRaw: (params?: SQLiteQueryParams): NitroSQLiteRawQueryResult => { + try { + return startOperationSync(queueKey, () => + nativeStatement.executeRaw(params), + ) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } + }, + executeRawAsync: async ( + params?: SQLiteQueryParams, + ): Promise => { + try { + return await queueOperationAsync(queueKey, () => + nativeStatement.executeRawAsync(params), + ) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } + }, finalize: () => { try { startOperationSync(queueKey, () => nativeStatement.finalize()) diff --git a/packages/react-native-nitro-sqlite/src/operations/session.ts b/packages/react-native-nitro-sqlite/src/operations/session.ts index cfc02703..7a84e004 100644 --- a/packages/react-native-nitro-sqlite/src/operations/session.ts +++ b/packages/react-native-nitro-sqlite/src/operations/session.ts @@ -9,7 +9,12 @@ import type { QueryResultRow, QueryResult, } from '../types' -import { executeAsyncManaged, executeManaged } from './execute' +import { + executeAsyncManaged, + executeManaged, + executeRawAsyncManaged, + executeRawManaged, +} from './execute' import { executeBatch, executeBatchAsync } from './executeBatch' import { prepare } from './prepare' import NitroSQLiteError from '../NitroSQLiteError' @@ -145,6 +150,14 @@ export function open( runOperation(() => executeAsyncManaged(connectionId, query, params, queueKey), ), + executeRaw: (query: string, params?: SQLiteQueryParams) => + runOperation(() => + executeRawManaged(connectionId, query, params, queueKey), + ), + executeRawAsync: async (query: string, params?: SQLiteQueryParams) => + runOperation(() => + executeRawAsyncManaged(connectionId, query, params, queueKey), + ), prepare: (query: string) => runOperation(() => prepare(connectionId, query, queueKey)), executeBatch: (commands: BatchQueryCommand[]) => diff --git a/packages/react-native-nitro-sqlite/src/operations/transaction.ts b/packages/react-native-nitro-sqlite/src/operations/transaction.ts index 9f879bf2..0c617ade 100644 --- a/packages/react-native-nitro-sqlite/src/operations/transaction.ts +++ b/packages/react-native-nitro-sqlite/src/operations/transaction.ts @@ -5,7 +5,13 @@ import type { QueryResult, QueryResultRow, } from '../types' -import { executeAsyncNative, executeNative } from './execute' +import { + executeAsyncNative, + executeNative, + executeRawAsyncNative, + executeRawNative, +} from './execute' +import type { NitroSQLiteRawQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' import NitroSQLiteError from '../NitroSQLiteError' import type { DatabaseQueueKey } from '../DatabaseQueue' @@ -44,29 +50,25 @@ export const transaction = async ( } } - const executeOnTransaction = ( - query: string, - params?: SQLiteQueryParams, - ): QueryResult => { + const throwIfNotActive = () => { if (state.current.kind !== 'active') { throw new NitroSQLiteError( `Cannot execute query on finalized transaction: ${dbName}`, ) } + } + + const runOnTransaction = (run: () => Value): Value => { + throwIfNotActive() throwIfAsyncPending() - return executeNative(dbName, query, params) + return run() } - const executeAsyncOnTransaction = ( - query: string, - params?: SQLiteQueryParams, - ): Promise> => { - if (state.current.kind !== 'active') { - throw new NitroSQLiteError( - `Cannot execute query on finalized transaction: ${dbName}`, - ) - } - const pending = executeAsyncNative(dbName, query, params) + const runAsyncOnTransaction = ( + run: () => Promise, + ): Promise => { + throwIfNotActive() + const pending = run() pendingAsyncStatements.add(pending) pending.then( () => pendingAsyncStatements.delete(pending), @@ -75,6 +77,30 @@ export const transaction = async ( return pending } + const executeOnTransaction = ( + query: string, + params?: SQLiteQueryParams, + ): QueryResult => + runOnTransaction(() => executeNative(dbName, query, params)) + + const executeAsyncOnTransaction = ( + query: string, + params?: SQLiteQueryParams, + ): Promise> => + runAsyncOnTransaction(() => executeAsyncNative(dbName, query, params)) + + const executeRawOnTransaction = ( + query: string, + params?: SQLiteQueryParams, + ): NitroSQLiteRawQueryResult => + runOnTransaction(() => executeRawNative(dbName, query, params)) + + const executeRawAsyncOnTransaction = ( + query: string, + params?: SQLiteQueryParams, + ): Promise => + runAsyncOnTransaction(() => executeRawAsyncNative(dbName, query, params)) + const commit = () => { if (state.current.kind !== 'active') { throw new NitroSQLiteError( @@ -130,6 +156,8 @@ export const transaction = async ( commit, execute: executeOnTransaction, executeAsync: executeAsyncOnTransaction, + executeRaw: executeRawOnTransaction, + executeRawAsync: executeRawAsyncOnTransaction, rollback, }) 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 38f4986d..a9bd407f 100644 --- a/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts +++ b/packages/react-native-nitro-sqlite/src/specs/NitroSQLite.nitro.ts @@ -8,6 +8,7 @@ import type { import type { NitroSQLiteQueryResult, NitroSQLiteQueryRows, + NitroSQLiteRawQueryResult, } from './NitroSQLiteQueryResult.nitro' import type { NitroSQLitePreparedStatement } from './NitroSQLitePreparedStatement.nitro' @@ -108,6 +109,28 @@ export interface NitroSQLite params?: SQLiteQueryParams, onRows?: (rows: NitroSQLiteQueryRows) => void, ): Promise + /** Execute one SQL statement synchronously on the calling thread, returning rows as arrays of column values. + * @param dbName Name of an open database. + * @param query SQL statement with optional positional placeholders. + * @param params Positional values bound to SQL placeholders. + * @returns Column names, rows as value arrays, affected row count, and insert ID. + */ + executeRaw( + dbName: string, + query: string, + params?: SQLiteQueryParams, + ): NitroSQLiteRawQueryResult + /** Execute one SQL statement on a background thread, returning rows as arrays of column values. + * @param dbName Name of an open database. + * @param query SQL statement with optional positional placeholders. + * @param params Positional values bound to SQL placeholders. + * @returns A promise of the native raw query result. + */ + executeRawAsync( + dbName: string, + query: string, + params?: SQLiteQueryParams, + ): Promise /** Prepare one SQL statement on an open native connection for repeated execution. * Finalize the returned statement before closing its connection. * @param dbName Name or ID of an open database connection. diff --git a/packages/react-native-nitro-sqlite/src/specs/NitroSQLitePreparedStatement.nitro.ts b/packages/react-native-nitro-sqlite/src/specs/NitroSQLitePreparedStatement.nitro.ts index de545bd9..e4ce3c62 100644 --- a/packages/react-native-nitro-sqlite/src/specs/NitroSQLitePreparedStatement.nitro.ts +++ b/packages/react-native-nitro-sqlite/src/specs/NitroSQLitePreparedStatement.nitro.ts @@ -1,6 +1,9 @@ import type { HybridObject } from 'react-native-nitro-modules' import type { SQLiteQueryParams } from '../types' -import type { NitroSQLiteQueryResult } from './NitroSQLiteQueryResult.nitro' +import type { + NitroSQLiteQueryResult, + NitroSQLiteRawQueryResult, +} from './NitroSQLiteQueryResult.nitro' /** Native prepared statement returned by `NitroSQLite.native.prepare()`. */ export interface NitroSQLitePreparedStatement @@ -23,6 +26,18 @@ export interface NitroSQLitePreparedStatement * @returns A promise of the native query result. */ executeAsync(params?: SQLiteQueryParams): Promise + /** Execute on the calling thread like `execute`, returning rows as arrays of column values. + * @param params Values bound to positional placeholders. + * @returns Column names, rows as value arrays, affected row count, and insert ID. + */ + executeRaw(params?: SQLiteQueryParams): NitroSQLiteRawQueryResult + /** Execute on a background thread like `executeAsync`, returning rows as arrays of column values. + * @param params Values bound to positional placeholders. + * @returns A promise of the native raw query result. + */ + executeRawAsync( + params?: SQLiteQueryParams, + ): Promise /** Release the native statement. Repeated calls are safe. */ finalize(): void } diff --git a/packages/react-native-nitro-sqlite/src/specs/NitroSQLiteQueryResult.nitro.ts b/packages/react-native-nitro-sqlite/src/specs/NitroSQLiteQueryResult.nitro.ts index efab64c1..970b86e9 100644 --- a/packages/react-native-nitro-sqlite/src/specs/NitroSQLiteQueryResult.nitro.ts +++ b/packages/react-native-nitro-sqlite/src/specs/NitroSQLiteQueryResult.nitro.ts @@ -24,6 +24,27 @@ export interface NitroSQLiteQueryResult { readonly metadata?: Record } +/** Result rows as arrays of column values, in column order. */ +export type NitroSQLiteRawQueryRows = CustomType< + SQLiteValue[][], + 'margelo::nitro::rnnitrosqlite::SQLiteRawQueryResults', + { include: 'NitroSQLiteQueryResults.hpp' } +> + +/** Native result of one SQL statement, with rows as arrays of column values. */ +export interface NitroSQLiteRawQueryResult { + /** SQLite's latest row change count. For a read-only query it may reflect an earlier write. */ + readonly rowsAffected: number + /** Last insert row ID for this connection. It may refer to an earlier statement. */ + readonly insertId?: number + + /** Result column names in column order. Repeated names are kept. */ + readonly columnNames: string[] + + /** Rows as arrays of column values, in the order of `columnNames`. */ + readonly rows: NitroSQLiteRawQueryRows +} + // TODO: Investigate why this cannot be represented in Nitro // export type NitroQueryResultRow = { // [key: string]: SQLiteValue diff --git a/packages/react-native-nitro-sqlite/src/types.ts b/packages/react-native-nitro-sqlite/src/types.ts index 7ce24aab..51448151 100644 --- a/packages/react-native-nitro-sqlite/src/types.ts +++ b/packages/react-native-nitro-sqlite/src/types.ts @@ -1,4 +1,7 @@ -import type { NitroSQLiteQueryResult } from './specs/NitroSQLiteQueryResult.nitro' +import type { + NitroSQLiteQueryResult, + NitroSQLiteRawQueryResult, +} from './specs/NitroSQLiteQueryResult.nitro' /** Options for `open()` and `NitroSQLite.open()`. */ export interface NitroSQLiteConnectionOptions { @@ -63,6 +66,10 @@ export interface NitroSQLiteConnection { execute: ExecuteQuery /** Queue one SQL statement and resolve with its result. */ executeAsync: ExecuteAsyncQuery + /** Execute one SQL statement synchronously, returning rows as arrays of column values. Throws while the connection is busy. */ + executeRaw: ExecuteRawQuery + /** Queue one SQL statement and resolve with rows as arrays of column values. */ + executeRawAsync: ExecuteRawAsyncQuery /** * Prepare one SQL statement on this connection for repeated execution. * Finalize the returned statement before closing the connection. @@ -174,6 +181,25 @@ export type ExecuteAsyncQuery = ( params?: SQLiteQueryParams, ) => Promise> +/** Execute one SQL statement synchronously and return rows as arrays of column values. + * Repeated column names are kept, in the order of `columnNames`. + * @param query SQL statement with optional positional placeholders. + * @param params Values bound to the placeholders. + */ +export type ExecuteRawQuery = ( + query: string, + params?: SQLiteQueryParams, +) => NitroSQLiteRawQueryResult + +/** Queue one SQL statement and resolve with rows as arrays of column values. + * @param query SQL statement with optional positional placeholders. + * @param params Values bound to the placeholders. + */ +export type ExecuteRawAsyncQuery = ( + query: string, + params?: SQLiteQueryParams, +) => Promise + /** * A reusable SQL statement bound to the connection that prepared it. Each * execution resets the statement and its bindings before applying new values. @@ -186,6 +212,10 @@ export interface PreparedStatement { execute: ExecutePreparedStatement /** Queue execution on a background thread and resolve with the query result. */ executeAsync: ExecutePreparedStatementAsync + /** Execute on the calling thread like `execute`, returning rows as arrays of column values. */ + executeRaw: ExecuteRawPreparedStatement + /** Queue execution like `executeAsync` and resolve with rows as arrays of column values. */ + executeRawAsync: ExecuteRawPreparedStatementAsync /** Release the native statement. Calling this more than once is safe. */ finalize(): void } @@ -212,6 +242,23 @@ export type ExecutePreparedStatementAsync = < params?: SQLiteQueryParams, ) => Promise> +/** + * Execute a prepared statement and return rows as arrays of column values. + * Each call replaces the previous parameter bindings. + * @param params Values bound to the statement's positional placeholders. + */ +export type ExecuteRawPreparedStatement = ( + params?: SQLiteQueryParams, +) => NitroSQLiteRawQueryResult + +/** + * Queue a prepared statement execution and resolve with rows as arrays of column values. + * @param params Values bound to the statement's positional placeholders. + */ +export type ExecuteRawPreparedStatementAsync = ( + params?: SQLiteQueryParams, +) => Promise + /** Handle valid only while its transaction callback is active. */ export interface Transaction { /** Commit now. Marks completion only after SQLite accepts COMMIT. @@ -229,6 +276,10 @@ export interface Transaction { execute: ExecuteQuery /** Execute within this transaction on a background thread. */ executeAsync: ExecuteAsyncQuery + /** Execute within this transaction on the calling thread, returning rows as arrays of column values. */ + executeRaw: ExecuteRawQuery + /** Execute within this transaction on a background thread, returning rows as arrays of column values. */ + executeRawAsync: ExecuteRawAsyncQuery } /** One command in a batch. A nested `params` array repeats the query for each parameter set. */ From f76d0c458ce054a4f6083c573a19e6725b71886a Mon Sep 17 00:00:00 2001 From: Dave Mkpa-Eke Date: Sat, 17 Oct 2026 22:23:37 +0100 Subject: [PATCH 02/13] feat: add drizzle driver package --- .github/ci-paths.json | 6 + bun.lock | 19 ++ .../.eslintrc.js | 8 + .../babel.config.js | 3 + .../package.json | 59 +++++ .../src/__tests__/driver.test.ts | 114 +++++++++ .../src/driver.ts | 71 ++++++ .../src/drizzle-orm.d.ts | 61 +++++ .../src/index.ts | 7 + .../src/session.ts | 231 ++++++++++++++++++ .../tsconfig.json | 17 ++ release-please-config.json | 12 +- scripts/publish-release.test.ts | 13 +- scripts/release-plan.ts | 4 + scripts/release-registry.ts | 1 + scripts/release.test.ts | 1 + tsconfig.json | 1 + 17 files changed, 622 insertions(+), 6 deletions(-) create mode 100644 packages/react-native-nitro-sqlite-drizzle/.eslintrc.js create mode 100644 packages/react-native-nitro-sqlite-drizzle/babel.config.js create mode 100644 packages/react-native-nitro-sqlite-drizzle/package.json create mode 100644 packages/react-native-nitro-sqlite-drizzle/src/__tests__/driver.test.ts create mode 100644 packages/react-native-nitro-sqlite-drizzle/src/driver.ts create mode 100644 packages/react-native-nitro-sqlite-drizzle/src/drizzle-orm.d.ts create mode 100644 packages/react-native-nitro-sqlite-drizzle/src/index.ts create mode 100644 packages/react-native-nitro-sqlite-drizzle/src/session.ts create mode 100644 packages/react-native-nitro-sqlite-drizzle/tsconfig.json diff --git a/.github/ci-paths.json b/.github/ci-paths.json index c0faedd9..b8def37b 100644 --- a/.github/ci-paths.json +++ b/.github/ci-paths.json @@ -33,6 +33,10 @@ "packages/react-native-nitro-sqlite-vec/**/*.jsx", "packages/react-native-nitro-sqlite-vec/tsconfig.json", "packages/react-native-nitro-sqlite-vec/package.json", + "packages/react-native-nitro-sqlite-drizzle/**/*.ts", + "packages/react-native-nitro-sqlite-drizzle/**/*.js", + "packages/react-native-nitro-sqlite-drizzle/tsconfig.json", + "packages/react-native-nitro-sqlite-drizzle/package.json", "scripts/**/*.ts", "example/jest*.js" ], @@ -75,6 +79,8 @@ "packages/react-native-nitro-sqlite/package.json", "packages/react-native-nitro-sqlite-vec/src/**", "packages/react-native-nitro-sqlite-vec/package.json", + "packages/react-native-nitro-sqlite-drizzle/src/**", + "packages/react-native-nitro-sqlite-drizzle/package.json", "config/tsconfig.json", "package.json", "bun.lock" diff --git a/bun.lock b/bun.lock index 47236b0a..9e120280 100644 --- a/bun.lock +++ b/bun.lock @@ -183,6 +183,19 @@ "better-sqlite3", ], }, + "packages/react-native-nitro-sqlite-drizzle": { + "name": "react-native-nitro-sqlite-drizzle", + "version": "10.2.0", + "devDependencies": { + "drizzle-orm": "0.45.4", + "react-native-nitro-sqlite": "10.2.0", + "typescript": "^5.8.3", + }, + "peerDependencies": { + "drizzle-orm": ">=0.45.4 <1.0.0", + "react-native-nitro-sqlite": ">=10.3.0", + }, + }, "packages/react-native-nitro-sqlite-vec": { "name": "react-native-nitro-sqlite-vec", "version": "10.2.0", @@ -1542,6 +1555,8 @@ "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], + "drizzle-orm": ["drizzle-orm@0.45.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -2590,6 +2605,8 @@ "react-native-nitro-sqlite": ["react-native-nitro-sqlite@workspace:packages/react-native-nitro-sqlite"], + "react-native-nitro-sqlite-drizzle": ["react-native-nitro-sqlite-drizzle@workspace:packages/react-native-nitro-sqlite-drizzle"], + "react-native-nitro-sqlite-example": ["react-native-nitro-sqlite-example@workspace:example"], "react-native-nitro-sqlite-macos-example": ["react-native-nitro-sqlite-macos-example@workspace:example/macos"], @@ -3758,6 +3775,8 @@ "react-native-macos-local/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "react-native-nitro-sqlite-drizzle/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "react-native-nitro-sqlite-example/@types/node": ["@types/node@22.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA=="], "react-native-nitro-sqlite-example/@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], diff --git a/packages/react-native-nitro-sqlite-drizzle/.eslintrc.js b/packages/react-native-nitro-sqlite-drizzle/.eslintrc.js new file mode 100644 index 00000000..0d3e3482 --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + root: true, + extends: '../../config/.eslintrc.js', + parserOptions: { + project: true, + tsconfigRootDir: __dirname, + }, +} diff --git a/packages/react-native-nitro-sqlite-drizzle/babel.config.js b/packages/react-native-nitro-sqlite-drizzle/babel.config.js new file mode 100644 index 00000000..3e0218e6 --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], +} diff --git a/packages/react-native-nitro-sqlite-drizzle/package.json b/packages/react-native-nitro-sqlite-drizzle/package.json new file mode 100644 index 00000000..7840186c --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/package.json @@ -0,0 +1,59 @@ +{ + "name": "react-native-nitro-sqlite-drizzle", + "version": "10.2.0", + "description": "Drizzle ORM driver for react-native-nitro-sqlite.", + "source": "./src/index", + "react-native": "./src/index", + "main": "./src/index", + "types": "./src/index", + "files": [ + "src", + "!**/__tests__", + "README.md" + ], + "scripts": { + "test": "jest", + "typecheck": "tsc --build", + "lint": "eslint \"**/*.{js,ts,tsx}\" --fix", + "prepublishOnly": "bun typecheck" + }, + "keywords": [ + "react-native", + "sqlite", + "drizzle", + "drizzle-orm", + "orm", + "nitro-modules", + "ios", + "android" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/margelo/react-native-nitro-sqlite.git" + }, + "author": "Christoph Pader", + "license": "MIT", + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "peerDependencies": { + "drizzle-orm": ">=0.45.4 <1.0.0", + "react-native-nitro-sqlite": ">=10.3.0" + }, + "devDependencies": { + "drizzle-orm": "0.45.4", + "react-native-nitro-sqlite": "10.2.0", + "typescript": "^5.8.3" + }, + "jest": { + "preset": "@react-native/jest-preset", + "watchman": false, + "moduleNameMapper": { + "^react-native-nitro-sqlite/mock$": "/../react-native-nitro-sqlite/src/mock.ts" + }, + "testMatch": [ + "/src/__tests__/**/*.test.ts" + ] + } +} diff --git a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/driver.test.ts b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/driver.test.ts new file mode 100644 index 00000000..f83c627c --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/driver.test.ts @@ -0,0 +1,114 @@ +import { eq, sql, TransactionRollbackError } from 'drizzle-orm' +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { open, resetAllDatabases } from 'react-native-nitro-sqlite/mock' +import type { NitroSQLiteConnection } from 'react-native-nitro-sqlite' +import { drizzle } from '../index' + +const users = sqliteTable('users', { + id: integer('id').primaryKey(), + name: text('name').notNull(), +}) + +const pets = sqliteTable('pets', { + id: integer('id').primaryKey(), + name: text('name').notNull(), + ownerId: integer('owner_id'), +}) + +function createDatabase() { + const connection = open({ name: 'drizzle' }) as NitroSQLiteConnection + connection.execute( + 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)', + ) + connection.execute( + 'CREATE TABLE pets (id INTEGER PRIMARY KEY, name TEXT NOT NULL, owner_id INTEGER)', + ) + return drizzle(connection) +} + +afterEach(resetAllDatabases) + +describe('drizzle', () => { + it('inserts and selects rows', async () => { + const db = createDatabase() + await db.insert(users).values([ + { id: 1, name: 'Dave' }, + { id: 2, name: 'Ritesh' }, + ]) + + expect(await db.select().from(users)).toEqual([ + { id: 1, name: 'Dave' }, + { id: 2, name: 'Ritesh' }, + ]) + expect(await db.select().from(users).where(eq(users.id, 2)).get()).toEqual({ + id: 2, + name: 'Ritesh', + }) + }) + + it('keeps both sides of a join whose columns share names', async () => { + const db = createDatabase() + await db.insert(users).values([ + { id: 1, name: 'Dave' }, + { id: 2, name: 'Ritesh' }, + ]) + await db.insert(pets).values({ id: 7, name: 'Rex', ownerId: 1 }) + + expect( + await db + .select() + .from(users) + .leftJoin(pets, eq(pets.ownerId, users.id)) + .orderBy(users.id), + ).toEqual([ + { + users: { id: 1, name: 'Dave' }, + pets: { id: 7, name: 'Rex', ownerId: 1 }, + }, + { users: { id: 2, name: 'Ritesh' }, pets: null }, + ]) + }) + + it('runs raw SQL and prepared queries', async () => { + const db = createDatabase() + await db.run(sql`INSERT INTO users (id, name) VALUES (1, 'Dave')`) + + expect(await db.all(sql`SELECT name FROM users`)).toEqual([ + { name: 'Dave' }, + ]) + expect(await db.get(sql`SELECT name FROM users`)).toEqual({ name: 'Dave' }) + expect(await db.values(sql`SELECT id, name FROM users`)).toEqual([ + [1, 'Dave'], + ]) + + const byId = db + .select() + .from(users) + .where(eq(users.id, sql.placeholder('id'))) + .prepare() + expect(await byId.get({ id: 1 })).toEqual({ id: 1, name: 'Dave' }) + expect(await byId.get({ id: 2 })).toBeUndefined() + }) + + it('commits transactions and rolls back savepoints and failures', async () => { + const db = createDatabase() + await db.transaction(async (tx) => { + await tx.insert(users).values({ id: 1, name: 'Dave' }) + await tx + .transaction(async (nested) => { + await nested.insert(users).values({ id: 2, name: 'Ritesh' }) + nested.rollback() + }) + .catch(() => undefined) + }) + expect(await db.select().from(users)).toEqual([{ id: 1, name: 'Dave' }]) + + await expect( + db.transaction(async (tx) => { + await tx.insert(users).values({ id: 3, name: 'Ada' }) + tx.rollback() + }), + ).rejects.toBeInstanceOf(TransactionRollbackError) + expect(await db.select().from(users)).toEqual([{ id: 1, name: 'Dave' }]) + }) +}) diff --git a/packages/react-native-nitro-sqlite-drizzle/src/driver.ts b/packages/react-native-nitro-sqlite-drizzle/src/driver.ts new file mode 100644 index 00000000..91924609 --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/src/driver.ts @@ -0,0 +1,71 @@ +import { + createTableRelationsHelpers, + DefaultLogger, + entityKind, + extractTablesRelationalConfig, +} from 'drizzle-orm' +import type { + DrizzleConfig, + RelationalSchemaConfig, + TablesRelationalConfig, +} from 'drizzle-orm' +import { BaseSQLiteDatabase, SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core' +import type { + NitroSQLiteConnection, + NitroSQLiteRawQueryResult, +} from 'react-native-nitro-sqlite' +import { NitroSQLiteSession } from './session' + +export class NitroSQLiteDatabase< + TSchema extends Record = Record, +> extends BaseSQLiteDatabase<'async', NitroSQLiteRawQueryResult, TSchema> { + static override readonly [entityKind]: string = 'NitroSQLiteDatabase' +} + +/** + * Create a Drizzle database whose queries run asynchronously on a Nitro SQLite connection. + * @param connection Connection returned by `open()`. + * @param config Drizzle options such as `schema`, `logger`, and `casing`. + */ +export function drizzle< + TSchema extends Record = Record, +>( + connection: NitroSQLiteConnection, + config: DrizzleConfig = {}, +): NitroSQLiteDatabase & { $client: NitroSQLiteConnection } { + const dialect = new SQLiteAsyncDialect({ casing: config.casing }) + let logger + if (config.logger === true) { + logger = new DefaultLogger() + } else if (config.logger !== false) { + logger = config.logger + } + + let schema: RelationalSchemaConfig | undefined + if (config.schema) { + const tablesConfig = extractTablesRelationalConfig( + config.schema, + createTableRelationsHelpers, + ) + schema = { + fullSchema: config.schema, + schema: tablesConfig.tables, + tableNamesMap: tablesConfig.tableNamesMap, + } + } + + const session = new NitroSQLiteSession( + connection, + connection, + dialect, + schema, + { logger }, + ) + const db = new NitroSQLiteDatabase( + 'async', + dialect, + session, + schema, + ) as NitroSQLiteDatabase + return Object.assign(db, { $client: connection }) +} diff --git a/packages/react-native-nitro-sqlite-drizzle/src/drizzle-orm.d.ts b/packages/react-native-nitro-sqlite-drizzle/src/drizzle-orm.d.ts new file mode 100644 index 00000000..a29588b2 --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/src/drizzle-orm.d.ts @@ -0,0 +1,61 @@ +// drizzle-orm 0.45 ships these at runtime but marks them internal, so its type declarations omit them. +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-shadow -- merged declarations must repeat drizzle-orm's type parameters */ +import type { + AnyColumn, + ExtractTablesWithRelations, + SelectedFieldsOrdered, + TablesRelationalConfig, +} from 'drizzle-orm' +import type { + PreparedQueryConfig, + SQLiteAsyncDialect, + SQLiteSession, + SQLiteSyncDialect, +} from 'drizzle-orm/sqlite-core' + +declare module 'drizzle-orm/utils' { + export function mapResultRow( + columns: SelectedFieldsOrdered, + row: unknown[], + joinsNotNullableMap: Record | undefined, + ): TResult +} + +declare module 'drizzle-orm/sqlite-core/session' { + interface SQLitePreparedQuery { + joinsNotNullableMap?: Record + } + + interface SQLiteSession< + TResultKind extends 'sync' | 'async', + TRunResult, + TFullSchema extends Record, + TSchema extends TablesRelationalConfig, + > { + readonly dialect: { + sync: SQLiteSyncDialect + async: SQLiteAsyncDialect + }[TResultKind] + } +} + +declare module 'drizzle-orm/sqlite-core/db' { + interface BaseSQLiteDatabase< + TResultKind extends 'sync' | 'async', + TRunResult, + TFullSchema extends Record = Record, + TSchema extends + TablesRelationalConfig = ExtractTablesWithRelations, + > { + readonly dialect: { + sync: SQLiteSyncDialect + async: SQLiteAsyncDialect + }[TResultKind] + readonly session: SQLiteSession< + TResultKind, + TRunResult, + TFullSchema, + TSchema + > + } +} diff --git a/packages/react-native-nitro-sqlite-drizzle/src/index.ts b/packages/react-native-nitro-sqlite-drizzle/src/index.ts new file mode 100644 index 00000000..088ef800 --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/src/index.ts @@ -0,0 +1,7 @@ +export { drizzle, NitroSQLiteDatabase } from './driver' +export { + NitroSQLitePreparedQuery, + NitroSQLiteSession, + NitroSQLiteTransaction, +} from './session' +export type { NitroSQLiteSessionOptions } from './session' diff --git a/packages/react-native-nitro-sqlite-drizzle/src/session.ts b/packages/react-native-nitro-sqlite-drizzle/src/session.ts new file mode 100644 index 00000000..396b02f5 --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/src/session.ts @@ -0,0 +1,231 @@ +import { entityKind, fillPlaceholders, NoopLogger, sql } from 'drizzle-orm' +import type { + Logger, + Query, + RelationalSchemaConfig, + TablesRelationalConfig, +} from 'drizzle-orm' +import { + SQLitePreparedQuery, + SQLiteSession, + SQLiteTransaction, +} from 'drizzle-orm/sqlite-core' +import type { + PreparedQueryConfig, + SelectedFieldsOrdered, + SQLiteAsyncDialect, + SQLiteExecuteMethod, + SQLiteTransactionConfig, +} from 'drizzle-orm/sqlite-core' +import { mapResultRow } from 'drizzle-orm/utils' +import type { + NitroSQLiteConnection, + NitroSQLiteRawQueryResult, + SQLiteQueryParams, + Transaction, +} from 'react-native-nitro-sqlite' + +export interface NitroSQLiteSessionOptions { + logger?: Logger +} + +export class NitroSQLiteSession< + TFullSchema extends Record, + TSchema extends TablesRelationalConfig, +> extends SQLiteSession< + 'async', + NitroSQLiteRawQueryResult, + TFullSchema, + TSchema +> { + static override readonly [entityKind]: string = 'NitroSQLiteSession' + + private logger: Logger + + constructor( + private connection: NitroSQLiteConnection, + // The connection, or the transaction handle while a transaction runs. + private client: NitroSQLiteConnection | Transaction, + dialect: SQLiteAsyncDialect, + private schema: RelationalSchemaConfig | undefined, + private options: NitroSQLiteSessionOptions = {}, + ) { + super(dialect) + this.logger = options.logger ?? new NoopLogger() + } + + prepareQuery( + query: Query, + fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + isResponseInArrayMode: boolean, + customResultMapper?: (rows: unknown[][]) => unknown, + ): NitroSQLitePreparedQuery { + return new NitroSQLitePreparedQuery( + this.client, + query, + this.logger, + fields, + executeMethod, + isResponseInArrayMode, + customResultMapper, + ) + } + + override async transaction( + transaction: ( + tx: NitroSQLiteTransaction, + ) => Promise, + _config?: SQLiteTransactionConfig, + ): Promise { + return this.connection.transaction(async (nitroTransaction) => { + const session = new NitroSQLiteSession( + this.connection, + nitroTransaction, + this.dialect, + this.schema, + this.options, + ) + const tx = new NitroSQLiteTransaction( + 'async', + this.dialect, + session, + this.schema, + ) + return transaction(tx) + }) + } +} + +export class NitroSQLiteTransaction< + TFullSchema extends Record, + TSchema extends TablesRelationalConfig, +> extends SQLiteTransaction< + 'async', + NitroSQLiteRawQueryResult, + TFullSchema, + TSchema +> { + static override readonly [entityKind]: string = 'NitroSQLiteTransaction' + + override async transaction( + transaction: ( + tx: NitroSQLiteTransaction, + ) => Promise, + ): Promise { + const savepointName = `sp${this.nestedIndex}` + const tx = new NitroSQLiteTransaction( + 'async', + this.dialect, + this.session, + this.schema, + this.nestedIndex + 1, + ) + await this.session.run(sql.raw(`savepoint ${savepointName}`)) + try { + const result = await transaction(tx) + await this.session.run(sql.raw(`release savepoint ${savepointName}`)) + return result + } catch (error) { + await this.session.run(sql.raw(`rollback to savepoint ${savepointName}`)) + throw error + } + } +} + +export class NitroSQLitePreparedQuery< + T extends PreparedQueryConfig = PreparedQueryConfig, +> extends SQLitePreparedQuery<{ + type: 'async' + run: NitroSQLiteRawQueryResult + all: T['all'] + get: T['get'] + values: T['values'] + execute: T['execute'] +}> { + static override readonly [entityKind]: string = 'NitroSQLitePreparedQuery' + + constructor( + private client: NitroSQLiteConnection | Transaction, + query: Query, + private logger: Logger, + private fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + private _isResponseInArrayMode: boolean, + private customResultMapper?: (rows: unknown[][]) => unknown, + ) { + super('async', executeMethod, query) + } + + async run( + placeholderValues?: Record, + ): Promise { + return this.client.executeRawAsync( + this.query.sql, + this.bindParams(placeholderValues), + ) + } + + async all(placeholderValues?: Record): Promise { + const { fields, customResultMapper } = this + if (!fields && !customResultMapper) { + const result = await this.client.executeAsync( + this.query.sql, + this.bindParams(placeholderValues), + ) + return result.results + } + + const rows = (await this.values(placeholderValues)) as unknown[][] + if (customResultMapper) { + return customResultMapper(rows) as T['all'] + } + return rows.map((row) => + mapResultRow(fields!, row, this.joinsNotNullableMap), + ) + } + + async get(placeholderValues?: Record): Promise { + const { fields, customResultMapper } = this + if (!fields && !customResultMapper) { + const result = await this.client.executeAsync( + this.query.sql, + this.bindParams(placeholderValues), + ) + return result.results[0] + } + + const rows = (await this.values(placeholderValues)) as unknown[][] + const row = rows[0] + if (!row) { + return undefined + } + if (customResultMapper) { + return customResultMapper(rows) as T['get'] + } + return mapResultRow(fields!, row, this.joinsNotNullableMap) + } + + async values( + placeholderValues?: Record, + ): Promise { + const result = await this.client.executeRawAsync( + this.query.sql, + this.bindParams(placeholderValues), + ) + return result.rows + } + + /** @internal */ + isResponseInArrayMode(): boolean { + return this._isResponseInArrayMode + } + + private bindParams( + placeholderValues: Record | undefined, + ): SQLiteQueryParams { + const params = fillPlaceholders(this.query.params, placeholderValues ?? {}) + this.logger.logQuery(this.query.sql, params) + return params as SQLiteQueryParams + } +} diff --git a/packages/react-native-nitro-sqlite-drizzle/tsconfig.json b/packages/react-native-nitro-sqlite-drizzle/tsconfig.json new file mode 100644 index 00000000..3ba88f1a --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../config/tsconfig.json", + "include": ["src"], + "references": [{ "path": "../react-native-nitro-sqlite" }], + "compilerOptions": { + "types": ["node", "jest"], + "rootDir": "src", + "paths": { + "react-native-nitro-sqlite": [ + "../react-native-nitro-sqlite/src/index.ts" + ], + "react-native-nitro-sqlite/mock": [ + "../react-native-nitro-sqlite/src/mock.ts" + ] + } + } +} diff --git a/release-please-config.json b/release-please-config.json index 347f45f5..ad2e99c4 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -7,7 +7,7 @@ "draft": true, "force-tag-creation": true, "pull-request-title-pattern": "chore: release ${version}", - "pull-request-header": "Publishing requires a matching version across both packages and the example. This release updates their versions together and records the changes below.", + "pull-request-header": "Publishing requires a matching version across all packages and the example. This release updates their versions together and records the changes below.", "changelog-sections": [ { "type": "chore", @@ -55,6 +55,16 @@ "path": "packages/react-native-nitro-sqlite-vec/package.json", "jsonpath": "$.devDependencies['react-native-nitro-sqlite']" }, + { + "type": "json", + "path": "packages/react-native-nitro-sqlite-drizzle/package.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": "packages/react-native-nitro-sqlite-drizzle/package.json", + "jsonpath": "$.devDependencies['react-native-nitro-sqlite']" + }, { "type": "json", "path": "example/package.json", diff --git a/scripts/publish-release.test.ts b/scripts/publish-release.test.ts index 8f275324..1f927fa9 100644 --- a/scripts/publish-release.test.ts +++ b/scripts/publish-release.test.ts @@ -14,7 +14,7 @@ import { fileURLToPath } from 'node:url' const script = fileURLToPath(new URL('./publish-release.ts', import.meta.url)) -test('publishing validates both packages and publishers before publishing in dependency order', () => { +test('publishing validates all packages and publishers before publishing in dependency order', () => { const result = runPublication() assert.equal(result.status, 0, result.stderr) const publish = result.calls.findIndex((line) => line.startsWith('npm:')) @@ -39,11 +39,12 @@ test('publishing validates both packages and publishers before publishing in dep [ 'npm:react-native-nitro-sqlite:publish --access public --provenance --tag latest --workspaces=false', 'npm:react-native-nitro-sqlite-vec:publish --access public --provenance --tag latest --workspaces=false', + 'npm:react-native-nitro-sqlite-drizzle:publish --access public --provenance --tag latest --workspaces=false', ], ) }) -test('validation failure prevents publishing either package', () => { +test('validation failure prevents publishing any package', () => { const result = runPublication({ PUBLISH_TEST_FAIL_VALIDATION: '1' }) assert.notEqual(result.status, 0) assert.equal( @@ -52,7 +53,7 @@ test('validation failure prevents publishing either package', () => { ) }) -test('second trusted publisher failure prevents publishing either package', () => { +test('second trusted publisher failure prevents publishing any package', () => { const result = runPublication({ PUBLISH_TEST_FAIL_OIDC: '1' }) assert.notEqual(result.status, 0) assert.match(result.stderr, /OIDC exchange failed/) @@ -66,8 +67,9 @@ test('retry skips a package already published from the selected commit', () => { const result = runPublication({ PUBLISH_TEST_CORE_EXISTS: '1' }) assert.equal(result.status, 0, result.stderr) const published = result.calls.filter((line) => line.startsWith('npm:')) - assert.equal(published.length, 1) + assert.equal(published.length, 2) assert.match(published[0]!, /^npm:react-native-nitro-sqlite-vec:/) + assert.match(published[1]!, /^npm:react-native-nitro-sqlite-drizzle:/) }) function runPublication(extraEnv: Record = {}) { @@ -84,6 +86,7 @@ function runPublication(extraEnv: Record = {}) { 'example', 'packages/react-native-nitro-sqlite', 'packages/react-native-nitro-sqlite-vec', + 'packages/react-native-nitro-sqlite-drizzle', ]) { mkdirSync(join(project, path), { recursive: true }) writeFileSync( @@ -129,7 +132,7 @@ function runPublication(extraEnv: Record = {}) { 'globalThis.fetch = async (input) => {', ' const url = String(input)', " if (url.startsWith('https://oidc.example.invalid')) return Response.json({ value: 'fixture-identity' })", - " const name = url.includes('react-native-nitro-sqlite-vec') ? 'react-native-nitro-sqlite-vec' : 'react-native-nitro-sqlite'", + " const name = ['react-native-nitro-sqlite-vec', 'react-native-nitro-sqlite-drizzle'].find((addon) => url.includes(addon)) ?? 'react-native-nitro-sqlite'", " if (url.includes('/token/exchange/')) {", " appendFileSync(process.env.PUBLISH_TEST_LOG, 'trusted:' + name + '\\n')", " if (name.endsWith('-vec') && process.env.PUBLISH_TEST_FAIL_OIDC) return new Response('', { status: 403 })", diff --git a/scripts/release-plan.ts b/scripts/release-plan.ts index f800781c..bd87c597 100644 --- a/scripts/release-plan.ts +++ b/scripts/release-plan.ts @@ -76,6 +76,10 @@ export function validateReleaseVersions(version: string): void { 'packages/react-native-nitro-sqlite-vec/package.json', ['version', 'devDependencies.react-native-nitro-sqlite'], ], + [ + 'packages/react-native-nitro-sqlite-drizzle/package.json', + ['version', 'devDependencies.react-native-nitro-sqlite'], + ], [ 'example/package.json', [ diff --git a/scripts/release-registry.ts b/scripts/release-registry.ts index 25e2faf3..0cbbe390 100644 --- a/scripts/release-registry.ts +++ b/scripts/release-registry.ts @@ -4,6 +4,7 @@ import { compareVersions, parseVersion } from './release-plan.ts' export const releasePackages = [ 'react-native-nitro-sqlite', 'react-native-nitro-sqlite-vec', + 'react-native-nitro-sqlite-drizzle', ] /** Select the channel without moving latest backwards on maintenance releases. diff --git a/scripts/release.test.ts b/scripts/release.test.ts index 39b0a749..a45bbb8d 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -334,6 +334,7 @@ function fixture(run: (directory: string) => void): void { 'package.json', 'packages/react-native-nitro-sqlite/package.json', 'packages/react-native-nitro-sqlite-vec/package.json', + 'packages/react-native-nitro-sqlite-drizzle/package.json', 'example/package.json', ]) { mkdirSync(join(directory, path, '..'), { recursive: true }) diff --git a/tsconfig.json b/tsconfig.json index ae8a25a4..4127043d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "references": [ { "path": "./packages/react-native-nitro-sqlite" }, { "path": "./packages/react-native-nitro-sqlite-vec" }, + { "path": "./packages/react-native-nitro-sqlite-drizzle" }, { "path": "./example" } ] } From b7f86080bc19913f2185962f1336d53bdde93a4e Mon Sep 17 00:00:00 2001 From: Dave Mkpa-Eke Date: Sat, 17 Oct 2026 22:49:55 +0100 Subject: [PATCH 03/13] feat: add drizzle migrator --- bun.lock | 121 ++++++++++++++++++ .../package.json | 3 + .../src/__tests__/migrator.test.ts | 52 ++++++++ .../src/index.ts | 1 + .../src/migrator.ts | 67 ++++++++++ 5 files changed, 244 insertions(+) create mode 100644 packages/react-native-nitro-sqlite-drizzle/src/__tests__/migrator.test.ts create mode 100644 packages/react-native-nitro-sqlite-drizzle/src/migrator.ts diff --git a/bun.lock b/bun.lock index 9e120280..fee78006 100644 --- a/bun.lock +++ b/bun.lock @@ -187,12 +187,15 @@ "name": "react-native-nitro-sqlite-drizzle", "version": "10.2.0", "devDependencies": { + "drizzle-kit": "0.31.11", "drizzle-orm": "0.45.4", + "react": "19.2.3", "react-native-nitro-sqlite": "10.2.0", "typescript": "^5.8.3", }, "peerDependencies": { "drizzle-orm": ">=0.45.4 <1.0.0", + "react": ">=17.0.0", "react-native-nitro-sqlite": ">=10.3.0", }, }, @@ -473,12 +476,18 @@ "@craftzdog/react-native-buffer": ["@craftzdog/react-native-buffer@6.1.2", "", { "dependencies": { "ieee754": "^1.2.1", "react-native-quick-base64": "^3.0.0" } }, "sha512-KV1HitN05FHLLDG7Zb/yftDsa+mKBYBzFMQ0PMldvUicq6vWOtAvz9mDavt7Fzozh+WNqORE+yFDkkdWysZ/SA=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@emnapi/core": ["@emnapi/core@1.7.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], @@ -1555,6 +1564,8 @@ "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], + "drizzle-kit": ["drizzle-kit@0.31.11", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg=="], + "drizzle-orm": ["drizzle-orm@0.45.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -1793,6 +1804,8 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], @@ -2705,6 +2718,8 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], @@ -2925,6 +2940,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsx": ["tsx@4.23.15", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -3103,6 +3120,10 @@ "@babel/eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@2.1.0", "", {}, "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "@esbuild-kit/core-utils/source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "@google-automations/git-file-utils/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], @@ -3387,6 +3408,8 @@ "del/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "errorhandler/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "eslint/@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], @@ -3923,6 +3946,52 @@ "yargs-unparser/is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + + "@esbuild-kit/core-utils/source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "@google-automations/git-file-utils/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -4127,6 +4196,58 @@ "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "drizzle-kit/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "drizzle-kit/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "errorhandler/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "errorhandler/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], diff --git a/packages/react-native-nitro-sqlite-drizzle/package.json b/packages/react-native-nitro-sqlite-drizzle/package.json index 7840186c..c21f2485 100644 --- a/packages/react-native-nitro-sqlite-drizzle/package.json +++ b/packages/react-native-nitro-sqlite-drizzle/package.json @@ -39,10 +39,13 @@ }, "peerDependencies": { "drizzle-orm": ">=0.45.4 <1.0.0", + "react": ">=17.0.0", "react-native-nitro-sqlite": ">=10.3.0" }, "devDependencies": { + "drizzle-kit": "0.31.11", "drizzle-orm": "0.45.4", + "react": "19.2.3", "react-native-nitro-sqlite": "10.2.0", "typescript": "^5.8.3" }, diff --git a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/migrator.test.ts b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/migrator.test.ts new file mode 100644 index 00000000..e8044691 --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/migrator.test.ts @@ -0,0 +1,52 @@ +import { open, resetAllDatabases } from 'react-native-nitro-sqlite/mock' +import type { NitroSQLiteConnection } from 'react-native-nitro-sqlite' +import { drizzle, migrate } from '../index' + +const migrations = { + journal: { + entries: [ + { idx: 0, when: 1, tag: '0000_users', breakpoints: true }, + { idx: 1, when: 2, tag: '0001_pets', breakpoints: true }, + ], + }, + migrations: { + m0000: + 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);\n--> statement-breakpoint\nCREATE INDEX users_name ON users (name);', + m0001: 'CREATE TABLE pets (id INTEGER PRIMARY KEY, name TEXT NOT NULL);', + }, +} + +afterEach(resetAllDatabases) + +describe('migrate', () => { + it('applies each migration once', async () => { + const connection = open({ name: 'migrate' }) as NitroSQLiteConnection + const db = drizzle(connection) + + await migrate(db, migrations) + await migrate(db, migrations) + + const { results } = connection.execute( + "SELECT name FROM sqlite_master WHERE name IN ('users', 'users_name', 'pets') ORDER BY name", + ) + expect(results).toEqual([ + { name: 'pets' }, + { name: 'users' }, + { name: 'users_name' }, + ]) + expect( + connection.execute('SELECT created_at FROM __drizzle_migrations').results, + ).toEqual([{ created_at: 1 }, { created_at: 2 }]) + }) + + it('rejects when a journal entry has no migration', async () => { + const db = drizzle(open({ name: 'migrate' }) as NitroSQLiteConnection) + + await expect( + migrate(db, { + ...migrations, + migrations: { m0001: migrations.migrations.m0001 }, + }), + ).rejects.toThrow('Missing migration: 0000_users') + }) +}) diff --git a/packages/react-native-nitro-sqlite-drizzle/src/index.ts b/packages/react-native-nitro-sqlite-drizzle/src/index.ts index 088ef800..de30459b 100644 --- a/packages/react-native-nitro-sqlite-drizzle/src/index.ts +++ b/packages/react-native-nitro-sqlite-drizzle/src/index.ts @@ -1,4 +1,5 @@ export { drizzle, NitroSQLiteDatabase } from './driver' +export { migrate, useMigrations } from './migrator' export { NitroSQLitePreparedQuery, NitroSQLiteSession, diff --git a/packages/react-native-nitro-sqlite-drizzle/src/migrator.ts b/packages/react-native-nitro-sqlite-drizzle/src/migrator.ts new file mode 100644 index 00000000..da793b02 --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/src/migrator.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react' +import type { MigrationMeta } from 'drizzle-orm/migrator' +import type { NitroSQLiteDatabase } from './driver' + +interface MigrationConfig { + journal: { + entries: { idx: number; when: number; tag: string; breakpoints: boolean }[] + } + migrations: Record +} + +function readMigrationFiles({ + journal, + migrations, +}: MigrationConfig): MigrationMeta[] { + return journal.entries.map((entry) => { + const query = migrations[`m${entry.idx.toString().padStart(4, '0')}`] + if (!query) { + throw new Error(`Missing migration: ${entry.tag}`) + } + return { + sql: query.split('--> statement-breakpoint'), + bps: entry.breakpoints, + folderMillis: entry.when, + hash: '', + } + }) +} + +/** + * Apply the migrations that drizzle-kit generated and that have not run on this database yet. + * @param db Database returned by `drizzle()`. + * @param config The `migrations.js` module that drizzle-kit generates. + */ +export async function migrate>( + db: NitroSQLiteDatabase, + config: MigrationConfig, +): Promise { + return db.dialect.migrate(readMigrationFiles(config), db.session) +} + +interface State { + success: boolean + error?: Error +} + +/** + * Run {@linkcode migrate} once when the component mounts. + * @param db Database returned by `drizzle()`. + * @param migrations The `migrations.js` module that drizzle-kit generates. + * @returns `success` once the migrations finished, or the `error` they failed with. + */ +export function useMigrations>( + db: NitroSQLiteDatabase, + migrations: MigrationConfig, +): State { + const [state, setState] = useState({ success: false }) + + useEffect(() => { + setState({ success: false }) + migrate(db, migrations) + .then(() => setState({ success: true })) + .catch((error: Error) => setState({ success: false, error })) + }, [db, migrations]) + + return state +} From d9832127b1e396ec0c160e13ecd92a605c9523a8 Mon Sep 17 00:00:00 2001 From: Dave Mkpa-Eke Date: Sat, 17 Oct 2026 23:06:30 +0100 Subject: [PATCH 04/13] test: run drizzle migrator on drizzle-kit output --- .github/workflows/ci.yml | 2 + .prettierignore | 1 + bun.lock | 9 ++++ .../.eslintrc.js | 1 + .../babel.config.js | 1 + .../package.json | 1 + .../src/__tests__/fixtures/drizzle.config.ts | 8 +++ .../__tests__/fixtures/drizzle/0000_init.sql | 12 +++++ .../fixtures/drizzle/meta/_journal.json | 13 +++++ .../__tests__/fixtures/drizzle/migrations.js | 12 +++++ .../src/__tests__/fixtures/schema.ts | 16 ++++++ .../src/__tests__/migrator.test.ts | 52 ++++++++----------- 12 files changed, 99 insertions(+), 29 deletions(-) create mode 100644 packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle.config.ts create mode 100644 packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/0000_init.sql create mode 100644 packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/meta/_journal.json create mode 100644 packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/migrations.js create mode 100644 packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/schema.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44ad266d..4117fb3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,12 +121,14 @@ jobs: bun --cwd example lint bun --cwd packages/react-native-nitro-sqlite lint bun --cwd packages/react-native-nitro-sqlite-vec lint + bun --cwd packages/react-native-nitro-sqlite-drizzle lint git diff --exit-code HEAD -- . ':(exclude)bun.lock' - name: Test JavaScript wrappers and example runners if: steps.select.outputs.typescript == 'true' run: | (cd packages/react-native-nitro-sqlite && ../../node_modules/.bin/jest --coverage --runInBand) (cd packages/react-native-nitro-sqlite-vec && ../../node_modules/.bin/jest --coverage --runInBand) + (cd packages/react-native-nitro-sqlite-drizzle && ../../node_modules/.bin/jest --coverage --runInBand) (cd example && ../node_modules/.bin/jest --config jest.mocha.config.js --runInBand --watchman=false) node --test example/macos/scripts/test-macos.test.mjs - name: Format C++ diff --git a/.prettierignore b/.prettierignore index 79e92d52..a037f45e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,7 @@ *.markdown packages/react-native-nitro-sqlite/lib +packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle .well-known android diff --git a/bun.lock b/bun.lock index fee78006..3fa2357a 100644 --- a/bun.lock +++ b/bun.lock @@ -187,6 +187,7 @@ "name": "react-native-nitro-sqlite-drizzle", "version": "10.2.0", "devDependencies": { + "babel-plugin-inline-import": "3.0.0", "drizzle-kit": "0.31.11", "drizzle-orm": "0.45.4", "react": "19.2.3", @@ -1296,6 +1297,8 @@ "babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], + "babel-plugin-inline-import": ["babel-plugin-inline-import@3.0.0", "", { "dependencies": { "require-resolve": "0.0.2" } }, "sha512-thnykl4FMb8QjMjVCuZoUmAM7r2mnTn5qJwrryCvDv6rugbJlTHZMctdjDtEgD0WBAXJOLJSGXN3loooEwx7UQ=="], + "babel-plugin-istanbul": ["babel-plugin-istanbul@6.1.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-instrument": "^5.0.4", "test-exclude": "^6.0.0" } }, "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA=="], "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@29.6.3", "", { "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", "@types/babel__core": "^7.1.14", "@types/babel__traverse": "^7.0.6" } }, "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg=="], @@ -2514,6 +2517,8 @@ "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + "path-extra": ["path-extra@1.0.3", "", {}, "sha512-vYm3+GCkjUlT1rDvZnDVhNLXIRvwFPaN8ebHAFcuMJM/H0RBOPD7JrcldiNLd9AS3dhAyUHLa4Hny5wp1A+Ffw=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -2710,6 +2715,8 @@ "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "require-resolve": ["require-resolve@0.0.2", "", { "dependencies": { "x-path": "^0.0.2" } }, "sha512-eafQVaxdQsWUB8HybwognkdcIdKdQdQBwTxH48FuE6WI0owZGKp63QYr1MRp73PoX0AcyB7MDapZThYUY8FD0A=="], + "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], @@ -3090,6 +3097,8 @@ "ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "x-path": ["x-path@0.0.2", "", { "dependencies": { "path-extra": "^1.0.2" } }, "sha512-zQ4WFI0XfJN1uEkkrB19Y4TuXOlHqKSxUJo0Yt+axPjRm8tCG6SJ6+Wo3/+Kjg4c2c8IvBXuJ0uYoshxNn4qMw=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], "xpath": ["xpath@0.0.34", "", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="], diff --git a/packages/react-native-nitro-sqlite-drizzle/.eslintrc.js b/packages/react-native-nitro-sqlite-drizzle/.eslintrc.js index 0d3e3482..07a980a5 100644 --- a/packages/react-native-nitro-sqlite-drizzle/.eslintrc.js +++ b/packages/react-native-nitro-sqlite-drizzle/.eslintrc.js @@ -1,6 +1,7 @@ module.exports = { root: true, extends: '../../config/.eslintrc.js', + ignorePatterns: ['src/__tests__/fixtures/drizzle'], parserOptions: { project: true, tsconfigRootDir: __dirname, diff --git a/packages/react-native-nitro-sqlite-drizzle/babel.config.js b/packages/react-native-nitro-sqlite-drizzle/babel.config.js index 3e0218e6..6eb8c4d0 100644 --- a/packages/react-native-nitro-sqlite-drizzle/babel.config.js +++ b/packages/react-native-nitro-sqlite-drizzle/babel.config.js @@ -1,3 +1,4 @@ module.exports = { presets: ['module:@react-native/babel-preset'], + plugins: [['inline-import', { extensions: ['.sql'] }]], } diff --git a/packages/react-native-nitro-sqlite-drizzle/package.json b/packages/react-native-nitro-sqlite-drizzle/package.json index c21f2485..68925352 100644 --- a/packages/react-native-nitro-sqlite-drizzle/package.json +++ b/packages/react-native-nitro-sqlite-drizzle/package.json @@ -43,6 +43,7 @@ "react-native-nitro-sqlite": ">=10.3.0" }, "devDependencies": { + "babel-plugin-inline-import": "3.0.0", "drizzle-kit": "0.31.11", "drizzle-orm": "0.45.4", "react": "19.2.3", diff --git a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle.config.ts b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle.config.ts new file mode 100644 index 00000000..42f0aab3 --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './src/__tests__/fixtures/schema.ts', + out: './src/__tests__/fixtures/drizzle', + dialect: 'sqlite', + driver: 'expo', +}) diff --git a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/0000_init.sql b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/0000_init.sql new file mode 100644 index 00000000..c07176fa --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/0000_init.sql @@ -0,0 +1,12 @@ +CREATE TABLE `pets` ( + `id` integer PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `owner_id` integer, + FOREIGN KEY (`owner_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `pets_owner_id` ON `pets` (`owner_id`);--> statement-breakpoint +CREATE TABLE `users` ( + `id` integer PRIMARY KEY NOT NULL, + `name` text NOT NULL +); diff --git a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/meta/_journal.json b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/meta/_journal.json new file mode 100644 index 00000000..6802c430 --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1792274745571, + "tag": "0000_init", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/migrations.js b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/migrations.js new file mode 100644 index 00000000..87020f29 --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/migrations.js @@ -0,0 +1,12 @@ +// This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo + +import journal from './meta/_journal.json'; +import m0000 from './0000_init.sql'; + + export default { + journal, + migrations: { + m0000 + } + } + \ No newline at end of file diff --git a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/schema.ts b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/schema.ts new file mode 100644 index 00000000..2fcc915a --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/schema.ts @@ -0,0 +1,16 @@ +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +export const users = sqliteTable('users', { + id: integer('id').primaryKey(), + name: text('name').notNull(), +}) + +export const pets = sqliteTable( + 'pets', + { + id: integer('id').primaryKey(), + name: text('name').notNull(), + ownerId: integer('owner_id').references(() => users.id), + }, + (table) => [index('pets_owner_id').on(table.ownerId)], +) diff --git a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/migrator.test.ts b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/migrator.test.ts index e8044691..b07d808e 100644 --- a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/migrator.test.ts +++ b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/migrator.test.ts @@ -1,52 +1,46 @@ +import { eq } from 'drizzle-orm' import { open, resetAllDatabases } from 'react-native-nitro-sqlite/mock' import type { NitroSQLiteConnection } from 'react-native-nitro-sqlite' import { drizzle, migrate } from '../index' - -const migrations = { - journal: { - entries: [ - { idx: 0, when: 1, tag: '0000_users', breakpoints: true }, - { idx: 1, when: 2, tag: '0001_pets', breakpoints: true }, - ], - }, - migrations: { - m0000: - 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);\n--> statement-breakpoint\nCREATE INDEX users_name ON users (name);', - m0001: 'CREATE TABLE pets (id INTEGER PRIMARY KEY, name TEXT NOT NULL);', - }, -} +import migrations from './fixtures/drizzle/migrations' +import { pets, users } from './fixtures/schema' afterEach(resetAllDatabases) describe('migrate', () => { - it('applies each migration once', async () => { + it('applies each drizzle-kit migration once', async () => { const connection = open({ name: 'migrate' }) as NitroSQLiteConnection const db = drizzle(connection) await migrate(db, migrations) await migrate(db, migrations) - const { results } = connection.execute( - "SELECT name FROM sqlite_master WHERE name IN ('users', 'users_name', 'pets') ORDER BY name", - ) - expect(results).toEqual([ - { name: 'pets' }, - { name: 'users' }, - { name: 'users_name' }, - ]) expect( connection.execute('SELECT created_at FROM __drizzle_migrations').results, - ).toEqual([{ created_at: 1 }, { created_at: 2 }]) + ).toEqual( + migrations.journal.entries.map((entry) => ({ created_at: entry.when })), + ) + expect( + connection.execute( + "SELECT name FROM sqlite_master WHERE name = 'pets_owner_id'", + ).results, + ).toEqual([{ name: 'pets_owner_id' }]) + + await db.insert(users).values({ id: 1, name: 'Dave' }) + await db.insert(pets).values({ id: 7, name: 'Rex', ownerId: 1 }) + expect( + await db + .select({ owner: users.name, pet: pets.name }) + .from(pets) + .innerJoin(users, eq(pets.ownerId, users.id)), + ).toEqual([{ owner: 'Dave', pet: 'Rex' }]) }) it('rejects when a journal entry has no migration', async () => { const db = drizzle(open({ name: 'migrate' }) as NitroSQLiteConnection) await expect( - migrate(db, { - ...migrations, - migrations: { m0001: migrations.migrations.m0001 }, - }), - ).rejects.toThrow('Missing migration: 0000_users') + migrate(db, { ...migrations, migrations: {} }), + ).rejects.toThrow('Missing migration: 0000_init') }) }) From e29debbf2fe71851b3c3753811d9f3b9ec2f1c20 Mon Sep 17 00:00:00 2001 From: Dave Mkpa-Eke Date: Sat, 17 Oct 2026 23:29:31 +0100 Subject: [PATCH 05/13] fix: load drizzle type declarations in consuming projects --- .../src/{drizzle-orm.d.ts => drizzle-orm.ts} | 1 - packages/react-native-nitro-sqlite-drizzle/src/migrator.ts | 1 + packages/react-native-nitro-sqlite-drizzle/src/session.ts | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) rename packages/react-native-nitro-sqlite-drizzle/src/{drizzle-orm.d.ts => drizzle-orm.ts} (98%) diff --git a/packages/react-native-nitro-sqlite-drizzle/src/drizzle-orm.d.ts b/packages/react-native-nitro-sqlite-drizzle/src/drizzle-orm.ts similarity index 98% rename from packages/react-native-nitro-sqlite-drizzle/src/drizzle-orm.d.ts rename to packages/react-native-nitro-sqlite-drizzle/src/drizzle-orm.ts index a29588b2..3bc1e376 100644 --- a/packages/react-native-nitro-sqlite-drizzle/src/drizzle-orm.d.ts +++ b/packages/react-native-nitro-sqlite-drizzle/src/drizzle-orm.ts @@ -7,7 +7,6 @@ import type { TablesRelationalConfig, } from 'drizzle-orm' import type { - PreparedQueryConfig, SQLiteAsyncDialect, SQLiteSession, SQLiteSyncDialect, diff --git a/packages/react-native-nitro-sqlite-drizzle/src/migrator.ts b/packages/react-native-nitro-sqlite-drizzle/src/migrator.ts index da793b02..82748952 100644 --- a/packages/react-native-nitro-sqlite-drizzle/src/migrator.ts +++ b/packages/react-native-nitro-sqlite-drizzle/src/migrator.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import type { MigrationMeta } from 'drizzle-orm/migrator' import type { NitroSQLiteDatabase } from './driver' +import './drizzle-orm' interface MigrationConfig { journal: { diff --git a/packages/react-native-nitro-sqlite-drizzle/src/session.ts b/packages/react-native-nitro-sqlite-drizzle/src/session.ts index 396b02f5..4a07d6c9 100644 --- a/packages/react-native-nitro-sqlite-drizzle/src/session.ts +++ b/packages/react-native-nitro-sqlite-drizzle/src/session.ts @@ -24,6 +24,7 @@ import type { SQLiteQueryParams, Transaction, } from 'react-native-nitro-sqlite' +import './drizzle-orm' export interface NitroSQLiteSessionOptions { logger?: Logger From 21db62015453e9f0bbdd5a0813b3ab150653360d Mon Sep 17 00:00:00 2001 From: Dave Mkpa-Eke Date: Sat, 17 Oct 2026 23:29:31 +0100 Subject: [PATCH 06/13] test: run drizzle driver on device --- .github/ci-paths.json | 20 +++- bun.lock | 6 + example/babel.config.shared.js | 1 + example/macos/metro.config.js | 4 +- example/macos/package.json | 3 + example/metro.config.js | 4 +- example/package.json | 3 + example/tests/harness/drizzle.harness.tsx | 33 ++++++ example/tests/runAll.ts | 2 + example/tests/unit/index.ts | 5 + example/tests/unit/specs/drizzle.spec.ts | 109 ++++++++++++++++++ example/tsconfig.json | 11 +- .../src/__tests__/driver.test.ts | 4 +- .../tsconfig.json | 6 +- release-please-config.json | 5 + scripts/publish-release.test.ts | 1 + scripts/release-plan.ts | 1 + scripts/release.test.ts | 1 + 18 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 example/tests/harness/drizzle.harness.tsx create mode 100644 example/tests/unit/specs/drizzle.spec.ts diff --git a/.github/ci-paths.json b/.github/ci-paths.json index b8def37b..dbc8a432 100644 --- a/.github/ci-paths.json +++ b/.github/ci-paths.json @@ -116,9 +116,14 @@ "scripts/android-sqlite-flags/**", "example/index.js", "example/babel.config*.js", + "example/metro.config.js", "example/app.json", "packages/react-native-nitro-sqlite-vec/src/**", - "!packages/react-native-nitro-sqlite-vec/src/__tests__/**" + "!packages/react-native-nitro-sqlite-vec/src/__tests__/**", + "packages/react-native-nitro-sqlite-drizzle/src/**", + "packages/react-native-nitro-sqlite-drizzle/package.json", + "!packages/react-native-nitro-sqlite-drizzle/src/__tests__/**", + "packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/**" ], "ios": [ ".github/workflows/**", @@ -154,9 +159,14 @@ "scripts/test-podspec-threadsafe.rb", "example/index.js", "example/babel.config*.js", + "example/metro.config.js", "example/app.json", "packages/react-native-nitro-sqlite-vec/src/**", - "!packages/react-native-nitro-sqlite-vec/src/__tests__/**" + "!packages/react-native-nitro-sqlite-vec/src/__tests__/**", + "packages/react-native-nitro-sqlite-drizzle/src/**", + "packages/react-native-nitro-sqlite-drizzle/package.json", + "!packages/react-native-nitro-sqlite-drizzle/src/__tests__/**", + "packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/**" ], "macos": [ ".github/workflows/**", @@ -193,6 +203,10 @@ "example/react-native.config.js", "example/macos/react-native.config.js", "!packages/react-native-nitro-sqlite/src/__tests__/**", - "!packages/react-native-nitro-sqlite-vec/src/__tests__/**" + "!packages/react-native-nitro-sqlite-vec/src/__tests__/**", + "packages/react-native-nitro-sqlite-drizzle/src/**", + "packages/react-native-nitro-sqlite-drizzle/package.json", + "!packages/react-native-nitro-sqlite-drizzle/src/__tests__/**", + "packages/react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/**" ] } diff --git a/bun.lock b/bun.lock index 3fa2357a..3f1b61e6 100644 --- a/bun.lock +++ b/bun.lock @@ -77,12 +77,14 @@ "@react-navigation/native-stack": "^7.6.2", "chai": "^4.3.7", "chance": "^1.1.9", + "drizzle-orm": "0.45.4", "events": "^3.3.0", "expo-status-bar": "^1.12.1", "react": "19.2.3", "react-native": "0.87.1", "react-native-nitro-modules": "*", "react-native-nitro-sqlite": "10.2.0", + "react-native-nitro-sqlite-drizzle": "10.2.0", "react-native-nitro-sqlite-vec": "10.2.0", "react-native-quick-base64": "^3.0.0", "react-native-quick-crypto": "^1.1.5", @@ -116,6 +118,7 @@ "@types/node": "^22.7.4", "@types/react": "^19.1.1", "@types/react-test-renderer": "^19.1.0", + "babel-plugin-inline-import": "3.0.0", "mocha": "^12.0.2", "postinstall-postinstall": "^2.1.0", "react-native-harness": "1.5.0", @@ -130,6 +133,7 @@ "buffer": "^6.0.3", "chai": "^4.3.7", "chance": "^1.1.9", + "drizzle-orm": "0.45.4", "events": "^3.3.0", "expo-status-bar": "^1.12.1", "mocha": "^10.1.0", @@ -139,6 +143,7 @@ "react-native-macos-local": "npm:react-native-macos@0.81.9", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "workspace:*", + "react-native-nitro-sqlite-drizzle": "workspace:*", "react-native-nitro-sqlite-vec": "workspace:*", "readable-stream": "^4.7.0", "reflect-metadata": "^0.2.2", @@ -154,6 +159,7 @@ "@react-native/babel-preset": "0.81.6", "@react-native/community-cli-plugin": "0.81.6", "@react-native/metro-config": "0.81.6", + "babel-plugin-inline-import": "3.0.0", }, }, "packages/react-native-nitro-sqlite": { diff --git a/example/babel.config.shared.js b/example/babel.config.shared.js index bc29acbe..0563b5b7 100644 --- a/example/babel.config.shared.js +++ b/example/babel.config.shared.js @@ -41,6 +41,7 @@ function createExampleBabelConfig(projectRoot, { buffer, crypto } = {}) { ], 'babel-plugin-transform-typescript-metadata', ['@babel/plugin-proposal-decorators', { legacy: true }], + ['inline-import', { extensions: ['.sql'] }], ], } } diff --git a/example/macos/metro.config.js b/example/macos/metro.config.js index 2a125816..4c15711a 100644 --- a/example/macos/metro.config.js +++ b/example/macos/metro.config.js @@ -14,6 +14,7 @@ const extraNodeModules = Object.keys(libraryPackage.peerDependencies).reduce( }, {}, ) +const defaultConfig = getDefaultConfig(__dirname) const config = { projectRoot: __dirname, @@ -25,6 +26,7 @@ const config = { path.join(root, 'node_modules'), ], platforms: ['macos', 'ios', 'android'], + sourceExts: [...defaultConfig.resolver.sourceExts, 'sql'], extraNodeModules, resolveRequest: (context, moduleName, platform) => { // TypeORM imports the workspace package without going through Babel. @@ -78,4 +80,4 @@ const config = { }, } -module.exports = mergeConfig(getDefaultConfig(__dirname), config) +module.exports = mergeConfig(defaultConfig, config) diff --git a/example/macos/package.json b/example/macos/package.json index 62d39c4c..2a7954cd 100644 --- a/example/macos/package.json +++ b/example/macos/package.json @@ -12,6 +12,7 @@ "buffer": "^6.0.3", "chai": "^4.3.7", "chance": "^1.1.9", + "drizzle-orm": "0.45.4", "events": "^3.3.0", "expo-status-bar": "^1.12.1", "mocha": "^10.1.0", @@ -22,6 +23,7 @@ "react-native-macos-local": "npm:react-native-macos@0.81.9", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "workspace:*", + "react-native-nitro-sqlite-drizzle": "workspace:*", "react-native-nitro-sqlite-vec": "workspace:*", "reflect-metadata": "^0.2.2", "typeorm": "1.1.1", @@ -34,6 +36,7 @@ "@react-native-community/cli-platform-apple": "19.1.2", "@react-native-community/cli-platform-ios": "19.1.2", "@react-native/babel-preset": "0.81.6", + "babel-plugin-inline-import": "3.0.0", "@react-native/community-cli-plugin": "0.81.6", "@react-native/metro-config": "0.81.6" } diff --git a/example/metro.config.js b/example/metro.config.js index 46f2eb69..56e800b4 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -4,6 +4,7 @@ const pak = require('../package.json') const root = path.resolve(__dirname, '..') const modules = Object.keys({ ...pak.peerDependencies }) +const defaultConfig = getDefaultConfig(__dirname) const config = { projectRoot: __dirname, @@ -16,6 +17,7 @@ const config = { acc[name] = path.join(__dirname, 'node_modules', name) return acc }, {}), + sourceExts: [...defaultConfig.resolver.sourceExts, 'sql'], }, transformer: { @@ -28,4 +30,4 @@ const config = { }, } -module.exports = mergeConfig(getDefaultConfig(__dirname), config) +module.exports = mergeConfig(defaultConfig, config) diff --git a/example/package.json b/example/package.json index 5f4e9e1a..247b1d50 100644 --- a/example/package.json +++ b/example/package.json @@ -28,12 +28,14 @@ "@react-navigation/native-stack": "^7.6.2", "chai": "^4.3.7", "chance": "^1.1.9", + "drizzle-orm": "0.45.4", "events": "^3.3.0", "expo-status-bar": "^1.12.1", "react": "19.2.3", "react-native": "0.87.1", "react-native-nitro-modules": "*", "react-native-nitro-sqlite": "10.2.0", + "react-native-nitro-sqlite-drizzle": "10.2.0", "react-native-nitro-sqlite-vec": "10.2.0", "react-native-quick-base64": "^3.0.0", "react-native-quick-crypto": "^1.1.5", @@ -67,6 +69,7 @@ "@types/node": "^22.7.4", "@types/react": "^19.1.1", "@types/react-test-renderer": "^19.1.0", + "babel-plugin-inline-import": "3.0.0", "mocha": "^12.0.2", "postinstall-postinstall": "^2.1.0", "react-native-harness": "1.5.0", diff --git a/example/tests/harness/drizzle.harness.tsx b/example/tests/harness/drizzle.harness.tsx new file mode 100644 index 00000000..1925ada0 --- /dev/null +++ b/example/tests/harness/drizzle.harness.tsx @@ -0,0 +1,33 @@ +import '@/initGlobals' +import React from 'react' +import { describe, expect, it, render, waitUntil } from 'react-native-harness' +import { open } from 'react-native-nitro-sqlite' +import { drizzle, useMigrations } from 'react-native-nitro-sqlite-drizzle' +import migrations from 'react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/migrations' +import { registerDrizzleUnitTests } from '@tests/unit' +import init from './init' + +init() +registerDrizzleUnitTests() + +describe('useMigrations', () => { + it('reports success once the migrations ran', async () => { + const connection = open({ name: 'test_drizzle_hook' }) + const db = drizzle(connection) + let state: ReturnType | undefined + function Migrations() { + state = useMigrations(db, migrations) + return null + } + + await render() + await waitUntil(() => state?.success) + + expect( + connection.execute("SELECT name FROM sqlite_master WHERE name = 'users'") + .results, + ).toEqual([{ name: 'users' }]) + connection.close() + connection.delete() + }) +}) diff --git a/example/tests/runAll.ts b/example/tests/runAll.ts index 428a4acc..b75b1aad 100644 --- a/example/tests/runAll.ts +++ b/example/tests/runAll.ts @@ -1,6 +1,7 @@ import type { MochaTestResult } from './MochaSetup' import { runTests } from './MochaSetup' import { + registerDrizzleUnitTests, registerSqliteVecUnitTests, registerTypeORMUnitTests, registerUnitTests, @@ -14,6 +15,7 @@ export async function runAllTests(): Promise { registerUnitTests, registerTypeORMUnitTests, registerSqliteVecUnitTests, + registerDrizzleUnitTests, ) return results diff --git a/example/tests/unit/index.ts b/example/tests/unit/index.ts index 977ca3cf..c5b00969 100644 --- a/example/tests/unit/index.ts +++ b/example/tests/unit/index.ts @@ -9,6 +9,7 @@ import registerPreparedStatementUnitTests from './specs/operations/preparedState import registerTypeORMUnitTestsSpecs from './specs/typeorm.spec' import registerDatabaseQueueUnitTests from './specs/DatabaseQueue.spec' import registerSqliteVecUnitTestsSpecs from './specs/sqlite-vec.spec' +import registerDrizzleUnitTestsSpecs from './specs/drizzle.spec' import registerConnectionUnitTests from './specs/Connections.spec' import registerBenchmarkDatabaseUnitTests from './specs/benchmarkDatabase.spec' @@ -36,3 +37,7 @@ export function registerTypeORMUnitTests() { export function registerSqliteVecUnitTests() { describe('sqlite-vec.spec.ts', registerSqliteVecUnitTestsSpecs) } + +export function registerDrizzleUnitTests() { + describe('drizzle.spec.ts', registerDrizzleUnitTestsSpecs) +} diff --git a/example/tests/unit/specs/drizzle.spec.ts b/example/tests/unit/specs/drizzle.spec.ts new file mode 100644 index 00000000..82fbd351 --- /dev/null +++ b/example/tests/unit/specs/drizzle.spec.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, it } from '@tests/TestApi' +import { expect } from '@tests/unit/common' +import { eq, sql } from 'drizzle-orm' +import { open } from 'react-native-nitro-sqlite' +import type { NitroSQLiteConnection } from 'react-native-nitro-sqlite' +import { drizzle, migrate } from 'react-native-nitro-sqlite-drizzle' +import type { NitroSQLiteDatabase } from 'react-native-nitro-sqlite-drizzle' +import migrations from 'react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/drizzle/migrations' +import { + pets, + users, +} from 'react-native-nitro-sqlite-drizzle/src/__tests__/fixtures/schema' + +const DB_NAME = 'test_drizzle' +let connection: NitroSQLiteConnection +let db: NitroSQLiteDatabase + +export default function registerDrizzleUnitTests() { + beforeEach(async () => { + connection = open({ name: DB_NAME }) + db = drizzle(connection) + await migrate(db, migrations) + }) + + afterEach(() => { + connection.close() + connection.delete() + }) + + it('applies drizzle-kit migrations once', async () => { + await migrate(db, migrations) + + expect( + connection.execute('SELECT created_at FROM __drizzle_migrations').results, + ).toEqual( + migrations.journal.entries.map((entry) => ({ created_at: entry.when })), + ) + }) + + it('keeps both sides of a join whose columns share names', async () => { + await db.insert(users).values([ + { id: 1, name: 'Dave' }, + { id: 2, name: 'Ritesh' }, + ]) + await db.insert(pets).values({ id: 7, name: 'Rex', ownerId: 1 }) + + expect( + await db + .select() + .from(users) + .leftJoin(pets, eq(pets.ownerId, users.id)) + .orderBy(users.id), + ).toEqual([ + { + users: { id: 1, name: 'Dave' }, + pets: { id: 7, name: 'Rex', ownerId: 1 }, + }, + { users: { id: 2, name: 'Ritesh' }, pets: null }, + ]) + }) + + it('runs raw SQL and prepared queries', async () => { + await db.run(sql`INSERT INTO users (id, name) VALUES (1, 'Dave')`) + + expect(await db.all(sql`SELECT name FROM users`)).toEqual([ + { name: 'Dave' }, + ]) + expect(await db.values(sql`SELECT id, name FROM users`)).toEqual([ + [1, 'Dave'], + ]) + + const byId = db + .select() + .from(users) + .where(eq(users.id, sql.placeholder('id'))) + .prepare() + expect(await byId.get({ id: 1 })).toEqual({ id: 1, name: 'Dave' }) + expect(await byId.get({ id: 2 })).toBe(undefined) + }) + + it('commits transactions and rolls back savepoints', async () => { + await db.transaction(async (tx) => { + await tx.insert(users).values({ id: 1, name: 'Dave' }) + await tx + .transaction(async (nested) => { + await nested.insert(users).values({ id: 2, name: 'Ritesh' }) + nested.rollback() + }) + .catch(() => undefined) + }) + + expect(await db.select().from(users)).toEqual([{ id: 1, name: 'Dave' }]) + }) + + it('rolls back when tx.rollback() is called', async () => { + let error: unknown + try { + await db.transaction(async (tx) => { + await tx.insert(users).values({ id: 1, name: 'Dave' }) + tx.rollback() + }) + } catch (e) { + error = e + } + + expect((error as Error).message).toBe('Rollback') + expect(await db.select().from(users)).toEqual([]) + }) +} diff --git a/example/tsconfig.json b/example/tsconfig.json index 91542814..79e27c6c 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -1,12 +1,21 @@ { "extends": "../config/tsconfig.json", "include": ["src", "tests", "index.js", "rn-harness.config.mjs"], - "references": [{ "path": "../packages/react-native-nitro-sqlite" }], + "references": [ + { "path": "../packages/react-native-nitro-sqlite" }, + { "path": "../packages/react-native-nitro-sqlite-drizzle" } + ], "compilerOptions": { "paths": { "react-native-nitro-sqlite": [ "../packages/react-native-nitro-sqlite/src/index.ts" ], + "react-native-nitro-sqlite-drizzle": [ + "../packages/react-native-nitro-sqlite-drizzle/src/index.ts" + ], + "react-native-nitro-sqlite-drizzle/*": [ + "../packages/react-native-nitro-sqlite-drizzle/*" + ], "@nitro-sqlite/*": ["../packages/react-native-nitro-sqlite/src/*"], "@/*": ["./src/*"], "@tests/*": ["./tests/*"] diff --git a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/driver.test.ts b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/driver.test.ts index f83c627c..0549a2fd 100644 --- a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/driver.test.ts +++ b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/driver.test.ts @@ -1,4 +1,4 @@ -import { eq, sql, TransactionRollbackError } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' import { open, resetAllDatabases } from 'react-native-nitro-sqlite/mock' import type { NitroSQLiteConnection } from 'react-native-nitro-sqlite' @@ -108,7 +108,7 @@ describe('drizzle', () => { await tx.insert(users).values({ id: 3, name: 'Ada' }) tx.rollback() }), - ).rejects.toBeInstanceOf(TransactionRollbackError) + ).rejects.toThrow('Rollback') expect(await db.select().from(users)).toEqual([{ id: 1, name: 'Dave' }]) }) }) diff --git a/packages/react-native-nitro-sqlite-drizzle/tsconfig.json b/packages/react-native-nitro-sqlite-drizzle/tsconfig.json index 3ba88f1a..288dd859 100644 --- a/packages/react-native-nitro-sqlite-drizzle/tsconfig.json +++ b/packages/react-native-nitro-sqlite-drizzle/tsconfig.json @@ -1,10 +1,14 @@ { "extends": "../../config/tsconfig.json", - "include": ["src"], + "include": ["src", "src/**/*.json"], "references": [{ "path": "../react-native-nitro-sqlite" }], "compilerOptions": { "types": ["node", "jest"], "rootDir": "src", + "outDir": ".tsbuild", + "emitDeclarationOnly": true, + "noEmit": false, + "tsBuildInfoFile": ".tsbuild/tsconfig.tsbuildinfo", "paths": { "react-native-nitro-sqlite": [ "../react-native-nitro-sqlite/src/index.ts" diff --git a/release-please-config.json b/release-please-config.json index ad2e99c4..ba5f2ad5 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -79,6 +79,11 @@ "type": "json", "path": "example/package.json", "jsonpath": "$.dependencies['react-native-nitro-sqlite-vec']" + }, + { + "type": "json", + "path": "example/package.json", + "jsonpath": "$.dependencies['react-native-nitro-sqlite-drizzle']" } ] } diff --git a/scripts/publish-release.test.ts b/scripts/publish-release.test.ts index 1f927fa9..363dde93 100644 --- a/scripts/publish-release.test.ts +++ b/scripts/publish-release.test.ts @@ -97,6 +97,7 @@ function runPublication(extraEnv: Record = {}) { dependencies: { 'react-native-nitro-sqlite': '1.0.0', 'react-native-nitro-sqlite-vec': '1.0.0', + 'react-native-nitro-sqlite-drizzle': '1.0.0', }, }), ) diff --git a/scripts/release-plan.ts b/scripts/release-plan.ts index bd87c597..9fefdb16 100644 --- a/scripts/release-plan.ts +++ b/scripts/release-plan.ts @@ -86,6 +86,7 @@ export function validateReleaseVersions(version: string): void { 'version', 'dependencies.react-native-nitro-sqlite', 'dependencies.react-native-nitro-sqlite-vec', + 'dependencies.react-native-nitro-sqlite-drizzle', ], ], ] satisfies [string, string[]][]) { diff --git a/scripts/release.test.ts b/scripts/release.test.ts index a45bbb8d..ea8bd58c 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -345,6 +345,7 @@ function fixture(run: (directory: string) => void): void { dependencies: { 'react-native-nitro-sqlite': '1.0.0', 'react-native-nitro-sqlite-vec': '1.0.0', + 'react-native-nitro-sqlite-drizzle': '1.0.0', }, devDependencies: { 'react-native-nitro-sqlite': '1.0.0' }, }), From 1cc5052c6271ff84c92c6d51b4135beb78ccad7d Mon Sep 17 00:00:00 2001 From: Dave Mkpa-Eke Date: Sat, 17 Oct 2026 23:34:53 +0100 Subject: [PATCH 07/13] docs: add drizzle guide --- docs/content/docs/integrations/drizzle.mdx | 117 ++++++++++++++++++ docs/content/docs/integrations/meta.json | 2 +- .../README.md | 31 +++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 docs/content/docs/integrations/drizzle.mdx create mode 100644 packages/react-native-nitro-sqlite-drizzle/README.md diff --git a/docs/content/docs/integrations/drizzle.mdx b/docs/content/docs/integrations/drizzle.mdx new file mode 100644 index 00000000..8e02a23a --- /dev/null +++ b/docs/content/docs/integrations/drizzle.mdx @@ -0,0 +1,117 @@ +--- +title: Drizzle ORM +description: Use Drizzle ORM with Nitro SQLite through the react-native-nitro-sqlite-drizzle driver. +--- + +`react-native-nitro-sqlite-drizzle` runs [Drizzle ORM](https://orm.drizzle.team) queries on a Nitro SQLite connection. Pass an open connection to `drizzle()` and query it with Drizzle's API. Every query is asynchronous. + +## Install + +Install the driver with Drizzle and Nitro SQLite. Drizzle Kit and the Babel plugin are only needed for migrations. + +```bash +npm install react-native-nitro-sqlite react-native-nitro-sqlite-drizzle drizzle-orm +npm install --save-dev drizzle-kit babel-plugin-inline-import +``` + +The driver supports `drizzle-orm` 0.45.4 and later 0.x releases. + +## Create a database + +```ts +import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core' +import { open } from 'react-native-nitro-sqlite' +import { drizzle } from 'react-native-nitro-sqlite-drizzle' + +export const users = sqliteTable('users', { + id: integer('id').primaryKey(), + name: text('name').notNull(), +}) + +export const db = drizzle(open({ name: 'app.sqlite' })) + +await db.insert(users).values({ id: 1, name: 'Ada' }) +const rows = await db.select().from(users) +``` + +Create `db` once, outside your components. `drizzle(connection, config)` accepts Drizzle's `schema`, `logger`, and `casing` options. `db.$client` is the connection passed to `drizzle()`. + +## Migrations + +Drizzle Kit generates SQL migration files from your schema. Set `driver: 'expo'` so it also writes a `migrations.js` file that bundles them for React Native: + +```ts title="drizzle.config.ts" +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './db/schema.ts', + out: './drizzle', + dialect: 'sqlite', + driver: 'expo', +}) +``` + +Run `npx drizzle-kit generate` after each schema change. + +`migrations.js` imports `.sql` files. Add the Babel plugin so they load as strings: + +```js title="babel.config.js" +module.exports = { + presets: ['module:@react-native/babel-preset'], + plugins: [['inline-import', { extensions: ['.sql'] }]], +} +``` + +Add `sql` to Metro's source extensions: + +```js title="metro.config.js" +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config') + +const defaultConfig = getDefaultConfig(__dirname) + +module.exports = mergeConfig(defaultConfig, { + resolver: { sourceExts: [...defaultConfig.resolver.sourceExts, 'sql'] }, +}) +``` + +Run the migrations when the app starts with `useMigrations`. It applies the migrations this database has not run yet, in one transaction, and records them in the `__drizzle_migrations` table: + +```tsx +import { Text } from 'react-native' +import { useMigrations } from 'react-native-nitro-sqlite-drizzle' +import migrations from './drizzle/migrations' +import { db } from './db' + +export function App() { + const { success, error } = useMigrations(db, migrations) + + if (error) return Migration failed: {error.message} + if (!success) return Migrating... + return Ready +} +``` + +To run migrations outside a component, such as in startup code or a test, call `migrate` and await it: + +```ts +import { migrate } from 'react-native-nitro-sqlite-drizzle' + +await migrate(db, migrations) +``` + +## Transactions + +Run every query inside a transaction through `tx`: + +```ts +import { eq } from 'drizzle-orm' + +await db.transaction(async (tx) => { + await tx.insert(users).values({ id: 2, name: 'Grace' }) + await tx.update(users).set({ name: 'Ada L.' }).where(eq(users.id, 1)) +}) +``` + +A transaction holds the connection until its callback finishes. A query awaited through `db` inside the callback, including a query prepared from `db`, waits for the transaction and never completes. Nested `tx.transaction()` calls use savepoints. + +`tx.rollback()` rolls back the transaction and rejects it with a `NitroSQLiteError` whose message is `Rollback`, not Drizzle's `TransactionRollbackError`. The `behavior` option of `db.transaction()` is ignored: transactions start with `BEGIN`. See [transactions](/docs/guides/transactions) for how Nitro SQLite runs them. diff --git a/docs/content/docs/integrations/meta.json b/docs/content/docs/integrations/meta.json index db829fc7..489578a1 100644 --- a/docs/content/docs/integrations/meta.json +++ b/docs/content/docs/integrations/meta.json @@ -1,4 +1,4 @@ { "title": "Integrations", - "pages": ["typeorm", "vector-search"] + "pages": ["drizzle", "typeorm", "vector-search"] } diff --git a/packages/react-native-nitro-sqlite-drizzle/README.md b/packages/react-native-nitro-sqlite-drizzle/README.md new file mode 100644 index 00000000..c0dd4a07 --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/README.md @@ -0,0 +1,31 @@ +# react-native-nitro-sqlite-drizzle + +[Drizzle ORM](https://orm.drizzle.team) driver for [`react-native-nitro-sqlite`](https://github.com/margelo/react-native-nitro-sqlite). + +## Installation + +```bash +npm install react-native-nitro-sqlite react-native-nitro-sqlite-drizzle drizzle-orm +``` + +## Usage + +```ts +import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core' +import { open } from 'react-native-nitro-sqlite' +import { drizzle } from 'react-native-nitro-sqlite-drizzle' + +const users = sqliteTable('users', { + id: integer('id').primaryKey(), + name: text('name').notNull(), +}) + +const db = drizzle(open({ name: 'app.sqlite' })) + +await db.insert(users).values({ id: 1, name: 'Ada' }) +const rows = await db.select().from(users) +``` + +Every query is asynchronous. Inside `db.transaction()`, run queries through `tx`: a query awaited through `db` waits for the transaction and never completes. + +See the [Drizzle ORM guide](https://sqlite.margelo.com/docs/integrations/drizzle) for migrations with Drizzle Kit, `useMigrations`, and transactions. From 52edd91b8491797d8a26a9713d17eebce5b1997f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 22 Oct 2026 21:04:43 +0100 Subject: [PATCH 08/13] fix(mock): refresh raw columns after schema changes Co-authored-by: Codex --- .../src/__tests__/mock.test.ts | 23 +++++++++++++++++++ .../src/mock/query.ts | 12 +++++----- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts index ac5f2e3e..64b55214 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/mock.test.ts @@ -86,6 +86,29 @@ describe('Node SQLite mock', () => { }) }) + it.each([0, 1])( + 'refreshes raw prepared column names after a schema change, id=%s', + async (id) => { + const database = open({ name: 'raw-schema-change' }) + database.execute('CREATE TABLE items (id INTEGER)') + database.execute('INSERT INTO items VALUES (1)') + const statement = database.prepare('SELECT * FROM items WHERE id = ?') + database.execute( + "ALTER TABLE items ADD COLUMN label TEXT DEFAULT 'added'", + ) + + const result = statement.executeRaw([id]) + expect(result.columnNames).toEqual(['id', 'label']) + expect(result.rows).toEqual(id === 1 ? [[1, 'added']] : []) + + database.execute('ALTER TABLE items RENAME COLUMN label TO name') + const asyncResult = await statement.executeRawAsync([id]) + expect(asyncResult.columnNames).toEqual(['id', 'name']) + expect(asyncResult.rows).toEqual(result.rows) + statement.finalize() + }, + ) + it('binds repeated named parameters in first-occurrence order', () => { const database = open({ name: 'named' }) expect( diff --git a/packages/react-native-nitro-sqlite/src/mock/query.ts b/packages/react-native-nitro-sqlite/src/mock/query.ts index 460d95e5..517bc48f 100644 --- a/packages/react-native-nitro-sqlite/src/mock/query.ts +++ b/packages/react-native-nitro-sqlite/src/mock/query.ts @@ -49,15 +49,15 @@ export function executePreparedRaw( let columnNames: string[] = [] let rows: SQLiteValue[][] = [] if (statement.reader) { - columnNames = statement.columns().map((column) => column.name) try { // better-sqlite3's types keep the object row type after raw(true). const rawStatement = statement.raw(true) as unknown as RawStatement - rows = rawStatement - .all(...bindings) - .map((row) => - row.map((value, index) => normalizeValue(value, columnNames[index]!)), - ) + const rawRows = rawStatement.all(...bindings) + // Execution may reprepare the statement after a schema change. + columnNames = statement.columns().map((column) => column.name) + rows = rawRows.map((row) => + row.map((value, index) => normalizeValue(value, columnNames[index]!)), + ) } finally { // Raw mode stays on the statement, and prepared statements also serve execute(). statement.raw(false) From 4f9013c0516e83191a7bc2546e58226f5f049847 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 22 Oct 2026 21:04:50 +0100 Subject: [PATCH 09/13] fix: route global raw queries through the managed queue Co-authored-by: Codex --- docs/content/docs/guides/sync-and-async.mdx | 6 +- docs/scripts/check-api.mjs | 2 + .../src/__tests__/entrypoints.test.ts | 3 + .../src/__tests__/executeRaw.test.ts | 112 ++++++++++++++++++ .../react-native-nitro-sqlite/src/index.ts | 9 +- .../src/operations/execute.ts | 42 +++++++ 6 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 packages/react-native-nitro-sqlite/src/__tests__/executeRaw.test.ts diff --git a/docs/content/docs/guides/sync-and-async.mdx b/docs/content/docs/guides/sync-and-async.mdx index 4b388bd3..02428332 100644 --- a/docs/content/docs/guides/sync-and-async.mdx +++ b/docs/content/docs/guides/sync-and-async.mdx @@ -45,7 +45,11 @@ See [prepared statements](/docs/guides/prepared-statements#connection-ordering-a ## Global helpers and native access -The [`NitroSQLite` export](/api/react-native-nitro-sqlite/variables/NitroSQLite) also has `execute`, `executeAsync`, `prepare`, `executeBatch`, `executeBatchAsync`, and `transaction` helpers that take a database name. `NitroSQLite.open(options)` is the same helper as the named [`open()` export](/api/react-native-nitro-sqlite/functions/open). Global queries join the default connection's JavaScript queue when the name was opened through `open()`. They do not address independent connections; use the object returned by `open()` for those. Without a default managed connection, global `execute` and `executeAsync` call the native methods directly and still need an open native database handle. Global `prepare`, batches, and transactions need a default connection opened through `open()`. +The [`NitroSQLite` export](/api/react-native-nitro-sqlite/variables/NitroSQLite) also has `execute`, `executeAsync`, `executeRaw`, `executeRawAsync`, `prepare`, `executeBatch`, `executeBatchAsync`, and `transaction` helpers that take a database name. `NitroSQLite.open(options)` is the same helper as the named [`open()` export](/api/react-native-nitro-sqlite/functions/open). + +Global queries join the default connection's JavaScript queue when the name was opened through `open()`. This includes `NitroSQLite.executeRaw(dbName, query, params)` and `NitroSQLite.executeRawAsync(dbName, query, params)`. A synchronous query throws while that connection is busy; an asynchronous query waits for an active transaction to finish. Global helpers do not address independent connections; use the object returned by `open()` for those. + +Without a default managed connection, global `execute`, `executeAsync`, `executeRaw`, and `executeRawAsync` call the native methods directly and still need an open native database handle. Global `prepare`, batches, and transactions need a default connection opened through `open()`. `NitroSQLite.native` exposes the underlying [`NitroSQLiteNative` hybrid object](/api/react-native-nitro-sqlite/hybrid-objects/NitroSQLiteNative). Its methods take a database name and return raw results without the connection helper's `rows` container. They also throw native errors without converting them to `NitroSQLiteError`. Its `executeAsync()` accepts an optional `onRows` callback that receives leading rows in batches, in order; the resolved `results` then holds only the rows after the last batch. diff --git a/docs/scripts/check-api.mjs b/docs/scripts/check-api.mjs index 3203242c..d094e1d7 100644 --- a/docs/scripts/check-api.mjs +++ b/docs/scripts/check-api.mjs @@ -67,6 +67,8 @@ const requiredMembers = { 'transaction', 'execute', 'executeAsync', + 'executeRaw', + 'executeRawAsync', 'executeBatch', 'executeBatchAsync', 'close', diff --git a/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts index 1cfe6048..299207ae 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/entrypoints.test.ts @@ -18,6 +18,7 @@ import { NitroSQLiteExceptionType as ExceptionTypeFromErrors, } from '../errors' import { HybridNitroSQLite } from '../nitro' +import { executeRaw, executeRawAsync } from '../operations/execute' import { init as androidInit } from '../OnLoad.android' import { init as iosInit } from '../OnLoad' @@ -32,6 +33,8 @@ describe('entrypoints', () => { expect(NitroSQLite.native).toBe(HybridNitroSQLite) expect(NitroSQLite.open).toBe(open) expect(NitroSQLite.open).not.toBe(HybridNitroSQLite.open) + expect(NitroSQLite.executeRaw).toBe(executeRaw) + expect(NitroSQLite.executeRawAsync).toBe(executeRawAsync) expect(iosInit()).toBeUndefined() }) diff --git a/packages/react-native-nitro-sqlite/src/__tests__/executeRaw.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/executeRaw.test.ts new file mode 100644 index 00000000..0982390e --- /dev/null +++ b/packages/react-native-nitro-sqlite/src/__tests__/executeRaw.test.ts @@ -0,0 +1,112 @@ +jest.mock('../nitro') + +import { NitroSQLite } from '../index' +import { HybridNitroSQLite } from '../nitro' +import { + closeDatabaseQueue, + isDatabaseOpen, + openDatabaseQueue, +} from '../DatabaseQueue' +import NitroSQLiteError from '../NitroSQLiteError' +import { deferred, nativeRawResult, nativeResult } from './testUtils' + +const dbName = 'execute-raw-test' +const query = 'SELECT ? AS value' +const raw = nativeRawResult(['value'], [[7]]) + +beforeEach(() => { + jest.clearAllMocks() + jest.mocked(HybridNitroSQLite.executeRaw).mockReturnValue(raw) + jest.mocked(HybridNitroSQLite.executeRawAsync).mockResolvedValue(raw) + jest.mocked(HybridNitroSQLite.execute).mockReturnValue(nativeResult()) + jest.mocked(HybridNitroSQLite.executeAsync).mockResolvedValue(nativeResult()) +}) + +afterEach(() => { + if (isDatabaseOpen(dbName)) closeDatabaseQueue(dbName) +}) + +it('forwards unmanaged raw queries and parameters without adapting their rows', async () => { + expect(NitroSQLite.executeRaw(dbName, query, [7])).toBe(raw) + await expect(NitroSQLite.executeRawAsync(dbName, query, [7])).resolves.toBe( + raw, + ) + expect(HybridNitroSQLite.executeRaw).toHaveBeenCalledWith(dbName, query, [7]) + expect(HybridNitroSQLite.executeRawAsync).toHaveBeenCalledWith( + dbName, + query, + [7], + ) +}) + +it('rejects global synchronous raw queries while managed work is pending', async () => { + openDatabaseQueue(dbName) + expect(NitroSQLite.executeRaw(dbName, query)).toBe(raw) + + const gate = deferred() + jest + .mocked(HybridNitroSQLite.executeRawAsync) + .mockReturnValueOnce(gate.promise) + const pending = NitroSQLite.executeRawAsync(dbName, query) + try { + expect(() => NitroSQLite.executeRaw(dbName, 'SELECT blocked')).toThrow( + 'busy', + ) + expect(HybridNitroSQLite.executeRaw).toHaveBeenCalledTimes(1) + } finally { + gate.resolve(raw) + await pending + } +}) + +it('keeps global raw queries outside an active managed transaction', async () => { + openDatabaseQueue(dbName) + const entered = deferred() + const finish = deferred() + const transaction = NitroSQLite.transaction(dbName, async () => { + entered.resolve() + await finish.promise + }) + await entered.promise + + const pending = NitroSQLite.executeRawAsync(dbName, query) + try { + expect(() => NitroSQLite.executeRaw(dbName, query)).toThrow('busy') + expect(HybridNitroSQLite.executeRaw).not.toHaveBeenCalled() + expect(HybridNitroSQLite.executeRawAsync).not.toHaveBeenCalled() + } finally { + finish.resolve() + await transaction + await pending + } + expect(HybridNitroSQLite.executeRawAsync).toHaveBeenCalledWith( + dbName, + query, + undefined, + ) + expect( + jest.mocked(HybridNitroSQLite.execute).mock.invocationCallOrder[0], + ).toBeLessThan( + jest.mocked(HybridNitroSQLite.executeRawAsync).mock.invocationCallOrder[0]!, + ) +}) + +it('normalizes native errors and releases the managed queue after failure', async () => { + openDatabaseQueue(dbName) + jest.mocked(HybridNitroSQLite.executeRaw).mockImplementationOnce(() => { + throw new Error('raw failed') + }) + expect(() => NitroSQLite.executeRaw(dbName, query)).toThrow(NitroSQLiteError) + expect(NitroSQLite.executeRaw(dbName, query)).toBe(raw) + + jest + .mocked(HybridNitroSQLite.executeRawAsync) + .mockRejectedValueOnce('raw async failed') + await expect( + NitroSQLite.executeRawAsync(dbName, query), + ).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'raw async failed', + }) + await expect(NitroSQLite.executeRawAsync(dbName, query)).resolves.toBe(raw) +}) diff --git a/packages/react-native-nitro-sqlite/src/index.ts b/packages/react-native-nitro-sqlite/src/index.ts index b241664e..173bcc42 100644 --- a/packages/react-native-nitro-sqlite/src/index.ts +++ b/packages/react-native-nitro-sqlite/src/index.ts @@ -1,7 +1,12 @@ import { transaction } from './operations/transaction' import { HybridNitroSQLite } from './nitro' import { open } from './operations/session' -import { execute, executeAsync } from './operations/execute' +import { + execute, + executeAsync, + executeRaw, + executeRawAsync, +} from './operations/execute' import { prepare } from './operations/prepare' import { init } from './OnLoad' import { executeBatch, executeBatchAsync } from './operations/executeBatch' @@ -20,6 +25,8 @@ export const NitroSQLite = { transaction, execute, executeAsync, + executeRaw, + executeRawAsync, prepare, executeBatch, executeBatchAsync, diff --git a/packages/react-native-nitro-sqlite/src/operations/execute.ts b/packages/react-native-nitro-sqlite/src/operations/execute.ts index 61a707a7..0aff4973 100644 --- a/packages/react-native-nitro-sqlite/src/operations/execute.ts +++ b/packages/react-native-nitro-sqlite/src/operations/execute.ts @@ -111,6 +111,27 @@ export async function executeAsyncNative( } } +/** Execute one SQL statement synchronously by database name, returning rows as arrays. + * Uses the default managed connection's queue when the database was opened through `open()`. + * Otherwise, calls the open native database directly. + * @param dbName Name of an open database. + * @param query SQL statement with optional positional placeholders. + * @param params Values bound to the placeholders. + * @returns Rows and column names in SQL column order, with affected row count and insert ID. + * @throws NitroSQLiteError if the managed connection is busy or the query fails. + */ +export function executeRaw( + dbName: string, + query: string, + params?: SQLiteQueryParams, +): NitroSQLiteRawQueryResult { + if (!isDatabaseOpen(dbName)) { + return executeRawNative(dbName, query, params) + } + + return executeRawManaged(dbName, query, params) +} + export function executeRawManaged( dbName: string, query: string, @@ -134,6 +155,27 @@ export function executeRawNative( } } +/** Execute one SQL statement asynchronously by database name, returning rows as arrays. + * Uses the default managed connection's queue when the database was opened through `open()`. + * Otherwise, calls the open native database directly. + * @param dbName Name of an open database. + * @param query SQL statement with optional positional placeholders. + * @param params Values bound to the placeholders. + * @returns A promise of rows and column names in SQL column order, with affected row count and insert ID. + * @throws NitroSQLiteError as a rejected promise if the query fails. + */ +export async function executeRawAsync( + dbName: string, + query: string, + params?: SQLiteQueryParams, +): Promise { + if (!isDatabaseOpen(dbName)) { + return executeRawAsyncNative(dbName, query, params) + } + + return executeRawAsyncManaged(dbName, query, params) +} + export async function executeRawAsyncManaged( dbName: string, query: string, From 500b99538a7ee5afc0d4b739a06abc0bc4d10b07 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 22 Oct 2026 21:10:48 +0100 Subject: [PATCH 10/13] fix(drizzle): preserve blobs and serialize migrations Document the driver API and include it in the generated reference. Co-authored-by: Codex --- docs/content/docs/integrations/drizzle.mdx | 4 + docs/scripts/check-api.mjs | 12 ++ docs/tsconfig.typedoc.json | 3 +- docs/typedoc.json | 1 + example/tests/unit/specs/drizzle.spec.ts | 17 +++ .../src/__tests__/driver.test.ts | 25 +++- .../src/__tests__/migrator.test.ts | 49 ++++++++ .../src/__tests__/useMigrations.test.ts | 86 +++++++++++++ .../src/driver.ts | 16 +++ .../src/index.ts | 1 + .../src/migrator.ts | 96 +++++++++++---- .../src/session.ts | 114 +++++++++++++++++- 12 files changed, 395 insertions(+), 29 deletions(-) create mode 100644 packages/react-native-nitro-sqlite-drizzle/src/__tests__/useMigrations.test.ts diff --git a/docs/content/docs/integrations/drizzle.mdx b/docs/content/docs/integrations/drizzle.mdx index 8e02a23a..73d3041e 100644 --- a/docs/content/docs/integrations/drizzle.mdx +++ b/docs/content/docs/integrations/drizzle.mdx @@ -36,6 +36,8 @@ const rows = await db.select().from(users) Create `db` once, outside your components. `drizzle(connection, config)` accepts Drizzle's `schema`, `logger`, and `casing` options. `db.$client` is the connection passed to `drizzle()`. +SQLite stores binary values as BLOBs. When you pass a `Buffer` or another typed-array view through Drizzle, the driver binds its bytes as an `ArrayBuffer`, preserving the view's byte range. + ## Migrations Drizzle Kit generates SQL migration files from your schema. Set `driver: 'expo'` so it also writes a `migrations.js` file that bundles them for React Native: @@ -99,6 +101,8 @@ import { migrate } from 'react-native-nitro-sqlite-drizzle' await migrate(db, migrations) ``` +Calls using the same connection wait for earlier migration calls before checking which migrations are pending. React Strict Mode can replay `useMigrations` without applying the same migration twice. If a migration fails, its transaction rolls back and a later call can retry it. + ## Transactions Run every query inside a transaction through `tx`: diff --git a/docs/scripts/check-api.mjs b/docs/scripts/check-api.mjs index 3203242c..fdf15215 100644 --- a/docs/scripts/check-api.mjs +++ b/docs/scripts/check-api.mjs @@ -43,6 +43,18 @@ const requiredExports = { 'createVectorTable', 'knnSearch', ], + 'react-native-nitro-sqlite-drizzle': [ + 'drizzle', + 'migrate', + 'useMigrations', + 'MigrationConfig', + 'MigrationState', + 'NitroSQLiteDatabase', + 'NitroSQLiteSessionOptions', + 'NitroSQLiteSession', + 'NitroSQLiteTransaction', + 'NitroSQLitePreparedQuery', + ], } for (const [packageName, exports] of Object.entries(requiredExports)) { diff --git a/docs/tsconfig.typedoc.json b/docs/tsconfig.typedoc.json index 85341fd1..a7ada0b1 100644 --- a/docs/tsconfig.typedoc.json +++ b/docs/tsconfig.typedoc.json @@ -12,6 +12,7 @@ "files": [ "../packages/react-native-nitro-sqlite/src/index.ts", "../packages/react-native-nitro-sqlite/src/mock.ts", - "../packages/react-native-nitro-sqlite-vec/src/index.ts" + "../packages/react-native-nitro-sqlite-vec/src/index.ts", + "../packages/react-native-nitro-sqlite-drizzle/src/index.ts" ] } diff --git a/docs/typedoc.json b/docs/typedoc.json index 58f712bc..211c1002 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -2,6 +2,7 @@ "entryPoints": [ "../packages/react-native-nitro-sqlite", "../packages/react-native-nitro-sqlite-vec", + "../packages/react-native-nitro-sqlite-drizzle", "./mock-api" ], "entryPointStrategy": "packages", diff --git a/example/tests/unit/specs/drizzle.spec.ts b/example/tests/unit/specs/drizzle.spec.ts index 82fbd351..a1d487e3 100644 --- a/example/tests/unit/specs/drizzle.spec.ts +++ b/example/tests/unit/specs/drizzle.spec.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, it } from '@tests/TestApi' import { expect } from '@tests/unit/common' import { eq, sql } from 'drizzle-orm' +import { blob, sqliteTable } from 'drizzle-orm/sqlite-core' import { open } from 'react-native-nitro-sqlite' import type { NitroSQLiteConnection } from 'react-native-nitro-sqlite' import { drizzle, migrate } from 'react-native-nitro-sqlite-drizzle' @@ -37,6 +38,22 @@ export default function registerDrizzleUnitTests() { ) }) + it('round trips blob views and JSON through native bindings', async () => { + connection.execute('CREATE TABLE files (content BLOB, metadata BLOB)') + const files = sqliteTable('files', { + content: blob('content', { mode: 'buffer' }).$type(), + metadata: blob('metadata', { mode: 'json' }), + }) + const bytes = Buffer.from([99, 1, 2, 88]).subarray(1, 3) + const metadata = { name: 'receipt.jpg' } + + await db.insert(files).values({ content: bytes, metadata }) + + expect(await db.select().from(files)).toEqual([ + { content: Buffer.from([1, 2]), metadata }, + ]) + }) + it('keeps both sides of a join whose columns share names', async () => { await db.insert(users).values([ { id: 1, name: 'Dave' }, diff --git a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/driver.test.ts b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/driver.test.ts index 0549a2fd..bb6c5bbe 100644 --- a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/driver.test.ts +++ b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/driver.test.ts @@ -1,5 +1,5 @@ import { eq, sql } from 'drizzle-orm' -import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { blob, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' import { open, resetAllDatabases } from 'react-native-nitro-sqlite/mock' import type { NitroSQLiteConnection } from 'react-native-nitro-sqlite' import { drizzle } from '../index' @@ -29,6 +29,29 @@ function createDatabase() { afterEach(resetAllDatabases) describe('drizzle', () => { + it('binds blob views as ArrayBuffers containing only their bytes', async () => { + const db = createDatabase() + db.$client.execute('CREATE TABLE files (content BLOB, metadata BLOB)') + const files = sqliteTable('files', { + content: blob('content', { mode: 'buffer' }), + metadata: blob('metadata', { mode: 'json' }), + }) + const execute = jest.spyOn(db.$client, 'executeRawAsync') + const bytes = Buffer.from([99, 1, 2, 88]).subarray(1, 3) + const metadata = { name: 'receipt.jpg' } + + await db.insert(files).values({ content: bytes, metadata }) + + const params = execute.mock.calls[0]?.[1] + expect(params?.[0]).toBeInstanceOf(ArrayBuffer) + expect(params?.[1]).toBeInstanceOf(ArrayBuffer) + expect(params?.[0]).toEqual(Uint8Array.from([1, 2]).buffer) + expect(await db.select().from(files)).toEqual([ + { content: Buffer.from([1, 2]), metadata }, + ]) + execute.mockRestore() + }) + it('inserts and selects rows', async () => { const db = createDatabase() await db.insert(users).values([ diff --git a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/migrator.test.ts b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/migrator.test.ts index b07d808e..21dcf2f5 100644 --- a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/migrator.test.ts +++ b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/migrator.test.ts @@ -8,6 +8,55 @@ import { pets, users } from './fixtures/schema' afterEach(resetAllDatabases) describe('migrate', () => { + it('serializes concurrent migrations across wrappers sharing a connection', async () => { + const connection = open({ + name: 'concurrent-migrate', + }) as NitroSQLiteConnection + const db = drizzle(connection) + + await Promise.all([ + migrate(db, migrations), + migrate(drizzle(connection), migrations), + migrate(db, migrations), + ]) + + expect( + connection.execute('SELECT created_at FROM __drizzle_migrations').results, + ).toEqual( + migrations.journal.entries.map((entry) => ({ created_at: entry.when })), + ) + }) + + it('rolls back a failed migration and lets the next queued call retry', async () => { + const connection = open({ name: 'retry-migrate' }) as NitroSQLiteConnection + const db = drizzle(connection) + const failed = { + ...migrations, + migrations: { + m0000: + 'CREATE TABLE rollback_probe (id integer);--> statement-breakpoint SELECT FROM invalid', + }, + } + + const results = await Promise.allSettled([ + migrate(db, failed), + migrate(db, migrations), + ]) + + expect(results.map((result) => result.status)).toEqual([ + 'rejected', + 'fulfilled', + ]) + expect( + connection.execute( + "SELECT name FROM sqlite_master WHERE name = 'rollback_probe'", + ).results, + ).toEqual([]) + expect( + connection.execute('SELECT created_at FROM __drizzle_migrations').results, + ).toHaveLength(1) + }) + it('applies each drizzle-kit migration once', async () => { const connection = open({ name: 'migrate' }) as NitroSQLiteConnection const db = drizzle(connection) diff --git a/packages/react-native-nitro-sqlite-drizzle/src/__tests__/useMigrations.test.ts b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/useMigrations.test.ts new file mode 100644 index 00000000..cf40fac3 --- /dev/null +++ b/packages/react-native-nitro-sqlite-drizzle/src/__tests__/useMigrations.test.ts @@ -0,0 +1,86 @@ +import { createElement, StrictMode } from 'react' +import { act, create } from 'react-test-renderer' +import type { ReactTestRenderer } from 'react-test-renderer' +import { open, resetAllDatabases } from 'react-native-nitro-sqlite/mock' +import type { NitroSQLiteConnection } from 'react-native-nitro-sqlite' +import { drizzle, useMigrations } from '../index' +import type { MigrationState, NitroSQLiteDatabase } from '../index' +import migrations from './fixtures/drizzle/migrations' + +let renderer: ReactTestRenderer | undefined + +afterEach(async () => { + await act(async () => renderer?.unmount()) + renderer = undefined + jest.restoreAllMocks() + resetAllDatabases() +}) + +it('finishes migrations after a Strict Mode effect replay', async () => { + const connection = open({ name: 'strict-migrate' }) as NitroSQLiteConnection + const db = drizzle(connection) + let state: MigrationState | undefined + function Migrations() { + state = useMigrations(db, migrations) + return null + } + + await act(async () => { + renderer = create( + createElement(StrictMode, null, createElement(Migrations)), + ) + }) + + expect(state).toEqual({ success: true }) + expect( + connection.execute('SELECT created_at FROM __drizzle_migrations').results, + ).toHaveLength(1) +}) + +it('ignores an earlier database result while the current migration is pending', async () => { + const firstDb = drizzle( + open({ name: 'first-migrate' }) as NitroSQLiteConnection, + ) + const nextDb = drizzle( + open({ name: 'next-migrate' }) as NitroSQLiteConnection, + ) + const first = Promise.withResolvers() + const next = Promise.withResolvers() + jest.spyOn(firstDb.dialect, 'migrate').mockReturnValue(first.promise) + jest.spyOn(nextDb.dialect, 'migrate').mockReturnValue(next.promise) + let state: MigrationState | undefined + function Migrations({ db }: { db: NitroSQLiteDatabase }) { + state = useMigrations(db, migrations) + return null + } + + await act(async () => { + renderer = create(createElement(Migrations, { db: firstDb })) + }) + await act(async () => { + renderer?.update(createElement(Migrations, { db: nextDb })) + }) + await act(async () => first.resolve()) + + expect(state).toEqual({ success: false }) + + await act(async () => next.resolve()) + expect(state).toEqual({ success: true }) +}) + +it('reports a migration failure', async () => { + const db = drizzle(open({ name: 'failed-migrate' }) as NitroSQLiteConnection) + const missing = { ...migrations, migrations: {} } + let state: MigrationState | undefined + function Migrations() { + state = useMigrations(db, missing) + return null + } + + await act(async () => { + renderer = create(createElement(Migrations)) + }) + + expect(state?.success).toBe(false) + expect(state?.error?.message).toBe('Missing migration: 0000_init') +}) diff --git a/packages/react-native-nitro-sqlite-drizzle/src/driver.ts b/packages/react-native-nitro-sqlite-drizzle/src/driver.ts index 91924609..136c4201 100644 --- a/packages/react-native-nitro-sqlite-drizzle/src/driver.ts +++ b/packages/react-native-nitro-sqlite-drizzle/src/driver.ts @@ -16,16 +16,32 @@ import type { } from 'react-native-nitro-sqlite' import { NitroSQLiteSession } from './session' +/** + * Asynchronous Drizzle database backed by an open Nitro SQLite connection. + * Create it with {@link drizzle}; queries and prepared queries remain usable + * until you close the connection. The database does not own or close it. + * Use the callback's transaction handle for every query inside a transaction. + * @typeParam TSchema Tables and relations supplied in the Drizzle configuration. + */ export class NitroSQLiteDatabase< TSchema extends Record = Record, > extends BaseSQLiteDatabase<'async', NitroSQLiteRawQueryResult, TSchema> { + /** @internal Drizzle entity discriminator. */ static override readonly [entityKind]: string = 'NitroSQLiteDatabase' + + /** Connection attached by {@link drizzle}. Close it after all queries finish. */ + $client?: NitroSQLiteConnection } /** * Create a Drizzle database whose queries run asynchronously on a Nitro SQLite connection. + * Creating the wrapper does not open, migrate, or close the connection. + * @typeParam TSchema Tables and relations supplied in `config.schema`. * @param connection Connection returned by `open()`. * @param config Drizzle options such as `schema`, `logger`, and `casing`. + * @returns A database whose `$client` is the supplied connection. + * @throws If Drizzle cannot read the supplied schema configuration. + * Query promises reject on binding, SQLite, or closed-connection errors. */ export function drizzle< TSchema extends Record = Record, diff --git a/packages/react-native-nitro-sqlite-drizzle/src/index.ts b/packages/react-native-nitro-sqlite-drizzle/src/index.ts index de30459b..c971bad2 100644 --- a/packages/react-native-nitro-sqlite-drizzle/src/index.ts +++ b/packages/react-native-nitro-sqlite-drizzle/src/index.ts @@ -1,5 +1,6 @@ export { drizzle, NitroSQLiteDatabase } from './driver' export { migrate, useMigrations } from './migrator' +export type { MigrationConfig, MigrationState } from './migrator' export { NitroSQLitePreparedQuery, NitroSQLiteSession, diff --git a/packages/react-native-nitro-sqlite-drizzle/src/migrator.ts b/packages/react-native-nitro-sqlite-drizzle/src/migrator.ts index 82748952..a54af385 100644 --- a/packages/react-native-nitro-sqlite-drizzle/src/migrator.ts +++ b/packages/react-native-nitro-sqlite-drizzle/src/migrator.ts @@ -3,50 +3,63 @@ import type { MigrationMeta } from 'drizzle-orm/migrator' import type { NitroSQLiteDatabase } from './driver' import './drizzle-orm' -interface MigrationConfig { +const pendingMigrations = new WeakMap>() + +/** Bundled SQL and journal metadata exported by drizzle-kit's `migrations.js`. */ +export interface MigrationConfig { + /** Generated journal listing migrations in application order. */ journal: { + /** Migration indexes, creation timestamps, file tags, and statement-breakpoint settings. */ entries: { idx: number; when: number; tag: string; breakpoints: boolean }[] } + /** SQL strings keyed by the journal index, such as `m0000`. */ migrations: Record } -function readMigrationFiles({ - journal, - migrations, -}: MigrationConfig): MigrationMeta[] { - return journal.entries.map((entry) => { - const query = migrations[`m${entry.idx.toString().padStart(4, '0')}`] - if (!query) { - throw new Error(`Missing migration: ${entry.tag}`) - } - return { - sql: query.split('--> statement-breakpoint'), - bps: entry.breakpoints, - folderMillis: entry.when, - hash: '', - } - }) -} - /** * Apply the migrations that drizzle-kit generated and that have not run on this database yet. + * Pending migrations run in one transaction and roll back together on failure. + * Calls sharing a connection wait for earlier calls before reading migration history. + * A failed call does not prevent later calls from retrying. + * @typeParam TSchema Tables and relations supplied to Drizzle. * @param db Database returned by `drizzle()`. * @param config The `migrations.js` module that drizzle-kit generates. + * @returns Resolves when all pending migrations finish. + * @throws Rejects if a journal entry has no SQL, or a query or transaction fails. */ export async function migrate>( db: NitroSQLiteDatabase, config: MigrationConfig, ): Promise { - return db.dialect.migrate(readMigrationFiles(config), db.session) + const connection = db.$client ?? db.session + const previous = pendingMigrations.get(connection) ?? Promise.resolve() + const pending = previous + .catch(() => undefined) + .then(() => db.dialect.migrate(readMigrationFiles(config), db.session)) + pendingMigrations.set(connection, pending) + + try { + await pending + } finally { + if (pendingMigrations.get(connection) === pending) { + pendingMigrations.delete(connection) + } + } } -interface State { +/** Result of the migration attempt for the hook's current inputs. */ +export interface MigrationState { + /** True after all pending migrations finish successfully. */ success: boolean + /** Failure from the migration attempt; absent while pending or after success. */ error?: Error } /** - * Run {@linkcode migrate} once when the component mounts. + * Run {@linkcode migrate} on mount and when the database or migration bundle changes. + * Concurrent calls on the same connection are serialized, including Strict Mode + * effect replays. Results from earlier inputs or after unmount do not update state. + * @typeParam TSchema Tables and relations supplied to Drizzle. * @param db Database returned by `drizzle()`. * @param migrations The `migrations.js` module that drizzle-kit generates. * @returns `success` once the migrations finished, or the `error` they failed with. @@ -54,15 +67,46 @@ interface State { export function useMigrations>( db: NitroSQLiteDatabase, migrations: MigrationConfig, -): State { - const [state, setState] = useState({ success: false }) +): MigrationState { + const [state, setState] = useState({ success: false }) useEffect(() => { + let active = true setState({ success: false }) migrate(db, migrations) - .then(() => setState({ success: true })) - .catch((error: Error) => setState({ success: false, error })) + .then(() => { + if (active) setState({ success: true }) + }) + .catch((error: unknown) => { + if (active) { + setState({ + success: false, + error: error instanceof Error ? error : new Error(String(error)), + }) + } + }) + return () => { + active = false + } }, [db, migrations]) return state } + +function readMigrationFiles({ + journal, + migrations, +}: MigrationConfig): MigrationMeta[] { + return journal.entries.map((entry) => { + const query = migrations[`m${entry.idx.toString().padStart(4, '0')}`] + if (!query) { + throw new Error(`Missing migration: ${entry.tag}`) + } + return { + sql: query.split('--> statement-breakpoint'), + bps: entry.breakpoints, + folderMillis: entry.when, + hash: '', + } + }) +} diff --git a/packages/react-native-nitro-sqlite-drizzle/src/session.ts b/packages/react-native-nitro-sqlite-drizzle/src/session.ts index 4a07d6c9..c078c70b 100644 --- a/packages/react-native-nitro-sqlite-drizzle/src/session.ts +++ b/packages/react-native-nitro-sqlite-drizzle/src/session.ts @@ -26,10 +26,19 @@ import type { } from 'react-native-nitro-sqlite' import './drizzle-orm' +/** Logging configuration shared by a session and its transactions. */ export interface NitroSQLiteSessionOptions { + /** Receives SQL and bound parameters before execution. Omit to disable logging. */ logger?: Logger } +/** + * Executes Drizzle queries through a Nitro SQLite connection or transaction. + * {@link drizzle} creates sessions for normal use. A session does not close its + * connection; a transaction-bound session is usable only within its callback. + * @typeParam TFullSchema Full schema object supplied to Drizzle. + * @typeParam TSchema Relational table configuration extracted from that schema. + */ export class NitroSQLiteSession< TFullSchema extends Record, TSchema extends TablesRelationalConfig, @@ -39,10 +48,19 @@ export class NitroSQLiteSession< TFullSchema, TSchema > { + /** @internal Drizzle entity discriminator. */ static override readonly [entityKind]: string = 'NitroSQLiteSession' private logger: Logger + /** + * Bind a session to an existing connection or active transaction. + * @param connection Open connection used to start managed transactions. + * @param client The connection, or the active transaction used for queries. + * @param dialect Asynchronous SQLite dialect used to compile queries. + * @param schema Relational schema configuration, or `undefined` when none was supplied. + * @param options Query logging options, inherited by transactions. + */ constructor( private connection: NitroSQLiteConnection, // The connection, or the transaction handle while a transaction runs. @@ -55,6 +73,16 @@ export class NitroSQLiteSession< this.logger = options.logger ?? new NoopLogger() } + /** + * Create a reusable query wrapper without preparing a native SQLite statement. + * @typeParam T Result types for the query's execution methods. + * @param query Compiled SQL and positional parameters, including placeholders. + * @param fields Ordered selected fields used to decode result rows. + * @param executeMethod Method invoked by the wrapper's `execute()` call. + * @param isResponseInArrayMode Whether Drizzle expects positional rows. + * @param customResultMapper Optional decoder for relational query results. + * @returns A wrapper bound to this session's client. It needs no disposal. + */ prepareQuery( query: Query, fields: SelectedFieldsOrdered | undefined, @@ -73,6 +101,18 @@ export class NitroSQLiteSession< ) } + /** + * Hold the connection until an asynchronous transaction callback finishes. + * Await every query through `tx`; awaiting queries through the outer database + * waits for this transaction and deadlocks the callback. + * @typeParam T Callback result type. + * @param transaction Callback that commits on success and rolls back on failure. + * @param _config Accepted for Drizzle compatibility. `behavior` is ignored; + * the connection starts the transaction with `BEGIN`. + * @returns The callback result after the transaction commits. + * @throws Rejects with `NitroSQLiteError` on query, callback, or finalization failure. + * An uncaught `tx.rollback()` rejects with the message `Rollback`. + */ override async transaction( transaction: ( tx: NitroSQLiteTransaction, @@ -98,6 +138,14 @@ export class NitroSQLiteSession< } } +/** + * Drizzle transaction handle supplied to a transaction callback. + * Use it for queries and nested transactions only while the callback is active. + * Inherited `rollback()` throws Drizzle's `TransactionRollbackError`; an error + * escaping the outer callback becomes a `NitroSQLiteError` with message `Rollback`. + * @typeParam TFullSchema Full schema object supplied to Drizzle. + * @typeParam TSchema Relational table configuration extracted from that schema. + */ export class NitroSQLiteTransaction< TFullSchema extends Record, TSchema extends TablesRelationalConfig, @@ -107,8 +155,17 @@ export class NitroSQLiteTransaction< TFullSchema, TSchema > { + /** @internal Drizzle entity discriminator. */ static override readonly [entityKind]: string = 'NitroSQLiteTransaction' + /** + * Run a nested transaction using a SQLite savepoint. + * @typeParam T Nested callback result type. + * @param transaction Callback whose changes roll back to the savepoint if it throws. + * @returns The callback result after releasing the savepoint. + * @throws Rejects with the callback error, or a query error if savepoint operations fail. + * Catch the rejection in the outer callback to continue that transaction. + */ override async transaction( transaction: ( tx: NitroSQLiteTransaction, @@ -134,6 +191,13 @@ export class NitroSQLiteTransaction< } } +/** + * Reusable compiled Drizzle query with asynchronous execution and row decoding. + * Each execution binds fresh placeholder values. No native prepared statement is + * retained, so no finalization is needed. A transaction-bound query must finish + * before its callback returns; other queries require an open connection. + * @typeParam T Result types supplied by Drizzle for each execution method. + */ export class NitroSQLitePreparedQuery< T extends PreparedQueryConfig = PreparedQueryConfig, > extends SQLitePreparedQuery<{ @@ -144,8 +208,19 @@ export class NitroSQLitePreparedQuery< values: T['values'] execute: T['execute'] }> { + /** @internal Drizzle entity discriminator. */ static override readonly [entityKind]: string = 'NitroSQLitePreparedQuery' + /** + * Create a query wrapper without executing SQL. + * @param client Open connection or active transaction used for execution. + * @param query Compiled SQL and positional parameters, including placeholders. + * @param logger Receives each query before execution. + * @param fields Ordered selected fields used to decode result rows. + * @param executeMethod Method invoked by `execute()`. + * @param _isResponseInArrayMode Whether Drizzle expects positional rows. + * @param customResultMapper Optional decoder for relational query results. + */ constructor( private client: NitroSQLiteConnection | Transaction, query: Query, @@ -158,6 +233,13 @@ export class NitroSQLitePreparedQuery< super('async', executeMethod, query) } + /** + * Execute SQL and return Nitro SQLite's raw result, including affected rows. + * @param placeholderValues Values for every named placeholder in the query. + * @returns Raw positional rows, column names, and execution metadata. + * @throws Rejects on missing placeholders, invalid bindings, SQLite errors, + * or a closed connection or finalized transaction. + */ async run( placeholderValues?: Record, ): Promise { @@ -167,6 +249,13 @@ export class NitroSQLitePreparedQuery< ) } + /** + * Execute SQL and decode every row with the selected fields or custom mapper. + * Without either, rows are objects keyed by SQLite column names. + * @param placeholderValues Values for every named placeholder in the query. + * @returns All decoded rows, or the custom mapper's result. + * @throws Rejects on binding, query, or decoding errors, including an inactive client. + */ async all(placeholderValues?: Record): Promise { const { fields, customResultMapper } = this if (!fields && !customResultMapper) { @@ -186,6 +275,12 @@ export class NitroSQLitePreparedQuery< ) } + /** + * Execute SQL and decode its first row. + * @param placeholderValues Values for every named placeholder in the query. + * @returns The first decoded row, `undefined` if empty, or the custom mapper's result. + * @throws Rejects on binding, query, or decoding errors, including an inactive client. + */ async get(placeholderValues?: Record): Promise { const { fields, customResultMapper } = this if (!fields && !customResultMapper) { @@ -207,6 +302,12 @@ export class NitroSQLitePreparedQuery< return mapResultRow(fields!, row, this.joinsNotNullableMap) } + /** + * Execute SQL and return positional rows without Drizzle field decoding. + * @param placeholderValues Values for every named placeholder in the query. + * @returns Row arrays in SQLite's selected-column order. + * @throws Rejects on binding or query errors, including an inactive client. + */ async values( placeholderValues?: Record, ): Promise { @@ -225,7 +326,18 @@ export class NitroSQLitePreparedQuery< private bindParams( placeholderValues: Record | undefined, ): SQLiteQueryParams { - const params = fillPlaceholders(this.query.params, placeholderValues ?? {}) + const params = fillPlaceholders( + this.query.params, + placeholderValues ?? {}, + ).map((value) => + ArrayBuffer.isView(value) + ? new Uint8Array( + value.buffer, + value.byteOffset, + value.byteLength, + ).slice().buffer + : value, + ) this.logger.logQuery(this.query.sql, params) return params as SQLiteQueryParams } From 09d979be25be5b5ff2ad0c83dfea45016dc53844 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 Oct 2026 00:05:49 +0100 Subject: [PATCH 11/13] fix(example): bundle shared Drizzle fixtures on macOS Include app and Metro output when integration tests time out. Co-authored-by: Codex --- example/macos/metro.config.js | 2 ++ example/macos/scripts/test-macos.mjs | 28 ++++++++++++----------- example/macos/scripts/test-macos.test.mjs | 11 ++++++++- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/example/macos/metro.config.js b/example/macos/metro.config.js index 4c15711a..f9e645a4 100644 --- a/example/macos/metro.config.js +++ b/example/macos/metro.config.js @@ -20,6 +20,8 @@ const config = { projectRoot: __dirname, watchFolders: [exampleRoot, root], resolver: { + // The example shares migration fixtures with the Drizzle package tests. + blockList: /\/__tests__\/(?!fixtures\/).*/, disableHierarchicalLookup: true, nodeModulesPaths: [ path.join(__dirname, 'node_modules'), diff --git a/example/macos/scripts/test-macos.mjs b/example/macos/scripts/test-macos.mjs index 43374187..119cd532 100644 --- a/example/macos/scripts/test-macos.mjs +++ b/example/macos/scripts/test-macos.mjs @@ -183,22 +183,23 @@ function watchProcessFailure(child, name, getOutput) { } } -async function waitForReport(report, failures) { +async function waitForReport(report, failures, getOutput) { let timeout try { return await Promise.race([ report, ...failures, new Promise((_, reject) => { - timeout = setTimeout( - () => - reject( - new Error( - `Timed out waiting for macOS test results after ${testTimeoutMs}ms`, - ), + timeout = setTimeout(() => { + const output = getOutput() + reject( + new Error( + `Timed out waiting for macOS test results after ${testTimeoutMs}ms${ + output.length > 0 ? `\n${output}` : '' + }`, ), - testTimeoutMs, - ) + ) + }, testTimeoutMs) }), ]) } finally { @@ -325,10 +326,11 @@ async function main() { .join('\n\n') } appFailure = watchProcessFailure(app, 'The macOS app', getAppAndMetroOutput) - const result = await waitForReport(resultServer.report, [ - appFailure.failure, - metroFailure.failure, - ]) + const result = await waitForReport( + resultServer.report, + [appFailure.failure, metroFailure.failure], + getAppAndMetroOutput, + ) appFailure.stopWatching() appFailure = undefined metroFailure.stopWatching() diff --git a/example/macos/scripts/test-macos.test.mjs b/example/macos/scripts/test-macos.test.mjs index 159d1c61..c23ba880 100644 --- a/example/macos/scripts/test-macos.test.mjs +++ b/example/macos/scripts/test-macos.test.mjs @@ -65,6 +65,11 @@ test('times out when the app does not report results', async () => { result.output, /Timed out waiting for macOS test results after 100ms/, ) + assert.match( + result.output, + /App output:[\s\S]*fake app did not report results/, + ) + assert.match(result.output, /Metro output:[\s\S]*fake Metro is ready/) }) test('fails promptly when Metro exits during startup', async () => { @@ -136,11 +141,15 @@ const server = createServer((_request, response) => { response.end('packager-status:running') }) -server.listen(port, '127.0.0.1') +server.listen(port, '127.0.0.1', () => console.log('fake Metro is ready')) process.on('SIGTERM', () => server.close(() => process.exit(0))) ` const fakeAppSource = `#!/usr/bin/env node +if (process.env.FAKE_APP_SCENARIO === 'no-report') { + console.error('fake app did not report results') +} + if (process.env.FAKE_APP_SCENARIO === 'early-exit') { console.error('fake app failed') process.exit(23) From 6e6d49d86990d1fc28d3bcea29f83a26a522236f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 Oct 2026 00:52:24 +0100 Subject: [PATCH 12/13] test: assert mixed queue submission order Check ordered writes after all operations finish instead of assuming native completion timing. Co-authored-by: Codex --- .../tests/unit/specs/DatabaseQueue.spec.ts | 41 +++++++++---------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/example/tests/unit/specs/DatabaseQueue.spec.ts b/example/tests/unit/specs/DatabaseQueue.spec.ts index 930e11f2..135588b3 100644 --- a/example/tests/unit/specs/DatabaseQueue.spec.ts +++ b/example/tests/unit/specs/DatabaseQueue.spec.ts @@ -106,50 +106,49 @@ export default function registerDatabaseQueueUnitTests() { expect(testDbQueue.inProgress).toBe(false) }) - it('mixed transactions and executeBatchAsync operations are queued', async () => { + it('runs mixed transactions and async batches in submission order', async () => { + testDb.execute('CREATE TABLE MixedQueueOrder (value INTEGER)') const transaction1Promise = testDb.transaction(async (tx) => { - tx.execute('SELECT * FROM [User];') + tx.execute('INSERT INTO MixedQueueOrder VALUES (1)') }) expect(testDbQueue.queue.length).toBe(0) expect(testDbQueue.inProgress).toBe(true) - const executeBatch1Promise = testDb.executeBatchAsync(TEST_BATCH_COMMANDS) + const executeBatch1Promise = testDb.executeBatchAsync([ + { query: 'INSERT INTO MixedQueueOrder VALUES (2)' }, + ]) expect(testDbQueue.queue.length).toBe(1) expect(testDbQueue.inProgress).toBe(true) const transaction2Promise = testDb.transaction(async (tx) => { - tx.execute(TEST_QUERY) + tx.execute('INSERT INTO MixedQueueOrder VALUES (3)') }) expect(testDbQueue.queue.length).toBe(2) expect(testDbQueue.inProgress).toBe(true) - const executeBatch2Promise = testDb.executeBatchAsync(TEST_BATCH_COMMANDS) + const executeBatch2Promise = testDb.executeBatchAsync([ + { query: 'INSERT INTO MixedQueueOrder VALUES (4)' }, + ]) expect(testDbQueue.queue.length).toBe(3) expect(testDbQueue.inProgress).toBe(true) - await transaction1Promise - - expect(testDbQueue.queue.length).toBe(2) - expect(testDbQueue.inProgress).toBe(true) - - await executeBatch1Promise - - expect(testDbQueue.queue.length).toBe(1) - expect(testDbQueue.inProgress).toBe(true) - - await transaction2Promise - - expect(testDbQueue.queue.length).toBe(0) - expect(testDbQueue.inProgress).toBe(true) - - await executeBatch2Promise + await Promise.all([ + transaction1Promise, + executeBatch1Promise, + transaction2Promise, + executeBatch2Promise, + ]) expect(testDbQueue.queue.length).toBe(0) expect(testDbQueue.inProgress).toBe(false) + expect( + testDb.execute('SELECT value FROM MixedQueueOrder ORDER BY rowid') + .results, + ).toEqual([{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }]) }) it('errors are thrown through DatabaseQueue', async () => { From fc2027dc07971fbe6e21f876fe3f0cb7133218f9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 Oct 2026 22:01:52 +0100 Subject: [PATCH 13/13] ci: allow macOS integration tests to finish after cold builds Raise the job budget to 40 minutes so setup and compilation leave time for the test runner. Co-authored-by: Codex --- .github/workflows/test-macos.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml index 9f498d99..283d4bca 100644 --- a/.github/workflows/test-macos.yml +++ b/.github/workflows/test-macos.yml @@ -13,7 +13,7 @@ jobs: test: name: macOS integration tests runs-on: macos-26 - timeout-minutes: 30 + timeout-minutes: 40 steps: - uses: actions/checkout@v7 - uses: actions/download-artifact@v8