Skip to content
Draft
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
145 commits
Select commit Hold shift + click to select a range
5afe783
wip: replace knex for new database migrations
Jan 10, 2025
7a0260d
move all the migrations; implement the migrator
Jan 14, 2025
2d9ff15
Add db migration test from other PR
Jan 14, 2025
42023e0
fix imports; lints
Jan 14, 2025
c838cf6
Add eslint rules
Jan 14, 2025
a9aea26
whitespace
Jan 14, 2025
609c34c
rename withDatabase()
Jan 14, 2025
c2ce4ba
lint?
Jan 14, 2025
714985f
make sure client closed
Jan 14, 2025
72bb6b0
add comment
Jan 14, 2025
65e7709
await
Jan 14, 2025
2fceecf
handle connection closing
Jan 14, 2025
2e42824
run migrations in tests another way
Jan 14, 2025
0821bdd
rename knexConnection in line with master changes
Jan 16, 2025
ae64408
Merge branch 'master' into prevent-new-migrations-with-knex
Jan 16, 2025
100c424
Change in line with #1371
Jan 16, 2025
bb0d883
js -> json
Jan 16, 2025
e2d64cc
reduce changes by renaming migration dirs
Jan 16, 2025
de1e5d7
Add TODOs
Jan 16, 2025
887c492
remove lint changes to legacy migrations
Jan 16, 2025
ed41ada
revert debug
Jan 16, 2025
0769356
revert knexConfig name change
Jan 16, 2025
71d4a8c
Merge branch 'master' into prevent-new-migrations-with-knex
Jan 18, 2025
c95cec6
renames to be less knexy
Jan 18, 2025
322c5ba
reduce changeset
Jan 18, 2025
6c1a62b
revert whitespace change
Jan 18, 2025
a415a70
import fixes for null content types
Jan 21, 2025
5d6218b
import query changes
Jan 21, 2025
fafc6fb
e2e test fix?
Jan 21, 2025
8c6cf29
import test changes
Jan 21, 2025
b264a83
ci/db-migrations: add new path
Jan 21, 2025
9a1db56
Make migration tests pass?
Jan 21, 2025
9987704
only move js?
Jan 21, 2025
f20dad4
lint
Jan 21, 2025
8aa2286
wip
Jan 21, 2025
62f043d
add migrator name
Jan 21, 2025
ec35f7c
correct arg count
Jan 21, 2025
f20e9a1
Merge branch 'master' into prevent-new-migrations-with-knex
alxndrsn Jan 28, 2025
1acb27c
Merge branch 'master' into prevent-new-migrations-with-knex
Feb 4, 2025
08f235c
add check
Feb 4, 2025
3f8aa12
rename new migration
Feb 4, 2025
26fe2ea
Fix test migrator
Feb 4, 2025
2d015c8
update
Feb 4, 2025
b21b486
remove logging
Feb 4, 2025
4c5ce3a
remove superseded migration
Feb 4, 2025
a15fe02
lint
Feb 4, 2025
b78683d
reintroduce faster migration
Feb 4, 2025
8b3b4db
Add review query
Feb 4, 2025
ee46a00
Merge branch 'master' into prevent-new-migrations-with-knex
Feb 11, 2025
b44e674
Resolve TODO
Feb 11, 2025
cf5a4fd
spelling
Feb 18, 2025
5dc4df6
Use same migrations table
Feb 18, 2025
9d0335f
revert name change for describeMigration()
Feb 18, 2025
5336ac9
fix knex_migrations table updates
Feb 18, 2025
86082d4
lint
Feb 18, 2025
d276085
ignore missing migrations
Feb 18, 2025
5aba8f8
document previous
Feb 18, 2025
9393aa4
change migration directories
Feb 18, 2025
0cee983
move migrations
Feb 18, 2025
244d36c
last migration
Feb 18, 2025
dfa2048
rename post-knex
Feb 18, 2025
9215950
move eslintrc
Feb 18, 2025
7d70054
remove dead file
Feb 18, 2025
7f6a8b6
fix requires
Feb 18, 2025
6bf53ad
name
Feb 18, 2025
c7fa35e
wip
Feb 18, 2025
4275829
raw -> query
Feb 18, 2025
929a976
fix?
Feb 18, 2025
d1802de
remove old TODOs
Feb 18, 2025
193f837
rename migrations tests
Feb 18, 2025
0c9610b
skip migration tests
Feb 18, 2025
de23f43
resolve REVIEW comment
Feb 18, 2025
398960f
resolve REVIEW
Feb 18, 2025
f908b9e
resolve TODO
Feb 18, 2025
38fdb4b
resolve TODO: validate migration names
Feb 18, 2025
ed6bb5e
resolve TODO with better comments
Feb 18, 2025
de06080
remove dead comment
Feb 19, 2025
1b68d91
Merge branch 'master' into prevent-new-migrations-with-knex
Mar 19, 2025
ee1e3f8
new migration: raw -> query
Mar 19, 2025
eb14edb
qyer
Mar 19, 2025
4414dc4
Merge branch 'master' into prevent-new-migrations-with-knex
Mar 25, 2025
176bc73
fix name!
Mar 25, 2025
d284918
reduce changeset
Mar 25, 2025
0b289d1
fix migration name
Mar 25, 2025
7cc1b19
Try removing disableMigrationsListValidation
Mar 25, 2025
cd0ff44
Revert "Try removing disableMigrationsListValidation"
Mar 25, 2025
e97f81f
expand comment ref disableMigrationsListValidation
Mar 25, 2025
1937710
Merge branch 'master' into prevent-new-migrations-with-knex
alxndrsn Apr 4, 2025
80b257c
Merge branch 'master' into prevent-new-migrations-with-knex
alxndrsn Apr 28, 2025
34d57fc
Merge branch 'master' into prevent-new-migrations-with-knex
alxndrsn Apr 30, 2025
f219a7b
raw -> query
Apr 30, 2025
26583da
build up full matrix
Apr 30, 2025
ea9dfcf
ci: add on: section
Apr 30, 2025
ef1f0f4
add failing outline of tests
Apr 30, 2025
45e4fbe
update and install
May 2, 2025
b6803ba
without update
May 2, 2025
14f3650
make executable
May 2, 2025
a5207d8
wip
May 2, 2025
5648c47
actually run migrations in the legacy style
May 2, 2025
6116ba0
more prep
May 2, 2025
e9ef7d2
wip
May 2, 2025
332c69b
wip
May 2, 2025
fbdc6fb
show output
May 2, 2025
54fbde8
remove js
May 2, 2025
6fbeb37
wip
May 2, 2025
4d8da90
test all in one
May 2, 2025
d50aed3
better cleran
May 2, 2025
b4b92f1
new filrst
May 2, 2025
2c7abdd
fix requires in legacy migrations
May 2, 2025
c4dfd9a
rebuild
May 2, 2025
efb9ade
build please
May 2, 2025
2ae8b03
get connection string
May 2, 2025
c21187a
nicer esxcaping
May 2, 2025
ba81777
more logging?
May 2, 2025
7b9acbb
flush output
May 2, 2025
95b2b14
fix fix
May 2, 2025
1fccd8b
helpful comments
May 2, 2025
c1df685
no escape backtick
May 2, 2025
9f2f4b0
add missing schema file
May 2, 2025
18ef0e1
expect table contnets
May 2, 2025
962c6a8
ye
May 2, 2025
f984324
revert some legacy to legacy
May 2, 2025
514d180
partial
May 2, 2025
201515a
try different syntax for JSONB_EXISTS
May 2, 2025
19c8ead
Revert "revert some legacy to legacy"
May 2, 2025
c63e4fa
Revert "partial"
May 2, 2025
b82c47e
add missing pgConnectionString
May 2, 2025
757bb93
tryail
May 2, 2025
edfc3fc
add expected schema
May 2, 2025
7f3ca0f
add trailing newline?
May 2, 2025
de3db3c
select less cols
May 2, 2025
c0dd6e6
flushings
May 2, 2025
083b604
expected table contents
May 2, 2025
6fd9531
trailing newline
May 2, 2025
1a7d8a7
remove x
May 2, 2025
30b71b5
remove random output in the middle
May 2, 2025
ff0a10d
disable knex debug
May 2, 2025
a7b4edd
Merge branch 'master' into prevent-new-migrations-with-knex
alxndrsn May 2, 2025
0271b3a
check half way
May 2, 2025
0391aff
wip
May 2, 2025
77d8d62
just print name
May 2, 2025
8048910
prefixes
May 2, 2025
9b95483
js only
May 2, 2025
6089fbf
sort
May 2, 2025
df96dbc
Merge branch 'master' into prevent-new-migrations-with-knex
Jul 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions lib/bin/check-migrations.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@
// including this file, may be copied, modified, propagated, or distributed
// except according to the terms contained in the LICENSE file.

