Files
node/test/parallel/test-sqlite-session.js
T
Guilherme Araújo f2b698f1cf sqlite: rename DatabaseSync and StatementSync
Rename the DatabaseSync and StatementSync classes to Database and
Statement, and the internal DatabaseSyncLimits helper to
DatabaseLimits. The old names are kept as aliases of the new classes
and are Documentation-only deprecated (DEP0210, DEP0211).

Assisted-by: Claude Code
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/65988
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Robert Nagy <ronagy@icloud.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
2026-09-26 00:03:45 +00:00

1050 lines
34 KiB
JavaScript

// Flags: --expose-gc --experimental-sqlite
'use strict';
const { skipIfSQLiteMissing } = require('../common');
skipIfSQLiteMissing();
const {
Database,
constants,
} = require('node:sqlite');
const { it, test, suite } = require('node:test');
const dc = require('node:diagnostics_channel');
const { nextDb } = require('../sqlite/next-db.js');
const { Worker } = require('worker_threads');
const { once } = require('events');
/**
* Convenience wrapper around assert.deepStrictEqual that sets a null
* prototype to the expected object.
* @returns {boolean}
*/
function deepStrictEqual(t) {
return (actual, expected, message) => {
if (Array.isArray(expected)) {
expected = expected.map((obj) => ({ ...obj, __proto__: null }));
} else if (typeof expected === 'object') {
expected = { ...expected, __proto__: null };
}
t.assert.deepStrictEqual(actual, expected, message);
};
}
test('creating and applying a changeset', (t) => {
const createDataTableSql = `
CREATE TABLE data(
key INTEGER PRIMARY KEY,
value TEXT
) STRICT`;
const createDatabase = () => {
const database = new Database(':memory:');
database.exec(createDataTableSql);
return database;
};
const databaseFrom = createDatabase();
const session = databaseFrom.createSession();
const select = 'SELECT * FROM data ORDER BY key';
const insert = databaseFrom.prepare('INSERT INTO data (key, value) VALUES (?, ?)');
insert.run(1, 'hello');
insert.run(2, 'world');
const databaseTo = createDatabase();
t.assert.strictEqual(databaseTo.applyChangeset(session.changeset()), true);
deepStrictEqual(t)(
databaseFrom.prepare(select).all(),
databaseTo.prepare(select).all()
);
});
test('database.createSession() - closed database results in exception', (t) => {
const database = new Database(':memory:');
database.close();
t.assert.throws(() => {
database.createSession();
}, {
name: 'Error',
message: 'database is not open',
});
});
test('session.changeset() - closed database results in exception', (t) => {
const database = new Database(':memory:');
const session = database.createSession();
database.close();
t.assert.throws(() => {
session.changeset();
}, {
name: 'Error',
message: 'database is not open',
});
});
test('session methods - reopened database results in exception', (t) => {
for (const method of ['changeset', 'close']) {
const database = new Database(':memory:');
const session = database.createSession();
database.close();
database.open();
t.assert.throws(() => {
session[method]();
}, {
name: 'Error',
message: 'session is not open',
});
}
});
test('database.applyChangeset() - closed database results in exception', (t) => {
const database = new Database(':memory:');
const session = database.createSession();
const changeset = session.changeset();
database.close();
t.assert.throws(() => {
database.applyChangeset(changeset);
}, {
name: 'Error',
message: 'database is not open',
});
});
test('database.createSession() - use table option to track specific table', (t) => {
const database1 = new Database(':memory:');
const database2 = new Database(':memory:');
const createData1TableSql = `CREATE TABLE data1 (
key INTEGER PRIMARY KEY,
value TEXT
) STRICT
`;
const createData2TableSql = `CREATE TABLE data2 (
key INTEGER PRIMARY KEY,
value TEXT
) STRICT
`;
database1.exec(createData1TableSql);
database1.exec(createData2TableSql);
database2.exec(createData1TableSql);
database2.exec(createData2TableSql);
const session = database1.createSession({
table: 'data1'
});
const insert1 = database1.prepare('INSERT INTO data1 (key, value) VALUES (?, ?)');
insert1.run(1, 'hello');
insert1.run(2, 'world');
const insert2 = database1.prepare('INSERT INTO data2 (key, value) VALUES (?, ?)');
insert2.run(1, 'hello');
insert2.run(2, 'world');
const select1 = 'SELECT * FROM data1 ORDER BY key';
const select2 = 'SELECT * FROM data2 ORDER BY key';
t.assert.strictEqual(database2.applyChangeset(session.changeset()), true);
deepStrictEqual(t)(
database1.prepare(select1).all(),
database2.prepare(select1).all()); // data1 table should be equal
deepStrictEqual(t)(database2.prepare(select2).all(), []); // data2 should be empty in database2
t.assert.strictEqual(database1.prepare(select2).all().length, 2); // data1 should have values in database1
});
suite('conflict resolution', () => {
const createDataTableSql = `CREATE TABLE data (
key INTEGER PRIMARY KEY,
value TEXT UNIQUE
) STRICT`;
const prepareConflict = () => {
const database1 = new Database(':memory:');
const database2 = new Database(':memory:');
database1.exec(createDataTableSql);
database2.exec(createDataTableSql);
const insertSql = 'INSERT INTO data (key, value) VALUES (?, ?)';
const session = database1.createSession();
database1.prepare(insertSql).run(1, 'hello');
database1.prepare(insertSql).run(2, 'foo');
database2.prepare(insertSql).run(1, 'world');
return {
database2,
changeset: session.changeset()
};
};
const prepareDataConflict = () => {
const database1 = new Database(':memory:');
const database2 = new Database(':memory:');
database1.exec(createDataTableSql);
database2.exec(createDataTableSql);
const insertSql = 'INSERT INTO data (key, value) VALUES (?, ?)';
database1.prepare(insertSql).run(1, 'hello');
database2.prepare(insertSql).run(1, 'othervalue');
const session = database1.createSession();
database1.prepare('UPDATE data SET value = ? WHERE key = ?').run('foo', 1);
return {
database2,
changeset: session.changeset()
};
};
const prepareNotFoundConflict = () => {
const database1 = new Database(':memory:');
const database2 = new Database(':memory:');
database1.exec(createDataTableSql);
database2.exec(createDataTableSql);
const insertSql = 'INSERT INTO data (key, value) VALUES (?, ?)';
database1.prepare(insertSql).run(1, 'hello');
const session = database1.createSession();
database1.prepare('DELETE FROM data WHERE key = 1').run();
return {
database2,
changeset: session.changeset()
};
};
const prepareFkConflict = () => {
const database1 = new Database(':memory:');
const database2 = new Database(':memory:');
database1.exec(createDataTableSql);
database2.exec(createDataTableSql);
const fkTableSql = `CREATE TABLE other (
key INTEGER PRIMARY KEY,
ref REFERENCES data(key)
)`;
database1.exec(fkTableSql);
database2.exec(fkTableSql);
const insertDataSql = 'INSERT INTO data (key, value) VALUES (?, ?)';
const insertOtherSql = 'INSERT INTO other (key, ref) VALUES (?, ?)';
database1.prepare(insertDataSql).run(1, 'hello');
database2.prepare(insertDataSql).run(1, 'hello');
database1.prepare(insertOtherSql).run(1, 1);
database2.prepare(insertOtherSql).run(1, 1);
database1.exec('DELETE FROM other WHERE key = 1'); // So we don't get a fk violation in database1
const session = database1.createSession();
database1.prepare('DELETE FROM data WHERE key = 1').run(); // Changeset with fk violation
database2.exec('PRAGMA foreign_keys = ON'); // Needs to be supported, otherwise will fail here
return {
database2,
changeset: session.changeset()
};
};
const prepareConstraintConflict = () => {
const database1 = new Database(':memory:');
const database2 = new Database(':memory:');
database1.exec(createDataTableSql);
database2.exec(createDataTableSql);
const insertSql = 'INSERT INTO data (key, value) VALUES (?, ?)';
const session = database1.createSession();
database1.prepare(insertSql).run(1, 'hello');
database2.prepare(insertSql).run(2, 'hello'); // database2 already contains hello
return {
database2,
changeset: session.changeset()
};
};
test('database.applyChangeset() - SQLITE_CHANGESET_CONFLICT conflict with default behavior (abort)', (t) => {
const { database2, changeset } = prepareConflict();
// When changeset is aborted due to a conflict, applyChangeset should return false
t.assert.strictEqual(database2.applyChangeset(changeset), false);
deepStrictEqual(t)(
database2.prepare('SELECT value from data').all(),
[{ value: 'world' }]); // unchanged
});
test('database.applyChangeset() - SQLITE_CHANGESET_CONFLICT conflict handled with SQLITE_CHANGESET_ABORT', (t) => {
const { database2, changeset } = prepareConflict();
let conflictType = null;
const result = database2.applyChangeset(changeset, {
onConflict: (conflictType_) => {
conflictType = conflictType_;
return constants.SQLITE_CHANGESET_ABORT;
}
});
// When changeset is aborted due to a conflict, applyChangeset should return false
t.assert.strictEqual(result, false);
t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_CONFLICT);
deepStrictEqual(t)(
database2.prepare('SELECT value from data').all(),
[{ value: 'world' }]); // unchanged
});
test('database.applyChangeset() - changeset detached by onConflict', (t) => {
const { database2, changeset } = prepareConflict();
const result = database2.applyChangeset(changeset, {
onConflict: () => {
const transferred = structuredClone(changeset.buffer, {
transfer: [changeset.buffer],
});
new Uint8Array(transferred).fill(0);
return constants.SQLITE_CHANGESET_REPLACE;
}
});
t.assert.strictEqual(result, true);
t.assert.strictEqual(changeset.byteLength, 0);
deepStrictEqual(t)(
database2.prepare('SELECT * FROM data ORDER BY key').all(),
[{ key: 1, value: 'hello' }, { key: 2, value: 'foo' }]);
});
test('database.applyChangeset() - SQLITE_CHANGESET_DATA conflict handled with SQLITE_CHANGESET_REPLACE', (t) => {
const { database2, changeset } = prepareDataConflict();
let conflictType = null;
const result = database2.applyChangeset(changeset, {
onConflict: (conflictType_) => {
conflictType = conflictType_;
return constants.SQLITE_CHANGESET_REPLACE;
}
});
// Not aborted due to conflict, so should return true
t.assert.strictEqual(result, true);
t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_DATA);
deepStrictEqual(t)(
database2.prepare('SELECT value from data ORDER BY key').all(),
[{ value: 'foo' }]); // replaced
});
test('database.applyChangeset() - SQLITE_CHANGESET_NOTFOUND conflict with SQLITE_CHANGESET_OMIT', (t) => {
const { database2, changeset } = prepareNotFoundConflict();
let conflictType = null;
const result = database2.applyChangeset(changeset, {
onConflict: (conflictType_) => {
conflictType = conflictType_;
return constants.SQLITE_CHANGESET_OMIT;
}
});
// Not aborted due to conflict, so should return true
t.assert.strictEqual(result, true);
t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_NOTFOUND);
deepStrictEqual(t)(database2.prepare('SELECT value from data').all(), []);
});
test('database.applyChangeset() - SQLITE_CHANGESET_FOREIGN_KEY conflict', (t) => {
const { database2, changeset } = prepareFkConflict();
let conflictType = null;
const result = database2.applyChangeset(changeset, {
onConflict: (conflictType_) => {
conflictType = conflictType_;
return constants.SQLITE_CHANGESET_OMIT;
}
});
// Not aborted due to conflict, so should return true
t.assert.strictEqual(result, true);
t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_FOREIGN_KEY);
deepStrictEqual(t)(database2.prepare('SELECT value from data').all(), []);
});
test('database.applyChangeset() - SQLITE_CHANGESET_CONSTRAINT conflict', (t) => {
const { database2, changeset } = prepareConstraintConflict();
let conflictType = null;
const result = database2.applyChangeset(changeset, {
onConflict: (conflictType_) => {
conflictType = conflictType_;
return constants.SQLITE_CHANGESET_OMIT;
}
});
// Not aborted due to conflict, so should return true
t.assert.strictEqual(result, true);
t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_CONSTRAINT);
deepStrictEqual(t)(database2.prepare('SELECT key, value from data').all(), [{ key: 2, value: 'hello' }]);
});
test('conflict resolution handler returns invalid value', (t) => {
const invalidHandlers = [
() => -1,
() => ({}),
() => null,
async () => constants.SQLITE_CHANGESET_ABORT,
];
for (const invalidHandler of invalidHandlers) {
const { database2, changeset } = prepareConflict();
t.assert.throws(() => {
database2.applyChangeset(changeset, {
onConflict: invalidHandler
});
}, {
name: 'Error',
message: 'bad parameter or other API misuse',
errcode: 21,
code: 'ERR_SQLITE_ERROR'
}, `Did not throw expected exception when using invalid onConflict handler: ${invalidHandler}`);
}
});
test('conflict resolution handler throws', (t) => {
const { database2, changeset } = prepareConflict();
t.assert.throws(() => {
database2.applyChangeset(changeset, {
onConflict: () => {
throw new Error('some error');
}
});
}, {
name: 'Error',
message: 'some error'
});
});
});
test('filter handler throws', (t) => {
const database1 = new Database(':memory:');
const database2 = new Database(':memory:');
const createTableSql = 'CREATE TABLE data1(key INTEGER PRIMARY KEY); CREATE TABLE data2(key INTEGER PRIMARY KEY);';
database1.exec(createTableSql);
database2.exec(createTableSql);
const session = database1.createSession();
database1.exec('INSERT INTO data1 (key) VALUES (1), (2), (3)');
database1.exec('INSERT INTO data2 (key) VALUES (1), (2), (3), (4), (5)');
t.assert.throws(() => {
database2.applyChangeset(session.changeset(), {
filter: (tableName) => {
throw new Error(`Error filtering table ${tableName}`);
}
});
}, {
name: 'Error',
message: 'Error filtering table data1'
});
t.assert.throws(() => {
database2.exec('CREATE TABLEEEE');
}, {
code: 'ERR_SQLITE_ERROR',
message: /syntax error/,
});
});
test('database.applyChangeset() - changeset detached by filter', (t) => {
const database1 = new Database(':memory:');
const database2 = new Database(':memory:');
database1.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
database2.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
const session = database1.createSession();
database1.exec('INSERT INTO data VALUES (1), (2), (3)');
const changeset = session.changeset();
const result = database2.applyChangeset(changeset, {
filter: () => {
const transferred = structuredClone(changeset.buffer, {
transfer: [changeset.buffer],
});
new Uint8Array(transferred).fill(0);
return true;
}
});
t.assert.strictEqual(result, true);
t.assert.strictEqual(changeset.byteLength, 0);
deepStrictEqual(t)(database2.prepare('SELECT * FROM data').all(), [
{ key: 1 },
{ key: 2 },
{ key: 3 },
]);
});
test('database.applyChangeset() - changeset detached by SQL function', (t) => {
const database1 = new Database(':memory:');
const database2 = new Database(':memory:');
let changeset;
let detached = false;
database1.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, value TEXT)');
database2.function('validate_changeset_value', (value) => {
if (!detached) {
detached = true;
const transferred = structuredClone(changeset.buffer, {
transfer: [changeset.buffer],
});
new Uint8Array(transferred).fill(0);
}
return value.length;
});
database2.exec(`
CREATE TABLE data(
key INTEGER PRIMARY KEY,
value TEXT CHECK (validate_changeset_value(value))
)
`);
const session = database1.createSession();
database1.exec("INSERT INTO data VALUES (1, 'hello'), (2, 'world')");
changeset = session.changeset();
t.assert.strictEqual(database2.applyChangeset(changeset), true);
t.assert.strictEqual(changeset.byteLength, 0);
deepStrictEqual(t)(database2.prepare('SELECT * FROM data').all(), [
{ key: 1, value: 'hello' },
{ key: 2, value: 'world' },
]);
});
test('database.createSession() - filter changes', (t) => {
const database1 = new Database(':memory:');
const database2 = new Database(':memory:');
const createTableSql = 'CREATE TABLE data1(key INTEGER PRIMARY KEY); CREATE TABLE data2(key INTEGER PRIMARY KEY);';
database1.exec(createTableSql);
database2.exec(createTableSql);
const session = database1.createSession();
database1.exec('INSERT INTO data1 (key) VALUES (1), (2), (3)');
database1.exec('INSERT INTO data2 (key) VALUES (1), (2), (3), (4), (5)');
database2.applyChangeset(session.changeset(), {
filter: (tableName) => tableName === 'data2'
});
const data1Rows = database2.prepare('SELECT * FROM data1').all();
const data2Rows = database2.prepare('SELECT * FROM data2').all();
// Expect no rows since all changes were filtered out
t.assert.strictEqual(data1Rows.length, 0);
// Expect 5 rows since these changes were not filtered out
t.assert.strictEqual(data2Rows.length, 5);
});
test('database.createSession() - specify other database', (t) => {
const database = new Database(':memory:');
const session = database.createSession();
const sessionMain = database.createSession({
db: 'main'
});
const sessionTest = database.createSession({
db: 'test'
});
database.exec('CREATE TABLE data (key INTEGER PRIMARY KEY)');
database.exec('INSERT INTO data (key) VALUES (1)');
t.assert.notStrictEqual(session.changeset().length, 0);
t.assert.notStrictEqual(sessionMain.changeset().length, 0);
// Since this session is attached to a different database, its changeset should be empty
t.assert.strictEqual(sessionTest.changeset().length, 0);
});
test('database.createSession() - wrong arguments', (t) => {
const database = new Database(':memory:');
t.assert.throws(() => {
database.createSession(null);
}, {
name: 'TypeError',
message: 'The "options" argument must be an object.'
});
t.assert.throws(() => {
database.createSession({
table: 123
});
}, {
name: 'TypeError',
message: 'The "options.table" argument must be a string.'
});
t.assert.throws(() => {
database.createSession({
db: 123
});
}, {
name: 'TypeError',
message: 'The "options.db" argument must be a string.'
});
});
test('database.applyChangeset() - wrong arguments', (t) => {
const database = new Database(':memory:');
const session = database.createSession();
t.assert.throws(() => {
database.applyChangeset(null);
}, {
name: 'TypeError',
message: 'The "changeset" argument must be a Uint8Array.'
});
t.assert.throws(() => {
database.applyChangeset(session.changeset(), null);
}, {
name: 'TypeError',
message: 'The "options" argument must be an object.'
});
t.assert.throws(() => {
database.applyChangeset(session.changeset(), {
filter: null
}, null);
}, {
name: 'TypeError',
message: 'The "options.filter" argument must be a function.'
});
t.assert.throws(() => {
database.applyChangeset(session.changeset(), {
onConflict: null
}, null);
}, {
name: 'TypeError',
message: 'The "options.onConflict" argument must be a function.'
});
});
test('database.applyChangeset() - malformed changeset returns SQLITE_CORRUPT', {
skip: process.config.variables.node_shared_sqlite ?
'requires the bundled SQLite session fix' : false,
}, (t) => {
const database = new Database(':memory:');
database.exec('CREATE TABLE t1(a INTEGER PRIMARY KEY, b, c, d)');
const changeset = Buffer.from(
'540401000000743100177e0072286565286565',
'hex');
t.assert.throws(() => {
database.applyChangeset(changeset);
}, {
name: 'Error',
message: 'database disk image is malformed',
errcode: 11,
code: 'ERR_SQLITE_ERROR',
});
});
test('session.patchset()', (t) => {
const database = new Database(':memory:');
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, value TEXT)');
database.exec("INSERT INTO data VALUES ('1', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.')");
const session = database.createSession();
database.exec("UPDATE data SET value = 'hi' WHERE key = 1");
const patchset = session.patchset();
const changeset = session.changeset();
t.assert.ok(patchset instanceof Uint8Array);
t.assert.ok(changeset instanceof Uint8Array);
t.assert.deepStrictEqual(patchset, session.patchset());
t.assert.deepStrictEqual(changeset, session.changeset());
t.assert.ok(
patchset.length < changeset.length,
'expected patchset to be smaller than changeset');
});
test('session.close() - using session after close throws exception', (t) => {
const database = new Database(':memory:');
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, value TEXT)');
database.exec("INSERT INTO data VALUES ('1', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.')");
const session = database.createSession();
database.exec("UPDATE data SET value = 'hi' WHERE key = 1");
session.close();
database.exec("UPDATE data SET value = 'world' WHERE key = 1");
t.assert.throws(() => {
session.changeset();
}, {
name: 'Error',
message: 'session is not open'
});
});
test('session.close() - after closing database throws exception', (t) => {
const database = new Database(':memory:');
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, value TEXT)');
database.exec("INSERT INTO data VALUES ('1', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.')");
const session = database.createSession();
database.close();
t.assert.throws(() => {
session.close();
}, {
name: 'Error',
message: 'database is not open'
});
});
test('session.close() - closing twice', (t) => {
const database = new Database(':memory:');
const session = database.createSession();
session.close();
t.assert.throws(() => {
session.close();
}, {
name: 'Error',
message: 'session is not open'
});
});
test('session close and dispose - while generating changes throws exception', (t) => {
for (const close of ['close', Symbol.dispose]) {
for (const method of ['changeset', 'patchset']) {
const database = new Database(':memory:');
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, value TEXT)');
const session = database.createSession({ table: 'data' });
database.exec("INSERT INTO data VALUES (1, 'a'), (2, 'b'), (3, 'c')");
database.setAuthorizer(() => {
session[close]();
return constants.SQLITE_OK;
});
t.assert.throws(() => session[method](), {
code: 'ERR_INVALID_STATE',
message: 'session is currently in use',
});
database.setAuthorizer(null);
t.assert.notStrictEqual(session[method]().length, 0);
}
}
});
test('session[Symbol.dispose]() - closed session is a no-op', () => {
const database = new Database(':memory:');
const session = database.createSession();
session.close();
session[Symbol.dispose]();
});
test('session[Symbol.dispose]() - after closing database is a no-op', () => {
const database = new Database(':memory:');
const session = database.createSession();
database.close();
session[Symbol.dispose]();
});
// SQLite runs "PRAGMA table_xinfo" from inside its pre-update hook, while it is
// still walking the connection's session list. Deleting a session from a
// callback that PRAGMA triggers frees memory the walk is still using, so the
// close has to be rejected instead.
suite('session.close() - from a callback', () => {
const expectedError =
'ERR_INVALID_STATE: session cannot be closed while in a callback';
for (const method of ['close', 'dispose']) {
const closeSession = (session) => {
if (method === 'close') {
session.close();
} else {
session[Symbol.dispose]();
}
};
it(`rejects ${method} from an authorizer callback`, (t) => {
const database = new Database(':memory:');
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
const session = database.createSession();
let outcome = 'callback did not run';
database.setAuthorizer((actionCode, param1) => {
if (actionCode === constants.SQLITE_PRAGMA && param1 === 'table_xinfo') {
try {
closeSession(session);
outcome = 'did not throw';
} catch (err) {
outcome = `${err.code}: ${err.message}`;
}
}
return constants.SQLITE_OK;
});
database.exec('INSERT INTO data VALUES (1)');
t.assert.strictEqual(outcome, expectedError);
// The session survived and kept recording the insert.
database.setAuthorizer(null);
t.assert.notStrictEqual(session.changeset().length, 0);
session.close();
});
it(`rejects ${method} from a 'sqlite.db.query' subscriber`, (t) => {
const database = new Database(':memory:');
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
const session = database.createSession();
let outcome = 'callback did not run';
const handler = ({ sql }) => {
if (sql.includes('table_xinfo')) {
try {
closeSession(session);
outcome = 'did not throw';
} catch (err) {
outcome = `${err.code}: ${err.message}`;
}
}
};
dc.subscribe('sqlite.db.query', handler);
t.after(() => dc.unsubscribe('sqlite.db.query', handler));
database.exec('INSERT INTO data VALUES (1)');
t.assert.strictEqual(outcome, expectedError);
dc.unsubscribe('sqlite.db.query', handler);
t.assert.notStrictEqual(session.changeset().length, 0);
session.close();
});
// Deliberately broader than the crash: the pre-update hook is not on the
// stack here, so this close is safe today. Node cannot tell whether SQLite
// is inside that hook, so every callback is rejected. This pins the
// trade-off rather than leaving it to be discovered as a regression.
it(`rejects ${method} from a user-defined function`, (t) => {
const database = new Database(':memory:');
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
const session = database.createSession();
let outcome = 'callback did not run';
database.function('f', (x) => {
try {
closeSession(session);
outcome = 'did not throw';
} catch (err) {
outcome = `${err.code}: ${err.message}`;
}
return x;
});
database.exec('SELECT f(1)');
t.assert.strictEqual(outcome, expectedError);
// Still closable once the callback is off the stack.
session.close();
t.assert.throws(() => session.close(), { message: 'session is not open' });
});
}
// Rejecting disposal has a cost: a `using` declaration inside a callback
// demotes the block's own error to SuppressedError. Accepted for symmetry
// with Statement's disposal, which throws for a busy statement the same
// way. Pinned here so the trade-off is visible rather than surprising.
it('demotes a callback error when disposal is rejected', (t) => {
const database = new Database(':memory:');
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
let caught;
database.function('f', (x) => {
try {
using session = database.createSession();
t.assert.ok(session);
throw new Error('callback error');
} catch (err) {
caught = err;
}
return x;
});
database.exec('SELECT f(1)');
t.assert.ok(caught instanceof SuppressedError);
t.assert.strictEqual(caught.suppressed.message, 'callback error');
t.assert.strictEqual(
caught.error.message,
'session cannot be closed while in a callback',
);
});
it('leaves an already closed session disposable from a callback', (t) => {
const database = new Database(':memory:');
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
const session = database.createSession();
session.close();
let outcome = 'callback did not run';
database.setAuthorizer(() => {
try {
session[Symbol.dispose]();
outcome = 'no-op';
} catch (err) {
outcome = `${err.code}: ${err.message}`;
}
return constants.SQLITE_OK;
});
database.exec('INSERT INTO data VALUES (1)');
t.assert.strictEqual(outcome, 'no-op');
});
});
test('session - keeps its database alive after the db handle is dropped', async (t) => {
const { gcUntil, onGC } = require('../common/gc');
// The Database handle is created in a nested scope and never referenced
// again, so the returned session is the only thing keeping it reachable.
let dbCollected = false;
const session = (() => {
const database = new Database(':memory:');
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, value TEXT)');
onGC(database, { ongc: () => { dbCollected = true; } });
const s = database.createSession();
database.exec("INSERT INTO data VALUES (1, 'hello')");
return s;
})();
// The session must keep the database alive across GC. Previously it held
// only a weak reference, so the database could be collected and using the
// session afterwards dereferenced a dangling pointer and crashed.
await gcUntil('database is collected', () => dbCollected, 5).then(
() => { throw new Error('session did not keep its database alive'); },
() => {}, // Expected: the database is never collected, so gcUntil rejects.
);
t.assert.strictEqual(dbCollected, false);
// The database is still open and usable through the still-alive session.
const changeset = session.changeset();
t.assert.ok(changeset.byteLength > 0);
session.close();
});
// SQLite runs "PRAGMA table_xinfo" from inside its pre-update hook, while it is
// still walking the connection's session list. Session objects are weak, so a
// GC during a callback that the PRAGMA triggers could collect a session that
// JavaScript no longer references and free memory the walk is still using.
test('session - survives GC during an authorizer callback', (t) => {
const database = new Database(':memory:');
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
database.createSession(); // Never referenced again, so it is collectable.
let ran = false;
database.setAuthorizer((actionCode, param1) => {
if (actionCode === constants.SQLITE_PRAGMA && param1 === 'table_xinfo') {
ran = true;
globalThis.gc();
globalThis.gc();
}
return constants.SQLITE_OK;
});
database.exec('INSERT INTO data VALUES (1)');
t.assert.ok(ran, 'the authorizer callback never ran');
});
test("session - survives GC during a 'sqlite.db.query' subscriber", (t) => {
const dc = require('node:diagnostics_channel');
const database = new Database(':memory:');
database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
database.createSession(); // Never referenced again, so it is collectable.
let ran = false;
const handler = ({ sql }) => {
if (sql.includes('table_xinfo')) {
ran = true;
globalThis.gc();
globalThis.gc();
}
};
dc.subscribe('sqlite.db.query', handler);
t.after(() => dc.unsubscribe('sqlite.db.query', handler));
database.exec('INSERT INTO data VALUES (1)');
t.assert.ok(ran, 'the subscriber never ran');
});
test('session supports ERM', (t) => {
const database = new Database(':memory:');
let afterDisposeSession;
{
using session = database.createSession();
afterDisposeSession = session;
const changeset = session.changeset();
t.assert.ok(changeset instanceof Uint8Array);
t.assert.strictEqual(changeset.length, 0);
}
t.assert.throws(() => afterDisposeSession.changeset(), {
message: /session is not open/,
});
});
test('concurrent applyChangeset with workers', async (t) => {
// Before adding this test, the callbacks were stored in static variables
// this could result in a crash
// this test is a regression test for that scenario
function modeToString(mode) {
if (mode === constants.SQLITE_CHANGESET_ABORT) return 'SQLITE_CHANGESET_ABORT';
if (mode === constants.SQLITE_CHANGESET_OMIT) return 'SQLITE_CHANGESET_OMIT';
}
const dbPath = nextDb();
const db1 = new Database(dbPath);
const db2 = new Database(':memory:');
const createTable = `
CREATE TABLE data(
key INTEGER PRIMARY KEY,
value TEXT
) STRICT`;
db1.exec(createTable);
db2.exec(createTable);
db1.prepare('INSERT INTO data (key, value) VALUES (?, ?)').run(1, 'hello');
db1.close();
const session = db2.createSession();
db2.prepare('INSERT INTO data (key, value) VALUES (?, ?)').run(1, 'world');
const changeset = session.changeset(); // Changeset with conflict (for db1)
const iterations = 10;
for (let i = 0; i < iterations; i++) {
const workers = [];
const expectedResults = new Map([
[constants.SQLITE_CHANGESET_ABORT, false],
[constants.SQLITE_CHANGESET_OMIT, true]]
);
// Launch two workers (abort and omit modes)
for (const mode of [constants.SQLITE_CHANGESET_ABORT, constants.SQLITE_CHANGESET_OMIT]) {
const worker = new Worker(`${__dirname}/../sqlite/worker.js`, {
workerData: {
dbPath,
changeset,
mode
},
});
workers.push(worker);
}
const results = await Promise.all(workers.map(async (worker) => {
const [message] = await once(worker, 'message');
return message;
}));
// Verify each result
for (const res of results) {
if (res.errorMessage) {
if (res.errcode === 5) { // SQLITE_BUSY
break; // ignore
}
t.assert.fail(`Worker error: ${res.error.message}`);
}
const expected = expectedResults.get(res.mode);
t.assert.strictEqual(
res.result,
expected,
`Iteration ${i}: Worker (${modeToString(res.mode)}) expected ${expected} but got ${res.result}`
);
}
workers.forEach((worker) => worker.terminate()); // Cleanup
}
});