fix(plugins): hold exec scripts to the time budget between statements

The time budget covered query() and tx() only. exec() without bound args
handed the plugin's script to Database#exec, which runs any number of
statements in one call with nothing checked between them, so a long
script held the host loop as long as it liked.

An exec script now runs one statement at a time. sql-script.ts cuts it
after every semicolon outside a string, a quoted identifier or a
comment; a piece SQLite reports as incomplete input (a trigger body cut
at its inner semicolon) is joined with the next until it prepares. The
clock is read after every statement, and past the budget the call
throws. As with Database#exec, the statements before that point stay
applied, and an error is SQLite's own; a transaction the script opened
itself is rolled back so the connection is not left inside it.

exec() with bound args is one statement and stays as it was. A single
statement cannot be bounded at all on the host thread, a write such as
INSERT ... SELECT over a cross join included, and the SDK README and the
wiki now say so next to the cross-join read. migrate() keeps running
unbudgeted on purpose: a migration stopped part of the way is rolled back
and fails the same way on every later start, leaving the plugin unable to
update its schema.

The ctx.db rows of the SDK README and the plugin wiki pages describe the
budget for scripts, and the lines touched there and in plugin-sdk's db
comment lose their em dashes.
This commit is contained in:
Maurice
2026-10-08 18:02:49 +02:00
parent 9dec36995e
commit d3be6fb2b8
9 changed files with 235 additions and 22 deletions
+5 -3
View File
@@ -70,10 +70,12 @@ export interface PluginContext {
settings: {
get(key: string): Promise<unknown>;
};
/** Your OWN sqlite database (`db:own`) — a separate file the plugin never gets a
/** Your OWN sqlite database (`db:own`): a separate file the plugin never gets a
* path or connection to directly. Quota: 256 MB per plugin (writes past it fail);
* result sets are capped at 100,000 rows, and a `query` or `tx` gets 2 s of wall-clock
* time, checked between the rows it reads (past it, it throws and a `tx` rolls back).
* result sets are capped at 100,000 rows, and a `query`, a `tx` or an `exec` script
* without args gets 2 s of wall-clock time, checked between the rows it reads and
* the statements it runs (past it, it throws, a `tx` rolls back and a script stops).
* A single statement and `migrate` are not timed.
* See `tx()` below for the atomic-batch cap and README § Runtime limits. */
db: {
query<T = unknown>(sql: string, ...args: unknown[]): Promise<T[]>;