Skip to content

BREAKING CHANGE: update: migrate to better-sqlite3#20

Open
UnschooledGamer wants to merge 13 commits intoAcode-Foundation:mainfrom
UnschooledGamer:remove/sqlite3
Open

BREAKING CHANGE: update: migrate to better-sqlite3#20
UnschooledGamer wants to merge 13 commits intoAcode-Foundation:mainfrom
UnschooledGamer:remove/sqlite3

Conversation

@UnschooledGamer
Copy link
Member

@UnschooledGamer UnschooledGamer commented Feb 8, 2026

Why

sqlite3 has received no updates in the past 2-3 years and has blockers on package updating, like tar/node-tar, which has vulnerabilities.

Better-sqlite3 is benchmarked to be faster, better, and more updated in comparison to sqlite3.


I've tested as much as I could; breaking changes could occur because of how the better-sqlite3 API is without callbacks.

To Do

The update schema script needs changes.

@UnschooledGamer UnschooledGamer changed the title update: migrate to better-sqlite3 BREAKING CHANGE: update: migrate to better-sqlite3 Feb 8, 2026
@UnschooledGamer UnschooledGamer marked this pull request as ready for review February 8, 2026 09:17
@UnschooledGamer UnschooledGamer added the enhancement New feature or request label Feb 8, 2026
@greptile-apps
Copy link

greptile-apps bot commented Feb 8, 2026

Greptile Summary

This PR migrates the database layer from sqlite3 (callback-based, unmaintained) to better-sqlite3 (synchronous, actively maintained and faster). All entity methods, API routes, and the schema-update script are updated to use the new synchronous API. Additional improvements include cascade-deletes in Plugin.deletePermanently, a User.delete guard, boolean-to-integer coercion for IS_DEFAULT, and an OTP-present check before email updates.

Issues found:

  • Plugin.getUsersWithPlugin crashes at runtimeEntity.execSql(sql) is called without the required values and entity arguments. stmt.all(...undefined) throws TypeError: undefined is not iterable, and the catch block then throws a second TypeError on entity.table. This breaks the earnings-calculation jobs (fixUserEarnings.js, updateEarnings.js) every time they run.
  • Wrong comment ID returned after insert — In server/apis/comment.js, after inserting a comment the code does Comment.get(columns, [PLUGIN_ID, id], [USER_ID, userId]). The third argument is silently treated as options by Entity.get, dropping the user_id filter. The query returns the first comment for that plugin (by any user), so the id sent back to the client may belong to another user's comment, causing the client to subsequently fetch or mutate the wrong record.

Confidence Score: 2/5

  • Not safe to merge — two runtime bugs will cause silent data corruption and server-side crashes in earnings jobs.
  • The core migration from sqlite3 to better-sqlite3 is well-executed, but two logic bugs introduced in this PR are critical: (1) getUsersWithPlugin will throw a TypeError every time it is invoked, breaking periodic earnings calculations; (2) the comment-insert flow can return a wrong comment ID to the client due to a misuse of the Entity.get API, leading to the client operating on another user's comment record.
  • server/entities/plugin.js (line 166) and server/apis/comment.js (line 149) require fixes before merging.

Important Files Changed

Filename Overview
server/lib/db.js Replaces sqlite3 with better-sqlite3, adds data directory creation, and enables foreign key constraints. Solid migration; fs.mkdirSync does not use { recursive: true } so it would fail if nested directories are missing, but for one level this is fine.
server/entities/entity.js Core ORM updated to use better-sqlite3 synchronous API. Critical issue: Entity.execSql(sql) call in plugin.js's getUsersWithPlugin passes no values or entity, causing a runtime TypeError when spread on undefined. Error path also crashes with entity.table on undefined.
server/entities/plugin.js Adds cascade delete logic for comments/downloads in deletePermanently, and the getUsersWithPlugin method calls Entity.execSql(sql) with no values or entity argument — which will throw a TypeError at runtime when called.
server/entities/user.js Adds a delete override that checks user existence before deleting; also changes single-quote IFNULL strings to template literals (cosmetic). Logic is straightforward and correct.
server/apis/comment.js After insert, fetches the newly-created comment via Comment.get(columns, [PLUGIN_ID, id], [USER_ID, userId]) where the third argument is misinterpreted as options, silently dropping the USER_ID filter and potentially returning the wrong comment's ID.
server/apis/plugin.js Adds default minVersionCode = -1, awaits Order.insert, and stringifies the downloads increment. These are correct changes; the minVersionCode type check becomes a dead branch for the absent-field case, but is still valid for wrong-typed values.
server/apis/user.js OTP validation guard added before email-change path, boolean-to-int fix for IS_DEFAULT, and optional-chaining for OTP row lookup. These are all correct improvements.
server/apis/otp.js OTP is now stringified before being stored to avoid float representation in SQLite. Straightforward and correct.
server/routes/apis.js Migrates /server-time route from callback-based db.all to synchronous db.prepare().get(). Correct usage of the better-sqlite3 API.
updateSchema.js Updated to use better-sqlite3's synchronous exec and close. The Promise wrapper around synchronous calls is unnecessary ceremony but not harmful.

