diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 1eed030..a1a716d 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -85,6 +85,7 @@ Every publishable package must have a `## $ARGUMENTS` entry in its CHANGELOG.md. | 2 | dart_node_express | | 2 | dart_node_ws | | 2 | dart_node_better_sqlite3 | +| 2 | dart_node_sql_js | | 2 | dart_node_mcp | | 3 | dart_node_react | | 3 | dart_node_react_native | @@ -93,7 +94,7 @@ Check each changelog: ```bash VERSION="$ARGUMENTS" -for pkg in dart_logging dart_node_core reflux dart_node_express dart_node_ws dart_node_better_sqlite3 dart_node_mcp dart_node_react dart_node_react_native; do +for pkg in dart_logging dart_node_core reflux dart_node_express dart_node_ws dart_node_better_sqlite3 dart_node_sql_js dart_node_mcp dart_node_react dart_node_react_native; do if grep -q "^## $VERSION" "packages/$pkg/CHANGELOG.md" 2>/dev/null; then echo "✅ $pkg" else @@ -109,7 +110,7 @@ If any are missing, **stop and update the changelogs** before proceeding. Each package should have a README.md. Quick sanity check: ```bash -for pkg in dart_logging dart_node_core reflux dart_node_express dart_node_ws dart_node_better_sqlite3 dart_node_mcp dart_node_react dart_node_react_native; do +for pkg in dart_logging dart_node_core reflux dart_node_express dart_node_ws dart_node_better_sqlite3 dart_node_sql_js dart_node_mcp dart_node_react dart_node_react_native; do if [[ -f "packages/$pkg/README.md" ]]; then LINES=$(wc -l < "packages/$pkg/README.md") echo "✅ $pkg - $LINES lines" @@ -124,7 +125,7 @@ done Verify which versions are currently published: ```bash -for pkg in dart_logging dart_node_core reflux dart_node_express dart_node_ws dart_node_better_sqlite3 dart_node_mcp dart_node_react dart_node_react_native; do +for pkg in dart_logging dart_node_core reflux dart_node_express dart_node_ws dart_node_better_sqlite3 dart_node_sql_js dart_node_mcp dart_node_react dart_node_react_native; do LATEST=$(curl -s "https://pub.dev/api/packages/$pkg" | grep -o '"version":"[^"]*"' | head -1 | cut -d'"' -f4) echo "$pkg: $LATEST" done @@ -185,7 +186,7 @@ git tag "Release-Tier2/$ARGUMENTS" git push origin "Release-Tier2/$ARGUMENTS" ``` -Publishes: reflux, dart_node_express, dart_node_ws, dart_node_better_sqlite3, dart_node_mcp +Publishes: reflux, dart_node_express, dart_node_ws, dart_node_better_sqlite3, dart_node_sql_js, dart_node_mcp ## Step 10: Tier 3 publishing @@ -214,7 +215,7 @@ After release, verify all packages are available: ```bash VERSION="$ARGUMENTS" -for pkg in dart_logging dart_node_core reflux dart_node_express dart_node_ws dart_node_better_sqlite3 dart_node_mcp dart_node_react dart_node_react_native; do +for pkg in dart_logging dart_node_core reflux dart_node_express dart_node_ws dart_node_better_sqlite3 dart_node_sql_js dart_node_mcp dart_node_react dart_node_react_native; do HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://pub.dev/api/packages/$pkg/versions/$VERSION") if [[ "$HTTP_CODE" == "200" ]]; then echo "✅ $pkg@$VERSION published" diff --git a/.github/workflows/publish-tier1.yml b/.github/workflows/publish-tier1.yml index 22d4339..ed0d47d 100644 --- a/.github/workflows/publish-tier1.yml +++ b/.github/workflows/publish-tier1.yml @@ -59,6 +59,7 @@ jobs: "dart_node_express" "dart_node_ws" "dart_node_better_sqlite3" + "dart_node_sql_js" "dart_node_mcp" "dart_node_react" "dart_node_react_native" diff --git a/.github/workflows/publish-tier2.yml b/.github/workflows/publish-tier2.yml index 5c46a27..8b6668c 100644 --- a/.github/workflows/publish-tier2.yml +++ b/.github/workflows/publish-tier2.yml @@ -35,4 +35,4 @@ jobs: - name: Publish packages run: | chmod +x .github/scripts/publish-packages.sh - .github/scripts/publish-packages.sh "${{ steps.version.outputs.VERSION }}" reflux dart_node_express dart_node_ws dart_node_better_sqlite3 dart_node_mcp + .github/scripts/publish-packages.sh "${{ steps.version.outputs.VERSION }}" reflux dart_node_express dart_node_ws dart_node_better_sqlite3 dart_node_sql_js dart_node_mcp diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 5ea2064..8b6fabb 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -9,6 +9,7 @@ "dart_node_express": 100.0, "dart_node_ws": 100.0, "dart_node_better_sqlite3": 100.0, + "dart_node_sql_js": 100.0, "dart_node_mcp": 100.0, "dart_node_react_native": 100.0, "dart_jsx": 82.5, diff --git a/packages/dart_node_sql_js/CHANGELOG.md b/packages/dart_node_sql_js/CHANGELOG.md new file mode 100644 index 0000000..27ff4b4 --- /dev/null +++ b/packages/dart_node_sql_js/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 0.13.0-beta + +- Initial beta release +- Typed Dart bindings for sql.js (SQLite compiled to WebAssembly) +- Synchronous in-memory SQLite with file persistence on Node.js +- `Result`-based API: open, prepare, exec, run, get, all, pragma diff --git a/packages/dart_node_sql_js/LICENSE b/packages/dart_node_sql_js/LICENSE new file mode 100644 index 0000000..5ee10fa --- /dev/null +++ b/packages/dart_node_sql_js/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2025, Christian Findlay + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/dart_node_sql_js/README.md b/packages/dart_node_sql_js/README.md new file mode 100644 index 0000000..85a645f --- /dev/null +++ b/packages/dart_node_sql_js/README.md @@ -0,0 +1,105 @@ +Typed Dart bindings for [sql.js](https://github.com/sql-js/sql.js) — SQLite +compiled to WebAssembly. Provides synchronous, in-memory SQLite for Node.js +applications, with explicit persistence to disk. + +Unlike native bindings, sql.js needs no compilation and runs the same +everywhere WebAssembly does. The database lives in memory; you persist it to a +file with `save` or `close`. + +## Installation + +```yaml +dependencies: + dart_node_sql_js: ^0.13.0-beta + nadz: ^0.0.7-beta +``` + +Also install the npm package: + +```bash +npm install sql.js +``` + +## Quick Start + +```dart +import 'package:dart_node_sql_js/dart_node_sql_js.dart'; +import 'package:nadz/nadz.dart'; + +Future main() async { + // Initialize the WebAssembly runtime once at startup. + final runtime = switch (await initializeSqlJs()) { + Success(:final value) => value, + Error(:final error) => throw Exception(error), + }; + + // Opens ./my.db if it exists, otherwise creates a new database. + final db = switch (openDatabase('./my.db', sqlJs: runtime)) { + Success(:final value) => value, + Error(:final error) => throw Exception(error), + }; + + db.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)'); + + final insert = switch (db.prepare('INSERT INTO users (name) VALUES (?)')) { + Success(:final value) => value, + Error(:final error) => throw Exception(error), + }; + insert.run(['Alice']); + + final query = switch (db.prepare('SELECT * FROM users')) { + Success(:final value) => value, + Error(:final error) => throw Exception(error), + }; + final rows = switch (query.all()) { + Success(:final value) => value, + Error(:final error) => throw Exception(error), + }; + print(rows); // [{id: 1, name: Alice}] + + // Persist the in-memory database to ./my.db. + db.close(); +} +``` + +## Persistence + +sql.js is entirely in-memory. The only way to serialize it (`export()`) frees +every live prepared statement, so this binding does **not** write to disk after +each statement. Instead: + +- `db.save()` flushes the current state to the backing file on demand. +- `db.close()` saves and then releases the database. + +Reopening the same path loads the persisted bytes. + +## API + +`initializeSqlJs()` returns a `Future>`. Pass the +runtime to every `openDatabase` call. + +`openDatabase(path, sqlJs: runtime)` returns a `Result` with: + +| Member | Description | +|--------|-------------| +| `prepare(sql)` | Prepare a reusable statement. | +| `exec(sql)` | Run one or more statements, ignoring results. | +| `pragma(value)` | Run a `PRAGMA`. | +| `save()` | Persist the in-memory database to its file. | +| `close()` | Persist, then close. | +| `isOpen()` | Whether the database is still open. | + +A prepared `Statement` exposes: + +| Member | Description | +|--------|-------------| +| `all([params])` | All rows as a list of column-keyed maps. | +| `get([params])` | The first row, or null. | +| `run([params])` | Execute, returning `changes` and `lastInsertRowid`. | + +Every operation returns a `Result` (from [nadz](https://pub.dev/packages/nadz)) +instead of throwing. + +## License + +BSD 3-Clause. See [LICENSE](LICENSE). diff --git a/packages/dart_node_sql_js/analysis_options.yaml b/packages/dart_node_sql_js/analysis_options.yaml new file mode 100644 index 0000000..97e411e --- /dev/null +++ b/packages/dart_node_sql_js/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:austerity/analysis_options.yaml + +analyzer: + errors: + avoid_catches_without_on_clauses: ignore + require_trailing_commas: ignore + public_member_api_docs: error diff --git a/packages/dart_node_sql_js/dart_test.yaml b/packages/dart_node_sql_js/dart_test.yaml new file mode 100644 index 0000000..165fe9d --- /dev/null +++ b/packages/dart_node_sql_js/dart_test.yaml @@ -0,0 +1 @@ +platforms: [node] diff --git a/packages/dart_node_sql_js/lib/dart_node_sql_js.dart b/packages/dart_node_sql_js/lib/dart_node_sql_js.dart index 39e4856..dbc338a 100644 --- a/packages/dart_node_sql_js/lib/dart_node_sql_js.dart +++ b/packages/dart_node_sql_js/lib/dart_node_sql_js.dart @@ -1,7 +1,7 @@ /// Typed Dart bindings for sql.js (SQLite compiled to WebAssembly). /// /// Provides synchronous SQLite3 access via WebAssembly. -/// Call [initializeSqlJs] once at startup, then use [openDatabase]. +/// Call `initializeSqlJs` once at startup, then use `openDatabase`. library; export 'src/database.dart'; diff --git a/packages/dart_node_sql_js/lib/src/database.dart b/packages/dart_node_sql_js/lib/src/database.dart index 25648a6..9fbc710 100644 --- a/packages/dart_node_sql_js/lib/src/database.dart +++ b/packages/dart_node_sql_js/lib/src/database.dart @@ -8,6 +8,42 @@ import 'package:dart_node_core/dart_node_core.dart'; import 'package:dart_node_sql_js/src/statement.dart'; import 'package:nadz/nadz.dart'; +/// Typed view over the sql.js runtime namespace returned by `initSqlJs()`. +extension type _SqlJsNamespace(JSObject _) implements JSObject { + @JS('Database') + external JSFunction get databaseConstructor; +} + +/// Typed view over a sql.js `Database` instance. +extension type SqlJsDatabase(JSObject _) implements JSObject { + /// Run a statement that returns no rows (e.g. `PRAGMA`). + external void run(String sql); + + /// Prepare a statement for repeated execution. + external SqlJsStatement prepare(String sql); + + /// Execute one or more statements, ignoring any result set. + external void exec(String sql); + + /// Serialize the in-memory database to bytes. + external JSUint8Array export(); + + /// Release the database and its associated memory. + external void close(); + + /// Number of rows modified by the most recently executed statement. + external int getRowsModified(); +} + +/// Typed view over the Node.js `fs` module (binary file operations). +extension type _BinaryFs(JSObject _) implements JSObject { + external bool existsSync(String path); + external JSUint8Array readFileSync(String path); + external void writeFileSync(String path, JSUint8Array data); +} + +final _BinaryFs _fs = _BinaryFs(requireModule('fs') as JSObject); + /// Pre-initialized sql.js runtime. /// /// Obtained from [initializeSqlJs], passed to [openDatabase]. @@ -19,16 +55,27 @@ typedef SqlJsRuntime = ({JSFunction databaseConstructor}); Future> initializeSqlJs() async { try { final initFn = requireModule('sql.js') as JSFunction; - final promise = initFn.callAsFunction(null) as JSPromise; - final sqlJs = await promise.toDart as JSObject; - final dbConstructor = sqlJs['Database'] as JSFunction; - return Success((databaseConstructor: dbConstructor)); + final promise = initFn.callAsFunction(); + if (promise == null) { + return const Error('sql.js init returned no promise'); + } + final namespace = await (promise as JSPromise).toDart; + if (namespace == null) { + return const Error('sql.js init resolved to null'); + } + final sqlJs = namespace as _SqlJsNamespace; + return Success((databaseConstructor: sqlJs.databaseConstructor)); } catch (e) { return Error('Failed to initialize sql.js: $e'); } } /// A sql.js database connection. +/// +/// sql.js is entirely in-memory. Because `export()` (the only way to +/// serialize) frees every live prepared statement, changes are NOT written +/// after each operation. Call `save` to flush to disk on demand, or rely on +/// `close`, which saves before closing. typedef Database = ({ /// Prepare a SQL statement. Result Function(String sql) prepare, @@ -36,7 +83,10 @@ typedef Database = ({ /// Execute raw SQL (no results). Result Function(String sql) exec, - /// Close the database. + /// Persist the in-memory database to its backing file. + Result Function() save, + + /// Close the database, persisting it to disk first. Result Function() close, /// Set a pragma value. @@ -50,109 +100,83 @@ typedef Database = ({ /// /// If [path] points to an existing file, loads it. /// Otherwise creates a new empty database. -/// Auto-persists to disk after write operations. +/// Changes are persisted to [path] on `save` and `close`. Result openDatabase( String path, { required SqlJsRuntime sqlJs, }) { try { - final fs = requireModule('fs') as JSObject; - final existsSyncFn = fs['existsSync'] as JSFunction; - final readFileSyncFn = fs['readFileSync'] as JSFunction; - - JSObject jsDb; - final fileExists = - (existsSyncFn.callAsFunction(fs, path.toJS) as JSBoolean).toDart; - - if (fileExists) { - final buffer = readFileSyncFn.callAsFunction(fs, path.toJS); - jsDb = sqlJs.databaseConstructor.callAsConstructor(buffer); - } else { - jsDb = sqlJs.databaseConstructor.callAsConstructor(); - } - + final fileExists = _fs.existsSync(path); // sql.js is in-memory; WAL and busy_timeout do not apply. // Enable foreign keys for referential integrity. - _dbRun(jsDb, 'PRAGMA foreign_keys = ON'); - - return Success(_createDatabase(jsDb, path, fs)); + final jsDb = + (fileExists + ? sqlJs.databaseConstructor.callAsConstructor( + _fs.readFileSync(path), + ) + : sqlJs.databaseConstructor.callAsConstructor()) + ..run('PRAGMA foreign_keys = ON'); + + return Success(_createDatabase(jsDb, path)); } catch (e) { return Error('Failed to open database: $e'); } } -/// Run a SQL statement directly on the JS database object. -void _dbRun(JSObject jsDb, String sql) { - (jsDb['run'] as JSFunction).callAsFunction(jsDb, sql.toJS); -} - /// Persist the in-memory database to disk. -void _save(JSObject jsDb, String path, JSObject fs) { - final exportFn = jsDb['export'] as JSFunction; - final data = exportFn.callAsFunction(jsDb); - - final bufferClass = requireModule('buffer') as JSObject; - final bufferFrom = (bufferClass['Buffer'] as JSObject)['from'] as JSFunction; - final nodeBuffer = bufferFrom.callAsFunction(null, data); - - final writeFileSyncFn = fs['writeFileSync'] as JSFunction; - writeFileSyncFn.callAsFunction(fs, path.toJS, nodeBuffer); +void _save(SqlJsDatabase jsDb, String path) { + _fs.writeFileSync(path, jsDb.export()); } -Database _createDatabase(JSObject jsDb, String path, JSObject fs) { +Database _createDatabase(SqlJsDatabase jsDb, String path) { var open = true; return ( - prepare: (sql) => _dbPrepare(jsDb, sql, path, fs), - exec: (sql) => _dbExec(jsDb, sql, path, fs), - close: () => _dbClose(jsDb, path, fs, () => open = false), + prepare: (sql) => _dbPrepare(jsDb, sql), + exec: (sql) => _dbExec(jsDb, sql), + save: () => _dbSave(jsDb, path), + close: () => _dbClose(jsDb, path, () => open = false), pragma: (pragmaValue) => _dbPragma(jsDb, pragmaValue), isOpen: () => open, ); } -Result _dbPrepare( - JSObject jsDb, - String sql, - String path, - JSObject fs, -) { +Result _dbPrepare(SqlJsDatabase jsDb, String sql) { try { - final prepareFn = jsDb['prepare'] as JSFunction; - final jsStmt = prepareFn.callAsFunction(jsDb, sql.toJS) as JSObject; - return Success( - createStatement(jsStmt, jsDb, onWrite: () => _save(jsDb, path, fs)), - ); + final jsStmt = jsDb.prepare(sql); + return Success(createStatement(jsStmt, jsDb)); } catch (e) { return Error('Failed to prepare statement: $e'); } } -Result _dbExec( - JSObject jsDb, - String sql, - String path, - JSObject fs, -) { +Result _dbExec(SqlJsDatabase jsDb, String sql) { try { // sql.js exec() handles multiple statements separated by ; - (jsDb['exec'] as JSFunction).callAsFunction(jsDb, sql.toJS); - _save(jsDb, path, fs); + jsDb.exec(sql); return const Success(null); } catch (e) { return Error('Failed to exec: $e'); } } +Result _dbSave(SqlJsDatabase jsDb, String path) { + try { + _save(jsDb, path); + return const Success(null); + } catch (e) { + return Error('Failed to save: $e'); + } +} + Result _dbClose( - JSObject jsDb, + SqlJsDatabase jsDb, String path, - JSObject fs, void Function() markClosed, ) { try { - _save(jsDb, path, fs); - (jsDb['close'] as JSFunction).callAsFunction(jsDb); + _save(jsDb, path); + jsDb.close(); markClosed(); return const Success(null); } catch (e) { @@ -160,9 +184,9 @@ Result _dbClose( } } -Result _dbPragma(JSObject jsDb, String pragmaValue) { +Result _dbPragma(SqlJsDatabase jsDb, String pragmaValue) { try { - _dbRun(jsDb, 'PRAGMA $pragmaValue'); + jsDb.run('PRAGMA $pragmaValue'); return const Success(null); } catch (e) { return Error('Failed to set pragma: $e'); diff --git a/packages/dart_node_sql_js/lib/src/statement.dart b/packages/dart_node_sql_js/lib/src/statement.dart index 405c15b..37d5048 100644 --- a/packages/dart_node_sql_js/lib/src/statement.dart +++ b/packages/dart_node_sql_js/lib/src/statement.dart @@ -2,11 +2,29 @@ library; import 'dart:js_interop'; -import 'dart:js_interop_unsafe'; +import 'package:dart_node_sql_js/src/database.dart'; import 'package:dart_node_sql_js/src/types.dart'; import 'package:nadz/nadz.dart'; +/// Typed view over a sql.js prepared `Statement` instance. +extension type SqlJsStatement(JSObject _) implements JSObject { + /// Bind positional parameters to the statement. + external void bind(JSArray values); + + /// Advance to the next row. Returns false when exhausted. + external bool step(); + + /// Return the current row as a column-keyed object. + external JSObject getAsObject(); + + /// Reset the statement so it can be executed again. + external void reset(); + + /// Release the statement and its memory. + external void free(); +} + /// A prepared SQL statement. typedef Statement = ({ /// Execute and return all rows. @@ -24,47 +42,33 @@ typedef Statement = ({ /// /// [jsStmt] is the sql.js Statement object. /// [jsDb] is the sql.js Database object (needed for getRowsModified). -/// [onWrite] is called after run() to persist changes to disk. -Statement createStatement( - JSObject jsStmt, - JSObject jsDb, { - void Function()? onWrite, -}) => ( +Statement createStatement(SqlJsStatement jsStmt, SqlJsDatabase jsDb) => ( all: ([params]) => _stmtAll(jsStmt, params), get: ([params]) => _stmtGet(jsStmt, params), - run: ([params]) => _stmtRun(jsStmt, jsDb, params, onWrite), + run: ([params]) => _stmtRun(jsStmt, jsDb, params), ); -void _bindParams(JSObject jsStmt, List? params) { - final bindFn = jsStmt['bind'] as JSFunction; +void _bindParams(SqlJsStatement jsStmt, List? params) { if (params != null && params.isNotEmpty) { - bindFn.callAsFunction(jsStmt, params.map(_jsifyParam).toList().toJS); - } else { - // Reset bindings for parameterless execution - bindFn.callAsFunction(jsStmt); + jsStmt.bind(params.map(_jsifyParam).toList().toJS); } } JSAny? _jsifyParam(Object? p) => p.jsify(); Result>, String> _stmtAll( - JSObject jsStmt, + SqlJsStatement jsStmt, List? params, ) { try { _bindParams(jsStmt, params); - final stepFn = jsStmt['step'] as JSFunction; - final getAsObjectFn = jsStmt['getAsObject'] as JSFunction; - final resetFn = jsStmt['reset'] as JSFunction; - final rows = >[]; - while ((stepFn.callAsFunction(jsStmt) as JSBoolean).toDart) { - final jsRow = getAsObjectFn.callAsFunction(jsStmt) as JSObject; - final row = _convertRow(jsRow.dartify()); + while (jsStmt.step()) { + final row = _convertRow(jsStmt.getAsObject().dartify()); if (row != null) rows.add(row); } - resetFn.callAsFunction(jsStmt); + jsStmt.reset(); return Success(rows); } catch (e) { @@ -79,24 +83,18 @@ Map? _convertRow(Object? dartified) { } Result?, String> _stmtGet( - JSObject jsStmt, + SqlJsStatement jsStmt, List? params, ) { try { _bindParams(jsStmt, params); - final stepFn = jsStmt['step'] as JSFunction; - final getAsObjectFn = jsStmt['getAsObject'] as JSFunction; - final resetFn = jsStmt['reset'] as JSFunction; - - final hasRow = (stepFn.callAsFunction(jsStmt) as JSBoolean).toDart; - if (!hasRow) { - resetFn.callAsFunction(jsStmt); + if (!jsStmt.step()) { + jsStmt.reset(); return const Success(null); } - final jsRow = getAsObjectFn.callAsFunction(jsStmt) as JSObject; - final row = _convertRow(jsRow.dartify()); - resetFn.callAsFunction(jsStmt); + final row = _convertRow(jsStmt.getAsObject().dartify()); + jsStmt.reset(); return Success(row); } catch (e) { @@ -105,50 +103,38 @@ Result?, String> _stmtGet( } Result _stmtRun( - JSObject jsStmt, - JSObject jsDb, + SqlJsStatement jsStmt, + SqlJsDatabase jsDb, List? params, - void Function()? onWrite, ) { try { _bindParams(jsStmt, params); - final stepFn = jsStmt['step'] as JSFunction; - final resetFn = jsStmt['reset'] as JSFunction; - - // Execute the statement - stepFn.callAsFunction(jsStmt); - resetFn.callAsFunction(jsStmt); - - // Get changes from the database object - final getRowsModifiedFn = jsDb['getRowsModified'] as JSFunction; - final changes = - (getRowsModifiedFn.callAsFunction(jsDb) as JSNumber).toDartInt; - - // Get last insert rowid via exec - final execFn = jsDb['exec'] as JSFunction; - final rowidResult = - execFn.callAsFunction(jsDb, 'SELECT last_insert_rowid() as id'.toJS) - as JSArray; - - var lastInsertRowid = 0; - if (rowidResult.length > 0) { - final resultObj = rowidResult[0] as JSObject; - final values = resultObj['values'] as JSArray; - if (values.length > 0) { - final firstRow = values[0] as JSArray; - if (firstRow.length > 0) { - final val = firstRow[0]; - if (val != null && !val.isUndefinedOrNull) { - lastInsertRowid = (val as JSNumber).toDartInt; - } - } - } - } + jsStmt + ..step() + ..reset(); + + final changes = jsDb.getRowsModified(); + final lastInsertRowid = _lastInsertRowid(jsDb); - onWrite?.call(); return Success((changes: changes, lastInsertRowid: lastInsertRowid)); } catch (e) { return Error('Statement.run failed: $e'); } } + +/// Read the rowid of the most recent insert via `last_insert_rowid()`. +/// +/// Uses a dedicated statement that is freed immediately so it never +/// interferes with the caller's live statements. +int _lastInsertRowid(SqlJsDatabase jsDb) { + final stmt = jsDb.prepare('SELECT last_insert_rowid() AS id'); + final id = stmt.step() ? _rowidValue(stmt) : 0; + stmt.free(); + return id; +} + +int _rowidValue(SqlJsStatement stmt) { + final value = _convertRow(stmt.getAsObject().dartify())?['id']; + return value is num ? value.toInt() : 0; +} diff --git a/packages/dart_node_sql_js/package-lock.json b/packages/dart_node_sql_js/package-lock.json new file mode 100644 index 0000000..935f381 --- /dev/null +++ b/packages/dart_node_sql_js/package-lock.json @@ -0,0 +1,18 @@ +{ + "name": "dart_node_sql_js", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "sql.js": "^1.14.1" + } + }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" + } + } +} diff --git a/packages/dart_node_sql_js/package.json b/packages/dart_node_sql_js/package.json new file mode 100644 index 0000000..21c848a --- /dev/null +++ b/packages/dart_node_sql_js/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "sql.js": "^1.14.1" + } +} diff --git a/packages/dart_node_sql_js/pubspec.yaml b/packages/dart_node_sql_js/pubspec.yaml index 18fa0c5..1595404 100644 --- a/packages/dart_node_sql_js/pubspec.yaml +++ b/packages/dart_node_sql_js/pubspec.yaml @@ -1,7 +1,6 @@ name: dart_node_sql_js description: Typed Dart bindings for sql.js (SQLite compiled to WebAssembly) -version: 0.1.0-beta -publish_to: none +version: 0.13.0-beta repository: https://github.com/MelbourneDeveloper/dart_node environment: diff --git a/packages/dart_node_sql_js/test/database_test.dart b/packages/dart_node_sql_js/test/database_test.dart new file mode 100644 index 0000000..3677057 --- /dev/null +++ b/packages/dart_node_sql_js/test/database_test.dart @@ -0,0 +1,531 @@ +/// Integration tests for dart_node_sql_js on Node.js. +library; + +import 'dart:js_interop'; + +import 'package:dart_node_core/dart_node_core.dart'; +import 'package:dart_node_coverage/dart_node_coverage.dart'; +import 'package:dart_node_sql_js/dart_node_sql_js.dart'; +import 'package:nadz/nadz.dart'; +import 'package:test/test.dart'; + +extension type _Fs(JSObject _) implements JSObject { + external void unlinkSync(String path); + external bool existsSync(String path); + external void writeFileSync(String path, String data); + external void mkdirSync(String path); + external void rmdirSync(String path); +} + +final _Fs _fs = _Fs(requireModule('fs') as JSObject); + +const String _dbPath = '.test_sql_js.db'; +const String _reopenPath = '.test_sql_js_reopen.db'; +const String _savePath = '.test_sql_js_save.db'; +const String _dirPath = '.test_sql_js_dir.db'; +const String _badDirPath = '/nonexistent_dir_sql_js/test.db'; + +void _deleteIfExists(String path) { + try { + if (_fs.existsSync(path)) { + _fs.unlinkSync(path); + } + } catch (_) { + // Ignore cleanup errors + } +} + +SqlJsRuntime _runtimeOf(Result result) => + (result as Success).value; + +Database _databaseOf(Result result) => + (result as Success).value; + +Statement _statementOf(Result result) => + (result as Success).value; + +void main() { + late SqlJsRuntime runtime; + + setUpAll(() async { + runtime = _runtimeOf(await initializeSqlJs()); + }); + + setUp(initCoverage); + tearDownAll(() => writeCoverageFile('coverage/coverage.json')); + + group('initializeSqlJs', () { + test('returns a runtime with a database constructor', () async { + final result = await initializeSqlJs(); + expect(result, isA>()); + }); + }); + + group('openDatabase', () { + test('creates a new database when the file does not exist', () { + _deleteIfExists(_dbPath); + final db = _databaseOf(openDatabase(_dbPath, sqlJs: runtime)); + expect(db.isOpen(), true); + db.close(); + _deleteIfExists(_dbPath); + }); + + test('loads an existing database file', () { + _deleteIfExists(_reopenPath); + final first = _databaseOf(openDatabase(_reopenPath, sqlJs: runtime)); + first.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)'); + final insert = _statementOf( + first.prepare('INSERT INTO t (v) VALUES (?)'), + ); + insert.run(['persisted']); + first.close(); + + expect(_fs.existsSync(_reopenPath), true); + + final reopened = _databaseOf(openDatabase(_reopenPath, sqlJs: runtime)); + final query = _statementOf(reopened.prepare('SELECT v FROM t')); + final row = (query.get() as Success?, String>).value; + expect(row, isNotNull); + expect(row!['v'], 'persisted'); + reopened.close(); + _deleteIfExists(_reopenPath); + }); + + test('returns error when the path cannot be read', () { + _deleteIfExists(_dirPath); + _fs.mkdirSync(_dirPath); + final result = openDatabase(_dirPath, sqlJs: runtime); + expect(result, isA>()); + _fs.rmdirSync(_dirPath); + }); + + test('save persists changes that survive reopen', () { + _deleteIfExists(_savePath); + final db = _databaseOf(openDatabase(_savePath, sqlJs: runtime)); + db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)'); + final insert = _statementOf(db.prepare('INSERT INTO t (v) VALUES (?)')); + insert.run(['flushed']); + final saveResult = db.save(); + expect(saveResult, isA>()); + + final reopened = _databaseOf(openDatabase(_savePath, sqlJs: runtime)); + final query = _statementOf(reopened.prepare('SELECT v FROM t')); + final row = (query.get() as Success?, String>).value; + expect(row!['v'], 'flushed'); + reopened.close(); + _deleteIfExists(_savePath); + }); + }); + + group('Database.exec', () { + late Database db; + + setUp(() { + _deleteIfExists(_dbPath); + db = _databaseOf(openDatabase(_dbPath, sqlJs: runtime)); + }); + + tearDown(() { + db.close(); + _deleteIfExists(_dbPath); + }); + + test('executes CREATE TABLE', () { + final result = db.exec(''' + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE + ) + '''); + expect(result, isA>()); + }); + + test('executes multiple statements', () { + final result = db.exec(''' + CREATE TABLE t1 (id INTEGER); + CREATE TABLE t2 (id INTEGER); + CREATE TABLE t3 (id INTEGER); + '''); + expect(result, isA>()); + }); + + test('returns error for invalid SQL', () { + final result = db.exec('NOT VALID SQL'); + expect(result, isA>()); + }); + }); + + group('Database.prepare', () { + late Database db; + + setUp(() { + _deleteIfExists(_dbPath); + db = _databaseOf(openDatabase(_dbPath, sqlJs: runtime)); + db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + }); + + tearDown(() { + db.close(); + _deleteIfExists(_dbPath); + }); + + test('prepares valid statement', () { + final result = db.prepare('SELECT * FROM users'); + expect(result, isA>()); + }); + + test('returns error for invalid SQL', () { + final result = db.prepare('SELECT * FROM nonexistent'); + expect(result, isA>()); + }); + }); + + group('Statement.run', () { + late Database db; + late Statement insertStmt; + + setUp(() { + _deleteIfExists(_dbPath); + db = _databaseOf(openDatabase(_dbPath, sqlJs: runtime)); + db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + insertStmt = _statementOf( + db.prepare('INSERT INTO users (name) VALUES (?)'), + ); + }); + + tearDown(() { + db.close(); + _deleteIfExists(_dbPath); + }); + + test('inserts row and returns lastInsertRowid', () { + final result = insertStmt.run(['Alice']); + expect(result, isA>()); + final runResult = (result as Success).value; + expect(runResult.changes, 1); + expect(runResult.lastInsertRowid, 1); + }); + + test('inserts multiple rows with incrementing rowid', () { + insertStmt.run(['Alice']); + insertStmt.run(['Bob']); + final result = insertStmt.run(['Charlie']); + final runResult = (result as Success).value; + expect(runResult.lastInsertRowid, 3); + }); + + test('updates rows and returns changes count', () { + insertStmt.run(['Alice']); + insertStmt.run(['Bob']); + final stmt = _statementOf(db.prepare('UPDATE users SET name = ?')); + final result = stmt.run(['Updated']); + final runResult = (result as Success).value; + expect(runResult.changes, 2); + }); + + test('deletes rows and returns changes count', () { + insertStmt.run(['Alice']); + insertStmt.run(['Bob']); + final stmt = _statementOf(db.prepare('DELETE FROM users')); + final result = stmt.run(); + final runResult = (result as Success).value; + expect(runResult.changes, 2); + }); + }); + + group('Statement.get', () { + late Database db; + + setUp(() { + _deleteIfExists(_dbPath); + db = _databaseOf(openDatabase(_dbPath, sqlJs: runtime)); + db.exec(''' + CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER); + INSERT INTO users (name, age) VALUES ('Alice', 30); + INSERT INTO users (name, age) VALUES ('Bob', 25); + '''); + }); + + tearDown(() { + db.close(); + _deleteIfExists(_dbPath); + }); + + test('returns first row', () { + final stmt = _statementOf(db.prepare('SELECT * FROM users ORDER BY id')); + final result = stmt.get(); + expect(result, isA?, String>>()); + final row = (result as Success?, String>).value; + expect(row, isNotNull); + expect(row!['name'], 'Alice'); + expect(row['age'], 30); + }); + + test('returns null for no results', () { + final stmt = _statementOf(db.prepare('SELECT * FROM users WHERE id = ?')); + final result = stmt.get([999]); + expect(result, isA?, String>>()); + final row = (result as Success?, String>).value; + expect(row, isNull); + }); + + test('uses parameters correctly', () { + final stmt = _statementOf( + db.prepare('SELECT * FROM users WHERE name = ?'), + ); + final result = stmt.get(['Bob']); + final row = (result as Success?, String>).value; + expect(row, isNotNull); + expect(row!['name'], 'Bob'); + expect(row['age'], 25); + }); + }); + + group('Statement.all', () { + late Database db; + + setUp(() { + _deleteIfExists(_dbPath); + db = _databaseOf(openDatabase(_dbPath, sqlJs: runtime)); + db.exec(''' + CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER); + INSERT INTO users (name, age) VALUES ('Alice', 30); + INSERT INTO users (name, age) VALUES ('Bob', 25); + INSERT INTO users (name, age) VALUES ('Charlie', 35); + '''); + }); + + tearDown(() { + db.close(); + _deleteIfExists(_dbPath); + }); + + test('returns all rows', () { + final stmt = _statementOf(db.prepare('SELECT * FROM users ORDER BY id')); + final result = stmt.all(); + expect(result, isA>, String>>()); + final rows = + (result as Success>, String>).value; + expect(rows.length, 3); + expect(rows[0]['name'], 'Alice'); + expect(rows[1]['name'], 'Bob'); + expect(rows[2]['name'], 'Charlie'); + }); + + test('returns empty list for no results', () { + final stmt = _statementOf( + db.prepare('SELECT * FROM users WHERE age > ?'), + ); + final result = stmt.all([100]); + final rows = + (result as Success>, String>).value; + expect(rows, isEmpty); + }); + + test('filters with parameters', () { + final stmt = _statementOf( + db.prepare('SELECT * FROM users WHERE age >= ?'), + ); + final result = stmt.all([30]); + final rows = + (result as Success>, String>).value; + expect(rows.length, 2); + }); + }); + + group('Database.close', () { + test('closes database successfully', () { + _deleteIfExists(_dbPath); + final db = _databaseOf(openDatabase(_dbPath, sqlJs: runtime)); + expect(db.isOpen(), true); + + final closeResult = db.close(); + expect(closeResult, isA>()); + expect(db.isOpen(), false); + _deleteIfExists(_dbPath); + }); + + test('returns error when the save target directory is missing', () { + final db = _databaseOf(openDatabase(_badDirPath, sqlJs: runtime)); + final closeResult = db.close(); + expect(closeResult, isA>()); + }); + }); + + group('Database.pragma', () { + late Database db; + + setUp(() { + _deleteIfExists(_dbPath); + db = _databaseOf(openDatabase(_dbPath, sqlJs: runtime)); + }); + + tearDown(() { + db.close(); + _deleteIfExists(_dbPath); + }); + + test('sets pragma successfully', () { + final result = db.pragma('cache_size = 10000'); + expect(result, isA>()); + }); + + test('returns error for invalid pragma', () { + final result = db.pragma('= = ='); + expect(result, isA>()); + }); + }); + + group('Data types', () { + late Database db; + + setUp(() { + _deleteIfExists(_dbPath); + db = _databaseOf(openDatabase(_dbPath, sqlJs: runtime)); + db.exec(''' + CREATE TABLE types_test ( + id INTEGER PRIMARY KEY, + int_col INTEGER, + real_col REAL, + text_col TEXT, + null_col TEXT + ) + '''); + }); + + tearDown(() { + db.close(); + _deleteIfExists(_dbPath); + }); + + test('handles integer values', () { + final stmt = _statementOf( + db.prepare('INSERT INTO types_test (int_col) VALUES (?)'), + ); + stmt.run([42]); + final select = _statementOf(db.prepare('SELECT int_col FROM types_test')); + final row = + (select.get() as Success?, String>).value; + expect(row!['int_col'], 42); + }); + + test('handles real/double values', () { + final stmt = _statementOf( + db.prepare('INSERT INTO types_test (real_col) VALUES (?)'), + ); + stmt.run([3.14159]); + final select = _statementOf( + db.prepare('SELECT real_col FROM types_test'), + ); + final row = + (select.get() as Success?, String>).value; + expect(row!['real_col'], closeTo(3.14159, 0.00001)); + }); + + test('handles text values', () { + final stmt = _statementOf( + db.prepare('INSERT INTO types_test (text_col) VALUES (?)'), + ); + stmt.run(['Hello, World!']); + final select = _statementOf( + db.prepare('SELECT text_col FROM types_test'), + ); + final row = + (select.get() as Success?, String>).value; + expect(row!['text_col'], 'Hello, World!'); + }); + + test('handles null values', () { + final stmt = _statementOf( + db.prepare('INSERT INTO types_test (null_col) VALUES (?)'), + ); + stmt.run([null]); + final select = _statementOf( + db.prepare('SELECT null_col FROM types_test'), + ); + final row = + (select.get() as Success?, String>).value; + expect(row!['null_col'], isNull); + }); + }); + + group('Transactions', () { + late Database db; + + setUp(() { + _deleteIfExists(_dbPath); + db = _databaseOf(openDatabase(_dbPath, sqlJs: runtime)); + db.exec( + 'CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)', + ); + db.exec('INSERT INTO accounts (balance) VALUES (100)'); + }); + + tearDown(() { + db.close(); + _deleteIfExists(_dbPath); + }); + + test('commits transaction', () { + db.exec('BEGIN'); + final stmt = _statementOf(db.prepare('UPDATE accounts SET balance = ?')); + stmt.run([200]); + db.exec('COMMIT'); + + final select = _statementOf(db.prepare('SELECT balance FROM accounts')); + final row = + (select.get() as Success?, String>).value; + expect(row!['balance'], 200); + }); + + test('rolls back transaction', () { + db.exec('BEGIN'); + final stmt = _statementOf(db.prepare('UPDATE accounts SET balance = ?')); + stmt.run([200]); + db.exec('ROLLBACK'); + + final select = _statementOf(db.prepare('SELECT balance FROM accounts')); + final row = + (select.get() as Success?, String>).value; + expect(row!['balance'], 100); + }); + }); + + group('Constraints', () { + late Database db; + + setUp(() { + _deleteIfExists(_dbPath); + db = _databaseOf(openDatabase(_dbPath, sqlJs: runtime)); + db.exec(''' + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT UNIQUE NOT NULL + ) + '''); + }); + + tearDown(() { + db.close(); + _deleteIfExists(_dbPath); + }); + + test('enforces UNIQUE constraint', () { + final stmt = _statementOf( + db.prepare('INSERT INTO users (email) VALUES (?)'), + ); + stmt.run(['alice@example.com']); + final result = stmt.run(['alice@example.com']); + expect(result, isA>()); + }); + + test('enforces NOT NULL constraint', () { + final stmt = _statementOf( + db.prepare('INSERT INTO users (email) VALUES (?)'), + ); + final result = stmt.run([null]); + expect(result, isA>()); + }); + }); +} diff --git a/tools/lib/packages.dart b/tools/lib/packages.dart index 88efb3e..bd97242 100644 --- a/tools/lib/packages.dart +++ b/tools/lib/packages.dart @@ -60,6 +60,11 @@ const _packageConfigs = { tier: 2, testPlatform: TestPlatform.node, ), + 'dart_node_sql_js': PackageConfig( + name: 'dart_node_sql_js', + tier: 2, + testPlatform: TestPlatform.node, + ), // Tier 3 - depends on tier 2 'dart_node_react': PackageConfig( name: 'dart_node_react', diff --git a/tools/test.sh b/tools/test.sh index 564e68f..9102f3a 100755 --- a/tools/test.sh +++ b/tools/test.sh @@ -75,7 +75,7 @@ else fi # Package type definitions -NODE_PACKAGES="dart_node_core dart_node_express dart_node_ws dart_node_better_sqlite3" +NODE_PACKAGES="dart_node_core dart_node_express dart_node_ws dart_node_better_sqlite3 dart_node_sql_js" NODE_INTEROP_PACKAGES="dart_node_mcp dart_node_react_native" BROWSER_PACKAGES="dart_node_react frontend jsx_demo mobile" NPM_PACKAGES="" @@ -86,7 +86,7 @@ BUILD_FIRST="" # check_all_packages_covered() so a package's coverage check is never silently # dropped. TIER1="packages/dart_logging packages/dart_node_core packages/dart_node_coverage" -TIER2="packages/reflux packages/dart_jsx packages/dart_node_express packages/dart_node_ws packages/dart_node_better_sqlite3 packages/dart_node_mcp packages/dart_node_react_native packages/dart_node_react signal_mesh" +TIER2="packages/reflux packages/dart_jsx packages/dart_node_express packages/dart_node_ws packages/dart_node_better_sqlite3 packages/dart_node_sql_js packages/dart_node_mcp packages/dart_node_react_native packages/dart_node_react signal_mesh" TIER3="examples/frontend examples/markdown_editor examples/reflux_demo/web_counter examples/jsx_demo examples/mobile" # Packages that have tests but are deliberately NOT run here, each with a reason.