update the abstraction extension model
authorJustin Wind <justin.wind+git@gmail.com>
Thu, 11 Jan 2024 19:34:47 +0000 (11:34 -0800)
committerJustin Wind <justin.wind+git@gmail.com>
Thu, 11 Jan 2024 19:52:15 +0000 (11:52 -0800)
15 files changed:
README.md
index.js
lib/factory.js
lib/postgres-creator.js [new file with mode: 0644]
lib/postgres.js [deleted file]
lib/sqlite-creator.js [new file with mode: 0644]
lib/sqlite.js [deleted file]
test-integration/abstract.js [new file with mode: 0644]
test-integration/postgres/index.js
test-integration/sqlite/index.js
test/helpers.js
test/integration.js
test/lib/factory.js
test/lib/postgres-creator.js [moved from test/lib/postgres.js with 86% similarity]
test/lib/sqlite-creator.js [moved from test/lib/sqlite.js with 98% similarity]

index 568c26d9f012643d3a6a0e24edc184ec21490b1e..fcbcc9b7be2ce6a76d77a0acd38e6b4f450e32b1 100644 (file)
--- a/README.md
+++ b/README.md
@@ -8,3 +8,8 @@ Helper classes, utilities, and opinions for supporting multiple database engines
 - A simple automated schema migration system is provided.
 
 See files in `test-integration` for example implementation.
+
+Your database interface is defined by extending the Abstract class.
+
+Your database engines are implemented by extending a class created by passing your abstract interface class to an engine class creator function.
+
index 6a55f64fa5e576c30d18f352840444774c39a747..2aeb924a19a82398a1647b5658193bf690995c86 100644 (file)
--- a/index.js
+++ b/index.js
@@ -4,14 +4,14 @@ const Abstract = require('./lib/abstract');
 const Errors = require('./lib/errors');
 const Factory = require('./lib/factory');
 const SchemaVersionHelper = require('./lib/schema-version-helper');
