Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a34b5f2
wip electron
colincarter Oct 6, 2025
e3bd2d6
wip
colincarter Oct 8, 2025
ffcac70
insertid
colincarter Oct 9, 2025
27a7437
tidy
colincarter Oct 9, 2025
902b77d
some tidy up
colincarter Oct 10, 2025
12096e0
update files
colincarter Oct 10, 2025
5b05194
executeSql on a Transaction
colincarter Oct 13, 2025
c4c2acc
add an SQLResultSet type
colincarter Oct 13, 2025
c4ecda0
formatting
colincarter Oct 13, 2025
96897d6
add eslint
colincarter Oct 14, 2025
fd8e0ea
remove old code
colincarter Oct 14, 2025
c0088ee
delete doesn't return anything
colincarter Oct 14, 2025
af85e08
better types
colincarter Oct 14, 2025
5c2008a
Fix rows returned by select statement
colincarter Oct 14, 2025
c1d8372
async-ify
colincarter Oct 14, 2025
d64ba13
remove unused deps
colincarter Oct 14, 2025
df8c0be
Adding new example app
colincarter Oct 15, 2025
2665975
Update podfile.lock
colincarter Oct 15, 2025
596adfc
finish ios and android example app
colincarter Oct 16, 2025
2b43606
fix require main queue setup warning
colincarter Oct 16, 2025
4b738d5
fix types
colincarter Oct 16, 2025
dcb98a8
wip on example app
colincarter Oct 20, 2025
b7deaca
new example app
colincarter Nov 18, 2025
32a2b3f
add main entry point to package.json
colincarter Nov 19, 2025
4435480
use init convention
colincarter Dec 3, 2025
97025db
types for electron init code
colincarter Dec 3, 2025
9d4b36a
make a module
colincarter Dec 3, 2025
fbee2f0
remove other specifiers
colincarter Dec 3, 2025
45bcb61
simplify exports
colincarter Dec 3, 2025
b5534d2
add package.json to exports
colincarter Dec 3, 2025
0d40fa9
export
colincarter Dec 3, 2025
5fe2934
types again
colincarter Dec 3, 2025
0ed84a3
remove package.json export
colincarter Dec 3, 2025
fdb912e
Add package.json main field
colincarter Jan 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,5 @@ gradle-app.setting
.vs/
# generated by bob
lib/

.vscode
3 changes: 3 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
presets: ["module:@react-native/babel-preset"]
};
5 changes: 5 additions & 0 deletions electron/main.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export declare const db: {
main: {
init(): void;
};
};
328 changes: 328 additions & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
import { ipcMain, app } from "electron";
import sqlite3 from "@journeyapps/sqlcipher";
import path from "path";
import { existsSync, unlinkSync } from "fs";

/**
* @import {Database, Statement, RunResult} from "@journeyapps/sqlcipher"
*
* @typedef {object} RunResultWithRows
* @prop {number} lastID
* @prop {number} changes
* @prop {Array<any>} rows
*/

