mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Migrate to SQLite (#174)
* Migrating Fredy from LowDb to SqLite 🎉
* adding new sql migration system for future sql migrations
* adding setting to change sqlite path for db files
* create migration plan for graceful migration lowdb -> sqlite
* Improving Documentation
* adding test for sqliteconnection
* upgrading dependencies
* making nodejs 22 as min version
* improve scraper
* adding overwrite ability for db migra
This commit is contained in:
committed by
GitHub
parent
18fdbd761a
commit
8d95f052c6
329
test/db/migrations/migrate.test.js
Normal file
329
test/db/migrations/migrate.test.js
Normal file
@@ -0,0 +1,329 @@
|
||||
import { expect } from 'chai';
|
||||
import esmock from 'esmock';
|
||||
|
||||
// We will fully mock fs, crypto, SqliteConnection, and dynamic import of migration modules
|
||||
|
||||
describe('db/migrations/migrate.js - runMigrations', () => {
|
||||
let calls;
|
||||
let runMigrations;
|
||||
let prevExitCode;
|
||||
|
||||
beforeEach(async () => {
|
||||
calls = {
|
||||
fs: { existsSync: [], mkdirSync: [], readdirSync: [], readFileSync: [] },
|
||||
sql: {
|
||||
getConnection: 0,
|
||||
tableExists: false,
|
||||
query: [],
|
||||
execute: [],
|
||||
withTransaction: [],
|
||||
optimize: 0,
|
||||
},
|
||||
logs: { info: [], warn: [], error: [] },
|
||||
};
|
||||
|
||||
// Mock fs to avoid touching disk
|
||||
const fsMock = {
|
||||
existsSync: (p) => {
|
||||
calls.fs.existsSync.push(p);
|
||||
return true;
|
||||
},
|
||||
mkdirSync: (p, opts) => {
|
||||
calls.fs.mkdirSync.push({ p, opts });
|
||||
},
|
||||
readdirSync: (p) => {
|
||||
calls.fs.readdirSync.push(p);
|
||||
return [];
|
||||
},
|
||||
readFileSync: (p) => {
|
||||
calls.fs.readFileSync.push(p);
|
||||
return Buffer.from('dummy');
|
||||
},
|
||||
};
|
||||
|
||||
// Mock crypto sha256
|
||||
const cryptoMock = {
|
||||
createHash: () => ({ update: () => ({ digest: () => 'sha256sum' }) }),
|
||||
};
|
||||
|
||||
// Mock logger
|
||||
const loggerMock = {
|
||||
info: (...a) => calls.logs.info.push(a),
|
||||
warn: (...a) => calls.logs.warn.push(a),
|
||||
error: (...a) => calls.logs.error.push(a),
|
||||
};
|
||||
|
||||
// Mock SqliteConnection
|
||||
const sqlMock = {
|
||||
getConnection: () => {
|
||||
calls.sql.getConnection += 1;
|
||||
return {};
|
||||
},
|
||||
tableExists: () => calls.sql.tableExists,
|
||||
query: (sql) => {
|
||||
calls.sql.query.push(sql);
|
||||
return [];
|
||||
},
|
||||
execute: (sql, params) => {
|
||||
calls.sql.execute.push({ sql, params });
|
||||
return { changes: 1 };
|
||||
},
|
||||
withTransaction: (cb) => {
|
||||
calls.sql.withTransaction.push(true);
|
||||
const db = {
|
||||
prepare: (s) => ({ run: (...args) => calls.sql.execute.push({ sql: s, params: args }) }),
|
||||
};
|
||||
return cb(db);
|
||||
},
|
||||
optimize: () => {
|
||||
calls.sql.optimize += 1;
|
||||
},
|
||||
};
|
||||
|
||||
// esmock with dependency replacements
|
||||
const path = await import('node:path');
|
||||
const ROOT = path.resolve('.');
|
||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||
const mod = await esmock(
|
||||
'../../../db/migrations/migrate.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
crypto: cryptoMock,
|
||||
[sqlPath]: sqlMock,
|
||||
[loggerPath]: loggerMock,
|
||||
},
|
||||
);
|
||||
|
||||
runMigrations = mod.runMigrations;
|
||||
|
||||
// remember original exitCode to restore later
|
||||
prevExitCode = process.exitCode;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// restore original process.exitCode
|
||||
process.exitCode = prevExitCode;
|
||||
});
|
||||
|
||||
it('logs and returns when no migration files are found', async () => {
|
||||
await runMigrations();
|
||||
expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).to.equal(true);
|
||||
expect(calls.sql.getConnection).to.equal(0);
|
||||
expect(calls.sql.optimize).to.equal(0);
|
||||
});
|
||||
|
||||
it('applies a single new migration inside a transaction and records it', async () => {
|
||||
// Re-mock with one file and module loader
|
||||
const fsMock = {
|
||||
existsSync: () => true,
|
||||
mkdirSync: () => {},
|
||||
readdirSync: () => ['1.init.js'],
|
||||
readFileSync: () => Buffer.from('dummy'),
|
||||
};
|
||||
const cryptoMock = { createHash: () => ({ update: () => ({ digest: () => 'abc' }) }) };
|
||||
const loggerMock = {
|
||||
info: (...a) => calls.logs.info.push(a),
|
||||
warn: (...a) => calls.logs.warn.push(a),
|
||||
error: (...a) => calls.logs.error.push(a),
|
||||
};
|
||||
|
||||
const sqlMock = {
|
||||
getConnection: () => {
|
||||
calls.sql.getConnection += 1;
|
||||
return {};
|
||||
},
|
||||
tableExists: () => false, // schema_migrations not present yet
|
||||
query: () => [],
|
||||
execute: (sql, params) => {
|
||||
calls.sql.execute.push({ sql, params });
|
||||
return { changes: 1 };
|
||||
},
|
||||
withTransaction: (cb) => {
|
||||
calls.sql.withTransaction.push(true);
|
||||
const db = {
|
||||
exec: () => {},
|
||||
prepare: (s) => ({ run: (...args) => calls.sql.execute.push({ sql: s, params: args }) }),
|
||||
};
|
||||
return cb(db);
|
||||
},
|
||||
optimize: () => {
|
||||
calls.sql.optimize += 1;
|
||||
},
|
||||
};
|
||||
|
||||
// The migration module: exports up(db)
|
||||
const migrationModule = {
|
||||
up: (db) => {
|
||||
db.exec && db.exec('CREATE TABLE schema_migrations(name TEXT)');
|
||||
},
|
||||
};
|
||||
|
||||
// We need to intercept dynamic import by esmock: provide a stub for import(url)
|
||||
// esmock supports mocking via a virtual module using URL matching, but simpler approach:
|
||||
// place the file path that migrate.js will compute and make Node import resolve to our stub
|
||||
// We simulate by mocking url.pathToFileURL is still used, but dynamic import will be handled by esmock when we map the computed path.
|
||||
|
||||
const path = await import('node:path');
|
||||
const ROOT = path.resolve('.');
|
||||
|
||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||
// Use global importer hook to bypass dynamic import
|
||||
globalThis.__TEST_MIGRATE_IMPORT__ = async () => migrationModule;
|
||||
|
||||
const mod = await esmock(
|
||||
'../../../db/migrations/migrate.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
crypto: cryptoMock,
|
||||
[sqlPath]: sqlMock,
|
||||
[loggerPath]: loggerMock,
|
||||
},
|
||||
);
|
||||
|
||||
runMigrations = mod.runMigrations;
|
||||
|
||||
await runMigrations();
|
||||
|
||||
// Should have started a transaction and inserted into schema_migrations
|
||||
expect(calls.sql.withTransaction.length).to.equal(1);
|
||||
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
|
||||
expect(!!inserted).to.equal(true);
|
||||
expect(calls.sql.optimize).to.equal(1);
|
||||
});
|
||||
|
||||
it('skips already executed migration with same checksum', async () => {
|
||||
const fsMock = {
|
||||
existsSync: () => true,
|
||||
mkdirSync: () => {},
|
||||
readdirSync: () => ['1.init.js'],
|
||||
readFileSync: () => Buffer.from('dummy'),
|
||||
};
|
||||
const cryptoMock = { createHash: () => ({ update: () => ({ digest: () => 'same' }) }) };
|
||||
const loggerMock = {
|
||||
info: (...a) => calls.logs.info.push(a),
|
||||
warn: (...a) => calls.logs.warn.push(a),
|
||||
error: (...a) => calls.logs.error.push(a),
|
||||
};
|
||||
|
||||
const sqlMock = {
|
||||
getConnection: () => {
|
||||
calls.sql.getConnection += 1;
|
||||
return {};
|
||||
},
|
||||
tableExists: () => true,
|
||||
query: () => [{ name: '1.init.js', checksum: 'same' }],
|
||||
execute: (sql, params) => {
|
||||
calls.sql.execute.push({ sql, params });
|
||||
return { changes: 1 };
|
||||
},
|
||||
withTransaction: (cb) => {
|
||||
calls.sql.withTransaction.push(true);
|
||||
const db = { prepare: (s) => ({ run: (...args) => calls.sql.execute.push({ sql: s, params: args }) }) };
|
||||
return cb(db);
|
||||
},
|
||||
optimize: () => {
|
||||
calls.sql.optimize += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const path = await import('node:path');
|
||||
const ROOT = path.resolve('.');
|
||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||
|
||||
globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({ up: () => {} });
|
||||
|
||||
const mod = await esmock(
|
||||
'../../../db/migrations/migrate.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
crypto: cryptoMock,
|
||||
[sqlPath]: sqlMock,
|
||||
[loggerPath]: loggerMock,
|
||||
},
|
||||
);
|
||||
|
||||
runMigrations = mod.runMigrations;
|
||||
|
||||
await runMigrations();
|
||||
|
||||
// Should not run transaction because it's skipped
|
||||
expect(calls.sql.withTransaction.length).to.equal(0);
|
||||
expect(calls.sql.optimize).to.equal(1);
|
||||
});
|
||||
|
||||
it('aborts with exitCode=1 when a migration throws, without applying insert', async () => {
|
||||
const fsMock = {
|
||||
existsSync: () => true,
|
||||
mkdirSync: () => {},
|
||||
readdirSync: () => ['1.bad.js'],
|
||||
readFileSync: () => Buffer.from('dummy'),
|
||||
};
|
||||
const cryptoMock = { createHash: () => ({ update: () => ({ digest: () => 'bad' }) }) };
|
||||
const loggerMock = {
|
||||
info: (...a) => calls.logs.info.push(a),
|
||||
warn: (...a) => calls.logs.warn.push(a),
|
||||
error: (...a) => calls.logs.error.push(a),
|
||||
};
|
||||
|
||||
const sqlMock = {
|
||||
getConnection: () => {
|
||||
calls.sql.getConnection += 1;
|
||||
return {};
|
||||
},
|
||||
tableExists: () => false,
|
||||
query: () => [],
|
||||
execute: (sql, params) => {
|
||||
calls.sql.execute.push({ sql, params });
|
||||
return { changes: 1 };
|
||||
},
|
||||
withTransaction: (cb) => {
|
||||
calls.sql.withTransaction.push(true);
|
||||
const db = {
|
||||
exec: () => {},
|
||||
prepare: (s) => ({ run: (...args) => calls.sql.execute.push({ sql: s, params: args }) }),
|
||||
};
|
||||
return cb(db);
|
||||
},
|
||||
optimize: () => {
|
||||
calls.sql.optimize += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const path = await import('node:path');
|
||||
const ROOT = path.resolve('.');
|
||||
|
||||
globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({
|
||||
up: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
});
|
||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||
|
||||
const mod = await esmock(
|
||||
'../../../db/migrations/migrate.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
crypto: cryptoMock,
|
||||
[sqlPath]: sqlMock,
|
||||
[loggerPath]: loggerMock,
|
||||
},
|
||||
);
|
||||
|
||||
runMigrations = mod.runMigrations;
|
||||
|
||||
await runMigrations();
|
||||
|
||||
expect(process.exitCode).to.equal(1);
|
||||
// No insert into schema_migrations should be recorded since transaction failed
|
||||
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
|
||||
expect(inserted).to.equal(undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
const db = {};
|
||||
export const setKnownListings = (jobKey, providerId, listings) => {
|
||||
export const storeListings = (jobKey, providerId, listings) => {
|
||||
if (!Array.isArray(listings)) throw Error('Not a valid array');
|
||||
db[providerId] = listings;
|
||||
};
|
||||
export const getKnownListings = (jobKey, providerId) => {
|
||||
export const getKnownListingHashesForJobAndProvider = (jobKey, providerId) => {
|
||||
return db[providerId] || [];
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('#einsAImmobilien testsuite()', () => {
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.size).to.be.not.empty;
|
||||
expect(notify.title).to.be.not.empty;
|
||||
|
||||
142
test/storage/SqliteConnection.test.js
Normal file
142
test/storage/SqliteConnection.test.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { expect } from 'chai';
|
||||
import esmock from 'esmock';
|
||||
|
||||
// We explicitly avoid touching the real filesystem or creating a real DB file.
|
||||
// better-sqlite3 is fully mocked and operates in-memory via our stubs.
|
||||
|
||||
describe('SqliteConnection', () => {
|
||||
let SqliteConnection;
|
||||
let calls;
|
||||
|
||||
beforeEach(async () => {
|
||||
calls = {
|
||||
fs: { existsSync: [], mkdirSync: [] },
|
||||
db: { pragma: [], prepare: [], transactionWraps: 0, close: 0 },
|
||||
prepareAll: [],
|
||||
prepareRun: [],
|
||||
prepareGet: [],
|
||||
processOnce: [],
|
||||
logs: { warn: [], debug: [] },
|
||||
};
|
||||
|
||||
// stub for fs
|
||||
const fsMock = {
|
||||
existsSync: (dir) => {
|
||||
calls.fs.existsSync.push(dir);
|
||||
// Pretend directory always exists to avoid mkdir
|
||||
return true;
|
||||
},
|
||||
mkdirSync: (dir, opts) => {
|
||||
calls.fs.mkdirSync.push({ dir, opts });
|
||||
},
|
||||
};
|
||||
|
||||
// Prepare object returned from db.prepare()
|
||||
const prepareObj = {
|
||||
all: (params) => {
|
||||
calls.prepareAll.push(params);
|
||||
return [{ x: 1 }];
|
||||
},
|
||||
run: (params) => {
|
||||
calls.prepareRun.push(params);
|
||||
return { changes: 1 };
|
||||
},
|
||||
get: (param) => {
|
||||
calls.prepareGet.push(param);
|
||||
// return truthy by default
|
||||
return { one: 1 };
|
||||
},
|
||||
};
|
||||
|
||||
// Database mock constructor
|
||||
const BetterSqlite3Mock = function (filepath, options) {
|
||||
// expose on instance
|
||||
this.filepath = filepath;
|
||||
this.options = options;
|
||||
this.pragma = (p) => {
|
||||
calls.db.pragma.push(p);
|
||||
return undefined;
|
||||
};
|
||||
this.prepare = (sql) => {
|
||||
calls.db.prepare.push(sql);
|
||||
return prepareObj;
|
||||
};
|
||||
this.transaction = (fn) => {
|
||||
// better-sqlite3 returns a function that executes inside a transaction
|
||||
return (cb) => {
|
||||
calls.db.transactionWraps += 1;
|
||||
return fn(cb);
|
||||
};
|
||||
};
|
||||
this.close = () => {
|
||||
calls.db.close += 1;
|
||||
};
|
||||
};
|
||||
|
||||
// esmock the module with our stubs
|
||||
SqliteConnection = await esmock(
|
||||
'../../lib/services/storage/SqliteConnection.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
'better-sqlite3': { default: BetterSqlite3Mock },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// ensure we can close between tests
|
||||
SqliteConnection.close();
|
||||
});
|
||||
|
||||
it('creates singleton connection and applies PRAGMAs without touching disk', () => {
|
||||
const db1 = SqliteConnection.getConnection();
|
||||
const db2 = SqliteConnection.getConnection();
|
||||
|
||||
expect(db1).to.equal(db2);
|
||||
// journal_mode, synchronous, cache_size, foreign_keys, optimize
|
||||
expect(calls.db.pragma).to.deep.equal([
|
||||
'journal_mode = WAL',
|
||||
'synchronous = NORMAL',
|
||||
'cache_size = -64000',
|
||||
'foreign_keys = ON',
|
||||
'optimize',
|
||||
]);
|
||||
// mkdirSync should not be called because existsSync returned true
|
||||
expect(calls.fs.mkdirSync).to.have.length(0);
|
||||
});
|
||||
|
||||
it('executes query and execute helpers', () => {
|
||||
const rows = SqliteConnection.query('SELECT 1', {});
|
||||
expect(rows).to.be.an('array');
|
||||
expect(rows[0]).to.deep.equal({ x: 1 });
|
||||
|
||||
const info = SqliteConnection.execute('UPDATE x SET y=1 WHERE id=@id', { id: 5 });
|
||||
expect(info).to.have.property('changes', 1);
|
||||
});
|
||||
|
||||
it('tableExists uses sqlite_master get()', () => {
|
||||
const exists = SqliteConnection.tableExists('users');
|
||||
expect(exists).to.equal(true);
|
||||
});
|
||||
|
||||
it('withTransaction wraps callback', () => {
|
||||
const result = SqliteConnection.withTransaction((db) => {
|
||||
// ensure we can use the db to prepare
|
||||
db.prepare('SELECT inside').all({});
|
||||
return 42;
|
||||
});
|
||||
expect(result).to.equal(42);
|
||||
expect(calls.db.prepare).to.include('SELECT inside');
|
||||
});
|
||||
|
||||
it('optimize() delegates to PRAGMA optimize and close() calls it again then closes', () => {
|
||||
SqliteConnection.optimize();
|
||||
// It will use the existing connection and call pragma('optimize')
|
||||
expect(calls.db.pragma).to.include('optimize');
|
||||
|
||||
SqliteConnection.close();
|
||||
// close increments close counter
|
||||
expect(calls.db.close).to.equal(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user