mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(cli): rollback
command for alteration (#2975)
This commit is contained in:
parent
3a5025740a
commit
d868e6ee49
17 changed files with 190 additions and 56 deletions
5
.changeset-staged/breezy-socks-joke.md
Normal file
5
.changeset-staged/breezy-socks-joke.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/cli": minor
|
||||
---
|
||||
|
||||
Add `logto database alteration rollback` command for running `down()` alteration scripts
|
27
.github/workflows/main.yml
vendored
27
.github/workflows/main.yml
vendored
|
@ -119,10 +119,6 @@ jobs:
|
|||
run: pnpm i && pnpm prepack
|
||||
# ** End **
|
||||
|
||||
- name: Check alteration sequence
|
||||
working-directory: ./fresh
|
||||
run: node .scripts/check-alterations-sequence.js
|
||||
|
||||
- name: Setup Postgres
|
||||
uses: ikalnytskyi/action-setup-postgres@v4
|
||||
|
||||
|
@ -153,5 +149,26 @@ jobs:
|
|||
# ** End **
|
||||
|
||||
# ** Setup old databases and compare (test `down`) **
|
||||
# TBD
|
||||
- name: Setup old database
|
||||
working-directory: ./alteration
|
||||
run: |
|
||||
cd packages/cli
|
||||
pnpm start db seed
|
||||
env:
|
||||
DB_URL: postgres://postgres:postgres@localhost:5432/old
|
||||
|
||||
- name: Revert fresh database to old
|
||||
working-directory: ./fresh
|
||||
run: pnpm cli db alt r v1.0.0-beta.11
|
||||
env:
|
||||
DB_URL: postgres://postgres:postgres@localhost:5432/fresh
|
||||
|
||||
- name: Compare manifests
|
||||
working-directory: ./fresh
|
||||
run: node .scripts/compare-database.js fresh old
|
||||
# ** End **
|
||||
|
||||
- name: Check alteration sequence
|
||||
continue-on-error: true # Temporarily skip this as we are fixing previous down scripts
|
||||
working-directory: ./fresh
|
||||
run: node .scripts/check-alterations-sequence.js
|
||||
|
|
|
@ -29,7 +29,7 @@ for (const alteration of committedAlterations) {
|
|||
|
||||
if (index < allAlterations.length - committedAlterations.length) {
|
||||
throw new Error(
|
||||
`Wrong alteration sequence for commited file: ${alteration}\nAll timestamps of committed alteration files should be greater than the biggest one in the base branch.`
|
||||
`Wrong alteration sequence for committed file: ${alteration}\nAll timestamps of committed alteration files should be greater than the biggest one in the base branch.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,6 +49,7 @@ const queryDatabaseManifest = async (database) => {
|
|||
order by tablename, indexname asc;
|
||||
`);
|
||||
|
||||
// Omit generated ids and values
|
||||
return {
|
||||
tables: omitArray(tables, 'table_catalog'),
|
||||
columns: omitArray(columns, 'table_catalog', 'udt_catalog', 'ordinal_position', 'dtd_identifier'),
|
||||
|
|
|
@ -86,6 +86,9 @@
|
|||
},
|
||||
"eslintConfig": {
|
||||
"extends": "@silverhand",
|
||||
"rules": {
|
||||
"complexity": ["error", 11]
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"src/package-json.ts"
|
||||
]
|
||||
|
|
|
@ -28,19 +28,19 @@ const { getCurrentDatabaseAlterationTimestamp } = await mockEsmWithActual(
|
|||
})
|
||||
);
|
||||
|
||||
const { getUndeployedAlterations } = await import('./index.js');
|
||||
const { getAvailableAlterations } = await import('./index.js');
|
||||
|
||||
describe('getUndeployedAlterations()', () => {
|
||||
describe('getAvailableAlterations()', () => {
|
||||
it('returns all files if database timestamp is 0', async () => {
|
||||
getCurrentDatabaseAlterationTimestamp.mockResolvedValue(0);
|
||||
|
||||
await expect(getUndeployedAlterations(pool)).resolves.toEqual(files);
|
||||
await expect(getAvailableAlterations(pool)).resolves.toEqual(files);
|
||||
});
|
||||
|
||||
it('returns files whose timestamp is greater then database timestamp', async () => {
|
||||
getCurrentDatabaseAlterationTimestamp.mockResolvedValue(1_663_923_770);
|
||||
|
||||
await expect(getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]);
|
||||
await expect(getAvailableAlterations(pool)).resolves.toEqual([files[1], files[2]]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import {
|
|||
import { log } from '../../../utilities.js';
|
||||
import type { AlterationFile } from './type.js';
|
||||
import { getAlterationFiles, getTimestampFromFilename } from './utils.js';
|
||||
import { chooseAlterationsByVersion } from './version.js';
|
||||
import { chooseAlterationsByVersion, chooseRevertAlterationsByVersion } from './version.js';
|
||||
|
||||
const importAlterationScript = async (filePath: string): Promise<AlterationScript> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
|
@ -33,24 +33,44 @@ export const getLatestAlterationTimestamp = async () => {
|
|||
return getTimestampFromFilename(lastFile.filename);
|
||||
};
|
||||
|
||||
export const getUndeployedAlterations = async (pool: DatabasePool) => {
|
||||
export const getAvailableAlterations = async (
|
||||
pool: DatabasePool,
|
||||
compareMode: 'gt' | 'lte' = 'gt'
|
||||
) => {
|
||||
const databaseTimestamp = await getCurrentDatabaseAlterationTimestamp(pool);
|
||||
|
||||
const files = await getAlterationFiles();
|
||||
|
||||
return files.filter(({ filename }) => getTimestampFromFilename(filename) > databaseTimestamp);
|
||||
return files.filter(({ filename }) =>
|
||||
compareMode === 'gt'
|
||||
? getTimestampFromFilename(filename) > databaseTimestamp
|
||||
: getTimestampFromFilename(filename) <= databaseTimestamp
|
||||
);
|
||||
};
|
||||
|
||||
const deployAlteration = async (
|
||||
pool: DatabasePool,
|
||||
{ path: filePath, filename }: AlterationFile
|
||||
{ path: filePath, filename }: AlterationFile,
|
||||
action: 'up' | 'down' = 'up'
|
||||
) => {
|
||||
const { up } = await importAlterationScript(filePath);
|
||||
const { up, down } = await importAlterationScript(filePath);
|
||||
|
||||
try {
|
||||
await pool.transaction(async (connection) => {
|
||||
if (action === 'up') {
|
||||
await up(connection);
|
||||
await updateDatabaseTimestamp(connection, getTimestampFromFilename(filename));
|
||||
}
|
||||
|
||||
if (action === 'down') {
|
||||
await down(connection);
|
||||
|
||||
const newTimestamp = getTimestampFromFilename(filename) - 1;
|
||||
|
||||
if (newTimestamp > 0) {
|
||||
await updateDatabaseTimestamp(connection, newTimestamp);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
@ -63,7 +83,7 @@ const deployAlteration = async (
|
|||
);
|
||||
}
|
||||
|
||||
log.info(`Run alteration ${filename} succeeded`);
|
||||
log.info(`Run alteration ${filename} \`${action}()\` function succeeded`);
|
||||
};
|
||||
|
||||
const alteration: CommandModule<unknown, { action: string; target?: string }> = {
|
||||
|
@ -72,7 +92,7 @@ const alteration: CommandModule<unknown, { action: string; target?: string }> =
|
|||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('action', {
|
||||
describe: 'The action to perform, now it only accepts `deploy` and `list`',
|
||||
describe: 'The action to perform, accepts `list`, `deploy`, and `rollback` (or `r`).',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
|
@ -90,7 +110,7 @@ const alteration: CommandModule<unknown, { action: string; target?: string }> =
|
|||
} else if (action === 'deploy') {
|
||||
const pool = await createPoolFromConfig();
|
||||
const alterations = await chooseAlterationsByVersion(
|
||||
await getUndeployedAlterations(pool),
|
||||
await getAvailableAlterations(pool),
|
||||
target
|
||||
);
|
||||
|
||||
|
@ -106,6 +126,27 @@ const alteration: CommandModule<unknown, { action: string; target?: string }> =
|
|||
await deployAlteration(pool, alteration);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
} else if (['rollback', 'r'].includes(action)) {
|
||||
const pool = await createPoolFromConfig();
|
||||
const alterations = await chooseRevertAlterationsByVersion(
|
||||
await getAvailableAlterations(pool, 'lte'),
|
||||
target ?? ''
|
||||
);
|
||||
|
||||
log.info(
|
||||
`Found ${alterations.length} alteration${conditionalString(
|
||||
alterations.length > 1 && 's'
|
||||
)} to revert`
|
||||
);
|
||||
|
||||
// The await inside the loop is intended, alterations should run in order
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||
for (const alteration of alterations.slice().reverse()) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deployAlteration(pool, alteration, 'down');
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
} else {
|
||||
log.error('Unsupported action');
|
||||
|
|
|
@ -6,12 +6,24 @@ import { SemVer, compare, eq, gt } from 'semver';
|
|||
import { findLastIndex, isTty, log } from '../../../utilities.js';
|
||||
import type { AlterationFile } from './type.js';
|
||||
|
||||
const getVersionStringFromFilename = (filename: string) =>
|
||||
filename.split('-')[0]?.replaceAll('_', '-') ?? 'unknown';
|
||||
|
||||
const getVersionFromFilename = (filename: string) => {
|
||||
try {
|
||||
return new SemVer(filename.split('-')[0]?.replaceAll('_', '-') ?? 'unknown');
|
||||
return new SemVer(getVersionStringFromFilename(filename));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const getAlterationVersions = (alterations: readonly AlterationFile[]) =>
|
||||
alterations
|
||||
.map(({ filename }) => getVersionFromFilename(filename))
|
||||
.filter((version): version is SemVer => version instanceof SemVer)
|
||||
// Cannot use `Set` to deduplicate since it's a class
|
||||
.filter((version, index, self) => index === self.findIndex((another) => eq(version, another)))
|
||||
.slice()
|
||||
.sort((i, j) => compare(j, i));
|
||||
|
||||
const latestTag = 'latest';
|
||||
const nextTag = 'next';
|
||||
|
||||
|
@ -35,14 +47,7 @@ export const chooseAlterationsByVersion = async (
|
|||
return alterations.slice(0, endIndex + 1);
|
||||
}
|
||||
|
||||
const versions = alterations
|
||||
.map(({ filename }) => getVersionFromFilename(filename))
|
||||
.filter((version): version is SemVer => version instanceof SemVer)
|
||||
// Cannot use `Set` to deduplicate since it's a class
|
||||
.filter((version, index, self) => index === self.findIndex((another) => eq(version, another)))
|
||||
.slice()
|
||||
.sort((i, j) => compare(j, i));
|
||||
|
||||
const versions = getAlterationVersions(alterations);
|
||||
const initialSemVersion = conditional(
|
||||
initialVersion && initialVersion !== latestTag && new SemVer(initialVersion)
|
||||
);
|
||||
|
@ -91,3 +96,22 @@ export const chooseAlterationsByVersion = async (
|
|||
return version && !gt(version, targetVersion);
|
||||
});
|
||||
};
|
||||
|
||||
export const chooseRevertAlterationsByVersion = async (
|
||||
alterations: readonly AlterationFile[],
|
||||
version: string
|
||||
) => {
|
||||
const semVersion = new SemVer(version);
|
||||
|
||||
log.info(`Revert target ${chalk.green(semVersion.version)}`);
|
||||
|
||||
return alterations.filter(({ filename }) => {
|
||||
if (getVersionStringFromFilename(filename) === nextTag) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const version = getVersionFromFilename(filename);
|
||||
|
||||
return version && gt(version, semVersion);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { getUndeployedAlterations } from '@logto/cli/lib/commands/database/alteration/index.js';
|
||||
import { getAvailableAlterations } from '@logto/cli/lib/commands/database/alteration/index.js';
|
||||
import chalk from 'chalk';
|
||||
import type { DatabasePool } from 'slonik';
|
||||
|
||||
export const checkAlterationState = async (pool: DatabasePool) => {
|
||||
const alterations = await getUndeployedAlterations(pool);
|
||||
const alterations = await getAvailableAlterations(pool);
|
||||
|
||||
if (alterations.length === 0) {
|
||||
return;
|
||||
|
|
|
@ -5,12 +5,13 @@ import type { AlterationScript } from '../lib/types/alteration.js';
|
|||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
await pool.query(sql`
|
||||
alter table sign_in_experiences drop column sign_in_methods
|
||||
alter table sign_in_experiences drop column sign_in_methods;
|
||||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(sql`
|
||||
alter table sign_in_experiences add column sign_in_methods jsonb not null default '{}'::jsonb,
|
||||
alter table sign_in_experiences add column sign_in_methods jsonb not null default '{}'::jsonb;
|
||||
alter table sign_in_experiences alter column sign_in_methods drop default;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -5,13 +5,18 @@ import type { AlterationScript } from '../lib/types/alteration.js';
|
|||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
await pool.query(sql`
|
||||
alter type passcode_type add value 'Continue'
|
||||
alter type passcode_type add value 'Continue';
|
||||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(sql`
|
||||
drop type passcode_type
|
||||
create type passcode_type as enum ('SignIn', 'Register', 'ForgotPassword');
|
||||
create type passcode_type_new as enum ('SignIn', 'Register', 'ForgotPassword');
|
||||
delete from passcodes where "type"='Continue';
|
||||
alter table passcodes
|
||||
alter column "type" type passcode_type_new
|
||||
using ("type"::text::passcode_type_new);
|
||||
drop type passcode_type;
|
||||
alter type passcode_type_new rename to passcode_type;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -31,11 +31,13 @@ const alteration: AlterationScript = {
|
|||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(sql`
|
||||
drop table permissions;
|
||||
alter index roles_pkey rename to roles_pkey_1;
|
||||
create unique index roles_pkey on roles using btree(name)
|
||||
drop index roles_pkey_1;
|
||||
alter table roles drop column id;
|
||||
drop table roles_scopes cascade;
|
||||
drop table scopes cascade;
|
||||
alter table roles
|
||||
drop constraint if exists roles_pkey,
|
||||
drop column id,
|
||||
add primary key (name);
|
||||
drop index roles__name;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -72,7 +72,8 @@ const alteration: AlterationScript = {
|
|||
const rows = await pool.many<SignInExperience>(sql`select * from sign_in_experiences`);
|
||||
|
||||
await pool.query(sql`
|
||||
alter table sign_in_experiences add column terms_of_use jsonb not null default '{}'::jsonb
|
||||
alter table sign_in_experiences add column terms_of_use jsonb not null default '{}'::jsonb;
|
||||
alter table sign_in_experiences alter column terms_of_use drop default;
|
||||
`);
|
||||
|
||||
await Promise.all(rows.map(async (row) => rollbackTermsOfUse(row, pool)));
|
||||
|
|
|
@ -62,7 +62,9 @@ const alteration: AlterationScript = {
|
|||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await pool.query(sql`
|
||||
update users set role_names = role_names || '[${role.name}]'::jsonb where id = ${relation.userId}
|
||||
update users
|
||||
set role_names = role_names || ${sql.jsonb([role.name])}
|
||||
where id = ${relation.userId}
|
||||
`);
|
||||
}
|
||||
|
||||
|
|
|
@ -5,14 +5,18 @@ import type { AlterationScript } from '../lib/types/alteration.js';
|
|||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
await pool.query(sql`
|
||||
ALTER TABLE roles_scopes ALTER COLUMN role_id SET NOT NULL;
|
||||
ALTER TABLE roles_scopes ALTER COLUMN scope_id SET NOT NULL;
|
||||
alter table roles_scopes alter column role_id set not null;
|
||||
alter table roles_scopes alter column scope_id set not null;
|
||||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(sql`
|
||||
ALTER TABLE roles_scopes ALTER COLUMN role_id DROP NOT NULL;
|
||||
ALTER TABLE roles_scopes ALTER COLUMN scope_id DROP NOT NULL;
|
||||
alter table roles_scopes
|
||||
drop constraint if exists roles_permissison_pkey,
|
||||
drop constraint if exists roles_scopes_pkey;
|
||||
alter table roles_scopes alter column role_id drop not null;
|
||||
alter table roles_scopes alter column scope_id drop not null;
|
||||
alter table roles_scopes add primary key (role_id, scope_id)
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -12,7 +12,11 @@ const alteration: AlterationScript = {
|
|||
alter table users_roles
|
||||
drop constraint users_roles_role_id_fkey;
|
||||
`);
|
||||
await pool.query(sql`drop index roles_pkey;`);
|
||||
await pool.query(sql`
|
||||
alter table roles
|
||||
drop constraint if exists roles_pkey;
|
||||
`);
|
||||
await pool.query(sql`drop index if exists roles_pkey;`);
|
||||
await pool.query(sql`
|
||||
alter table roles
|
||||
add primary key (id);
|
||||
|
@ -20,7 +24,8 @@ const alteration: AlterationScript = {
|
|||
|
||||
await pool.query(sql`
|
||||
alter table roles_scopes
|
||||
drop constraint roles_permissison_pkey,
|
||||
drop constraint if exists roles_permissison_pkey,
|
||||
drop constraint if exists roles_scopes_pkey,
|
||||
add primary key (role_id, scope_id);
|
||||
`);
|
||||
|
||||
|
@ -34,7 +39,30 @@ const alteration: AlterationScript = {
|
|||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
throw new Error('Not implemented');
|
||||
await pool.query(sql`
|
||||
alter table roles_scopes
|
||||
drop constraint roles_scopes_role_id_fkey;
|
||||
`);
|
||||
await pool.query(sql`
|
||||
alter table users_roles
|
||||
drop constraint users_roles_role_id_fkey;
|
||||
`);
|
||||
|
||||
await pool.query(sql`
|
||||
alter table roles_scopes
|
||||
drop constraint if exists roles_permissison_pkey,
|
||||
drop constraint if exists roles_scopes_pkey,
|
||||
add primary key (role_id, scope_id);
|
||||
`);
|
||||
|
||||
await pool.query(sql`
|
||||
alter table users_roles
|
||||
add foreign key (role_id) references roles (id) on update cascade on delete cascade;
|
||||
`);
|
||||
await pool.query(sql`
|
||||
alter table roles_scopes
|
||||
add foreign key (role_id) references roles (id) on update cascade on delete cascade;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in a new issue