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
@@ -0,0 +1,41 @@
/**
* splitAfterSemicolons: where a plugin's exec() script is cut into statements, so
* the time budget can be read between them (plugin-data.service.ts).
*/
import { describe, expect, it } from 'vitest';
import { splitAfterSemicolons } from '../../../src/nest/plugins/host/sql-script';
describe('splitAfterSemicolons', () => {
it('SQLSCRIPT-001 cuts after each statement and keeps every character', () => {
const sql = 'CREATE TABLE a (x); INSERT INTO a VALUES (1);\nSELECT 1';
const pieces = splitAfterSemicolons(sql);
expect(pieces).toEqual(['CREATE TABLE a (x);', ' INSERT INTO a VALUES (1);', '\nSELECT 1']);
expect(pieces.join('')).toBe(sql);
});
it('SQLSCRIPT-002 leaves the semicolons in strings, quoted identifiers and comments alone', () => {
expect(splitAfterSemicolons("INSERT INTO t VALUES ('a;b', 'it''s;');")).toEqual(["INSERT INTO t VALUES ('a;b', 'it''s;');"]);
expect(splitAfterSemicolons('SELECT "x;y", `p;q`, [r;s] FROM t;')).toEqual(['SELECT "x;y", `p;q`, [r;s] FROM t;']);
expect(splitAfterSemicolons('SELECT 1; -- one; two\nSELECT 2;')).toEqual(['SELECT 1;', ' -- one; two\nSELECT 2;']);
expect(splitAfterSemicolons('SELECT 1 /* a; b */;')).toEqual(['SELECT 1 /* a; b */;']);
});
it('SQLSCRIPT-003 cuts inside a trigger body, which the caller joins back up', () => {
expect(splitAfterSemicolons('CREATE TRIGGER t AFTER INSERT ON a BEGIN SELECT 1; END;')).toEqual([
'CREATE TRIGGER t AFTER INSERT ON a BEGIN SELECT 1;',
' END;',
]);
});
it('SQLSCRIPT-004 runs an unterminated string, identifier or comment to the end', () => {
expect(splitAfterSemicolons("SELECT 'open; still")).toEqual(["SELECT 'open; still"]);
expect(splitAfterSemicolons('SELECT [open; still')).toEqual(['SELECT [open; still']);
expect(splitAfterSemicolons('SELECT 1 -- trailing; comment')).toEqual(['SELECT 1 -- trailing; comment']);
expect(splitAfterSemicolons('SELECT 1 /* open; comment')).toEqual(['SELECT 1 /* open; comment']);
});
it('SQLSCRIPT-005 gives nothing for an empty script and a piece per stray semicolon', () => {
expect(splitAfterSemicolons('')).toEqual([]);
expect(splitAfterSemicolons(';;')).toEqual([';', ';']);
});
});