Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import { readFileSync } from 'fs';
2import { resolve as pathResolve } from 'path';
3import {
4 makeSequelizeUmzugStorage,
5 SCRIPT_TEMPLATES_DIR_ABSOLUTE_PATH,
6 wrapMigration,
7 type DatabaseConfig,
8} from '@roostorg/db-migrator';
9import { Kysely, PostgresDialect } from 'kysely';
10import pg from 'pg';
11import {
12 QueryTypes,
13 Sequelize,
14 type Options,
15 type QueryInterface,
16} from 'sequelize';
17import { Umzug } from 'umzug';
18
19const postgresSupportedScriptFormats = ['sql', 'cjs'] as const;
20type PostgresSupportedScriptFormats =
21 (typeof postgresSupportedScriptFormats)[number];
22
23type PostgresContext = QueryInterface & { kysely: Kysely<unknown> };
24
25export function makePostgresDatabaseConfig(opts: {
26 defaultScriptFormat: PostgresSupportedScriptFormats;
27 scriptsDirectory: string;
28 driverOpts: Options & { schema: string };
29 maintenanceDatabase?: string;
30}): DatabaseConfig<PostgresSupportedScriptFormats, PostgresContext> {
31 const { driverOpts, scriptsDirectory, defaultScriptFormat } = opts;
32 // DB used for CREATE/DROP DATABASE (can't be the target DB itself).
33 // Defaults to `postgres`; some managed providers use a different name
34 // (e.g. `defaultdb`).
35 const maintenanceDatabase = opts.maintenanceDatabase ?? 'postgres';
36
37 return {
38 supportedEnvironments: ['staging', 'prod'],
39 supportedScriptFormats: postgresSupportedScriptFormats,
40 defaultScriptFormat,
41 scriptsDirectory,
42
43 createStorage() {
44 return makeSequelizeUmzugStorage(new Sequelize(driverOpts), {
45 charset: 'utf8',
46 collate: 'utf8_unicode_ci',
47 schema: driverOpts.schema,
48 });
49 },
50
51 createContext() {
52 // For backwards compatibility w/ existing migrations, the context
53 // object that we give to umzug needs to continue to be a sequelize
54 // QueryInterface object. However, some migrations need to be able to
55 // use kysely, so we just attach a kysely instance directly onto the
56 // existing object, exploiting JS dynamism.
57 const sequelize = new Sequelize(driverOpts);
58 const queryInterface = sequelize.getQueryInterface() as QueryInterface & {
59 kysely: Kysely<unknown>;
60 };
61
62 queryInterface.kysely = new Kysely({
63 dialect: new PostgresDialect({
64 pool: new pg.Pool({
65 ...driverOpts,
66 user: driverOpts.username,
67 }),
68 }),
69 });
70 return queryInterface;
71 },
72
73 destroyContext(context) {
74 return context.sequelize.close();
75 },
76
77 resolveScript(params) {
78 const { path } = params;
79 const { sequelize } = params.context;
80
81 const baseResult = path.endsWith('.cjs')
82 ? Umzug.defaultResolver(params)
83 : {
84 name: params.name,
85 up() {
86 const sql = readFileSync(path).toString();
87 return sequelize.query(sql);
88 },
89 };
90
91 async function validatePostgresViews() {
92 const viewNames = await sequelize.query<{
93 viewName: string;
94 schemaName: string;
95 }>(
96 `select
97 table_schema as "schemaName",
98 table_name as "viewName"
99 from information_schema.views
100 where LOWER(table_schema) not in ('information_schema', 'pg_catalog')`,
101 { type: QueryTypes.SELECT },
102 );
103
104 await Promise.all(
105 viewNames.map(async (it) =>
106 sequelize.query(
107 `SELECT * FROM "${it.schemaName}"."${it.viewName}" LIMIT 10`,
108 { type: QueryTypes.SELECT },
109 ),
110 ),
111 );
112 }
113
114 return wrapMigration({ runAfter: validatePostgresViews }, baseResult);
115 },
116
117 getTemplate(filePath: string) {
118 return filePath.endsWith('.cjs')
119 ? readFileSync(
120 pathResolve(SCRIPT_TEMPLATES_DIR_ABSOLUTE_PATH, './sequelize.cjs'),
121 'utf8',
122 )
123 : '';
124 },
125
126 async dropDbAndDisconnect() {
127 const targetDbconn = new Sequelize(driverOpts);
128 await targetDbconn.authenticate();
129 await targetDbconn.close();
130
131 const sequelize = new Sequelize({
132 ...driverOpts,
133 database: maintenanceDatabase,
134 });
135 await sequelize.query(
136 `DROP DATABASE "${driverOpts.database}" WITH (FORCE);`,
137 );
138 await sequelize.close();
139 },
140
141 async prepareDbAndDisconnect() {
142 const sequelize = new Sequelize({
143 ...driverOpts,
144 database: maintenanceDatabase,
145 });
146 const existing = await sequelize.query(
147 `SELECT 1 FROM pg_database WHERE datname = ?`,
148 { replacements: [driverOpts.database], type: QueryTypes.SELECT },
149 );
150 if (existing.length === 0) {
151 // DDL identifiers can't be bound; driverOpts.database comes from
152 // trusted config (env vars), not user input.
153 await sequelize.query(`CREATE DATABASE "${driverOpts.database}";`);
154 }
155 await sequelize.close();
156 },
157 };
158}