class SQLite {
/**
* @type {Map<string, AsyncDatabase>}
* @private
*/
#databases = new Map();

/**
* @type {string}
* @private
*/
#dbLocation = app.getPath("userData");

/**
* @param {Event} event
* @param {{name: string, key: string}} options
* @returns {Promise<void>}
*/
open = async (event, options) => {
const openPath = path.resolve(path.join(this.#dbLocation, options.name));
const db = await AsyncDatabase.newDatabase(openPath);
if (options.key) {
await db.run(`PRAGMA key = '${options.key}'`);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Encryption Key Opens Door to SQL Injection

Database encryption key is directly interpolated into SQL string using template literals, creating a SQL injection vulnerability. If the key contains quotes or SQL syntax, it could break out and inject arbitrary commands. The key should be passed through a parameterized approach or properly escaped.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: SQL injection vulnerability in PRAGMA key

The database encryption key is directly interpolated into the SQL string using template literals without parameterization. An attacker controlling options.key could inject arbitrary SQL code, compromising database security and application integrity.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Encryption keys containing single quotes break database opening

The encryption key is directly interpolated into the PRAGMA statement without escaping single quotes. If options.key contains a single quote character (e.g., a passphrase like it's secret), the resulting SQL becomes malformed and the database will fail to open with a syntax error.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: SQL injection vulnerability in database key parameter

The encryption key is interpolated directly into the SQL string using template literals without sanitization. If options.key contains a single quote, it could break the SQL or allow injection. Since this value comes from IPC (renderer process), a malicious or compromised renderer could exploit this to execute arbitrary SQL commands.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: SQL injection via unsanitized key in PRAGMA

The options.key value is directly interpolated into the SQL string for PRAGMA key without any sanitization or escaping. Since this value comes from the renderer process via IPC and could contain single quotes or other SQL-significant characters, a malicious or malformed key value could break the SQL statement or potentially allow SQL injection attacks.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SQL injection vulnerability in PRAGMA key statement

Medium Severity

The encryption key is directly interpolated into the SQL string using template literals: `PRAGMA key = '${options.key}'`. If the key contains single quotes or other special characters, it could break the query or potentially allow SQL injection. The key value comes from IPC which could be influenced by renderer process code.

Fix in Cursor Fix in Web

await db.run("PRAGMA cipher_migrate");
Comment on lines +37 to +38
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

SQL injection in PRAGMA key.

String interpolation in PRAGMA key = '${options.key}' enables SQL injection. Use parameterized queries.

Unfortunately, SQLite PRAGMA statements don't support bound parameters. Instead, validate and escape the key:

     if (options.key) {
-      await db.run(`PRAGMA key = '${options.key}'`);
+      const key = String(options.key).replace(/'/g, "''");
+      await db.run(`PRAGMA key = '${key}'`);
       await db.run("PRAGMA cipher_migrate");
     }

Better yet, validate that the key doesn't contain quotes:

     if (options.key) {
+      if (typeof options.key !== 'string' || options.key.includes("'")) {
+        throw new Error("Invalid key format");
+      }
       await db.run(`PRAGMA key = '${options.key}'`);
       await db.run("PRAGMA cipher_migrate");
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await db.run(`PRAGMA key = '${options.key}'`);
await db.run("PRAGMA cipher_migrate");
// … inside your open logic …
if (options.key) {
// reject any key containing a quote
if (typeof options.key !== 'string' || options.key.includes("'")) {
throw new Error("Invalid key format");
}
// escape any remaining single-quotes (defense-in-depth)
const key = String(options.key).replace(/'/g, "''");
await db.run(`PRAGMA key = '${key}'`);
await db.run("PRAGMA cipher_migrate");
}
// …
🤖 Prompt for AI Agents
In electron/main.js around lines 37-38, the PRAGMA key line uses string
interpolation which allows SQL injection; instead validate and sanitize
options.key before embedding it: ensure options.key is a non-empty string and
reject/throw if it contains single-quote, double-quote, semicolon, or other
unsafe characters (or restrict to a safe charset like [A-Za-z0-9-_]+=), and only
then construct the PRAGMA statement; if you need to support arbitrary bytes,
transform the key into a safe encoding (e.g., base64) and use that encoded value
in the PRAGMA; do not accept raw user input with quotes—either reject or escape
single quotes by doubling them before interpolation if you must embed.

}
this.#databases.set(options.name, db);
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Database connection leaked when PRAGMA commands fail

Medium Severity

In the open method, if the PRAGMA key or PRAGMA cipher_migrate commands fail after the database connection is created at line 35, the connection is never closed and never stored in #databases. This creates an orphaned SQLite connection that holds file locks and memory, with no way to clean it up. Repeated failed open attempts (e.g., with malformed keys) will accumulate leaked connections.

Fix in Cursor Fix in Web


/**
* @param {Event} event
* @param {{path: string}} options
* @returns {Promise<void>}
*/
close = async (event, options) => {
const db = this.#databases.get(options.path);
if (db) {
await db.close();
this.#databases.delete(options.path);
}
};

/**
* @param {Event} event
* @param {{path: string}} options
*/
delete = async (event, options) => {
if (this.#databases.has(options.path)) {
await this.close(event, options);
this.#deleteDatabase(options.path);
}
};

/**
* @param {Event} event
* @param {{dbargs: {dbname: string}, executes: Array<{qid: string, sql: string, params: any[]}>}} options
* @returns {Promise<{
* qid: string,
* type: "success" | "error",
* result: string | {
* rowsAffected: number,
* rows: any[]
* }
* }[]>}
*/
backgroundExecuteSqlBatch = async (event, options) => {
const db = this.#databases.get(options.dbargs.dbname);
if (!db) {
throw new Error("Database does not exist");
}

const results = [];
const executes = options.executes;

for (const e of executes) {
const execute = e;
const qid = execute.qid;
const sql = execute.sql;
const params = execute.params;

let resultInfo = { qid };

try {
const { rows, rowsAffected, insertId } = await this.#all(
db,
sql,
params
);

const hasInsertId = rowsAffected > 0 && insertId !== 0;

resultInfo = {
...resultInfo,
type: "success",
result: {
rowsAffected,
rows,
},
};
if (hasInsertId) {
resultInfo.result.insertId = insertId;
}
} catch (err) {
resultInfo = {
...resultInfo,
type: "error",
message: err,
result: err,
};
}

results.push(resultInfo);
}

return results;
};

/**
* @param {AsyncDatabase} db
* @param {string} sql
* @param {any[]} params
* @returns {Promise<{rows: any[], rowsAffected: number, insertId: number}>}
*/
async #all(db, sql, params) {
const statement = await db.prepare(sql);

let result;

if (sql.toLocaleLowerCase().startsWith("select")) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: SELECT detection fails for queries with leading whitespace

The startsWith("select") check fails to identify SELECT queries that have leading whitespace, comments, or use CTEs (e.g., WITH ... SELECT). Such queries would be incorrectly processed using statement.run() instead of statement.all(), causing them to return no rows. A trimmed or regex-based approach would handle these valid SQL patterns correctly.

Fix in Cursor Fix in Web

const all = await statement.all(params);
result = {
rowsAffected: all.changes,
insertId: all.lastID,
rows: all.rows,
};
} else {
const all = await statement.run(params);
result = {
rowsAffected: all.changes,
insertId: all.insertId,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Wrong property name causes undefined insertId for non-SELECT queries

The AsyncStatement.run() method resolves with an object containing lastID and changes properties, but line 153 accesses all.insertId which doesn't exist. This causes insertId to always be undefined for INSERT/UPDATE/DELETE operations. The property should be all.lastID to match the sqlite3 API, consistent with how SELECT queries handle it on line 146.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Wrong property name causes undefined insertId for writes

The #all method accesses all.insertId for non-SELECT queries, but AsyncStatement.run returns an object with lastID property (not insertId). This is inconsistent with the SELECT branch at line 146 which correctly uses all.lastID. As a result, insertId will always be undefined for INSERT/UPDATE/DELETE operations, breaking insert ID tracking functionality.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Wrong property name for insert ID in run result

The #all method accesses all.insertId for non-SELECT queries, but the AsyncStatement.run() method resolves with the sqlite3 context object which exposes the property as lastID, not insertId. The SELECT branch correctly uses all.lastID (line 146), but the non-SELECT branch uses all.insertId, which will always be undefined. This causes insert ID tracking to fail for INSERT/UPDATE/DELETE operations.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Wrong property name causes undefined insertId

The non-SELECT branch of #all accesses all.insertId, but AsyncStatement.run() resolves with the SQLite statement context which has lastID property, not insertId. The SELECT branch correctly uses all.lastID on line 146. This causes insertId to always be undefined for INSERT, UPDATE, and DELETE operations, breaking the ability to get the last inserted row ID.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Wrong property name causes undefined insertId for writes

In the #all method, when handling non-SELECT queries (INSERT, UPDATE, DELETE), the code accesses all.insertId but the AsyncStatement.run method returns the sqlite3 statement context which has a lastID property, not insertId. The SELECT branch at line 146 correctly uses all.lastID. This causes insertId to always be undefined for INSERT statements, breaking functionality that depends on retrieving the last inserted row ID.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Wrong property name causes undefined insertId for writes

The code accesses all.insertId but the sqlite3 callback context returned by AsyncStatement.run() provides lastID, not insertId. This causes insertId to always be undefined for INSERT/UPDATE/DELETE operations. The SELECT branch on line 146 correctly uses all.lastID, but this non-SELECT branch uses the non-existent all.insertId property.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong property name causes undefined insertId for writes

High Severity

The #all method accesses all.insertId for non-SELECT queries, but AsyncStatement.run() returns the sqlite3 callback context which has a lastID property, not insertId. This causes insertId to always be undefined for INSERT, UPDATE, and DELETE operations. The SELECT branch correctly uses all.lastID at line 146, but the non-SELECT branch at line 153 incorrectly uses all.insertId.

Fix in Cursor Fix in Web

};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Missing rows property for non-SELECT query results

For non-SELECT queries, the #all method returns an object without a rows property. When destructured at line 97, rows becomes undefined and is assigned to the result object at line 110. Consumers expecting rows to always be an array will encounter undefined, which could cause runtime errors when iterating over results.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Non-SELECT queries return undefined rows instead of array

The #all method returns a result object without a rows property for non-SELECT queries (INSERT/UPDATE/DELETE), while SELECT queries include rows. When destructured in backgroundExecuteSqlBatch, this causes rows to be undefined for non-SELECT operations. Client code expecting rows to always be an array will fail with TypeError when accessing properties like length or iterating over the result.

Fix in Cursor Fix in Web

}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Type Contract Broken: Non-SELECT Queries Lack Expected Data

Non-SELECT query results are missing the rows property, but the return type annotation (line 135) declares rows as a required property. This causes type mismatch where consumers expect rows to always be present, but it's undefined for INSERT/UPDATE/DELETE operations.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing rows property for non-SELECT query results

Medium Severity

The #all method's non-SELECT branch (lines 150-154) omits the rows property from the result object. When backgroundExecuteSqlBatch destructures rows from this result at line 97, it receives undefined instead of an empty array. This undefined value propagates to the response at line 110, violating the documented return type (rows: any[]) and potentially causing runtime errors if consumers attempt array operations like rows.length or rows.forEach().

Fix in Cursor Fix in Web


await statement.finalize();

return result;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prepared statement leaked when query execution fails

Medium Severity

In the #all method, if statement.all(params) or statement.run(params) throws an error (e.g., constraint violation, type mismatch, or invalid parameters), statement.finalize() at line 157 is never called. The prepared statement remains allocated, leaking resources. In a long-running Electron app with frequent query failures, this accumulates unfinalizable statements.

Fix in Cursor Fix in Web


/**
* @param {string} dbname
*/
#deleteDatabase(dbname) {
const dbPath = path.resolve(path.join(this.#dbLocation, dbname));
if (existsSync(dbPath)) {
unlinkSync(dbPath);
}
}
}

class AsyncDatabase {
/**
* @type {Database}
*/
#db;

/**
* @param {Database} db
*/
constructor(db) {
/**
* @type {Database}
*/
this.#db = db;
}

/**
* @param {string} openPath
* @returns {Promise<AsyncDatabase>}
*/
static newDatabase(openPath) {
return new Promise((resolve, reject) => {
const db = new sqlite3.Database(openPath, (err) => {
if (err) {
reject(err);
} else {
resolve(new AsyncDatabase(db));
}
});
});
}

/**
* @param {string} sql
* @returns {{Promise<RunResult>}}
*/
run(sql) {
return new Promise((resolve, reject) => {
this.#db.run(sql, function (err) {
if (err) {
reject(err);
} else {
resolve(this);
}
});
});
}

/**
* @returns {Promise<void>}
*/
close() {
return new Promise((resolve, reject) => {
this.#db.close((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}

/**
* @param {string} sql
* @returns {AsyncStatement}
*/
prepare(sql) {
return new Promise((resolve, reject) => {
this.#db.prepare(sql, function (err) {
if (err) {
reject(err);
} else {
resolve(new AsyncStatement(this));
}
});
});
}
}

class AsyncStatement {
/**
* @type {Statement}
*/
#statement;

/**
* @param {Statement} statement
*/
constructor(statement) {
this.#statement = statement;
}

/**
* @param {any[]} params
* @returns {Promise<RunResultWithRows>}
*/
all(params) {
return new Promise((resolve, reject) => {
this.#statement.all(params, function (err, rows) {
if (err) {
reject(err);
} else {
resolve({ lastID: this.lastID, changes: this.changes, rows });
}
});
});
}

/**
* @param {any[]} params
* @returns {Promise<RunResult>}
*/
run(params) {
return new Promise((resolve, reject) => {
this.#statement.run(params, function (err) {
if (err) {
reject(err);
} else {
resolve(this);
}
});
});
}

/**
* @returns {Promise<void>}
*/
finalize() {
return new Promise((resolve, reject) => {
this.#statement.finalize((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
}

const sqlite = new SQLite();

export const db = {
main: {
init() {
ipcMain.handle("sqlite:open", sqlite.open);
ipcMain.handle("sqlite:close", sqlite.close);
ipcMain.handle("sqlite:delete", sqlite.delete);
ipcMain.handle(
"sqlite:backgroundExecuteSqlBatch",
sqlite.backgroundExecuteSqlBatch
);
},
},
};
5 changes: 5 additions & 0 deletions electron/preload.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export declare const db: {
preload: {
init(): void;
};
};
Loading