Sequence Diagram

sequenceDiagram
    participant Client
    participant CommentAPI as server/apis/comment.js
    participant Entity as Entity.execSql
    participant DB as better-sqlite3

    Client->>CommentAPI: POST /api/comment (plugin_id, comment, vote)
    CommentAPI->>Entity: Comment.insert(...)
    Entity->>DB: db.prepare(INSERT).run(...values)
    DB-->>Entity: (write result, ignored → [])
    Entity-->>CommentAPI: []

    CommentAPI->>Entity: Comment.get(columns, [PLUGIN_ID, id], [USER_ID, userId])
    Note over Entity: ⚠️ [USER_ID, userId] treated as options,<br/>filter silently dropped
    Entity->>DB: db.prepare(SELECT ... WHERE plugin_id=?).all(id, limit, offset)
    DB-->>Entity: first comment for plugin (may belong to another user)
    Entity-->>CommentAPI: [row]  ← possibly wrong comment

    CommentAPI-->>Client: { id: row.id, comment, vote }  ← id may be wrong
Loading

Last reviewed commit: 465f2ed

greptile-apps[bot]

This comment was marked as outdated.

@greptile-apps

This comment was marked as resolved.

@UnschooledGamer

This comment was marked as outdated.

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

4 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

@greptile-apps

This comment was marked as outdated.

UnschooledGamer and others added 2 commits February 9, 2026 18:38
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
@UnschooledGamer
Copy link
Member Author

@greptileai Review it again.

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

10 files reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

@greptile-apps
Copy link

greptile-apps bot commented Feb 9, 2026

Additional Comments (1)

server/apis/plugin.js
Missing await on insert

In the paid-download flow, Order.insert(...) is called without await (and without handling the returned Promise). With the new DB layer being synchronous under the hood but still wrapped in Promises, this means the request can proceed (and later logic can observe no order) before the insert completes, and errors from the insert will be unhandled. Make this await Order.insert(...) (or otherwise handle/rethrow failures).

@UnschooledGamer
Copy link
Member Author

@greptileai Review it again.

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

5 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +360 to 364
// (if needed, in future) `result` could be set as { rows: [], changes: ..., lastInsertRowid: ... }
// for now, we'll just set it as an empty array
stmt.run(...values);
result = [];
}
Copy link

Choose a reason for hiding this comment

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

Write operations (INSERT/UPDATE/DELETE) now return empty array instead of the actual result info (changes, lastInsertRowid). If callers ever need the insert ID or affected row count, they won't have access to it. This could break code that relies on these values.

Copy link
Member Author

Choose a reason for hiding this comment

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

This can be handled in future if needed, Currently it returns empty array for compatibility

@deadlyjack
Copy link
Member

@greptileai Review changes.

@deadlyjack
Copy link
Member

deadlyjack commented Feb 17, 2026

@greptileai review again

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

15 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

updateVoteInPlugin(vote, pluginId);
}

const [row] = await Comment.get([Comment.ID, Comment.COMMENT, Comment.VOTE], [Comment.PLUGIN_ID, pluginId], [Comment.USER_ID, loggedInUser.id]);
Copy link

Choose a reason for hiding this comment

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

Third argument silently treated as options, dropping the USER_ID filter

Entity.get(columns, where, options) takes exactly three positional parameters. Here, [Comment.USER_ID, loggedInUser.id] is passed as the third argument (options), not as an additional WHERE clause. The get method will destructure { page, limit, orderBy, operator } from this array — all of which will be undefined and fall back to defaults — while the user_id filter is completely ignored.

As a result the query only filters by plugin_id, so row will be the first comment on that plugin (in default sort order), not necessarily the one just inserted by the current user. The id sent back to the client in res.send({ ..., id: row.id, ... }) could belong to a different user's comment, causing the client to display or later modify the wrong comment.

The correct way to filter by multiple columns is to pass an array of where-clause tuples as the second argument:

Suggested change
const [row] = await Comment.get([Comment.ID, Comment.COMMENT, Comment.VOTE], [Comment.PLUGIN_ID, pluginId], [Comment.USER_ID, loggedInUser.id]);
const [row] = await Comment.get([Comment.ID, Comment.COMMENT, Comment.VOTE], [
[Comment.PLUGIN_ID, pluginId],
[Comment.USER_ID, loggedInUser.id],
]);

@greptile-apps
Copy link

greptile-apps bot commented Feb 17, 2026

Additional Comments (1)

server/entities/plugin.js
execSql called without required values and entity arguments

Entity.execSql(sql) is called with only one argument. Inside execSql, values is undefined and entity is undefined. When the query runs, stmt.all(...values) will throw TypeError: undefined is not iterable. Additionally, if an error is caught, entity.table will itself throw TypeError: Cannot read properties of undefined, masking the original error and crashing the server.

This will break the earnings calculation jobs in fixUserEarnings.js and updateEarnings.js, both of which call Plugin.getUsersWithPlugin().

    return Entity.execSql(sql, [], this);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments