current commit
260926.1 · sqlite: rename DatabaseSync and StatementSync
'use strict';
const { skipIfSQLiteMissing } = require('../common');
skipIfSQLiteMissing();
const tmpdir = require('../common/tmpdir');
const { join } = require('node:path');
const { backup, Database } = require('node:sqlite');
const { suite, test } = require('node:test');

tmpdir.refresh();

const invalidState = {
  code: 'ERR_INVALID_STATE',
  message: /database is not open/,
};

// A property getter on the options bag runs arbitrary JavaScript in the middle
// of the call, so state validated before the options were read can be stale by
// the time it is used.
suite('closing the database from an options getter', () => {
  test('prepare() throws instead of using a closed connection', (t) => {
    const db = new Database(':memory:');
    t.assert.throws(() => {
      db.prepare('SELECT 1', {
        get returnArrays() {
          db.close();
          return false;
        },
      });
    }, invalidState);
  });

  test('function() throws instead of using a closed connection', (t) => {
    const db = new Database(':memory:');
    t.assert.throws(() => {
      db.function('fn', {
        get useBigIntArguments() {
          db.close();
          return false;
        },
      }, () => 1);
    }, invalidState);
  });

  test('function() throws when the length getter closes the database', (t) => {
    const db = new Database(':memory:');
    const fn = () => 1;
    Object.defineProperty(fn, 'length', {
      configurable: true,
      get() {
        db.close();
        return 0;
      },
    });
    t.assert.throws(() => {
      db.function('fn', fn);
    }, invalidState);
  });

  test('aggregate() throws instead of using a closed connection', (t) => {
    const db = new Database(':memory:');
    t.assert.throws(() => {
      db.aggregate('agg', {
        get start() {
          db.close();
          return 0;
        },
        step: (acc, value) => acc,
      });
    }, invalidState);
  });

  test('aggregate() throws when the length getter closes the database', (t) => {
    const db = new Database(':memory:');
    const step = (acc, value) => acc;
    Object.defineProperty(step, 'length', {
      configurable: true,
      get() {
        db.close();
        return 2;
      },
    });
    t.assert.throws(() => {
      db.aggregate('agg', { start: 0, step });
    }, invalidState);
  });

  test('createModule() throws instead of using a closed connection', (t) => {
    const db = new Database(':memory:');
    t.assert.throws(() => {
      db.createModule('mod', {
        columns: [{ name: 'value', type: 'INTEGER' }],
        *rows() {
          yield [1];
        },
        get directOnly() {
          db.close();
          return false;
        },
      });
    }, invalidState);
  });

  test('createModule() throws when a column getter closes the database', (t) => {
    const db = new Database(':memory:');
    t.assert.throws(() => {
      db.createModule('mod', {
        columns: [{
          name: 'value',
          get type() {
            db.close();
            return 'INTEGER';
          },
        }],
        *rows() {
          yield [1];
        },
      });
    }, invalidState);
  });

  test('deserialize() throws instead of using a closed connection', (t) => {
    const source = new Database(':memory:');
    source.exec('CREATE TABLE data(value TEXT)');
    const image = source.serialize();

    const db = new Database(':memory:');
    t.assert.throws(() => {
      db.deserialize(image, {
        get dbName() {
          db.close();
          return 'main';
        },
      });
    }, invalidState);
  });

  test('createSession() throws instead of using a closed connection', (t) => {
    const db = new Database(':memory:');
    t.assert.throws(() => {
      db.createSession({
        get db() {
          db.close();
          return 'main';
        },
      });
    }, invalidState);
  });

  test('applyChangeset() throws instead of using a closed connection', (t) => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
    const session = db.createSession();
    db.exec('INSERT INTO data (key) VALUES (1)');
    const changeset = session.changeset();

    t.assert.throws(() => {
      db.applyChangeset(changeset, {
        get onConflict() {
          db.close();
          return undefined;
        },
      });
    }, invalidState);
  });

  test('backup() throws instead of using a closed connection', (t) => {
    const db = new Database(':memory:');
    t.assert.throws(() => {
      backup(db, join(tmpdir.path, 'getter-backup.db'), {
        get rate() {
          db.close();
          return 1;
        },
      });
    }, invalidState);
  });
});

// The state check runs before the options bag is read, so a call that is
// already doomed must not execute any of the caller's getters.
test('options getters do not run on an already-closed database', (t) => {
  const source = new Database(':memory:');
  source.exec('CREATE TABLE data(key INTEGER PRIMARY KEY)');
  const image = source.serialize();
  const session = source.createSession();
  source.exec('INSERT INTO data (key) VALUES (1)');
  const changeset = session.changeset();

  const cases = {
    prepare: (db, options) => db.prepare('SELECT 1', options),
    function: (db, options) => db.function('fn', options, () => 1),
    aggregate: (db, options) => db.aggregate('agg', options),
    deserialize: (db, options) => db.deserialize(image, options),
    createSession: (db, options) => db.createSession(options),
    applyChangeset: (db, options) => db.applyChangeset(changeset, options),
    backup: (db, options) =>
      backup(db, join(tmpdir.path, 'closed-backup.db'), options),
  };

  // The property each method reads first, and a valid value for it, so that a
  // getter which does run leaves the "did not run" assertion as the failure
  // rather than a type error from the returned value.
  const probes = {
    prepare: ['returnArrays', false],
    function: ['useBigIntArguments', false],
    aggregate: ['start', 0],
    deserialize: ['dbName', 'main'],
    createSession: ['db', 'main'],
    applyChangeset: ['onConflict', undefined],
    backup: ['rate', 1],
  };

  for (const [name, invoke] of Object.entries(cases)) {
    const db = new Database(':memory:');
    db.close();

    const [key, value] = probes[name];
    let ran = false;
    const options = {
      get [key]() {
        ran = true;
        return value;
      },
    };

    t.assert.throws(() => invoke(db, options), invalidState, name);
    t.assert.strictEqual(ran, false, `${name} ran an options getter`);
  }
});

suite('resizing a deserialize() buffer from an options getter', () => {
  test('throws rather than handing uninitialized memory to SQLite', (t) => {
    const source = new Database(':memory:');
    source.exec('CREATE TABLE data(value TEXT)');
    source.prepare('INSERT INTO data (value) VALUES (?)').run('hello');
    const image = source.serialize();

    const buffer = new ArrayBuffer(image.byteLength, {
      maxByteLength: image.byteLength,
    });
    new Uint8Array(buffer).set(image);

    const db = new Database(':memory:');
    t.assert.throws(() => {
      db.deserialize(new Uint8Array(buffer), {
        get dbName() {
          buffer.resize(1024);
          return 'main';
        },
      });
    }, {
      code: 'ERR_INVALID_STATE',
      message: /"buffer" argument was resized/,
    });
  });

  test('throws when the buffer is detached', (t) => {
    const source = new Database(':memory:');
    source.exec('CREATE TABLE data(value TEXT)');
    const image = source.serialize();

    const buffer = new ArrayBuffer(image.byteLength);
    new Uint8Array(buffer).set(image);

    const db = new Database(':memory:');
    t.assert.throws(() => {
      db.deserialize(new Uint8Array(buffer), {
        get dbName() {
          structuredClone(buffer, { transfer: [buffer] });
          return 'main';
        },
      });
    }, {
      code: 'ERR_INVALID_STATE',
      message: /"buffer" argument was resized/,
    });
  });
});