// 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 } });