mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
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 the PRAGMA triggers frees memory that walk is still using. Both an authorizer callback and a 'sqlite.db.query' subscriber reach that window, and either one segfaults. Reject session.close() and Symbol.dispose when the connection is inside any callback. An already-closed session stays a no-op so that disposal remains idempotent. Fixes: https://github.com/nodejs/node/issues/65428 Signed-off-by: Trevor Burnham <trevorburnham@gmail.com> PR-URL: https://github.com/nodejs/node/pull/65454 Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
966 lines
32 KiB
JavaScript
966 lines
32 KiB
JavaScript
// Flags: --expose-gc --experimental-sqlite
|
|
'use strict';
|
|
const { skipIfSQLiteMissing } = require('../common');
|
|
skipIfSQLiteMissing();
|
|
const {
|
|
DatabaseSync,
|
|
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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':memory:');
|
|
const database2 = new DatabaseSync(':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 DatabaseSync(':memory:');
|
|
const database2 = new DatabaseSync(':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 DatabaseSync(':memory:');
|
|
const database2 = new DatabaseSync(':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 DatabaseSync(':memory:');
|
|
const database2 = new DatabaseSync(':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 DatabaseSync(':memory:');
|
|
const database2 = new DatabaseSync(':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 DatabaseSync(':memory:');
|
|
const database2 = new DatabaseSync(':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() - 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 DatabaseSync(':memory:');
|
|
const database2 = new DatabaseSync(':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.createSession() - filter changes', (t) => {
|
|
const database1 = new DatabaseSync(':memory:');
|
|
const database2 = new DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':memory:');
|
|
const session = database.createSession();
|
|
session.close();
|
|
|
|
session[Symbol.dispose]();
|
|
});
|
|
|
|
test('session[Symbol.dispose]() - after closing database is a no-op', () => {
|
|
const database = new DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 StatementSync'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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync 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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(dbPath);
|
|
const db2 = new DatabaseSync(':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
|
|
}
|
|
});
|