// Flags: --experimental-sqlite 'use strict'; require('../common'); const { DatabaseSync, SQLITE_CHANGESET_OMIT, SQLITE_CHANGESET_REPLACE, SQLITE_CHANGESET_ABORT } = require('node:sqlite'); const { test, suite } = require('node:test'); /** * Convenience wrapper around assert.deepStrictEqual that sets a null * prototype to the expected object. */ 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 prepareConflict = () => { const database1 = new DatabaseSync(':memory:'); const database2 = new DatabaseSync(':memory:'); const createDataTableSql = `CREATE TABLE data ( key INTEGER PRIMARY KEY, value TEXT ) STRICT `; 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() }; }; test('database.applyChangeset() - 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() - conflict with SQLITE_CHANGESET_ABORT', (t) => { const { database2, changeset } = prepareConflict(); const result = database2.applyChangeset(changeset, { onConflict: SQLITE_CHANGESET_ABORT }); // When changeset is aborted due to a conflict, applyChangeset should return false t.assert.strictEqual(result, false); deepStrictEqual(t)( database2.prepare('SELECT value from data').all(), [{ value: 'world' }]); // unchanged }); test('database.applyChangeset() - conflict with SQLITE_CHANGESET_REPLACE', (t) => { const { database2, changeset } = prepareConflict(); const result = database2.applyChangeset(changeset, { onConflict: SQLITE_CHANGESET_REPLACE }); // Not aborted due to conflict, so should return true t.assert.strictEqual(result, true); deepStrictEqual(t)( database2.prepare('SELECT value from data ORDER BY key').all(), [{ value: 'hello' }, { value: 'foo' }]); // replaced }); test('database.applyChangeset() - conflict with SQLITE_CHANGESET_OMIT', (t) => { const { database2, changeset } = prepareConflict(); const result = database2.applyChangeset(changeset, { onConflict: SQLITE_CHANGESET_OMIT }); // Not aborted due to conflict, so should return true t.assert.strictEqual(result, true); deepStrictEqual(t)( database2.prepare('SELECT value from data ORDER BY key ASC').all(), [{ value: 'world' }, { value: 'foo' }]); // Conflicting change omitted }); }); test('session related constants are defined', (t) => { t.assert.strictEqual(SQLITE_CHANGESET_OMIT, 0); t.assert.strictEqual(SQLITE_CHANGESET_REPLACE, 1); t.assert.strictEqual(SQLITE_CHANGESET_ABORT, 2); }); 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 number.' }); }); 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' }); });