| // Flags: --experimental-sqlite |
| 'use strict'; |
| const { skipIfSQLiteMissing } = require('../common'); |
| skipIfSQLiteMissing(); |
| const { |
| DatabaseSync, |
| constants, |
| } = require('node:sqlite'); |
| const { test, suite } = require('node:test'); |
| 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('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 constains 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' |
| }); |
| }); |
| |
| 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('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 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 |
| } |
| }); |