const { withKnex, checkMigrations } = require('../model/migrate');
const { withKnex, checkKnexMigrations, checkPostKnexMigrations } = require('../model/migrate');

// REVIEW why is check-migrations required in the first place?

(async () => {
try {
await withKnex(require('config').get('default.database'))(checkMigrations);
const config = require('config').get('default.database');
await withKnex(config)(checkKnexMigrations);
await checkPostKnexMigrations(config);
} catch (err) {
console.error('Error:', err.message);
process.exit(1);
Expand Down
13 changes: 10 additions & 3 deletions lib/bin/run-migrations.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@
// including this file, may be copied, modified, propagated, or distributed
// except according to the terms contained in the LICENSE file.

const { withKnex, migrate } = require('../model/migrate');
const { // eslint-disable-line object-curly-newline
withKnex,
knexMigrations,

postKnexMigrations,
} = require('../model/migrate'); // eslint-disable-line object-curly-newline

(async () => {
try {
await withKnex(require('config').get('default.database'))(migrate);
const config = require('config').get('default.database');
await withKnex(config)(knexMigrations);
await postKnexMigrations(config);
} catch (err) {
console.error('Error:', err.message);
console.error(err);
process.exit(1);
}
})();
185 changes: 182 additions & 3 deletions lib/model/migrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,207 @@
// This is a variety of functions helpful for connecting to and performing
// top-level operations with a database, like migrations.

const { lstatSync, readdirSync } = require('node:fs');

const _ = require('lodash'); // eslint-disable-line import/no-extraneous-dependencies
const knex = require('knex');
const pg = require('pg');
const { knexConnection } = require('../util/db');

// Connects to the postgres database specified in configuration and returns it.
const knexConnect = (config) => knex({ client: 'pg', connection: knexConnection(config) });

const legacyPath = `${__dirname}/migrations`; // TODO rename to /migrations/legacy
const postKnexPath = `${__dirname}/migrations-post-knex`; // TODO rename to /migrations/current

// Connects to a database, passes it to a function for operations, then ensures its closure.
const withKnex = (config) => (mutator) => {
const db = knexConnect(config);
return mutator(db).finally(() => db.destroy());
};

// Given a database, initiates migrations on it.
const migrate = (db) => db.migrate.latest({ directory: `${__dirname}/migrations` });
const knexMigrations = (db) => db.migrate.latest({ directory: legacyPath });

const withPg = async (config, fn) => {
const log = (...args) => console.log('[withPg]', ...args); // eslint-disable-line no-console
log('ENTRY');

const { Client } = pg;
const client = new Client(config);

log('client created');

log('Connecting to client...');
await client.connect();
log('Client connected OK.');

try {
await fn(client);
} finally {
log('Ending client...');
await client.end();
log('Client ended.');
}
};

const getPostKnexMigrationsToRun = async client => {
const log = (...args) => console.log('[getPostKnexMigrationsToRun]', ...args); // eslint-disable-line no-console
log('ENTRY');

const migrationsDir = postKnexPath;
const allMigrations = readdirSync(migrationsDir)
.filter(f => f.endsWith('.js') && lstatSync(`${migrationsDir}/${f}`).isFile())
.sort();
log('allMigrations:', allMigrations);

const alreadyRun = (await client.query('SELECT name FROM post_knex_migrations')).rows.map(r => r.name);
log('alreadyRun:', alreadyRun);

const toRunNames = allMigrations.filter(m => !alreadyRun.includes(m));
log('toRunNames:', toRunNames);

const toRun = toRunNames.map(name => {
const path = `${migrationsDir}/${name}`;
const migration = require(path); // eslint-disable-line import/no-dynamic-require
return { name, path, migration };
});
log('toRun:', toRun);

return toRun;
};

const postKnexMigrations = async (config) => {
const log = (...args) => console.log('[postKnexMigrations]', ...args); // eslint-disable-line no-console
log('ENTRY');

// In the main, this migrator is written to behave similarly to knex's:
//
// * expects transaction property async .up({ raw })
// * provides implementation of db.raw()
// * runs all new migrations in the same transaction
//
// Notable differences
//
// * uses new post_knex_migrations table
// * ONLY provides db.raw()-equivalent function to transactions - no knex query builder etc.
// * ONLY implements up(); will throw if a transaction has other properties, except for `down()` which is currently ignored TODO implement this if it's useful to devs
// * gets list of migrations to run _after_ acquiring db lock
// * sets migration_time to be the start of the migration batch's transaction rather than some other intermediate time

await withPg(config, async client => {
try {
log('Starting transaction...');
await client.query('BEGIN'); // TODO do we need a specific transaction type?
log('Transaction started.');

log('Acquiring knex lock...');
// TODO do this... if it's useful. Need to think of _some_ way to prevent new migrations and old migrations running simultaneously.
log('Knex lock acquired');

log('Creating new table if not exists...');
// N.B. this table is created to be similar to the legacy knex-created table.
// The key difference is that name, batch and migration_time columns are
// not nullable.
const maxLen = 255;
await client.query(`
CREATE TABLE IF NOT EXISTS post_knex_migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(${maxLen}) NOT NULL,
batch INTEGER NOT NULL,
migration_time TIMESTAMP(3) WITH TIME ZONE NOT NULL
)`);
log('Table now definitely exists.');

log('Acquiring lock on post_knex_migrations table...');
await client.query('LOCK TABLE post_knex_migrations IN EXCLUSIVE MODE NOWAIT');
log('Lock acquired.');

const toRun = await getPostKnexMigrationsToRun(client);

if (!toRun.length) {
log('No migrations to run - exiting.');
await client.query('ROLLBACK');
return;
}

log('Validating', toRun.length, 'migrations...');
for (const { migration, name } of toRun) {
log('Validing migration:', name, '...');

if (name.length > maxLen) throw new Error(`Migration name '${name}' is too long - max length is ${maxLen}, but got ${name.length}`);

// TODO check for illegal chars in name?

const keys = Object.keys(migration);
const unexpectedKeys = _.omit(keys, 'up', 'down');
if (unexpectedKeys.length) throw new Error(`Unexpected key(s) found in migration ${name}: ${unexpectedKeys}`);

if (!migration.up) throw new Error(`Required prop .up not found in migration ${name}`);
if (typeof migration.up !== 'function') {
throw new Error(`Required prop .up of migration ${name} has incorrect type - expected 'function', but got '${typeof migration.up}'`);
}

if (migration.down && typeof migration.down !== 'function') {
throw new Error(`Optional prop .down of migration ${name} has incorrect type - expected 'function' but got '${typeof migration.down}'`);
}

log('Migration', name, 'looks valid.');
}
log(toRun.length, 'migrations look valid.');

log('Running', toRun.length, 'migrations...');
for (const { migration, name } of toRun) {
log('Running migration:', name);
await migration.up(client); // eslint-disable-line no-await-in-loop
log('Migration complete:', name);
}
log(toRun.length, 'migrations ran OK.');

const { lastBatch } = (await client.query(`SELECT COALESCE(MAX(batch), 0) AS "lastBatch" FROM post_knex_migrations`)).rows[0];
log('lastBatch:', lastBatch);

// Note that migration_time is CLOCK_TIMESTAMP() to match knex implementation.
// TODO confirm in relevant version of knex source code that this is actually the case, and link here.
const namesJson = JSON.stringify(toRun.map(m => m.name));
// See: https://www.postgresql.org/docs/current/functions-json.html
await client.query(`
INSERT INTO post_knex_migrations(name, batch, migration_time)
SELECT value#>>'{}' AS name
, ${lastBatch + 1} AS batch
, CLOCK_TIMESTAMP() AS migration_time
FROM JSON_ARRAY_ELEMENTS($1)
`, [ namesJson ]);

log('Committing migrations...');
await client.query('COMMIT');
log('Migrations committed.');
} catch (err) {
log('Caught error; rolling back', err);
await client.query('ROLLBACK');
throw err;
}
});
};

// Checks for pending migrations and returns an exit code of 1 if any are
// still pending/unapplied (e.g. automatically running migrations just failed).
const checkMigrations = (db) => db.migrate.list({ directory: `${__dirname}/migrations` })
const checkKnexMigrations = (db) => db.migrate.list({ directory: legacyPath })
.then((res) => {
if (res[1].length > 0)
process.exitCode = 1;
});

module.exports = { checkMigrations, knexConnect, withKnex, migrate };
// Checks for pending migrations and returns an exit code of 1 if any are
// still pending/unapplied (e.g. automatically running migrations just failed).
const checkPostKnexMigrations = async config => {
const log = (...args) => console.log('[checkPostKnexMigrations]', ...args); // eslint-disable-line no-console
log('ENTRY');

await withPg(config, async client => {
const toRun = await getPostKnexMigrationsToRun(client);
if (toRun.length) process.exitCode = 1;
});
};

module.exports = { checkKnexMigrations, checkPostKnexMigrations, knexConnect, withKnex, withPg, knexMigrations, postKnexMigrations };
6 changes: 6 additions & 0 deletions lib/model/migrations-post-knex/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": "../../../.eslintrc.json",
"rules": {
"no-restricted-modules": [ "error", { "patterns": [ "../*" ] } ]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2025 ODK Central Developers
// See the NOTICE file at the top-level directory of this distribution and at
// https://github.com/getodk/central-backend/blob/master/NOTICE.
// This file is part of ODK Central. It is subject to the license terms in
// the LICENSE file found in the top-level directory of this distribution and at
// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
// including this file, may be copied, modified, propagated, or distributed
// except according to the terms contained in the LICENSE file.

const up = (db) => db.query(`
ALTER TABLE blobs
ALTER COLUMN "contentType" TYPE TEXT USING(COALESCE("contentType", 'application/octet-stream')),
ALTER COLUMN "contentType" SET DEFAULT 'application/octet-stream',
ALTER COLUMN "contentType" SET NOT NULL
`);

const down = (db) => db.query(`
ALTER TABLE blobs
ALTER COLUMN "contentType" DROP NOT NULL,
ALTER COLUMN "contentType" DROP DEFAULT
`);

module.exports = { up, down };
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const assert = require('node:assert/strict');
const { hash, randomBytes } = require('node:crypto');

const { // eslint-disable-line object-curly-newline
assertTableContents,
describeMigration,
rowsExistFor,
} = require('./utils'); // eslint-disable-line object-curly-newline

describeMigration('20250113-01-disable-nullable-blob-content-types', ({ runMigrationBeingTested }) => {
const aBlobWith = props => {
const randomContent = randomBytes(100);
const md5 = hash('md5', randomContent); // eslint-disable-line no-multi-spaces
const sha = hash('sha1', randomContent);
return { md5, sha, ...props };
};
const aBlob = () => aBlobWith({});

const blob1 = aBlobWith({ contentType: null });
const blob2 = aBlobWith({ contentType: 'text/plain' });

before(async () => {
await rowsExistFor('blobs', blob1, blob2);

await runMigrationBeingTested();
});

it('should change existing NULL contentType values to application/octet-stream, and preserve non-NULL values', async () => {
await assertTableContents('blobs',
{ ...blob1, contentType: 'application/octet-stream' },
{ ...blob2, contentType: 'text/plain' },
);
});

it(`should create new blobs with contentType 'application/octet-stream' (contentType not supplied)`, async () => {
const { md5, sha } = aBlob();

const created = await db.oneFirst(sql`
INSERT INTO blobs (md5, sha, "contentType")
VALUES(${md5}, ${sha}, DEFAULT)
RETURNING "contentType"
`);

assert.equal(created, 'application/octet-stream');
});

it(`should create new blobs with contentType 'application/octet-stream' (supplied DEFAULT contentType)`, async () => {
const { md5, sha } = aBlob();

const created = await db.oneFirst(sql`
INSERT INTO blobs (md5, sha, "contentType")
VALUES(${md5}, ${sha}, DEFAULT)
RETURNING "contentType"
`);

assert.equal(created, 'application/octet-stream');
});
});
Loading
Loading