Skip to main content
Glama
migrate.ts50.1 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { FileBuilder } from '@medplum/core'; import { deepClone, deepEquals, getResourceTypes, indexSearchParameterBundle, indexStructureDefinitionBundle, SearchParameterType, } from '@medplum/core'; import { readJson, SEARCH_PARAMETER_BUNDLE_FILES } from '@medplum/definitions'; import type { Bundle, ResourceType, SearchParameter } from '@medplum/fhirtypes'; import { escapeIdentifier } from 'pg'; import { systemResourceProjectId } from '../constants'; import { getStandardAndDerivedSearchParameters } from '../fhir/lookups/util'; import type { SearchParameterImplementation } from '../fhir/searchparameter'; import { getSearchParameterImplementation } from '../fhir/searchparameter'; import type { SqlFunctionDefinition } from '../fhir/sql'; import { TokenArrayToTextFn } from '../fhir/sql'; import * as fns from './migrate-functions'; import { ColumnNameAbbreviations, escapeMixedCaseIdentifier, escapeUnicode, getColumns, getFunctionDefinition, parseIndexDefinition, TableNameAbbreviations, tsVectorExpression, } from './migrate-utils'; import type { CheckConstraintDefinition, ColumnDefinition, DbClient, IndexDefinition, IndexType, MigrationAction, MigrationActionResult, SchemaDefinition, TableDefinition, } from './types'; import { SerialColumnTypes } from './types'; // Custom SQL functions should be avoided unless absolutely necessary. // Do not add any functions to this list unless you have a really good reason for doing so. const TargetFunctions: SqlFunctionDefinition[] = [TokenArrayToTextFn]; export function indexStructureDefinitionsAndSearchParameters(): void { indexStructureDefinitionBundle(readJson('fhir/r4/profiles-types.json') as Bundle); indexStructureDefinitionBundle(readJson('fhir/r4/profiles-resources.json') as Bundle); indexStructureDefinitionBundle(readJson('fhir/r4/profiles-medplum.json') as Bundle); for (const filename of SEARCH_PARAMETER_BUNDLE_FILES) { const bundle = readJson(filename) as Bundle<SearchParameter>; indexSearchParameterBundle(bundle); } } export type BuildMigrationOptions = { dbClient: DbClient; dropUnmatchedIndexes?: boolean; skipPostDeployActions?: boolean; allowPostDeployActions?: boolean; analyzeResourceTables?: boolean; writeSchema?: boolean; skipMigration?: boolean; }; export async function buildMigration(b: FileBuilder, options: BuildMigrationOptions): Promise<void> { const actions = await generateMigrationActions(options); writeActionsToBuilder(b, actions); } export function buildSchema(builder: FileBuilder): void { const targetDefinition = buildTargetDefinition(); const actions: MigrationAction[] = []; for (const targetFunction of targetDefinition.functions) { actions.push({ type: 'CREATE_FUNCTION', name: targetFunction.name, createQuery: targetFunction.createQuery, }); } for (const targetTable of targetDefinition.tables) { actions.push({ type: 'CREATE_TABLE', definition: targetTable }); } writeSchema(builder, actions); } export async function generateMigrationActions(options: BuildMigrationOptions): Promise<MigrationAction[]> { const ctx: GenerateActionsContext = { postDeployAction: (action, description) => { if (options.skipPostDeployActions) { console.log(`Skipping post-deploy migration for: ${description}`); return; } if (!options.allowPostDeployActions) { throw new Error(`Post-deploy migration required for: ${description}`); } action(); }, }; const startDefinition = await buildStartDefinition(options); const targetDefinition = buildTargetDefinition(); const actions: MigrationAction[] = []; for (const targetFunction of targetDefinition.functions) { const startFunction = startDefinition.functions.find((f) => f.name === targetFunction.name); if (!startFunction) { actions.push({ type: 'CREATE_FUNCTION', name: targetFunction.name, createQuery: targetFunction.createQuery, }); } } const matchedStartTables = new Set<TableDefinition>(); for (const targetTable of targetDefinition.tables) { const startTable = startDefinition.tables.find((t) => t.name === targetTable.name); if (startTable) { matchedStartTables.add(startTable); actions.push( ...generateColumnsActions(ctx, startTable, targetTable), ...generateIndexesActions(ctx, startTable, targetTable, options), ...generateConstraintsActions(ctx, startTable, targetTable) ); } else { actions.push({ type: 'CREATE_TABLE', definition: targetTable }); } } for (const startTable of startDefinition.tables) { if (!matchedStartTables.has(startTable)) { ctx.postDeployAction( () => { actions.push({ type: 'DROP_TABLE', tableName: startTable.name }); }, `DROP TABLE ${escapeMixedCaseIdentifier(startTable.name)}` ); } } if (options.analyzeResourceTables) { for (const resourceType of getResourceTypes()) { actions.push({ type: 'ANALYZE_TABLE', tableName: resourceType }); } } return actions; } async function buildStartDefinition(options: BuildMigrationOptions): Promise<SchemaDefinition> { const db = options.dbClient; const functions: SqlFunctionDefinition[] = []; for (const func of TargetFunctions) { const def = await getFunctionDefinition(db, func.name); if (def) { functions.push(def); } } const tableNames = await getTableNames(db); const tables: TableDefinition[] = []; for (const tableName of tableNames) { tables.push(await getTableDefinition(db, tableName)); } return { tables, functions }; } async function getTableNames(db: DbClient): Promise<string[]> { const rs = await db.query("SELECT * FROM information_schema.tables WHERE table_schema='public'"); return rs.rows.map((row) => row.table_name); } async function getTableDefinition(db: DbClient, name: string): Promise<TableDefinition> { return { name, columns: await getColumns(db, name), indexes: await getIndexes(db, name), constraints: await getCheckConstraints(db, name), }; } async function getIndexes(db: DbClient, tableName: string): Promise<IndexDefinition[]> { const rs = await db.query(`SELECT indexdef FROM pg_indexes WHERE schemaname='public' AND tablename=$1`, [tableName]); return rs.rows.map((row) => parseIndexDefinition(row.indexdef)); } export function parseIndexName(indexdef: string): string | undefined { return /INDEX "?([^"]+)"? ON/i.exec(indexdef)?.[1]; } export async function getCheckConstraints( db: DbClient, tableName: string ): Promise<(CheckConstraintDefinition & { valid: boolean })[]> { const rs = await db.query<{ table_name: string; conname: string; contype: string; convalidated: boolean; condef: string; }>( `SELECT conrelid::regclass AS table_name, conname, contype, convalidated, pg_get_constraintdef(oid, TRUE) as condef FROM pg_catalog.pg_constraint WHERE connamespace = 'public'::regnamespace AND conrelid IN($1::regclass) AND contype = 'c'`, [escapeIdentifier(tableName)] ); const cds: (CheckConstraintDefinition & { valid: boolean })[] = []; for (const row of rs.rows) { if (row.contype === 'c') { const expressionMatch = /CHECK \((.*)\)/.exec(row.condef); if (!expressionMatch) { throw new Error('Could not parse check constraint expression from ' + row.condef); } cds.push({ name: row.conname, type: 'check', expression: expressionMatch[1], valid: row.convalidated, }); } } return cds; } function buildTargetDefinition(): SchemaDefinition { const result: SchemaDefinition = { tables: [], functions: TargetFunctions }; for (const resourceType of getResourceTypes()) { buildCreateTables(result, resourceType); } buildAddressTable(result); buildContactPointTable(result); buildIdentifierTable(result); buildHumanNameTable(result); buildCodingTable(result); buildCodingPropertyTable(result); buildCodeSystemPropertyTable(result); buildConceptMappingTable(result); buildCodingSystemTable(result); buildConceptMappingAttributeTable(result); buildDatabaseMigrationTable(result); return result; } export function buildCreateTables(result: SchemaDefinition, resourceType: ResourceType): void { const tableDefinition: TableDefinition = { name: resourceType, columns: [ { name: 'id', type: 'UUID', primaryKey: true, notNull: true }, { name: 'content', type: 'TEXT', notNull: true }, { name: 'lastUpdated', type: 'TIMESTAMPTZ', notNull: true }, { name: 'deleted', type: 'BOOLEAN', notNull: true, defaultValue: 'false' }, { name: 'projectId', type: 'UUID', notNull: true }, { name: '__version', type: 'INTEGER', notNull: true }, { name: '_source', type: 'TEXT' }, { name: '_profile', type: 'TEXT[]' }, ], indexes: [ { columns: ['lastUpdated'], indexType: 'btree' }, { columns: ['projectId', 'lastUpdated'], indexType: 'btree' }, { columns: ['projectId'], indexType: 'btree' }, { columns: ['_source'], indexType: 'btree' }, { columns: ['_profile'], indexType: 'gin' }, { columns: ['__version'], indexType: 'btree' }, // This index is used to efficiently paginate through resources during reindexing { columns: ['lastUpdated', '__version'], where: 'deleted = false', indexType: 'btree', indexNameOverride: `${resourceType}_reindex_idx`, }, ], }; if (resourceType !== 'Binary') { tableDefinition.columns.push({ name: 'compartments', type: 'UUID[]', notNull: true }); tableDefinition.indexes.push({ columns: ['compartments'], indexType: 'gin' }); } if (resourceType === 'Project') { tableDefinition.constraints = tableDefinition.constraints ?? []; tableDefinition.constraints.push({ name: 'reserved_project_id_check', type: 'check', expression: `id <> '${systemResourceProjectId}'::uuid`, }); } buildSearchColumns(tableDefinition, resourceType); buildSearchIndexes(tableDefinition, resourceType); result.tables.push( tableDefinition, { name: resourceType + '_History', columns: [ { name: 'versionId', type: 'UUID', primaryKey: true, notNull: true }, { name: 'id', type: 'UUID', notNull: true }, { name: 'content', type: 'TEXT', notNull: true }, { name: 'lastUpdated', type: 'TIMESTAMPTZ', notNull: true }, ], indexes: [ { columns: ['id'], indexType: 'btree' }, { columns: ['lastUpdated'], indexType: 'btree' }, ], }, { name: resourceType + '_References', columns: [ { name: 'resourceId', type: 'UUID', notNull: true }, { name: 'targetId', type: 'UUID', notNull: true }, { name: 'code', type: 'TEXT', notNull: true }, ], compositePrimaryKey: ['resourceId', 'targetId', 'code'], indexes: [{ columns: ['targetId', 'code'], indexType: 'btree', include: ['resourceId'] }], } ); } const IgnoredSearchParameters = new Set(['_id', '_lastUpdated', '_profile', '_compartment', '_source']); function buildSearchColumns(tableDefinition: TableDefinition, resourceType: string): void { for (const searchParam of getStandardAndDerivedSearchParameters(resourceType)) { if (searchParam.type === 'composite') { continue; } if (IgnoredSearchParameters.has(searchParam.code)) { continue; } if (!searchParam.base?.includes(resourceType as ResourceType)) { throw new Error( `${searchParam.id}: SearchParameter.base ${searchParam.base.join(',')} does not include resourceType ${resourceType}` ); } const impl = getSearchParameterImplementation(resourceType, searchParam); for (const column of getSearchParameterColumns(impl)) { const existing = tableDefinition.columns.find((c) => c.name === column.name); if (existing) { if (!columnDefinitionsEqual(tableDefinition, existing, column)) { throw new Error( `Search Parameter ${searchParam.id ?? searchParam.code} attempting to define the same column on ${tableDefinition.name} with conflicting types: ${existing.type} vs ${column.type}` ); } continue; } tableDefinition.columns.push(column); } for (const index of getSearchParameterIndexes(searchParam, impl)) { const existing = tableDefinition.indexes.find((i) => indexDefinitionsEqual(i, index)); if (existing) { continue; } tableDefinition.indexes.push(index); } } for (const add of additionalSearchColumns) { if (add.table !== tableDefinition.name) { continue; } tableDefinition.columns.push({ name: add.column, type: add.type }); tableDefinition.indexes.push({ columns: [add.column], indexType: add.indexType }); } } function getSearchParameterColumns(impl: SearchParameterImplementation): ColumnDefinition[] { switch (impl.searchStrategy) { case 'token-column': { if (impl.type !== SearchParameterType.TEXT) { throw new Error('Expected SearchParameterDetails.type to be TEXT but got ' + impl.type); } const columns = [ { name: impl.tokenColumnName, type: 'UUID[]' }, { name: impl.textSearchColumnName, type: 'TEXT[]' }, { name: impl.sortColumnName, type: 'TEXT' }, ]; return columns; } case 'column': return [getColumnDefinition(impl.columnName, impl)]; case 'lookup-table': { if (impl.sortColumnName) { return [{ name: impl.sortColumnName, type: 'TEXT' }]; } return []; } default: throw new Error('Unexpected searchStrategy: ' + (impl as SearchParameterImplementation).searchStrategy); } } function getSearchParameterIndexes( searchParam: SearchParameter, impl: SearchParameterImplementation ): IndexDefinition[] { switch (impl.searchStrategy) { case 'token-column': { const indexes: IndexDefinition[] = [ { columns: [impl.tokenColumnName], indexType: 'gin' }, { columns: [ { expression: `${TokenArrayToTextFn.name}(${escapeIdentifier(impl.textSearchColumnName)}) gin_trgm_ops`, name: impl.textSearchColumnName + 'Trgm', }, ], indexType: 'gin', }, ]; return indexes; } case 'column': { const indexes: IndexDefinition[] = [{ columns: [impl.columnName], indexType: impl.array ? 'gin' : 'btree' }]; if (!impl.array && (searchParam.code === 'date' || searchParam.code === 'sent')) { indexes.push({ columns: ['projectId', impl.columnName], indexType: 'btree' }); } return indexes; } case 'lookup-table': { if (impl.sortColumnName) { return [{ columns: [impl.sortColumnName], indexType: 'btree' }]; } return []; } default: throw new Error('Unexpected searchStrategy: ' + (impl as SearchParameterImplementation).searchStrategy); } } const additionalSearchColumns: { table: string; column: string; type: string; indexType: IndexType }[] = [ { table: 'MeasureReport', column: 'period_range', type: 'TSTZRANGE', indexType: 'gist' }, ]; function getColumnDefinition(name: string, impl: SearchParameterImplementation): ColumnDefinition { let baseColumnType: string; switch (impl.type) { case SearchParameterType.BOOLEAN: baseColumnType = 'BOOLEAN'; break; case SearchParameterType.DATE: baseColumnType = 'DATE'; break; case SearchParameterType.DATETIME: baseColumnType = 'TIMESTAMPTZ'; break; case SearchParameterType.NUMBER: case SearchParameterType.QUANTITY: if ('columnName' in impl && impl.columnName === 'priorityOrder') { baseColumnType = 'INTEGER'; } else { baseColumnType = 'DOUBLE PRECISION'; } break; default: baseColumnType = 'TEXT'; break; } if (impl.searchStrategy === 'token-column') { if (baseColumnType.toLocaleUpperCase() !== 'TEXT') { throw new Error('Token columns must have TEXT column type'); } return { name, type: 'TEXT[]' }; } return { name, type: impl.array ? baseColumnType + '[]' : baseColumnType, notNull: false }; } function buildSearchIndexes(result: TableDefinition, resourceType: ResourceType): void { if (resourceType === 'UserConfiguration') { const nameCol = result.columns.find((c) => c.name === 'name'); if (!nameCol) { throw new Error('Could not find UserConfiguration.name column'); } nameCol.defaultValue = "''::text"; } if (resourceType === 'User') { result.indexes.push( { columns: ['project', 'email'], indexType: 'btree', unique: true }, { columns: ['project', 'externalId'], indexType: 'btree', unique: true } ); } if (resourceType === 'Encounter') { result.indexes.push({ columns: ['compartments', 'deleted', 'appointment'], indexType: 'gin', unique: false }); } // uniqueness of SearchParameter-based indexes cannot be specified anywhere, so do it manually here // perhaps this should also be moved to getSearchParameterDetails. Or preferably, where ever the // implementation-specific parts of SearchParameterDetails are moved to? // Or maybe even better would be an extension on the SearchParameter resource itself that // getSearchParameterDetails looks for if (resourceType === 'DomainConfiguration') { const domainIdx = result.indexes.find((i) => i.columns.length === 1 && i.columns[0] === 'domain'); if (!domainIdx) { throw new Error('DomainConfiguration.domain index not found'); } domainIdx.unique = true; } if (resourceType === 'ServiceRequest') { const orderDetail = result.columns.find((c) => c.name === 'orderDetail'); if (!orderDetail) { throw new Error('Could not find ServiceRequest.orderDetail column'); } orderDetail.defaultValue = "'{}'::text[]"; } if (resourceType === 'ProjectMembership') { const profileCol = result.columns.find((c) => c.name === 'profile'); if (!profileCol) { throw new Error('Could not find ProjectMembership.profile column'); } profileCol.defaultValue = "''::text"; result.indexes.push( { columns: ['project', 'externalId'], indexType: 'btree', unique: true, }, { columns: ['project', 'userName'], indexType: 'btree', unique: true } ); } } function buildAddressTable(result: SchemaDefinition): void { buildLookupTable( result, 'Address', ['address', 'city', 'country', 'postalCode', 'state', 'use'], [ { columns: [{ expression: tsVectorExpression('simple', 'address'), name: 'address' }], indexType: 'gin', indexNameSuffix: 'idx_tsv', }, { columns: [{ expression: tsVectorExpression('simple', 'postalCode'), name: 'postalCode' }], indexType: 'gin', indexNameSuffix: 'idx_tsv', }, { columns: [{ expression: tsVectorExpression('simple', 'city'), name: 'city' }], indexType: 'gin', indexNameSuffix: 'idx_tsv', }, { columns: [{ expression: tsVectorExpression('simple', 'use'), name: 'use' }], indexType: 'gin', indexNameSuffix: 'idx_tsv', }, { columns: [{ expression: tsVectorExpression('simple', 'country'), name: 'country' }], indexType: 'gin', indexNameSuffix: 'idx_tsv', }, { columns: [{ expression: tsVectorExpression('simple', 'state'), name: 'state' }], indexType: 'gin', indexNameSuffix: 'idx_tsv', }, ] ); } function buildContactPointTable(result: SchemaDefinition): void { buildLookupTable(result, 'ContactPoint', ['system', 'value']); } function buildIdentifierTable(result: SchemaDefinition): void { buildLookupTable(result, 'Identifier', ['system', 'value']); } function buildHumanNameTable(result: SchemaDefinition): void { buildLookupTable( result, 'HumanName', ['name', 'given', 'family'], [ { columns: [{ expression: 'name gin_trgm_ops', name: 'nameTrgm' }], indexType: 'gin', }, { columns: [{ expression: 'given gin_trgm_ops', name: 'givenTrgm' }], indexType: 'gin', }, { columns: [{ expression: 'family gin_trgm_ops', name: 'familyTrgm' }], indexType: 'gin', }, { columns: [{ expression: tsVectorExpression('simple', 'name'), name: 'name' }], indexType: 'gin', unique: false, indexNameSuffix: 'idx_tsv', }, { columns: [{ expression: tsVectorExpression('simple', 'given'), name: 'given' }], indexType: 'gin', unique: false, indexNameSuffix: 'idx_tsv', }, { columns: [{ expression: tsVectorExpression('simple', 'family'), name: 'family' }], indexType: 'gin', unique: false, indexNameSuffix: 'idx_tsv', }, ] ); } function buildLookupTable( result: SchemaDefinition, tableName: string, columns: string[], additionalIndexes?: IndexDefinition[] ): void { const tableDefinition: TableDefinition = { name: tableName, columns: [{ name: 'resourceId', type: 'UUID', notNull: true }], indexes: [{ columns: ['resourceId'], indexType: 'btree' }], }; for (const column of columns) { tableDefinition.columns.push({ name: column, type: 'TEXT' }); tableDefinition.indexes.push({ columns: [column], indexType: 'btree' }); } if (additionalIndexes?.length) { tableDefinition.indexes.push(...additionalIndexes); } result.tables.push(tableDefinition); } function buildCodingTable(result: SchemaDefinition): void { result.tables.push({ name: 'Coding', columns: [ { name: 'id', type: 'BIGSERIAL', primaryKey: true, }, { name: 'system', type: 'UUID', notNull: true }, { name: 'code', type: 'TEXT', notNull: true }, { name: 'display', type: 'TEXT' }, { name: 'isSynonym', type: 'BOOLEAN', notNull: true }, { name: 'synonymOf', type: 'BIGINT' }, { name: 'language', type: 'TEXT' }, ], indexes: [ { columns: ['id'], indexType: 'btree', unique: true }, { columns: ['system', 'code'], indexType: 'btree', unique: true, include: ['id'], where: `"synonymOf" IS NULL`, indexNameSuffix: 'primary_idx', }, { columns: [ 'system', 'code', 'display', { expression: `COALESCE("synonymOf", ('-1'::integer)::bigint)`, name: 'synonymOf' }, ], indexType: 'btree', unique: true, }, { columns: ['system', { expression: 'display gin_trgm_ops', name: 'displayTrgm' }], indexType: 'gin' }, ], }); } function buildCodingPropertyTable(result: SchemaDefinition): void { result.tables.push({ name: 'Coding_Property', columns: [ { name: 'coding', type: 'BIGINT', notNull: true }, { name: 'property', type: 'BIGINT', notNull: true }, { name: 'target', type: 'BIGINT' }, { name: 'value', type: 'TEXT', notNull: true }, ], indexes: [ { columns: ['target', 'property', 'coding'], indexType: 'btree', unique: false, where: 'target IS NOT NULL' }, { columns: ['coding', 'property'], indexType: 'btree', unique: false, indexNameSuffix: '_idx' }, { columns: ['property', 'value', 'coding', 'target'], indexType: 'btree', unique: true, indexNameSuffix: 'full_idx', }, ], }); } function buildCodeSystemPropertyTable(result: SchemaDefinition): void { result.tables.push({ name: 'CodeSystem_Property', columns: [ { name: 'id', type: 'BIGSERIAL', primaryKey: true, }, { name: 'system', type: 'UUID', notNull: true }, { name: 'code', type: 'TEXT', notNull: true }, { name: 'type', type: 'TEXT', notNull: true }, { name: 'uri', type: 'TEXT' }, { name: 'description', type: 'TEXT' }, ], indexes: [{ columns: ['system', 'code'], indexType: 'btree', unique: true, include: ['id'] }], }); } function buildConceptMappingTable(result: SchemaDefinition): void { result.tables.push({ name: 'ConceptMapping', columns: [ { name: 'id', type: 'BIGINT', primaryKey: true, identity: 'ALWAYS' }, { name: 'conceptMap', type: 'UUID', notNull: true }, { name: 'sourceSystem', type: 'BIGINT', notNull: true }, { name: 'sourceCode', type: 'TEXT', notNull: true }, { name: 'targetSystem', type: 'BIGINT', notNull: true }, { name: 'targetCode', type: 'TEXT', notNull: true }, { name: 'relationship', type: 'TEXT' }, { name: 'sourceDisplay', type: 'TEXT' }, { name: 'targetDisplay', type: 'TEXT' }, { name: 'comment', type: 'TEXT' }, ], indexes: [ { indexNameOverride: 'ConceptMapping_map_source_target_idx', indexType: 'btree', columns: ['conceptMap', 'sourceSystem', 'sourceCode', 'targetSystem', 'targetCode'], unique: true, }, { indexNameOverride: 'ConceptMapping_map_reverse_idx', indexType: 'btree', columns: ['conceptMap', 'targetSystem', 'targetCode', 'sourceSystem'], }, ], }); } function buildCodingSystemTable(result: SchemaDefinition): void { result.tables.push({ name: 'CodingSystem', columns: [ { name: 'id', type: 'BIGINT', primaryKey: true, identity: 'ALWAYS' }, { name: 'system', type: 'TEXT', notNull: true }, ], indexes: [{ columns: ['system'], indexType: 'btree', unique: true, include: ['id'] }], }); } function buildConceptMappingAttributeTable(result: SchemaDefinition): void { result.tables.push({ name: 'ConceptMapping_Attribute', columns: [ { name: 'mapping', type: 'BIGINT', notNull: true }, { name: 'uri', type: 'TEXT', notNull: true }, { name: 'type', type: 'TEXT', notNull: true }, { name: 'value', type: 'TEXT', notNull: true }, { name: 'kind', type: 'TEXT', notNull: true }, ], compositePrimaryKey: ['mapping', 'uri', 'type', 'value', 'kind'], indexes: [], }); } function buildDatabaseMigrationTable(result: SchemaDefinition): void { result.tables.push({ name: 'DatabaseMigration', columns: [ { name: 'id', type: 'INTEGER', notNull: true, primaryKey: true }, { name: 'version', type: 'INTEGER', notNull: true }, { name: 'dataVersion', type: 'INTEGER', notNull: true }, { name: 'firstBoot', type: 'BOOLEAN', notNull: true, defaultValue: 'false' }, ], indexes: [], }); } export async function executeMigrationActions( client: DbClient, results: MigrationActionResult[], actions: MigrationAction[] ): Promise<void> { for (const action of actions) { switch (action.type) { case 'ANALYZE_TABLE': await fns.analyzeTable(client, results, action.tableName); break; case 'CREATE_FUNCTION': { await fns.query(client, results, action.createQuery); break; } case 'CREATE_TABLE': { const queries = getCreateTableQueries(action.definition, { includeIfExists: true }); for (const query of queries) { await fns.query(client, results, query); } break; } case 'DROP_TABLE': { const query = getDropTableQuery(action.tableName); await fns.query(client, results, query); break; } case 'ADD_COLUMN': { const query = getAddColumnQuery(action.tableName, action.columnDefinition); await fns.query(client, results, query); break; } case 'DROP_COLUMN': { const query = getDropColumnQuery(action.tableName, action.columnName); await fns.query(client, results, query); break; } case 'ALTER_COLUMN_SET_DEFAULT': { const query = getAlterColumnSetDefaultQuery(action.tableName, action.columnName, action.defaultValue); await fns.query(client, results, query); break; } case 'ALTER_COLUMN_DROP_DEFAULT': { const query = getAlterColumnDropDefaultQuery(action.tableName, action.columnName); await fns.query(client, results, query); break; } case 'ALTER_COLUMN_UPDATE_NOT_NULL': { if (action.notNull) { await fns.nonBlockingAlterColumnNotNull(client, results, action.tableName, action.columnName); } else { const query = getAlterColumnUpdateNotNullQuery(action.tableName, action.columnName, action.notNull); await fns.query(client, results, query); } break; } case 'ALTER_COLUMN_TYPE': { const query = getAlterColumnTypeQuery(action.tableName, action.columnName, action.columnType); await fns.query(client, results, query); break; } case 'CREATE_INDEX': { await fns.idempotentCreateIndex(client, results, action.indexName, action.createIndexSql); break; } case 'DROP_INDEX': { await fns.query(client, results, getDropIndexQuery(action.indexName)); break; } case 'ADD_CONSTRAINT': { await fns.nonBlockingAddCheckConstraint( client, results, action.tableName, action.constraintName, action.constraintExpression ); break; } } } } function writeSchema(b: FileBuilder, actions: MigrationAction[]): void { b.append(String.raw`\set ON_ERROR_STOP true`); b.append(String.raw`\set QUIET on`); b.newLine(); b.appendNoWrap(String.raw`DROP DATABASE IF EXISTS medplum;`); b.appendNoWrap(String.raw`CREATE DATABASE medplum;`); b.newLine(); b.appendNoWrap(String.raw`\c medplum`); b.newLine(); b.append('DO $$'); b.append('BEGIN'); b.append(` IF current_database() NOT IN ('medplum') THEN`); b.append(` RAISE EXCEPTION 'Connected to wrong database: %', current_database();`); b.append(' END IF;'); b.append('END $$;'); b.newLine(); b.appendNoWrap(`CREATE EXTENSION IF NOT EXISTS btree_gin;`); b.appendNoWrap(`CREATE EXTENSION IF NOT EXISTS pg_trgm;`); b.newLine(); for (const action of actions) { switch (action.type) { case 'CREATE_FUNCTION': { b.appendNoWrap(ensureEndsWithSemicolon(escapeUnicode(action.createQuery))); b.newLine(); break; } case 'CREATE_TABLE': { const queries = getCreateTableQueries(action.definition, { includeIfExists: false }); for (const query of queries) { b.appendNoWrap(ensureEndsWithSemicolon(query)); } b.newLine(); break; } default: throw new Error('Unsupported writeSchema action type: ' + action.type); } } } function writeActionsToBuilder(b: FileBuilder, actions: MigrationAction[]): void { b.append("import type { PoolClient } from 'pg';"); b.append("import * as fns from '../migrate-functions';"); b.newLine(); b.append('// prettier-ignore'); // To prevent prettier from reformatting the SQL statements b.append('export async function run(client: PoolClient): Promise<void> {'); b.indentCount++; b.append('const results: { name: string; durationMs: number }[] = []'); for (const action of actions) { switch (action.type) { case 'ANALYZE_TABLE': b.appendNoWrap(`await fns.analyzeTable(client, results, '${action.tableName}');`); break; case 'CREATE_FUNCTION': { b.appendNoWrap(`await fns.query(client, results, \`${escapeUnicode(action.createQuery)}\`);`); break; } case 'CREATE_TABLE': { const queries = getCreateTableQueries(action.definition, { includeIfExists: true }); for (const query of queries) { b.appendNoWrap(`await fns.query(client, results, \`${query}\`);`); } break; } case 'DROP_TABLE': { const query = getDropTableQuery(action.tableName); b.appendNoWrap(`await fns.query(client, results, \`${query}\`);`); break; } case 'ADD_COLUMN': { const query = getAddColumnQuery(action.tableName, action.columnDefinition); b.appendNoWrap(`await fns.query(client, results, \`${query}\`);`); break; } case 'DROP_COLUMN': { const query = getDropColumnQuery(action.tableName, action.columnName); b.appendNoWrap(`await fns.query(client, results, \`${query}\`);`); break; } case 'ALTER_COLUMN_SET_DEFAULT': { const query = getAlterColumnSetDefaultQuery(action.tableName, action.columnName, action.defaultValue); b.appendNoWrap(`await fns.query(client, results, \`${query}\`);`); break; } case 'ALTER_COLUMN_DROP_DEFAULT': { const query = getAlterColumnDropDefaultQuery(action.tableName, action.columnName); b.appendNoWrap(`await fns.query(client, results, \`${query}\`);`); break; } case 'ALTER_COLUMN_UPDATE_NOT_NULL': { if (action.notNull) { b.appendNoWrap( `await fns.nonBlockingAlterColumnNotNull(client, results, \`${action.tableName}\`, \`${action.columnName}\`);` ); } else { const query = getAlterColumnUpdateNotNullQuery(action.tableName, action.columnName, action.notNull); b.appendNoWrap(`await fns.query(client, results, \`${query}\`);`); } break; } case 'ALTER_COLUMN_TYPE': { const query = getAlterColumnTypeQuery(action.tableName, action.columnName, action.columnType); b.appendNoWrap(`await fns.query(client, results, \`${query}\`);`); break; } case 'CREATE_INDEX': { b.appendNoWrap( `await fns.idempotentCreateIndex(client, results, '${action.indexName}', \`${action.createIndexSql}\`);` ); break; } case 'DROP_INDEX': { const query = getDropIndexQuery(action.indexName); b.appendNoWrap(`await fns.query(client, results, \`${query}\`);`); break; } case 'ADD_CONSTRAINT': { b.appendNoWrap( `await fns.nonBlockingAddConstraint(client, results, '${action.tableName}', '${action.constraintName}', \`${action.constraintExpression}\`);` ); break; } default: { action satisfies never; throw new Error('Unsupported action type', { cause: action }); } } } b.indentCount--; b.append('}'); } function generateColumnsActions( ctx: GenerateActionsContext, startTable: TableDefinition, targetTable: TableDefinition ): MigrationAction[] { const actions: MigrationAction[] = []; for (const targetColumn of targetTable.columns) { const startColumn = startTable.columns.find((c) => c.name === targetColumn.name); if (!startColumn) { actions.push({ type: 'ADD_COLUMN', tableName: targetTable.name, columnDefinition: targetColumn }); } else if (!columnDefinitionsEqual(startTable, startColumn, targetColumn)) { actions.push(...generateAlterColumnActions(ctx, targetTable, startColumn, targetColumn)); } } for (const startColumn of startTable.columns) { if (!targetTable.columns.some((c) => c.name === startColumn.name)) { ctx.postDeployAction(() => { actions.push({ type: 'DROP_COLUMN', tableName: targetTable.name, columnName: startColumn.name }); }, `Dropping column ${startColumn.name} from ${targetTable.name}`); } } return actions; } type GenerateActionsContext = { /** * Guard function that should be called before writes of post-deploy actions. * If `--skipPostDeploy` is provided, the action is skipped. * If `--allowPostDeploy` is not provided, the action is performed. * Otherwise, an error is thrown to halt the migration generation process. * @param action - The post-deploy action to perform if the guard passes. * @param description - A human-readable description of the action that is being checked */ postDeployAction: (action: () => void, description: string) => void; }; function generateAlterColumnActions( ctx: GenerateActionsContext, tableDefinition: TableDefinition, startDef: ColumnDefinition, targetDef: ColumnDefinition ): MigrationAction[] { const actions: MigrationAction[] = []; if (startDef.defaultValue !== targetDef.defaultValue) { ctx.postDeployAction(() => { if (targetDef.defaultValue) { actions.push({ type: 'ALTER_COLUMN_SET_DEFAULT', tableName: tableDefinition.name, columnName: targetDef.name, defaultValue: targetDef.defaultValue, }); } else { actions.push({ type: 'ALTER_COLUMN_DROP_DEFAULT', tableName: tableDefinition.name, columnName: targetDef.name, }); } }, `Change default value of ${tableDefinition.name}.${targetDef.name}`); } if (startDef.notNull !== targetDef.notNull) { ctx.postDeployAction(() => { actions.push({ type: 'ALTER_COLUMN_UPDATE_NOT_NULL', tableName: tableDefinition.name, columnName: targetDef.name, notNull: targetDef.notNull ?? false, }); }, `Change NOT NULL of ${tableDefinition.name}.${targetDef.name}`); } if (startDef.type !== targetDef.type) { ctx.postDeployAction(() => { actions.push({ type: 'ALTER_COLUMN_TYPE', tableName: tableDefinition.name, columnName: targetDef.name, columnType: targetDef.type, }); }, `Change type of ${tableDefinition.name}.${targetDef.name}`); } return actions; } export function getCreateTableQueries(tableDef: TableDefinition, options: { includeIfExists: boolean }): string[] { const queries: string[] = []; const createTableLines = []; for (const column of tableDef.columns) { const parts: string[] = [escapeIdentifier(column.name), column.type]; if (column.identity) { parts.push(`GENERATED ${column.identity} AS IDENTITY`); } if (column.primaryKey) { parts.push('PRIMARY KEY'); } if (column.notNull && !column.primaryKey && !column.identity) { parts.push('NOT NULL'); } if (column.defaultValue) { if (column.identity) { throw new Error(`Cannot set default value on identity column ${tableDef.name}.${column.name}`); } parts.push(`DEFAULT ${column.defaultValue}`); } createTableLines.push(` ${parts.join(' ')}`); } if (tableDef.compositePrimaryKey !== undefined && tableDef.compositePrimaryKey.length > 0) { createTableLines.push(` PRIMARY KEY (${tableDef.compositePrimaryKey.map(escapeMixedCaseIdentifier).join(', ')})`); } for (const constraint of tableDef.constraints ?? []) { if (constraint.type === 'check') { createTableLines.push(` CONSTRAINT "${constraint.name}" CHECK (${constraint.expression})`); } else { throw new Error(`Unsupported constraint type: ${constraint.type}`); } } queries.push( [ `CREATE TABLE ${options.includeIfExists ? 'IF NOT EXISTS ' : ''}${escapeIdentifier(tableDef.name)} (`, createTableLines.join(',\n'), ')', ].join('\n') ); for (const indexDef of tableDef.indexes) { if (indexDef.primaryKey) { continue; } const indexName = getIndexName(tableDef.name, indexDef); const createIndexSql = buildIndexSql(tableDef.name, indexName, indexDef, { concurrent: false, ifNotExists: options.includeIfExists, }); queries.push(createIndexSql); } return queries; } function getDropTableQuery(tableName: string): string { return `DROP TABLE IF EXISTS "${tableName}"`; } function getAddColumnQuery(tableName: string, columnDefinition: ColumnDefinition): string { const { name, type, notNull, primaryKey, defaultValue } = columnDefinition; return `ALTER TABLE IF EXISTS "${tableName}" ADD COLUMN IF NOT EXISTS "${name}" ${type}${notNull ? ' NOT NULL' : ''}${primaryKey ? ' PRIMARY KEY' : ''}${defaultValue ? ' DEFAULT ' + defaultValue : ''}`; } function getDropColumnQuery(tableName: string, columnName: string): string { return `ALTER TABLE IF EXISTS "${tableName}" DROP COLUMN IF EXISTS "${columnName}"`; } function getAlterColumnSetDefaultQuery(tableName: string, columnName: string, defaultValue: string): string { return `ALTER TABLE IF EXISTS "${tableName}" ALTER COLUMN "${columnName}" SET DEFAULT ${defaultValue}`; } function getAlterColumnDropDefaultQuery(tableName: string, columnName: string): string { return `ALTER TABLE IF EXISTS "${tableName}" ALTER COLUMN "${columnName}" DROP DEFAULT`; } function getAlterColumnUpdateNotNullQuery(tableName: string, columnName: string, notNull: boolean): string { return `ALTER TABLE IF EXISTS "${tableName}" ALTER COLUMN "${columnName}" ${notNull ? 'SET' : 'DROP'} NOT NULL`; } function getAlterColumnTypeQuery(tableName: string, columnName: string, columnType: string): string { return `ALTER TABLE IF EXISTS "${tableName}" ALTER COLUMN "${columnName}" TYPE ${columnType}`; } function getDropIndexQuery(indexName: string): string { return `DROP INDEX CONCURRENTLY IF EXISTS "${indexName}"`; } function generateIndexesActions( ctx: GenerateActionsContext, startTable: TableDefinition, targetTable: TableDefinition, options: BuildMigrationOptions ): MigrationAction[] { const actions: MigrationAction[] = []; const matchedIndexes = new Set<IndexDefinition>(); const seenIndexNames = new Set<string>(); const computedIndexes: IndexDefinition[] = []; let pkIndex: IndexDefinition | undefined; if (targetTable.compositePrimaryKey) { pkIndex = { columns: targetTable.compositePrimaryKey, indexType: 'btree', unique: true, primaryKey: true, }; } else { const pkColumn = targetTable.columns.find((c) => c.primaryKey); if (pkColumn) { pkIndex = { columns: [pkColumn.name], indexType: 'btree', unique: true, primaryKey: true, }; } } if (pkIndex) { computedIndexes.push(pkIndex); } for (const targetIndex of [...targetTable.indexes, ...computedIndexes]) { const indexName = getIndexName(targetTable.name, targetIndex); if (seenIndexNames.has(indexName)) { throw new Error('Duplicate index name: ' + indexName, { cause: targetIndex }); } seenIndexNames.add(indexName); const startIndex = startTable.indexes.find((i) => indexDefinitionsEqual(i, targetIndex)); if (startIndex) { matchedIndexes.add(startIndex); } else { ctx.postDeployAction( () => { const createIndexSql = buildIndexSql(targetTable.name, indexName, targetIndex, { concurrent: true, ifNotExists: true, }); actions.push({ type: 'CREATE_INDEX', indexName, createIndexSql }); }, `CREATE INDEX ${escapeIdentifier(indexName)} ON ${escapeIdentifier(targetTable.name)} ...` ); } } for (const startIndex of startTable.indexes) { if (!matchedIndexes.has(startIndex)) { console.log( `[${startTable.name}] Existing index should not exist:`, startIndex.indexdef || JSON.stringify(startIndex) ); if (options?.dropUnmatchedIndexes) { const indexName = parseIndexName(startIndex.indexdef ?? ''); if (!indexName) { throw new Error('Could not extract index name from ' + startIndex.indexdef, { cause: startIndex }); } actions.push({ type: 'DROP_INDEX', indexName }); } } } return actions; } function generateConstraintsActions( ctx: GenerateActionsContext, startTable: TableDefinition, targetTable: TableDefinition ): MigrationAction[] { const actions: MigrationAction[] = []; const matchedConstraints = new Set<CheckConstraintDefinition>(); const seenConstraintNames = new Set<string>(); for (const targetConstraint of targetTable.constraints ?? []) { if (seenConstraintNames.has(targetConstraint.name)) { throw new Error('Duplicate constraint name: ' + targetConstraint.name, { cause: targetConstraint }); } seenConstraintNames.add(targetConstraint.name); const startConstraint = startTable.constraints?.find((c) => constraintDefinitionsEqual(c, targetConstraint)); if (startConstraint) { matchedConstraints.add(startConstraint); } else { ctx.postDeployAction( () => { actions.push({ type: 'ADD_CONSTRAINT', tableName: targetTable.name, constraintName: targetConstraint.name, constraintExpression: targetConstraint.expression, }); }, `ADD CONSTRAINT ${escapeIdentifier(targetConstraint.name)} ON ${escapeIdentifier(targetTable.name)} ...` ); } } for (const startConstraint of startTable.constraints ?? []) { if (!matchedConstraints.has(startConstraint)) { console.log( `[${startTable.name}] Existing constraint should not exist:`, startConstraint.expression || JSON.stringify(startConstraint) ); } } return actions; } function getIndexName(tableName: string, index: IndexDefinition): string { if (index.indexNameOverride) { return index.indexNameOverride; } if (index.primaryKey) { return tableName + '_pkey'; } let indexName = applyAbbreviations(tableName, TableNameAbbreviations) + '_'; indexName += index.columns .map((c) => (typeof c === 'string' ? c : c.name)) .map((c) => applyAbbreviations(c, ColumnNameAbbreviations)) .join('_'); indexName += '_' + (index.indexNameSuffix ?? 'idx'); if (indexName.length > 63) { throw new Error('Index name too long: ' + indexName); } return indexName; } function buildIndexSql( tableName: string, indexName: string, index: IndexDefinition, options: { concurrent: boolean; ifNotExists: boolean } ): string { let result = 'CREATE '; if (index.unique) { result += 'UNIQUE '; } result += 'INDEX '; if (options.concurrent) { result += 'CONCURRENTLY '; } if (options.ifNotExists) { result += 'IF NOT EXISTS '; } result += '"'; result += indexName; result += '" ON "'; result += tableName; result += '" '; if (index.indexType !== 'btree') { result += 'USING ' + index.indexType + ' '; } result += '('; result += index.columns.map((c) => (typeof c === 'string' ? `"${c}"` : c.expression)).join(', '); result += ')'; if (index.include) { result += ' INCLUDE ('; result += index.include.map((c) => `"${c}"`).join(', '); result += ')'; } if (index.where) { result += ' WHERE ('; result += index.where; result += ')'; } return result; } export function indexDefinitionsEqual(a: IndexDefinition, b: IndexDefinition): boolean { const [aPrime, bPrime] = [a, b].map((d) => { return { ...d, unique: (d.primaryKey || d.unique) ?? false, // parseIndexDefinition does not include primary key information primaryKey: undefined, // for expressions, ignore names since those are only used for index name generation columns: d.columns.map((c) => (typeof c === 'string' ? c : c.expression)), // don't care about these indexNameOverride: undefined, indexNameSuffix: undefined, indexdef: undefined, }; }); return deepEquals(aPrime, bPrime); } /** * Translate SERIAL types to INT types based on {@link https://www.postgresql.org/docs/16/datatype-numeric.html#DATATYPE-SERIAL} * Translate IDENTITY types to NOT NULL * * @param tableDef - the table definition * @param inputColumnDef - the column definition to desugar * @returns the desugared column definition if it was a SERIAL/IDENTITY column, otherwise the original column definition */ function desugarColumnDefinition(tableDef: TableDefinition, inputColumnDef: ColumnDefinition): ColumnDefinition { if (SerialColumnTypes.has(inputColumnDef.type.toLocaleUpperCase())) { const columnDef = deepClone(inputColumnDef); columnDef.type = columnDef.type.toLocaleUpperCase().replace('SERIAL', 'INT'); columnDef.notNull = true; const sequenceName = [tableDef.name, columnDef.name, 'seq'].join('_'); columnDef.defaultValue = `nextval('${escapeIdentifier(sequenceName)}'::regclass)`; return columnDef; } if (inputColumnDef.identity) { const columnDef = deepClone(inputColumnDef); columnDef.identity = undefined; columnDef.notNull = true; return columnDef; } return inputColumnDef; } export function columnDefinitionsEqual(table: TableDefinition, a: ColumnDefinition, b: ColumnDefinition): boolean { // Populate optional fields with default values before comparing for (const def of [a, b]) { def.defaultValue ??= undefined; def.notNull ??= false; def.primaryKey ??= false; } // deepEquals has FHIR-specific logic, but ColumnDefinition is simple enough that it works fine return deepEquals(desugarColumnDefinition(table, a), desugarColumnDefinition(table, b)); } export function constraintDefinitionsEqual(a: CheckConstraintDefinition, b: CheckConstraintDefinition): boolean { return deepEquals({ ...a, valid: undefined }, { ...b, valid: undefined }); } function applyAbbreviations(name: string, abbreviations: Record<string, string | undefined>): string { let result = name; // Shorten _References suffix to _Refs if (result.endsWith('_References')) { result = result.slice(0, -'References'.length) + 'Refs'; } for (const [original, abbrev] of Object.entries(abbreviations as Record<string, string>)) { result = result.replace(original, abbrev); } return result; } function ensureEndsWithSemicolon(query: string): string { if (query.endsWith(';')) { return query; } return query + ';'; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server