current commit
260926.1 · sqlite: rename DatabaseSync and StatementSync
// 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
  }
});