-const Postgres = require('./lib/postgres');
-const SQLite = require('./lib/sqlite');
+const PostgresCreator = require('./lib/postgres-creator');
+const SQLiteCreator = require('./lib/sqlite-creator');
 
 module.exports = {
   Abstract,
   Errors,
   Factory,
   SchemaVersionHelper,
-  Postgres,
-  SQLite,
-};
\ No newline at end of file
+  SQLiteCreator,
+  PostgresCreator,
+};
index d232a7ca0b713062de577cb3898627749442e4c7..acaf47f697e8de9a580fc8ea197dff0022f0ae35 100644 (file)
@@ -14,11 +14,12 @@ class DatabaseFactory {
    * @param  {...any} engineArguments 
    * @returns {Database}
    */
-  constructor(logger, options, ...engineArguments) {
+  constructor(Abstract, logger, options, ...engineArguments) {
     const connectionString = options.db.connectionString || '';
     const protocol = connectionString.slice(0, connectionString.indexOf('://')).toLowerCase();
 
-    const Engine = DatabaseFactory.requireEngine(protocol);
+    const EngineCreator = DatabaseFactory.requireEngineCreator(protocol);
+    const Engine = EngineCreator(Abstract);
     return new Engine(logger, options, ...engineArguments);
   }
 
@@ -28,14 +29,14 @@ class DatabaseFactory {
    * @param {String} protocol
    * @returns {Object}
    */
-  static requireEngine(protocol) {
+  static requireEngineCreator(protocol) {
     const Engines = DatabaseFactory.Engines;
     switch (protocol) {
       case Engines.PostgreSQL:
-        return require('./postgres');
+        return require('./postgres-creator');
 
       case Engines.SQLite:
-        return require('./sqlite');
+        return require('./sqlite-creator');
 
       default:
         throw new UnsupportedEngine(protocol);
diff --git a/lib/postgres-creator.js b/lib/postgres-creator.js
new file mode 100644 (file)
index 0000000..29c6fe6
--- /dev/null
@@ -0,0 +1,292 @@
+'use strict';
+
+const PGPromise = require('pg-promise');
+const path = require('node:path');
+const DBErrors = require('./errors');
+const { unappliedSchemaVersions } = require('./schema-version-helper');
+const { fileScope } = require('@squeep/log-helper');
+
+const _fileScope = fileScope(__filename);
+
+const PGTypeId = {
+  INT8: 20, // Type Id 20 == INT8 (BIGINT)
+  INT8Array: 1016, // Type Id 1016 == INT8[] (BIGINT[])
+};
+
+const PostgresCreator = (Abstract) => {
+
+  class DatabasePostgres extends Abstract {
+    _pgpInitOptions = {
+      capSQL: true,
+    };
+
+
+    // eslint-disable-next-line class-methods-use-this
+    get schemaVersionsSupported() {
+      return {
+        min: {
+          major: 0,
+          minor: 0,
+          patch: 0,
+        },
+        max: {
+          major: 0,
+          minor: 0,
+          patch: 0,
+        },
+      };
+    }
+
+
+    /**
+     * 
+     * @param {ConsoleLike} logger
+     * @param {Object} options
+     * @param {Object} options.db
+     * @param {String} options.db.connectionString
+     * @param {String} options.db.queryLogLevel
+     * @param {Boolean} options.db.noWarnings set when running tests
+     * @param {} options.db.
+     * @param {*} pgpStub for tests
+     */
+    constructor(logger, options, pgpStub) {
+      super(logger, options);
+
+      // Set up 
+      this._pgpInitOptionsConfigure(options.db);
+
+      // istanbul ignore next
+      this._pgp = pgpStub || PGPromise(this._pgpInitOptions);
+
+      // Return native BigInts
+      this._pgp.pg.types.setTypeParser(PGTypeId.INT8, BigInt); // Type Id 20 = INT8 (BIGINT)
+      const parseBigIntArray = this._pgp.pg.types.getTypeParser(PGTypeId.INT8Array); // Type Id 1016 = INT8[] (BIGINT[])
+      this._pgp.pg.types.setTypeParser(PGTypeId.INT8Array, (a) => parseBigIntArray(a).map(BigInt));
+
+      // Establish db connection
+      this.db = this._pgp(options.db.connectionString);
+
+      // Suppress QueryFile warnings when running tests
+      this.noWarnings = options.db.noWarnings;
+    }
+
+
+    async initialize(sqlPath = __dirname, applyMigrations = true) {
+      const _scope = _fileScope('initialize');
+      this.logger.debug(_scope, 'called', { applyMigrations });
+      if (applyMigrations) {
+        await this._initTables(sqlPath);
+      }
+      this._initStatements(sqlPath);
+      await super.initialize();
+    }
+
+
+    async _tableExists(tableName, schema) {
+      if (schema) {
+        return this.db.oneOrNone('SELECT table_name FROM information_schema.tables WHERE table_name=$(tableName) AND table_schema=$(schema)', { tableName, schema });
+      } else {
+        return this.db.oneOrNone('SELECT table_name FROM information_schema.tables WHERE table_name=$(tableName)', { tableName });
+      }
+    }
+
+    /**
+     * Create schema version tracking table and other common stuff.
+     */
+    async _createMetaVersionTable() {
+      const schema = this.pgSchema ? `CREATE SCHEMA ${this.pgSchema};
+    SET SCHEMA '${this.pgSchema}';` : '';
+      return await this.db.multiResult(`BEGIN;
+CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+${schema}
+CREATE TABLE IF NOT EXISTS _meta_schema_version (
+  major BIGINT NOT NULL,
+  minor BIGINT NOT NULL,
+  patch BIGINT NOT NULL,
+  applied TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+  PRIMARY KEY (major, minor, patch)
+);
+INSERT INTO _meta_schema_version (major, minor, patch) VALUES (0, 0, 0);
+COMMIT;`);
+    }
+
+
+    async _initTables(sqlPath = __dirname) {
+      const _scope = _fileScope('_initTables');
+      this.logger.debug(_scope, 'called', {});
+
+      const _queryFile = this._queryFileHelper();
+
+      // Migrations rely upon this table, ensure it exists.
+      const metaVersionTable = '_meta_schema_version';
+
+      let metaExists = await this._tableExists(metaVersionTable, this.pgSchema);
+      if (!metaExists) {
+        const results = await this._createMetaVersionTable();
+        this.logger.debug(_scope, 'created meta version table', { results });
+        metaExists = await this._tableExists(metaVersionTable, this.pgSchema);
+        /* istanbul ignore if */
+        if (!metaExists) {
+          throw new DBErrors.UnexpectedResult(`did not create ${metaVersionTable} table`);
+        }
+        this.logger.info(_scope, 'created schema version table', { metaVersionTable });
+      }
+
+      // Apply migrations
+      const currentSchema = await this._currentSchema();
+      const migrationsWanted = unappliedSchemaVersions(sqlPath, currentSchema, this.schemaVersionsSupported);
+      this.logger.debug(_scope, 'schema migrations wanted', { migrationsWanted });
+      for (const v of migrationsWanted) {
+        const fPath = path.join(sqlPath, 'sql', 'schema', v, 'apply.sql');
+        const migrationSql = _queryFile(fPath);
+        const results = await this.db.multiResult(migrationSql);
+        this.logger.debug(_scope, 'executed migration sql', { version: v, results });
+        this.logger.info(_scope, 'applied migration', { version: v });
+      }
+    }
+
+
+    /**
+     * Configure the pgp init options object with assorted logging hooks,
+     * and converting returned column names from snake_case to camelCase.
+     * @param {Object} options
+     * @param {String} options.queryLogLevel
+     */
+    _pgpInitOptionsConfigure({ queryLogLevel }) {
+      // Log errors
+      const errorScope = _fileScope('pgp:error');
+      const errorLogger = this.logger.error;
+
+      this._pgpInitOptions.error = (err, event) => {
+        errorLogger(errorScope, '', { err, event });
+      };
+
+      // Log queries
+      let queryLogger;
+      if (queryLogLevel) {
+        const queryScope = _fileScope('pgp:query');
+        queryLogger = this.logger[queryLogLevel]; // eslint-disable-line security/detect-object-injection
+        this._pgpInitOptions.query = (event) => {
+          queryLogger(queryScope, '', {
+            query: event?.query,
+            params: event?.params,
+          });
+        };
+      }
+
+      // Deophidiate column names in-place, log results
+      const resultScope = _fileScope('pgp:result');
+      this._pgpInitOptions.receive = ({ data, result, ctx: event }) => {
+        const exemplaryRow = data[0];
+        for (const prop in exemplaryRow) {
+          const camel = Abstract._camelfy(prop);
+          if (!(camel in exemplaryRow)) {
+            for (const d of data) {
+              d[camel] = d[prop]; // eslint-disable-line security/detect-object-injection
+              delete d[prop]; // eslint-disable-line security/detect-object-injection
+            }
+          }
+        }
+        if (queryLogLevel) {
+          // Omitting .rows
+          queryLogger(resultScope, '', {
+            query: event?.query,
+            command: result?.command,
+            rowCount: result?.rowCount,
+            duration: result?.duration,
+          });
+        }
+      };
+    }
+
+
+    /**
+     * Wrap pgp QueryFile to impose settings and log errors.
+     * @returns {QueryFile}
+     */
+    _queryFileHelper() {
+      return (file) => {
+        const _scope = _fileScope('_queryFile');
+        /* istanbul ignore next */
+        const qfParams = {
+          minify: true,
+          ...(this.noWarnings && { noWarnings: this.noWarnings }),
+        };
+        const qf = new this._pgp.QueryFile(file, qfParams);
+        if (qf.error) {
+          this.logger.error(_scope, 'failed to create SQL statement', { error: qf.error, file });
+          throw qf.error;
+        }
+        return qf;
+      };
+    }
+
+
+    /**
+     * Load and prepare SQL from files.
+     */
+    _initStatements(sqlPath = __dirname) {
+      const _scope = _fileScope('_initStatements');
+      const _queryFile = this._queryFileHelper();
+      this.statement = this._pgp.utils.enumSql(path.join(sqlPath, 'sql'), {}, _queryFile);
+      this.logger.debug(_scope, 'statements initialized', { statements: Object.keys(this.statement).length });
+    }
+
+
+    async _currentSchema() {
+      return this.db.one(`SELECT major, minor, patch FROM ${this.pgSchema ? (this.pgSchema + '.') : ''}_meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1`);
+    }
+
+
+    async context(fn) {
+      return this.db.task(async (t) => fn(t));
+    }
+
+
+    // eslint-disable-next-line class-methods-use-this
+    async transaction(dbCtx, fn) {
+      return dbCtx.txIf(async (t) => fn(t));
+    }
+
+
+    async healthCheck() {
+      const _scope = _fileScope('healthCheck');
+      this.logger.debug(_scope, 'called', {});
+      const c = await this.db.connect();
+      c.done();
+      return { serverVersion: c.client.serverVersion };
+    }
+
+
+    async _closeConnection() {
+      const _scope = _fileScope('_closeConnection');
+      try {
+        await this._pgp.end();
+      } catch (e) {
+        this.logger.error(_scope, 'failed', { error: e });
+        throw e;
+      }
+    }
+
+
+    /* istanbul ignore next */
+    async _purgeTables(really = false) {
+      const _scope = _fileScope('_purgeTables');
+      try {
+        if (really) {
+          await this.db.tx(async (t) => {
+            await t.batch(this._tableNames.map(async (table) => t.query('TRUNCATE TABLE $(table:name) CASCADE', { table })));
+          });
+        }
+      } catch (e) {
+        this.logger.error(_scope, 'failed', { error: e });
+        throw e;
+      }
+    }
+
+  }
+  return DatabasePostgres;
+};
+
+module.exports = PostgresCreator;
\ No newline at end of file
diff --git a/lib/postgres.js b/lib/postgres.js
deleted file mode 100644 (file)
index d0e4091..0000000
+++ /dev/null
@@ -1,282 +0,0 @@
-'use strict';
-
-const PGPromise = require('pg-promise');
-const path = require('node:path');
-const Database = require('./abstract');
-const DBErrors = require('./errors');
-const { unappliedSchemaVersions } = require('./schema-version-helper');
-const { fileScope } = require('@squeep/log-helper');
-
-const _fileScope = fileScope(__filename);
-
-const PGTypeId = {
-  INT8: 20, // Type Id 20 == INT8 (BIGINT)
-  INT8Array: 1016, // Type Id 1016 == INT8[] (BIGINT[])
-};
-
-class DatabasePostgres extends Database {
-  _pgpInitOptions = {
-    capSQL: true,
-  };
-
-
-  // eslint-disable-next-line class-methods-use-this
-  get schemaVersionsSupported() {
-    return {
-      min: {
-        major: 0,
-        minor: 0,
-        patch: 0,
-      },
-      max: {
-        major: 0,
-        minor: 0,
-        patch: 0,
-      },
-    };
-  }
-
-
-  /**
-   * 
-   * @param {ConsoleLike} logger
-   * @param {Object} options
-   * @param {Object} options.db
-   * @param {String} options.db.connectionString
-   * @param {String} options.db.queryLogLevel
-   * @param {Boolean} options.db.noWarnings set when running tests
-   * @param {} options.db.
-   * @param {*} pgpStub for tests
-   */
-  constructor(logger, options, pgpStub) {
-    super(logger, options);
-
-    // Set up 
-    this._pgpInitOptionsConfigure(options.db);
-
-    // istanbul ignore next
-    this._pgp = pgpStub || PGPromise(this._pgpInitOptions);
-
-    // Return native BigInts
-    this._pgp.pg.types.setTypeParser(PGTypeId.INT8, BigInt); // Type Id 20 = INT8 (BIGINT)
-    const parseBigIntArray = this._pgp.pg.types.getTypeParser(PGTypeId.INT8Array); // Type Id 1016 = INT8[] (BIGINT[])
-    this._pgp.pg.types.setTypeParser(PGTypeId.INT8Array, (a) => parseBigIntArray(a).map(BigInt));
-
-    // Establish db connection
-    this.db = this._pgp(options.db.connectionString);
-
-    // Suppress QueryFile warnings when running tests
-    this.noWarnings = options.db.noWarnings;
-  }
-
-
-  async initialize(sqlPath = __dirname, applyMigrations = true) {
-    const _scope = _fileScope('initialize');
-    this.logger.debug(_scope, 'called', { applyMigrations });
-    if (applyMigrations) {
-      await this._initTables(sqlPath);
-    }
-    this._initStatements(sqlPath);
-    await super.initialize();
-  }
-
-
-  async _tableExists(tableName) {
-    return this.db.oneOrNone('SELECT table_name FROM information_schema.tables WHERE table_name=$(tableName)', { tableName });
-  }
-
-  /**
-   * Create schema version tracking table and other common stuff.
-   */
-  async _createMetaVersionTable() {
-    return await this.db.multiResult(`BEGIN;
-CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-CREATE TABLE IF NOT EXISTS _meta_schema_version (
-  major BIGINT NOT NULL,
-  minor BIGINT NOT NULL,
-  patch BIGINT NOT NULL,
-  applied TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
-  PRIMARY KEY (major, minor, patch)
-);
-INSERT INTO _meta_schema_version (major, minor, patch) VALUES (0, 0, 0);
-COMMIT;`);
-  }
-
-
-  async _initTables(sqlPath = __dirname) {
-    const _scope = _fileScope('_initTables');
-    this.logger.debug(_scope, 'called', {});
-
-    const _queryFile = this._queryFileHelper();
-
-    // Migrations rely upon this table, ensure it exists.
-    const metaVersionTable = '_meta_schema_version';
-
-    let metaExists = await this._tableExists(metaVersionTable);
-    if (!metaExists) {
-      const results = await this._createMetaVersionTable();
-      this.logger.debug(_scope, 'created meta version table', { results });
-      metaExists = await this._tableExists(metaVersionTable);
-      /* istanbul ignore if */
-      if (!metaExists) {
-        throw new DBErrors.UnexpectedResult(`did not create ${metaVersionTable} table`);
-      }
-      this.logger.info(_scope, 'created schema version table', { metaVersionTable });
-    }
-
-    // Apply migrations
-    const currentSchema = await this._currentSchema();
-    const migrationsWanted = unappliedSchemaVersions(sqlPath, currentSchema, this.schemaVersionsSupported);
-    this.logger.debug(_scope, 'schema migrations wanted', { migrationsWanted });
-    for (const v of migrationsWanted) {
-      const fPath = path.join(sqlPath, 'sql', 'schema', v, 'apply.sql');
-      const migrationSql = _queryFile(fPath);
-      const results = await this.db.multiResult(migrationSql);
-      this.logger.debug(_scope, 'executed migration sql', { version: v, results });
-      this.logger.info(_scope, 'applied migration', { version: v });
-    }
-  }
-
-
-  /**
-   * Configure the pgp init options object with assorted logging hooks,
-   * and converting returned column names from snake_case to camelCase.
-   * @param {Object} options
-   * @param {String} options.queryLogLevel
-   */
-  _pgpInitOptionsConfigure({ queryLogLevel }) {
-    // Log errors
-    const errorScope = _fileScope('pgp:error');
-    const errorLogger = this.logger.error;
-
-    this._pgpInitOptions.error = (err, event) => {
-      errorLogger(errorScope, '', { err, event });
-    };
-
-    // Log queries
-    let queryLogger;
-    if (queryLogLevel) {
-      const queryScope = _fileScope('pgp:query');
-      queryLogger = this.logger[queryLogLevel]; // eslint-disable-line security/detect-object-injection
-      this._pgpInitOptions.query = (event) => {
-        queryLogger(queryScope, '', {
-          query: event?.query,
-          params: event?.params,
-        });
-      };
-    }
-
-    // Deophidiate column names in-place, log results
-    const resultScope = _fileScope('pgp:result');
-    this._pgpInitOptions.receive = ({ data, result, ctx: event }) => {
-      const exemplaryRow = data[0];
-      for (const prop in exemplaryRow) {
-        const camel = Database._camelfy(prop);
-        if (!(camel in exemplaryRow)) {
-          for (const d of data) {
-            d[camel] = d[prop]; // eslint-disable-line security/detect-object-injection
-            delete d[prop]; // eslint-disable-line security/detect-object-injection
-          }
-        }
-      }
-      if (queryLogLevel) {
-        // Omitting .rows
-        queryLogger(resultScope, '', {
-          query: event?.query,
-          command: result?.command,
-          rowCount: result?.rowCount,
-          duration: result?.duration,
-        });
-      }
-    };
-  }
-
-
-  /**
-   * Wrap pgp QueryFile to impose settings and log errors.
-   * @returns {QueryFile}
-   */
-  _queryFileHelper() {
-    return (file) => {
-      const _scope = _fileScope('_queryFile');
-      /* istanbul ignore next */
-      const qfParams = {
-        minify: true,
-        ...(this.noWarnings && { noWarnings: this.noWarnings }),
-      };
-      const qf = new this._pgp.QueryFile(file, qfParams);
-      if (qf.error) {
-        this.logger.error(_scope, 'failed to create SQL statement', { error: qf.error, file });
-        throw qf.error;
-      }
-      return qf;
-    };
-  }
-
-
-  /**
-   * Load and prepare SQL from files.
-   */
-  _initStatements(sqlPath = __dirname) {
-    const _scope = _fileScope('_initStatements');
-    const _queryFile = this._queryFileHelper();
-    this.statement = this._pgp.utils.enumSql(path.join(sqlPath, 'sql'), {}, _queryFile);
-    this.logger.debug(_scope, 'statements initialized', { statements: Object.keys(this.statement).length });
-  }
-
-
-  async _currentSchema() {
-    return this.db.one('SELECT major, minor, patch FROM _meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1');
-  }
-
-
-  async context(fn) {
-    return this.db.task(async (t) => fn(t));
-  }
-
-
-  // eslint-disable-next-line class-methods-use-this
-  async transaction(dbCtx, fn) {
-    return dbCtx.txIf(async (t) => fn(t));
-  }
-
-
-  async healthCheck() {
-    const _scope = _fileScope('healthCheck');
-    this.logger.debug(_scope, 'called', {});
-    const c = await this.db.connect();
-    c.done();
-    return { serverVersion: c.client.serverVersion };
-  }
-
-
-  async _closeConnection() {
-    const _scope = _fileScope('_closeConnection');
-    try {
-      await this._pgp.end();
-    } catch (e) {
-      this.logger.error(_scope, 'failed', { error: e });
-      throw e;
-    }
-  }
-
-  
-  /* istanbul ignore next */
-  async _purgeTables(really = false) {
-    const _scope = _fileScope('_purgeTables');
-    try {
-      if (really) {
-        await this.db.tx(async (t) => {
-          await t.batch(this._tableNames.map(async (table) => t.query('TRUNCATE TABLE $(table:name) CASCADE', { table })));
-        });
-      }
-    } catch (e) {
-      this.logger.error(_scope, 'failed', { error: e });
-      throw e;
-    }
-  }
-
-}
-
-module.exports = DatabasePostgres;
diff --git a/lib/sqlite-creator.js b/lib/sqlite-creator.js
new file mode 100644 (file)
index 0000000..bd3fc44
--- /dev/null
@@ -0,0 +1,316 @@
+'use strict';
+
+const SQLite = require('better-sqlite3');
+const fs = require('fs');
+const path = require('path');
+const { performance } = require('perf_hooks');
+const Database = require('./abstract');
+const DBErrors = require('./errors');
+const { unappliedSchemaVersions } = require('./schema-version-helper');
+const { fileScope } = require('@squeep/log-helper');
+
+const _fileScope = fileScope(__filename);
+
+const SQLiteCreator = (Abstract) => {
+
+  class DatabaseSQLite extends Abstract {
+    // eslint-disable-next-line class-methods-use-this
+    get schemaVersionsSupported() {
+      return {
+        min: {
+          major: 0,
+          minor: 0,
+          patch: 0,
+        },
+        max: {
+          major: 0,
+          minor: 0,
+          patch: 0,
+        },
+      };
+    }
+
+    /**
+     * 
+     * @param {ConsoleLike} logger
+     * @param {Object} options
+     * @param {Object} options.db
+     * @param {String} options.db.connectionString
+     * @param {String=} options.db.queryLogLevel
+     * @param {BigInt=} options.db.sqliteOptimizeAfterChanges
+     */
+    constructor(logger, options) {
+      super(logger, options);
+
+      const connectionString = options.db.connectionString || 'sqlite://:memory:';
+      const csDelim = '://';
+      const dbFilename = connectionString.slice(connectionString.indexOf(csDelim) + csDelim.length);
+
+      const queryLogLevel = options.db.queryLogLevel;
+
+      const sqliteOptions = {
+        ...(queryLogLevel && {
+          // eslint-disable-next-line security/detect-object-injection
+          verbose: (query) => this.logger[queryLogLevel](_fileScope('SQLite:verbose'), '', { query }),
+        }),
+      };
+      this.db = new SQLite(dbFilename, sqliteOptions);
+      this.changesSinceLastOptimize = BigInt(0);
+      this.optimizeAfterChanges = options.db.sqliteOptimizeAfterChanges || 0; // Default to no periodic optimization.
+      this.db.pragma('foreign_keys = on'); // Enforce consistency.
+      this.db.pragma('journal_mode = WAL'); // Be faster, expect local filesystem.
+      this.db.defaultSafeIntegers(true); // This probably isn't necessary, but by using these BigInts we keep weird floats out of the query logs.
+    }
+
+
+    initialize(sqlPath = __dirname, applyMigrations = true) {
+      const _scope = _fileScope('initialize');
+      this.logger.debug(_scope, 'called', { applyMigrations });
+      if (applyMigrations) {
+        this._initTables(sqlPath);
+      }
+      super.initialize();
+      this._initStatements(sqlPath);
+    }
+
+
+    _tableExists(tableName) {
+      return this.db.prepare('SELECT name FROM sqlite_master WHERE type=:type AND name=:tableName')
+        .pluck(true)
+        .bind({ type: 'table', tableName })
+        .get();
+    }
+
+
+    /**
+     * Create schema version tracking table and other common stuff.
+     */
+    _createMetaVersionTable() {
+      return this.db.exec(`BEGIN;
+CREATE TABLE _meta_schema_version (
+  major INTEGER NOT NULL CHECK (typeof(major) = 'integer'),
+  minor INTEGER NOT NULL CHECK (typeof(minor) = 'integer'),
+  patch INTEGER NOT NULL CHECK (typeof(patch) = 'integer'),
+  applied INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) CHECK (typeof(applied) = 'integer'),
+  PRIMARY KEY (major DESC, minor DESC, patch DESC)
+) WITHOUT ROWID;
+INSERT INTO _meta_schema_version (major, minor, patch) VALUES (0, 0, 0);
+COMMIT;`);
+    }
+
+
+    /**
+   * SQLite cannot prepare its statements without a schema, ensure such exists.
+   */
+    _initTables(sqlPath = __dirname) {
+      const _scope = _fileScope('_initTables');
+
+      // Migrations rely upon this table, ensure it exists.
+      const metaVersionTable = '_meta_schema_version';
+      let metaExists = this._tableExists(metaVersionTable);
+      if (metaExists === undefined) {
+        this._createMetaVersionTable();
+        metaExists = this._tableExists(metaVersionTable);
+        /* istanbul ignore if */
+        if (metaExists === undefined) {
+          throw new DBErrors.UnexpectedResult(`did not create ${metaVersionTable} table`);
+        }
+        this.logger.info(_scope, 'created schema version table', { metaVersionTable });
+      }
+
+      // Apply migrations
+      const currentSchema = this._currentSchema();
+      const migrationsWanted = unappliedSchemaVersions(sqlPath, currentSchema, this.schemaVersionsSupported);
+      this.logger.debug(_scope, 'schema migrations wanted', { migrationsWanted });
+      migrationsWanted.forEach((v) => {
+        const fPath = path.join(sqlPath, 'sql', 'schema', v, 'apply.sql');
+        // eslint-disable-next-line security/detect-non-literal-fs-filename
+        const fSql = fs.readFileSync(fPath, { encoding: 'utf8' });
+        this.logger.info(_scope, 'applying migration', { version: v });
+        this.db.exec(fSql);
+      });
+    }
+
+
+    _initStatements(sqlPath = __dirname) {
+      const _scope = _fileScope('_initStatements');
+      const sqlDir = path.join(sqlPath, 'sql');
+      this.statement = {};
+
+      // Decorate the statement calls we use with timing and logging.
+      const wrapFetch = (logName, statementName, fn) => {
+        const _wrapScope = _fileScope(logName);
+        return (...args) => {
+          const startTimestampMs = performance.now();
+          const rows = fn(...args);
+          DatabaseSQLite._deOphidiate(rows);
+          const elapsedTimeMs = performance.now() - startTimestampMs;
+          this.logger.debug(_wrapScope, 'complete', { statementName, elapsedTimeMs });
+          return rows;
+        };
+      };
+      const wrapRun = (logName, statementName, fn) => {
+        const _wrapScope = _fileScope(logName);
+        return (...args) => {
+          const startTimestampMs = performance.now();
+          const result = fn(...args);
+          const elapsedTimeMs = performance.now() - startTimestampMs;
+          this._updateChanges(result);
+          this.logger.debug(_wrapScope, 'complete', { ...result, statementName, elapsedTimeMs });
+          result.duration = elapsedTimeMs;
+          return result;
+        };
+      };
+
+      // eslint-disable-next-line security/detect-non-literal-fs-filename
+      for (const f of fs.readdirSync(sqlDir)) {
+        const fPath = path.join(sqlDir, f);
+        const { name: fName, ext: fExt } = path.parse(f);
+        // eslint-disable-next-line security/detect-non-literal-fs-filename
+        const stat = fs.statSync(fPath);
+        if (!stat.isFile()
+          || fExt.toLowerCase() !== '.sql') {
+          continue;
+        }
+        // eslint-disable-next-line security/detect-non-literal-fs-filename
+        const fSql = fs.readFileSync(fPath, { encoding: 'utf8' });
+        const statementName = Database._camelfy(fName.toLowerCase(), '-');
+        let statement;
+        try {
+          statement = this.db.prepare(fSql);
+        } catch (e) /* istanbul ignore next */ {
+          this.logger.error(_scope, 'failed to prepare statement', { error: e, file: f });
+          throw e;
+        }
+        // eslint-disable-next-line security/detect-object-injection
+        this.statement[statementName] = statement;
+        const { get: origGet, all: origAll, run: origRun } = statement;
+        statement.get = wrapFetch('SQLite:get', statementName, origGet.bind(statement));
+        statement.all = wrapFetch('SQLite:all', statementName, origAll.bind(statement));
+        statement.run = wrapRun('SQLite:run', statementName, origRun.bind(statement));
+      }
+      this.statement._optimize = this.db.prepare('SELECT * FROM pragma_optimize(0xffff)');
+
+      this.logger.debug(_scope, 'statements initialized', { statements: Object.keys(this.statement).length });
+    }
+
+
+    static _deOphidiate(rows) {
+      const rowsIsArray = Array.isArray(rows);
+      if (!rowsIsArray) {
+        rows = [rows];
+      }
+      const exemplaryRow = rows[0];
+      for (const prop in exemplaryRow) {
+        const camel = Database._camelfy(prop);
+        if (!(camel in exemplaryRow)) {
+          for (const d of rows) {
+            d[camel] = d[prop]; // eslint-disable-line security/detect-object-injection
+            delete d[prop]; // eslint-disable-line security/detect-object-injection
+          }
+        }
+      }
+      return rowsIsArray ? rows : rows[0];
+    }
+
+
+    _currentSchema() {
+      return this.db.prepare('SELECT major, minor, patch FROM _meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1').get();
+    }
+
+
+    healthCheck() {
+      const _scope = _fileScope('healthCheck');
+      this.logger.debug(_scope, 'called', {});
+      if (!this.db.open) {
+        throw new DBErrors.UnexpectedResult('database is not open');
+      }
+      return { open: this.db.open };
+    }
+
+
+    _closeConnection() {
+      this.db.close();
+    }
+
+
+    _optimize() {
+      const _scope = _fileScope('_optimize');
+
+      const optimize = this.statement._optimize.all();
+      this.logger.debug(_scope, 'optimize', { optimize, changes: this.changesSinceLastOptimize });
+      this.db.pragma('optimize');
+      this.changesSinceLastOptimize = BigInt(0);
+    }
+
+
+    _updateChanges(dbResult) {
+      if (this.optimizeAfterChanges) {
+        this.changesSinceLastOptimize += BigInt(dbResult.changes);
+        if (this.changesSinceLastOptimize >= this.optimizeAfterChanges) {
+          this._optimize();
+        }
+      }
+    }
+
+
+    _purgeTables(really) {
+      if (really) {
+        this._tableNames.forEach((table) => {
+          const result = this.db.prepare(`DELETE FROM ${table}`).run();
+          this.logger.debug(_fileScope('_purgeTables'), 'success', { table, result });
+        });
+      }
+    }
+
+
+    context(fn) {
+      return fn(this.db);
+    }
+
+
+    transaction(dbCtx, fn) {
+      dbCtx = dbCtx || this.db;
+      return dbCtx.transaction(fn)();
+    }
+
+
+    /**
+     * Boolean to 0/1 representation for SQLite params.
+     * @param {Boolean} bool
+     * @returns {Number}
+     */
+    static _booleanToNumeric(bool) {
+      // eslint-disable-next-line security/detect-object-injection
+      return {
+        true: 1,
+        false: 0,
+      }[bool];
+    }
+
+
+    /**
+     * Epoch seconds to Date.
+     * @param {Number} epoch
+     * @returns {Date}
+     */
+    static _epochToDate(epoch) {
+      return new Date(Number(epoch) * 1000);
+    }
+
+
+    /**
+     * Date to epoch seconds.
+     * @param {Date} date
+     * @returns {Number}
+     */
+    static _dateToEpoch(date) {
+      return Math.ceil(date.getTime() / 1000);
+    }
+
+  }
+
+  return DatabaseSQLite;
+};
+
+module.exports = SQLiteCreator;
diff --git a/lib/sqlite.js b/lib/sqlite.js
deleted file mode 100644 (file)
index 6ca9bf0..0000000
+++ /dev/null
@@ -1,311 +0,0 @@
-'use strict';
-
-const SQLite = require('better-sqlite3');
-const fs = require('fs');
-const path = require('path');
-const { performance } = require('perf_hooks');
-const Database = require('./abstract');
-const DBErrors = require('./errors');
-const { unappliedSchemaVersions } = require('./schema-version-helper');
-const { fileScope } = require('@squeep/log-helper');
-
-const _fileScope = fileScope(__filename);
-
-class DatabaseSQLite extends Database {
-  // eslint-disable-next-line class-methods-use-this
-  get schemaVersionsSupported() {
-    return {
-      min: {
-        major: 0,
-        minor: 0,
-        patch: 0,
-      },
-      max: {
-        major: 0,
-        minor: 0,
-        patch: 0,
-      },
-    };
-  }
-
-  /**
-   * 
-   * @param {ConsoleLike} logger
-   * @param {Object} options
-   * @param {Object} options.db
-   * @param {String} options.db.connectionString
-   * @param {String=} options.db.queryLogLevel
-   * @param {BigInt=} options.db.sqliteOptimizeAfterChanges
-   */
-  constructor(logger, options) {
-    super(logger, options);
-
-    const connectionString = options.db.connectionString || 'sqlite://:memory:';
-    const csDelim = '://';
-    const dbFilename = connectionString.slice(connectionString.indexOf(csDelim) + csDelim.length);
-
-    const queryLogLevel = options.db.queryLogLevel;
-
-    const sqliteOptions = {
-      ...(queryLogLevel && {
-        // eslint-disable-next-line security/detect-object-injection
-        verbose: (query) => this.logger[queryLogLevel](_fileScope('SQLite:verbose'), '', { query }),
-      }),
-    };
-    this.db = new SQLite(dbFilename, sqliteOptions);
-    this.changesSinceLastOptimize = BigInt(0);
-    this.optimizeAfterChanges = options.db.sqliteOptimizeAfterChanges || 0; // Default to no periodic optimization.
-    this.db.pragma('foreign_keys = on'); // Enforce consistency.
-    this.db.pragma('journal_mode = WAL'); // Be faster, expect local filesystem.
-    this.db.defaultSafeIntegers(true); // This probably isn't necessary, but by using these BigInts we keep weird floats out of the query logs.
-  }
-
-
-  initialize(sqlPath = __dirname, applyMigrations = true) {
-    const _scope = _fileScope('initialize');
-    this.logger.debug(_scope, 'called', { applyMigrations });
-    if (applyMigrations) {
-      this._initTables(sqlPath);
-    }
-    super.initialize();
-    this._initStatements(sqlPath);
-  }
-
-
-  _tableExists(tableName) {
-    return this.db.prepare('SELECT name FROM sqlite_master WHERE type=:type AND name=:tableName')
-      .pluck(true)
-      .bind({ type: 'table', tableName })
-      .get();
-  }
-
-
-  /**
-   * Create schema version tracking table and other common stuff.
-   */
-  _createMetaVersionTable() {
-    return this.db.exec(`BEGIN;
-CREATE TABLE _meta_schema_version (
-  major INTEGER NOT NULL CHECK (typeof(major) = 'integer'),
-  minor INTEGER NOT NULL CHECK (typeof(minor) = 'integer'),
-  patch INTEGER NOT NULL CHECK (typeof(patch) = 'integer'),
-  applied INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) CHECK (typeof(applied) = 'integer'),
-  PRIMARY KEY (major DESC, minor DESC, patch DESC)
-) WITHOUT ROWID;
-INSERT INTO _meta_schema_version (major, minor, patch) VALUES (0, 0, 0);
-COMMIT;`);
-  }
-
-
-  /**
- * SQLite cannot prepare its statements without a schema, ensure such exists.
- */
-  _initTables(sqlPath = __dirname) {
-    const _scope = _fileScope('_initTables');
-
-    // Migrations rely upon this table, ensure it exists.
-    const metaVersionTable = '_meta_schema_version';
-    let metaExists = this._tableExists(metaVersionTable);
-    if (metaExists === undefined) {
-      this._createMetaVersionTable();
-      metaExists = this._tableExists(metaVersionTable);
-      /* istanbul ignore if */
-      if (metaExists === undefined) {
-        throw new DBErrors.UnexpectedResult(`did not create ${metaVersionTable} table`);
-      }
-      this.logger.info(_scope, 'created schema version table', { metaVersionTable });
-    }
-
-    // Apply migrations
-    const currentSchema = this._currentSchema();
-    const migrationsWanted = unappliedSchemaVersions(sqlPath, currentSchema, this.schemaVersionsSupported);
-    this.logger.debug(_scope, 'schema migrations wanted', { migrationsWanted });
-    migrationsWanted.forEach((v) => {
-      const fPath = path.join(sqlPath, 'sql', 'schema', v, 'apply.sql');
-      // eslint-disable-next-line security/detect-non-literal-fs-filename
-      const fSql = fs.readFileSync(fPath, { encoding: 'utf8' });
-      this.logger.info(_scope, 'applying migration', { version: v });
-      this.db.exec(fSql);
-    });
-  }
-
-
-  _initStatements(sqlPath = __dirname) {
-    const _scope = _fileScope('_initStatements');
-    const sqlDir = path.join(sqlPath, 'sql');
-    this.statement = {};
-
-    // Decorate the statement calls we use with timing and logging.
-    const wrapFetch = (logName, statementName, fn) => {
-      const _wrapScope = _fileScope(logName);
-      return (...args) => {
-        const startTimestampMs = performance.now();
-        const rows = fn(...args);
-        DatabaseSQLite._deOphidiate(rows);
-        const elapsedTimeMs = performance.now() - startTimestampMs;
-        this.logger.debug(_wrapScope, 'complete', { statementName, elapsedTimeMs });
-        return rows;
-      };
-    };
-    const wrapRun = (logName, statementName, fn) => {
-      const _wrapScope = _fileScope(logName);
-      return (...args) => {
-        const startTimestampMs = performance.now();
-        const result = fn(...args);
-        const elapsedTimeMs = performance.now() - startTimestampMs;
-        this._updateChanges(result);
-        this.logger.debug(_wrapScope, 'complete', { ...result, statementName, elapsedTimeMs });
-        result.duration = elapsedTimeMs;
-        return result;
-      };
-    };
-
-    // eslint-disable-next-line security/detect-non-literal-fs-filename
-    for (const f of fs.readdirSync(sqlDir)) {
-      const fPath = path.join(sqlDir, f);
-      const { name: fName, ext: fExt } = path.parse(f);
-      // eslint-disable-next-line security/detect-non-literal-fs-filename
-      const stat = fs.statSync(fPath);
-      if (!stat.isFile()
-      ||  fExt.toLowerCase() !== '.sql') {
-        continue;
-      }
-      // eslint-disable-next-line security/detect-non-literal-fs-filename
-      const fSql = fs.readFileSync(fPath, { encoding: 'utf8' });
-      const statementName = Database._camelfy(fName.toLowerCase(), '-');
-      let statement;
-      try {
-        statement = this.db.prepare(fSql);
-      } catch (e) /* istanbul ignore next */ {
-        this.logger.error(_scope, 'failed to prepare statement', { error: e, file: f });
-        throw e;
-      }
-      // eslint-disable-next-line security/detect-object-injection
-      this.statement[statementName] = statement;
-      const { get: origGet, all: origAll, run: origRun } = statement;
-      statement.get = wrapFetch('SQLite:get', statementName, origGet.bind(statement));
-      statement.all = wrapFetch('SQLite:all', statementName, origAll.bind(statement));
-      statement.run = wrapRun('SQLite:run', statementName, origRun.bind(statement));
-    }
-    this.statement._optimize = this.db.prepare('SELECT * FROM pragma_optimize(0xffff)');
-
-    this.logger.debug(_scope, 'statements initialized', { statements: Object.keys(this.statement).length });
-  }
-
-
-  static _deOphidiate(rows) {
-    const rowsIsArray = Array.isArray(rows);
-    if (!rowsIsArray) {
-      rows = [rows];
-    }
-    const exemplaryRow = rows[0];
-    for (const prop in exemplaryRow) {
-      const camel = Database._camelfy(prop);
-      if (!(camel in exemplaryRow)) {
-        for (const d of rows) {
-          d[camel] = d[prop]; // eslint-disable-line security/detect-object-injection
-          delete d[prop]; // eslint-disable-line security/detect-object-injection
-        }
-      }
-    }
-    return rowsIsArray ? rows : rows[0];
-  }
-
-
-  _currentSchema() {
-    return this.db.prepare('SELECT major, minor, patch FROM _meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1').get();
-  }
-
-
-  healthCheck() {
-    const _scope = _fileScope('healthCheck');
-    this.logger.debug(_scope, 'called', {});
-    if (!this.db.open) {
-      throw new DBErrors.UnexpectedResult('database is not open');
-    }
-    return { open: this.db.open };
-  }
-
-
-  _closeConnection() {
-    this.db.close();
-  }
-
-
-  _optimize() {
-    const _scope = _fileScope('_optimize');
-
-    const optimize = this.statement._optimize.all();
-    this.logger.debug(_scope, 'optimize', { optimize, changes: this.changesSinceLastOptimize });
-    this.db.pragma('optimize');
-    this.changesSinceLastOptimize = BigInt(0);
-  }
-
-
-  _updateChanges(dbResult) {
-    if (this.optimizeAfterChanges) {
-      this.changesSinceLastOptimize += BigInt(dbResult.changes);
-      if (this.changesSinceLastOptimize >= this.optimizeAfterChanges) {
-        this._optimize();
-      }
-    }
-  }
-
-
-  _purgeTables(really) {
-    if (really) {
-      this._tableNames.forEach((table) => {
-        const result = this.db.prepare(`DELETE FROM ${table}`).run();
-        this.logger.debug(_fileScope('_purgeTables'), 'success', { table, result });
-      });
-    }
-  }
-
-
-  context(fn) {
-    return fn(this.db);
-  }
-
-
-  transaction(dbCtx, fn) {
-    dbCtx = dbCtx || this.db;
-    return dbCtx.transaction(fn)();
-  }
-
-
-  /**
-   * Boolean to 0/1 representation for SQLite params.
-   * @param {Boolean} bool
-   * @returns {Number}
-   */
-  static _booleanToNumeric(bool) {
-    // eslint-disable-next-line security/detect-object-injection
-    return {
-      true: 1,
-      false: 0,
-    }[bool];
-  }
-
-
-  /**
-   * Epoch seconds to Date.
-   * @param {Number} epoch
-   * @returns {Date}
-   */
-  static _epochToDate(epoch) {
-    return new Date(Number(epoch) * 1000);
-  }
-
-
-  /**
-   * Date to epoch seconds.
-   * @param {Date} date
-   * @returns {Number}
-   */
-  static _dateToEpoch(date) {
-    return Math.ceil(date.getTime() / 1000);
-  }
-
-}
-
-module.exports = DatabaseSQLite;
diff --git a/test-integration/abstract.js b/test-integration/abstract.js
new file mode 100644 (file)
index 0000000..c968603
--- /dev/null
@@ -0,0 +1,34 @@
+'use strict';
+
+const Abstract = require('../lib/abstract');
+
+class AbstractIntegration extends Abstract {
+  constructor(...args) {
+    super(...args);
+    if (!this._isProduction) {
+      this._abstractMethods.push(...[
+        'almanacGetAll',
+        'almanacGet',
+        'almanacUpsert',
+      ]);
+      this._tableNames.push(...[
+        'almanac',
+      ]);
+    }
+  }
+
+  almanacGetAll(dbCtx) {
+    this._notImplemented('almanacGetAll', arguments);
+  }
+
+  almanacGet(dbCtx, event) {
+    this._notImplemented('almanacGetAll', arguments);
+  }
+
+  almanacUpsert(dbCtx, event, date) {
+    this._notImplemented('almanacGetAll', arguments);
+  }
+
+}
+
+module.exports = AbstractIntegration;
index d0d137b7af03dfc0dd553562da548784e5d60cf2..8af0c6044301118bfd733327570119d68a92ab01 100644 (file)
@@ -1,14 +1,15 @@
 /* eslint-env mocha */
 'use strict';
 
-const DatabasePostgresBase = require('../../lib/postgres');
+const Abstract = require('../abstract');
+const PostgresCreator = require('../../lib/postgres-creator');
 const DBErrors = require('../../lib/errors');
 const assert = require('node:assert');
 
 /**
  * A minimal but actual db class implementation.
  */
-class DB extends DatabasePostgresBase {
+class PostgresDB extends PostgresCreator(Abstract) {
 
   // eslint-disable-next-line class-methods-use-this
   get schemaVersionsSupported() {
@@ -85,4 +86,4 @@ class DB extends DatabasePostgresBase {
 
 }
 
-module.exports = DB;
\ No newline at end of file
+module.exports = PostgresDB;
index 40d1010f5099ed5dd3fe0edeb9b9ad22893c4bc4..1379767b15f9dd58105859b5c4eb4ac051395b7c 100644 (file)
@@ -1,13 +1,15 @@
 /* eslint-env mocha */
 'use strict';
 
-const DatabaseSQLiteBase = require('../../lib/sqlite');
+const assert = require('node:assert');
+const SQLiteCreator = require('../../lib/sqlite-creator');
+const Abstract = require('../abstract');
 const DBErrors = require('../../lib/errors');
 
 /**
  * A minimal but actual db class implementation.
  */
-class DB extends DatabaseSQLiteBase {
+class SQLiteDB extends SQLiteCreator(Abstract) {
 
   // eslint-disable-next-line class-methods-use-this
   get schemaVersionsSupported() {
@@ -46,7 +48,7 @@ class DB extends DatabaseSQLiteBase {
     this.logger.debug(_scope, 'called');
     try {
       const entries = this.statement.almanacGetAll.all();
-      return entries.map((entry) => DB._almanacEntryToNative(entry));
+      return entries.map((entry) => this.constructor._almanacEntryToNative(entry));
     } catch (error) {
       this.logger.error(_scope, 'failed', { error });
       throw error;
@@ -56,7 +58,7 @@ class DB extends DatabaseSQLiteBase {
   static _almanacEntryToNative(entry) {
     return {
       event: entry.event,
-      date: DatabaseSQLiteBase._epochToDate(entry.epoch),
+      date: super._epochToDate(entry.epoch),
     };
   }
 
@@ -65,7 +67,7 @@ class DB extends DatabaseSQLiteBase {
     this.logger.debug(_scope, 'called', { event });
     try {
       const entry = this.statement.almanacGet.get({ event });
-      const { date } = DB._almanacEntryToNative(entry);
+      const { date } = this.constructor._almanacEntryToNative(entry);
       return date;
     } catch (error) {
       this.logger.error(_scope, 'failed', { error, event });
@@ -77,7 +79,7 @@ class DB extends DatabaseSQLiteBase {
     const _scope = 'almanacUpsert';
     this.logger.debug(_scope, 'called', { event, date });
     try {
-      const epoch = DatabaseSQLiteBase._dateToEpoch(date);
+      const epoch = this.constructor._dateToEpoch(date);
       const result = this.statement.almanacUpsert.run({ event, epoch });
       if (result.changes != 1) {
         throw new DBErrors.UnexpectedResult('did not update almanac');
@@ -90,4 +92,4 @@ class DB extends DatabaseSQLiteBase {
 
 }
 
-module.exports = DB;
\ No newline at end of file
+module.exports = SQLiteDB;
index b76f0b85028c09883a24b37ed5444758c397dd22..5e12177abf1df5ec5cb3bd9f28bac25c5d84a21b 100644 (file)
@@ -5,7 +5,7 @@ const assert = require('node:assert');
 const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
 const DBErrors = require('../lib/errors');
 
-const nop = () => { /* */ };
+const nop = () => undefined;
 
 class StubLogger {
   constructor(backend) {
@@ -81,4 +81,4 @@ module.exports = {
   StubLogger,
   stubPgp,
   itChecksImplementation,
-};
\ No newline at end of file
+};
index 4c86515d58cd584a674a23715cceaf2d8093ce9f..63e755f40326866f63fe9a864f6a9c59092d1b2d 100644 (file)
@@ -129,10 +129,24 @@ describe('Database Integration', function () {
         step('fetch all records', async function () {
           const expected = events;
           await db.context(async (dbCtx) => {
-            const events = await db.almanacGetAll(dbCtx);
-            assert.deepStrictEqual(events, expected);
+            const allEvents = await db.almanacGetAll(dbCtx);
+            assert.deepStrictEqual(allEvents, expected);
           });
         });      
+
+        step('transaction', async function () {
+          const expected = events;
+          await db.context(async (dbCtx) => {
+            await db.transaction(dbCtx, async (txCtx) => {
+              for await (const event of events) {
+                await db.almanacUpsert(txCtx, event.event, event.date);
+              }
+            }); // txCtx
+            const allEvents = await db.almanacGetAll(dbCtx);
+            assert.deepStrictEqual(allEvents, expected);
+          });
+        });
+
       }); // Almanac
 
     }); // specific implementation
index ef690f2b4a158251ddc150d97b41312a60c00ac9..e92691f13bdf1a9ff9f373d737d7dabe55c5a70c 100644 (file)
@@ -4,10 +4,11 @@
 const assert = require('node:assert');
 const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
 const Factory = require('../../lib/factory');
+const Abstract = require('../../lib/abstract');
 const { UnsupportedEngine } = require('../../lib/errors');
 const pgp = require('pg-promise');
 
-const nop = () => {};
+const nop = () => unknown;
 
 describe('Factory', function () {
   let logger, options;
@@ -24,24 +25,24 @@ describe('Factory', function () {
   it('covers postgres', function () {
     sinon.stub(pgp.utils, 'enumSql').returns({});
     options.db.connectionString = 'postgresql://example';
-    const factory = new Factory(logger, options);
+    const factory = new Factory(Abstract, logger, options);
     assert(factory);
   });
 
   it('covers sqlite', function () {
     options.db.connectionString = 'sqlite://:memory:';
-    const factory = new Factory(logger, options);
+    const factory = new Factory(Abstract, logger, options);
     assert(factory);
   });
 
   it('covers unknown', function () {
     options.db.connectionString = 'redis://example';
-    assert.throws(() => new Factory(logger, options), UnsupportedEngine);
+    assert.throws(() => new Factory(Abstract, logger, options), UnsupportedEngine);
   });
   
-
   it('covers empty', function () {
     delete options.db.connectionString;
-    assert.throws(() => new Factory(logger, options), UnsupportedEngine);
+    assert.throws(() => new Factory(Abstract, logger, options), UnsupportedEngine);
   });
+
 }); // Factory
similarity index 86%
rename from test/lib/postgres.js
rename to test/lib/postgres-creator.js
index 65f1b944af189a2b7aa9be624ea0fb6daac8e80a..916ef38ca5cb456f0a86bf06bc925f28377af66a 100644 (file)
@@ -4,13 +4,15 @@
 const assert = require('node:assert');
 const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
 const fs = require('node:fs');
-const DatabasePostgres = require('../../lib/postgres');
+const Abstract = require('../../lib/abstract');
+const PostgresCreator = require('../../lib/postgres-creator');
 const DBErrors = require('../../lib/errors');
 const { StubLogger, stubPgp, nop, itChecksImplementation } = require('../helpers');
 
-describe('DatabasePostgres', function () {
+describe('Postgres Creator', function () {
   let db, options, stubLogger;
   const expectedException = new Error('oh no');
+  const DatabasePostgres = PostgresCreator(Abstract);
 
   beforeEach(function () {
     options = {
@@ -119,6 +121,36 @@ describe('DatabasePostgres', function () {
     }); // receive
   }); // pgpInitOptions
 
+  describe('_tableExists', function () {
+    beforeEach(function () {
+      sinon.stub(db.db, 'oneOrNone');
+    });
+    it('covers no pgSchema', async function () {
+      await db._tableExists('almanac', db.pgSchema);
+      assert(db.db.oneOrNone.called);
+    });
+    it('covers pgSchema', async function () {
+      db.pgSchema = 'some_schema';
+      await db._tableExists('almanac', db.pgSchema);
+      assert(db.db.oneOrNone.called);
+    });
+  }); // _tableExists
+
+  describe('_currentSchema', function () {
+    beforeEach(function () {
+      sinon.stub(db.db, 'one');
+    });
+    it('covers no pgSchema', async function () {
+      await db._currentSchema();
+      assert(db.db.one.called);
+    });
+    it('covers pgSchema', async function () {
+      db.pgSchema = 'some_schema';
+      await db._currentSchema();
+      assert(db.db.one.called);
+    });
+  }); // _currentSchema
+
   describe('_initTables', function () {
     beforeEach(function () {
       sinon.stub(db.db, 'multiResult').resolves();
@@ -138,6 +170,14 @@ describe('DatabasePostgres', function () {
         .onCall(1).resolves({ 'table_name': '_meta_schema_version' });
       await db._initTables();
     });
+    it('covers apply with pgSchema', async function () {
+      db.pgSchema = 'some_schema';
+      db._currentSchema.resolves({ major: 0, minor: 0, patch: 0 });
+      db._tableExists
+        .onCall(0).resolves()
+        .onCall(1).resolves({ 'table_name': '_meta_schema_version' });
+      await db._initTables();
+    });
     it('covers exists', async function () {
       db._tableExists.resolves({ 'table_name': '_meta_schema_version' });
       db._currentSchema.resolves(db.schemaVersionsSupported.max);
similarity index 98%
rename from test/lib/sqlite.js
rename to test/lib/sqlite-creator.js
index e6d1af9215526f2fdd6530c42ec24ecf402ec367..99026798bbfcfe1bb96bb877247e6a7762c9397c 100644 (file)
@@ -4,13 +4,15 @@
 const assert = require('node:assert');
 const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
 const fs = require('node:fs');
-const DatabaseSQLite = require('../../lib/sqlite');
+const Abstract = require('../../lib/abstract');
+const SQLiteCreator = require('../../lib/sqlite-creator');
 const DBErrors = require('../../lib/errors');
 const { StubLogger, stubPgp, nop, itChecksImplementation } = require('../helpers');
 
 describe('DatabaseSQLite', function () {
   let db, options, stubLogger;
   const expectedException = new Error('oh no');
+  const DatabaseSQLite = SQLiteCreator(Abstract);
 
   beforeEach(function () {
     options = {