import type { Queryable } from './schema_catalog.ts' import type { SchemaCompatibilityIssue } from './schema_compatibility.ts' import { readReplicaSchemaCatalog, REPLICA_FUNCTIONS, REPLICA_TABLES, } from './schema_catalog.ts' import { readReplicaSchemaCompatibilityIssues } from './schema_compatibility.ts' interface SchemaColumn { table: string name: string type: string notNull: boolean default: string | null identity: string generated: string } interface SchemaConstraint { table: string name: string type: 'c' | 'p' | 'u' definition: string valid?: boolean } interface SchemaIndex { table: string name: string definition: string valid: boolean constraintOwned?: boolean } interface SchemaCompositeAttribute { position: number name: string type: string } interface SchemaType { name: string kind: string definition: unknown } interface TypeSyncPlan { statements: string[] reason: string | null } interface SchemaSequence { name: string type: string start: string increment: string min: string max: string cache: string cycle: boolean ownedTable: string | null ownedColumn: string | null } interface SchemaFunction { name: string arguments: string definition: string } interface SchemaTable { name: string } interface SchemaCatalog { columns?: SchemaColumn[] constraints?: SchemaConstraint[] functions?: SchemaFunction[] indexes?: SchemaIndex[] sequences?: SchemaSequence[] tables?: SchemaTable[] types?: SchemaType[] } type SyncStatementKind = | 'table' | 'column' | 'check_constraint' | 'constraint' | 'drop_check_constraint' | 'function' | 'index' | 'invalid_index' | 'drop_index' | 'sequence' | 'type' type SyncObjectKind = 'table' | 'column' | 'constraint' | 'function' | 'index' | 'sequence' | 'type' export interface ReadReplicaSchemaSyncStatement { kind: SyncStatementKind table: string name: string sql: string } export interface ReadReplicaSchemaSyncClient extends Queryable { assertCanApplyReadReplicaSchemaPlan?: ( plan: ReadReplicaSchemaSyncPlan, ) => void applyReadReplicaSchemaPlan?: ( plan: ReadReplicaSchemaSyncPlan, ) => Promise } interface SkippedChange { kind: SyncObjectKind table: string name: string reason: string } export interface ReadReplicaSchemaSyncPlan { statements: ReadReplicaSchemaSyncStatement[] skipped: SkippedChange[] } export type AdditiveSchemaSyncPlan = ReadReplicaSchemaSyncPlan export interface ReadReplicaSchemaSyncResult { applied: Array> skipped: SkippedChange[] } export type AdditiveSchemaSyncResult = ReadReplicaSchemaSyncResult export interface ReadReplicaSchemaSyncOptions { statementTimeoutMs?: number maxDurationMs?: number deadline?: number } export type AdditiveSchemaSyncOptions = ReadReplicaSchemaSyncOptions export interface ReadReplicaSchemaReconciliationResult extends ReadReplicaSchemaSyncResult { issues: SchemaCompatibilityIssue[] } const SAFE_IDENTIFIER_RE = /^[A-Z_]\w*$/i const SAFE_SQL_FRAGMENT_RE = /^[\w .()[\],:'"{}+\-=<>!]+$/ const REPLICA_TABLE_SET = new Set(REPLICA_TABLES) const REPLICA_FUNCTION_SET = new Set(REPLICA_FUNCTIONS) // Replica tables the release job may create on the subscriber before primary // migrations add them to the publication. Each entry pins the exact SQL and // the exact catalog shape the migrations must produce; any other missing // table (or a different shape) stays a blocker, since an absent established // table means lost replica data. interface AllowedNewReplicaTable { sql: string catalog: Required> } function replicaIdentityKeyCatalog( table: string, type: 'bigint' | 'integer', ): Pick { return { constraints: [ { definition: 'PRIMARY KEY (id)', name: `${table}_pkey`, table, type: 'p', valid: true }, ], indexes: [ { constraintOwned: true, definition: `CREATE UNIQUE INDEX ${table}_pkey ON public.${table} USING btree (id)`, name: `${table}_pkey`, table, valid: true }, ], sequences: [ { cache: '1', cycle: false, increment: '1', max: type === 'bigint' ? '9223372036854775807' : '2147483647', min: '1', name: `${table}_id_seq`, ownedColumn: 'id', ownedTable: table, start: '1', type }, ], } } export const ALLOWED_NEW_REPLICA_TABLES: Readonly> = { delta_files: { sql: [ 'CREATE TABLE public."delta_files" (', ' "id" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,', ' "app_id" character varying NOT NULL,', ' "s3_path" character varying NOT NULL,', ' "file_name" character varying NOT NULL,', ' "file_hash" character varying NOT NULL,', ' "file_size" bigint DEFAULT 0 NOT NULL,', ' "path_key" bytea NOT NULL,', ' "entry_key" bytea NOT NULL,', ' "created_at" timestamp with time zone DEFAULT now() NOT NULL,', ' "path_org" uuid,', ' "path_session" bytea,', ' "path_sha" bytea,', ' "file_hash_bin" bytea,', ' "path_session_id" integer,', ' CONSTRAINT "delta_files_pkey" PRIMARY KEY ("id")', ')', ].join('\n'), catalog: { columns: [ { default: null, generated: '', identity: 'd', name: 'id', notNull: true, table: 'delta_files', type: 'bigint' }, { default: null, generated: '', identity: '', name: 'app_id', notNull: true, table: 'delta_files', type: 'character varying' }, { default: null, generated: '', identity: '', name: 's3_path', notNull: true, table: 'delta_files', type: 'character varying' }, { default: null, generated: '', identity: '', name: 'file_name', notNull: true, table: 'delta_files', type: 'character varying' }, { default: null, generated: '', identity: '', name: 'file_hash', notNull: true, table: 'delta_files', type: 'character varying' }, { default: '0', generated: '', identity: '', name: 'file_size', notNull: true, table: 'delta_files', type: 'bigint' }, { default: null, generated: '', identity: '', name: 'path_key', notNull: true, table: 'delta_files', type: 'bytea' }, { default: null, generated: '', identity: '', name: 'entry_key', notNull: true, table: 'delta_files', type: 'bytea' }, { default: 'now()', generated: '', identity: '', name: 'created_at', notNull: true, table: 'delta_files', type: 'timestamp with time zone' }, { default: null, generated: '', identity: '', name: 'path_org', notNull: false, table: 'delta_files', type: 'uuid' }, { default: null, generated: '', identity: '', name: 'path_session', notNull: false, table: 'delta_files', type: 'bytea' }, { default: null, generated: '', identity: '', name: 'path_sha', notNull: false, table: 'delta_files', type: 'bytea' }, { default: null, generated: '', identity: '', name: 'file_hash_bin', notNull: false, table: 'delta_files', type: 'bytea' }, { default: null, generated: '', identity: '', name: 'path_session_id', notNull: false, table: 'delta_files', type: 'integer' }, ], ...replicaIdentityKeyCatalog('delta_files', 'bigint'), }, }, delta_sessions: { sql: [ 'CREATE TABLE public."delta_sessions" (', ' "id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL,', ' "app_id" character varying NOT NULL,', ' "session" bytea NOT NULL,', ' CONSTRAINT "delta_sessions_pkey" PRIMARY KEY ("id")', ')', ].join('\n'), catalog: { columns: [ { default: null, generated: '', identity: 'd', name: 'id', notNull: true, table: 'delta_sessions', type: 'integer' }, { default: null, generated: '', identity: '', name: 'app_id', notNull: true, table: 'delta_sessions', type: 'character varying' }, { default: null, generated: '', identity: '', name: 'session', notNull: true, table: 'delta_sessions', type: 'bytea' }, ], ...replicaIdentityKeyCatalog('delta_sessions', 'integer'), }, }, version_files: { sql: [ 'CREATE TABLE public."version_files" (', ' "app_version_id" bigint NOT NULL,', ' "file_ids" bigint[] NOT NULL,', ' "created_at" timestamp with time zone DEFAULT now() NOT NULL,', ' CONSTRAINT "version_files_pkey" PRIMARY KEY ("app_version_id")', ')', ].join('\n'), catalog: { columns: [ { default: null, generated: '', identity: '', name: 'app_version_id', notNull: true, table: 'version_files', type: 'bigint' }, { default: null, generated: '', identity: '', name: 'file_ids', notNull: true, table: 'version_files', type: 'bigint[]' }, { default: 'now()', generated: '', identity: '', name: 'created_at', notNull: true, table: 'version_files', type: 'timestamp with time zone' }, ], constraints: [ { definition: 'PRIMARY KEY (app_version_id)', name: 'version_files_pkey', table: 'version_files', type: 'p', valid: true }, ], indexes: [ { constraintOwned: true, definition: 'CREATE UNIQUE INDEX version_files_pkey ON public.version_files USING btree (app_version_id)', name: 'version_files_pkey', table: 'version_files', valid: true }, ], sequences: [], }, }, } const DEFAULT_SCHEMA_SYNC_STATEMENT_TIMEOUT_MS = 550_000 const DEFAULT_SCHEMA_SYNC_MAX_DURATION_MS = 585_000 const SCHEMA_SYNC_RESPONSE_BUFFER_MS = 5_000 export function planReadReplicaSchemaSync( expected: unknown, actual: unknown, ): ReadReplicaSchemaSyncPlan { const expectedCatalog = assertSchemaCatalog(expected) const actualCatalog = assertSchemaCatalog(actual) const actualTables = new Set( (actualCatalog.tables ?? []).map(table => table.name), ) const actualColumnsByKey = new Map( (actualCatalog.columns ?? []).map(column => [columnKey(column), column]), ) const actualIndexByName = new Map( (actualCatalog.indexes ?? []).map(index => [index.name, index]), ) const expectedIndexByName = new Map( (expectedCatalog.indexes ?? []).map(index => [index.name, index]), ) const actualConstraintsByKey = new Map( (actualCatalog.constraints ?? []).map(constraint => [ constraintKey(constraint), constraint, ]), ) const expectedConstraintsByKey = new Map( (expectedCatalog.constraints ?? []).map(constraint => [ constraintKey(constraint), constraint, ]), ) const actualFunctionsByKey = new Map( (actualCatalog.functions ?? []).map(fn => [functionKey(fn), fn]), ) const actualTypesByName = new Map( (actualCatalog.types ?? []).map(type => [type.name, type]), ) const skippedColumnsByTable = new Map>() const statements: ReadReplicaSchemaSyncStatement[] = [] const skipped: SkippedChange[] = [] const createdTables = new Set() for (const table of expectedCatalog.tables ?? []) { const allowed = ALLOWED_NEW_REPLICA_TABLES[table.name] if (actualTables.has(table.name) || !allowed) continue if (!matchesAllowedNewReplicaTable(expectedCatalog, table.name, allowed)) { skipped.push({ kind: 'table', table: table.name, name: table.name, reason: 'unexpected_new_table_definition', }) continue } statements.push({ kind: 'table', table: table.name, name: table.name, sql: allowed.sql }) createdTables.add(table.name) } for (const type of expectedCatalog.types ?? []) { if (typeMatches(type, actualTypesByName.get(type.name))) continue const typeSync = planTypeSync(type, actualTypesByName.get(type.name)) if (typeSync.reason) { skipped.push({ kind: 'type', table: 'public', name: type.name, reason: typeSync.reason, }) continue } for (const sql of typeSync.statements) { statements.push({ kind: 'type', table: 'public', name: type.name, sql, }) } } for (const fn of expectedCatalog.functions ?? []) { if (functionMatches(fn, actualFunctionsByKey.get(functionKey(fn)))) continue const sql = buildCreateOrReplaceFunctionStatement(fn) if (!sql) { skipped.push({ kind: 'function', table: 'public', name: functionKey(fn), reason: 'unsupported_function_reconciliation', }) continue } statements.push({ kind: 'function', table: 'public', name: functionKey(fn), sql, }) } for (const sequence of expectedCatalog.sequences ?? []) { // Identity sequences of a created table come with its CREATE TABLE. if (sequence.ownedTable && createdTables.has(sequence.ownedTable)) continue const actualSequence = (actualCatalog.sequences ?? []).find( candidate => candidate.name === sequence.name, ) if (sequenceMatches(sequence, actualCatalog.sequences ?? [])) continue const sql = actualSequence ? buildAlterSequenceStatement(sequence, actualTables) : buildCreateSequenceStatement(sequence, actualTables) if (!sql) { skipped.push({ kind: 'sequence', table: 'public', name: sequence.name, reason: 'unsupported_sequence_reconciliation', }) continue } statements.push({ kind: 'sequence', table: 'public', name: sequence.name, sql, }) } for (const column of expectedCatalog.columns ?? []) { if (createdTables.has(column.table)) continue const actualColumn = actualColumnsByKey.get(columnKey(column)) if (!actualColumn) { const addColumnSql = buildAddColumnStatement(column, actualTables) if (!addColumnSql) { trackSkippedColumn(skippedColumnsByTable, column) skipped.push({ kind: 'column', table: column.table, name: column.name, reason: 'unsupported_additive_column', }) continue } statements.push({ kind: 'column', table: column.table, name: column.name, sql: addColumnSql, }) continue } if (actualColumn.notNull !== column.notNull) { // Tightening or loosening an existing subscriber column before the // publisher changes can reject replicated rows. Leave it for a proven // publisher-compatible transition rather than risk stopping replication. skipped.push({ kind: 'column', table: column.table, name: column.name, reason: 'pre_primary_column_nullability', }) } } for (const index of expectedCatalog.indexes ?? []) { if (createdTables.has(index.table)) continue const actualIndex = actualIndexByName.get(index.name) if (indexMatches(index, actualIndex)) continue if (hasUnresolvedColumnDependency(index, skippedColumnsByTable)) { skipped.push({ kind: 'index', table: index.table, name: index.name, reason: 'unresolved_column_dependency', }) continue } if ( actualIndex?.constraintOwned !== undefined && actualIndex.constraintOwned ) { skipped.push({ kind: 'index', table: index.table, name: index.name, reason: 'constraint_owned_index', }) continue } if (actualIndex && actualIndex.table !== index.table) { skipped.push({ kind: 'index', table: index.table, name: index.name, reason: 'index_name_conflict', }) continue } if (actualIndex && actualIndex.definition !== index.definition) { const dropIndexSql = buildDropIndexStatement(actualIndex, actualTables) if (!dropIndexSql) { skipped.push({ kind: 'index', table: index.table, name: index.name, reason: actualIndex.constraintOwned === undefined ? 'unknown_index_ownership' : 'unsupported_index_replacement', }) continue } statements.push({ kind: 'drop_index', table: actualIndex.table, name: actualIndex.name, sql: dropIndexSql, }) } else if (actualIndex) { const reindexSql = buildReindexIndexStatement(index, actualTables) if (!reindexSql) { skipped.push({ kind: 'index', table: index.table, name: index.name, reason: actualIndex.constraintOwned === undefined ? 'unknown_index_ownership' : 'unsupported_additive_index', }) continue } statements.push({ kind: 'invalid_index', table: index.table, name: index.name, sql: reindexSql, }) continue } const addIndexSql = buildCreateIndexStatement(index, actualTables) if (!addIndexSql) { skipped.push({ kind: 'index', table: index.table, name: index.name, reason: 'unsupported_additive_index', }) continue } statements.push({ kind: 'index', table: index.table, name: index.name, sql: addIndexSql, }) } for (const constraint of expectedCatalog.constraints ?? []) { if (createdTables.has(constraint.table)) continue if (constraint.type === 'c') { const actualConstraint = actualConstraintsByKey.get( constraintKey(constraint), ) // Missing publisher CHECK constraints stay optional on read-only subscribers. if (!actualConstraint || constraintMatches(constraint, actualConstraint)) continue const dropSql = buildDropCheckConstraintStatement( constraint, actualTables, ) const addSql = buildAddCheckConstraintStatement( constraint, actualTables, ) if (!dropSql || !addSql) { skipped.push({ kind: 'constraint', table: constraint.table, name: constraint.name, reason: 'check_constraint_conflict', }) continue } statements.push({ kind: 'drop_check_constraint', table: constraint.table, name: constraint.name, sql: dropSql, }) statements.push({ kind: 'check_constraint', table: constraint.table, name: constraint.name, sql: addSql, }) continue } const actualConstraint = actualConstraintsByKey.get( constraintKey(constraint), ) if (constraintMatches(constraint, actualConstraint)) continue if (actualConstraint) { skipped.push({ kind: 'constraint', table: constraint.table, name: constraint.name, reason: 'constraint_conflict', }) continue } const expectedIndex = expectedIndexByName.get(constraint.name) const actualIndex = expectedIndex ? actualIndexByName.get(expectedIndex.name) : undefined const sql = buildAttachConstraintStatement( constraint, expectedIndex, actualIndex, actualTables, ) if (!sql) { skipped.push({ kind: 'constraint', table: constraint.table, name: constraint.name, reason: 'unsupported_key_constraint', }) continue } statements.push({ kind: 'constraint', table: constraint.table, name: constraint.name, sql, }) } for (const constraint of actualCatalog.constraints ?? []) { if (expectedConstraintsByKey.has(constraintKey(constraint))) continue skipped.push({ kind: 'constraint', table: constraint.table, name: constraint.name, reason: 'unexpected_constraint', }) } for (const index of actualCatalog.indexes ?? []) { if (expectedIndexByName.has(index.name)) continue const dropIndexSql = buildDropIndexStatement(index, actualTables) if (!dropIndexSql) { skipped.push({ kind: 'index', table: index.table, name: index.name, reason: index.constraintOwned === undefined ? 'unknown_index_ownership' : 'constraint_owned_index', }) continue } statements.push({ kind: 'drop_index', table: index.table, name: index.name, sql: dropIndexSql, }) } return { statements, skipped } } function matchesAllowedNewReplicaTable( catalog: SchemaCatalog, table: string, allowed: AllowedNewReplicaTable, ): boolean { // Compare the fields the allowlist pins, in a stable key order. const same = (actual: readonly object[], expected: readonly object[]) => { const byText = (left: string, right: string) => left.localeCompare(right) const keys = expected.length ? Object.keys(expected[0]).sort(byText) : [] const shape = (entries: readonly object[]) => entries.map(entry => JSON.stringify(entry, keys)).sort(byText) return JSON.stringify(shape(actual)) === JSON.stringify(shape(expected)) } return same((catalog.columns ?? []).filter(entry => entry.table === table), allowed.catalog.columns) && same((catalog.constraints ?? []).filter(entry => entry.table === table), allowed.catalog.constraints) && same((catalog.indexes ?? []).filter(entry => entry.table === table), allowed.catalog.indexes) && same((catalog.sequences ?? []).filter(entry => entry.ownedTable === table), allowed.catalog.sequences) } export const planReadReplicaAdditiveSchemaSync = planReadReplicaSchemaSync export async function applyReadReplicaSchemaSync( client: ReadReplicaSchemaSyncClient, expected: unknown, options: ReadReplicaSchemaSyncOptions = {}, ): Promise { const deadline = options.deadline ?? ( Date.now() + positiveIntegerOrDefault( options.maxDurationMs, DEFAULT_SCHEMA_SYNC_MAX_DURATION_MS, ) ) assertTimeRemaining(deadline, 'read the subscriber schema catalog') const actual = await readReplicaSchemaCatalog(client) assertTimeRemaining(deadline, 'plan subscriber schema reconciliation') const plan = planReadReplicaSchemaSync(expected, actual) const statementTimeoutMs = positiveIntegerOrDefault( options.statementTimeoutMs, DEFAULT_SCHEMA_SYNC_STATEMENT_TIMEOUT_MS, ) const applyPlan = client.applyReadReplicaSchemaPlan const assertPlan = client.assertCanApplyReadReplicaSchemaPlan if (applyPlan && !assertPlan) { throw new Error( 'Transactional read-replica schema execution must preflight the whole reconciliation plan before applying schema changes', ) } assertPlan?.(plan) try { if (applyPlan) { if (plan.statements.length) { assertTimeRemaining(deadline, 'apply the transactional subscriber schema plan') await applyPlan(plan) assertTimeRemaining(deadline, 'finish the transactional subscriber schema plan') } } else { for (const statement of plan.statements) { await setStatementTimeoutForRemainingBudget( client, statementTimeoutMs, deadline, statement, ) await client.query(statement.sql) assertTimeRemaining( deadline, `finish ${statement.kind} ${statement.table}.${statement.name}`, ) } } } finally { await client.query('RESET statement_timeout') } assertTimeRemaining(deadline, 'read the post-DDL subscriber schema catalog') const finalActual = await readReplicaSchemaCatalog(client) assertTimeRemaining(deadline, 'validate applied subscriber schema changes') assertAppliedStatementsVisible(plan.statements, expected, finalActual) return { applied: plan.statements.map(({ kind, table, name }) => ({ kind, table, name, })), skipped: plan.skipped, } } export const applyReadReplicaAdditiveSchemaSync = applyReadReplicaSchemaSync export async function reconcileReadReplicaSchema( master: Queryable, replica: ReadReplicaSchemaSyncClient, options: ReadReplicaSchemaSyncOptions = {}, ): Promise { const deadline = options.deadline ?? ( Date.now() + positiveIntegerOrDefault( options.maxDurationMs, DEFAULT_SCHEMA_SYNC_MAX_DURATION_MS, ) ) assertTimeRemaining(deadline, 'read the primary schema catalog') const expected = await readReplicaSchemaCatalog(master) assertTimeRemaining(deadline, 'reconcile the subscriber schema') const result = await applyReadReplicaSchemaSync(replica, expected, { ...options, deadline, }) assertTimeRemaining(deadline, 'read the final primary schema catalog') const finalExpected = await readReplicaSchemaCatalog(master) assertTimeRemaining(deadline, 'read the final subscriber schema catalog') const finalActual = await readReplicaSchemaCatalog(replica) assertTimeRemaining(deadline, 'compare the final selected schemas') return { ...result, issues: readReplicaSchemaCompatibilityIssues(finalExpected, finalActual), } } function assertAppliedStatementsVisible( applied: readonly ReadReplicaSchemaSyncStatement[], expected: unknown, actual: unknown, ): void { const remaining = new Set( planReadReplicaSchemaSync(expected, actual).statements.map(statementKey), ) const unavailable = applied.filter(statement => remaining.has(statementKey(statement)), ) if (!unavailable.length) return throw new Error( `Read-replica schema sync did not persist ${unavailable.map(statement => `${statement.kind} ${statement.table}.${statement.name}`).join(', ')}`, ) } function statementKey( statement: Pick, ): string { const kind = statement.kind === 'invalid_index' ? 'index' : statement.kind return `${kind}:${statement.table}.${statement.name}` } function indexMatches( expected: SchemaIndex, actual: SchemaIndex | undefined, ): boolean { return ( actual?.table === expected.table && actual.valid && actual.definition === expected.definition ) } async function setStatementTimeoutForRemainingBudget( client: Queryable, maxStatementTimeoutMs: number, deadline: number, statement: ReadReplicaSchemaSyncStatement, ): Promise { const remainingMs = deadline - Date.now() - SCHEMA_SYNC_RESPONSE_BUFFER_MS if (remainingMs <= 0) { throw new Error( `Read-replica schema sync exceeded max duration before ${statement.kind} ${statement.table}.${statement.name}`, ) } await client.query( `SET statement_timeout = ${Math.min(maxStatementTimeoutMs, remainingMs)}`, ) } function assertTimeRemaining(deadline: number, action: string): void { if (Date.now() >= deadline) { throw new Error( `Read-replica schema sync exceeded max duration before it could ${action}`, ) } } function positiveIntegerOrDefault( value: number | undefined, fallback: number, ): number { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback return Math.trunc(value) } function assertSchemaCatalog(value: unknown): SchemaCatalog { if (!value || typeof value !== 'object') throw new Error('Expected read-replica schema catalog JSON object') const catalog = value as SchemaCatalog for (const collection of [ 'columns', 'constraints', 'functions', 'indexes', 'sequences', 'tables', 'types', ] as const) { if (catalog[collection] && !Array.isArray(catalog[collection])) throw new Error(`Read-replica schema catalog ${collection} must be an array`) } for (const column of catalog.columns ?? []) assertSchemaColumn(column) for (const constraint of catalog.constraints ?? []) assertSchemaConstraint(constraint) for (const fn of catalog.functions ?? []) assertSchemaFunction(fn) for (const index of catalog.indexes ?? []) assertSchemaIndex(index) for (const sequence of catalog.sequences ?? []) assertSchemaSequence(sequence) for (const table of catalog.tables ?? []) assertSchemaTable(table) for (const type of catalog.types ?? []) assertSchemaType(type) return catalog } function assertSchemaColumn(value: unknown): asserts value is SchemaColumn { const column = assertCatalogObject(value, 'columns') as Partial if ( typeof column.table !== 'string' || typeof column.name !== 'string' || typeof column.type !== 'string' ) { throw new TypeError( 'Read-replica schema catalog columns must include string table, name, and type', ) } if (typeof column.notNull !== 'boolean') { throw new TypeError( 'Read-replica schema catalog columns must include boolean notNull', ) } if (column.default !== null && typeof column.default !== 'string') { throw new Error( 'Read-replica schema catalog column defaults must be strings or null', ) } if ( typeof column.identity !== 'string' || typeof column.generated !== 'string' ) { throw new TypeError( 'Read-replica schema catalog columns must include string identity and generated fields', ) } } function assertSchemaConstraint( value: unknown, ): asserts value is SchemaConstraint { const constraint = assertCatalogObject( value, 'constraints', ) as Partial if ( typeof constraint.table !== 'string' || typeof constraint.name !== 'string' || typeof constraint.definition !== 'string' || !isConstraintType(constraint.type) ) { throw new TypeError( 'Read-replica schema catalog constraints must include table, name, type, and definition', ) } if (constraint.valid !== undefined && typeof constraint.valid !== 'boolean') { throw new TypeError( 'Read-replica schema catalog constraint valid must be boolean when present', ) } } function assertSchemaFunction(value: unknown): asserts value is SchemaFunction { const fn = assertCatalogObject(value, 'functions') as Partial if ( typeof fn.name !== 'string' || typeof fn.arguments !== 'string' || typeof fn.definition !== 'string' ) { throw new TypeError( 'Read-replica schema catalog functions must include name, arguments, and definition', ) } } function assertSchemaIndex(value: unknown): asserts value is SchemaIndex { const index = assertCatalogObject(value, 'indexes') as Partial if ( typeof index.table !== 'string' || typeof index.name !== 'string' || typeof index.definition !== 'string' ) { throw new TypeError( 'Read-replica schema catalog indexes must include string table, name, and definition', ) } if (typeof index.valid !== 'boolean') { throw new TypeError( 'Read-replica schema catalog indexes must include boolean valid', ) } if ( index.constraintOwned !== undefined && typeof index.constraintOwned !== 'boolean' ) { throw new Error( 'Read-replica schema catalog indexes constraintOwned must be boolean when present', ) } } function assertSchemaSequence(value: unknown): asserts value is SchemaSequence { const sequence = assertCatalogObject( value, 'sequences', ) as Partial const textFields = [ sequence.name, sequence.type, sequence.start, sequence.increment, sequence.min, sequence.max, sequence.cache, ] if (textFields.some(field => typeof field !== 'string')) { throw new TypeError( 'Read-replica schema catalog sequences must include string structural fields', ) } if (typeof sequence.cycle !== 'boolean') { throw new TypeError( 'Read-replica schema catalog sequences must include boolean cycle', ) } if ( (sequence.ownedTable !== null && typeof sequence.ownedTable !== 'string') || (sequence.ownedColumn !== null && typeof sequence.ownedColumn !== 'string') ) { throw new TypeError( 'Read-replica schema catalog sequences must include nullable ownership fields', ) } } function assertSchemaTable(value: unknown): asserts value is SchemaTable { const table = assertCatalogObject(value, 'tables') as Partial if (typeof table.name !== 'string') { throw new TypeError( 'Read-replica schema catalog tables must include string names', ) } } function assertSchemaType(value: unknown): asserts value is SchemaType { const type = assertCatalogObject(value, 'types') as Partial if (typeof type.name !== 'string' || typeof type.kind !== 'string') { throw new TypeError( 'Read-replica schema catalog types must include string name and kind', ) } if (type.kind === 'e') { if ( !Array.isArray(type.definition) || !type.definition.every(label => typeof label === 'string') || new Set(type.definition).size !== type.definition.length ) { throw new TypeError( 'Read-replica enum types must include unique string labels', ) } return } if (type.kind !== 'c') return if (!Array.isArray(type.definition)) { throw new TypeError( 'Read-replica composite types must include attribute definitions', ) } const names = new Set() const positions = new Set() for (const value of type.definition) { assertSchemaCompositeAttribute(value) if (names.has(value.name) || positions.has(value.position)) { throw new TypeError( 'Read-replica composite types must include unique attributes', ) } names.add(value.name) positions.add(value.position) } } function assertSchemaCompositeAttribute( value: unknown, ): asserts value is SchemaCompositeAttribute { const attribute = assertCatalogObject( value, 'types', ) as Partial if ( typeof attribute.position !== 'number' || !Number.isSafeInteger(attribute.position) || attribute.position <= 0 || typeof attribute.name !== 'string' || typeof attribute.type !== 'string' ) { throw new TypeError( 'Read-replica composite type attributes must include position, name, and type', ) } } function assertCatalogObject(value: unknown, collection: string): object { if (!value || typeof value !== 'object') { throw new Error( `Read-replica schema catalog ${collection} must contain objects`, ) } return value } function typeMatches( expected: SchemaType, actual: SchemaType | undefined, ): boolean { if (actual?.kind !== expected.kind) return false return JSON.stringify(actual.definition) === JSON.stringify(expected.definition) } function planTypeSync( expected: SchemaType, actual: SchemaType | undefined, ): TypeSyncPlan { if (!isSafeIdentifier(expected.name)) return { statements: [], reason: 'unsafe_type_name' } if (!actual) { const statement = buildCreateTypeStatement(expected) return statement ? { statements: [statement], reason: null } : { statements: [], reason: 'unsupported_type_creation' } } if (actual.kind !== expected.kind) return { statements: [], reason: 'type_kind_conflict' } if (expected.kind === 'e') return planEnumTypeSync(expected, actual) if (expected.kind === 'c') return planCompositeTypeSync(expected, actual) return { statements: [], reason: 'unsupported_type_reconciliation' } } function buildCreateTypeStatement(type: SchemaType): string | null { if (type.kind === 'e') { const labels = enumLabels(type) const values = labels && quoteEnumValues(labels) if (!values?.length) return null return `CREATE TYPE public.${quoteIdent(type.name)} AS ENUM (${values.join(', ')})` } if (type.kind !== 'c') return null const attributes = compositeTypeAttributes(type) if (!attributes?.length || !hasSequentialCompositePositions(attributes)) return null const definitions = compositeAttributeDefinitions(attributes) if (!definitions) return null return `CREATE TYPE public.${quoteIdent(type.name)} AS (${definitions.join(', ')})` } function planEnumTypeSync( expected: SchemaType, actual: SchemaType, ): TypeSyncPlan { const expectedLabels = enumLabels(expected) const actualLabels = enumLabels(actual) if (!expectedLabels || !actualLabels) { return { statements: [], reason: 'unsupported_enum_reconciliation' } } if (!isEnumPrefix(actualLabels, expectedLabels)) { return { statements: [], reason: 'unsafe_enum_reconciliation' } } const values = quoteEnumValues(expectedLabels.slice(actualLabels.length)) if (!values) return { statements: [], reason: 'unsupported_enum_reconciliation' } return { statements: values.map( value => `ALTER TYPE public.${quoteIdent(expected.name)} ADD VALUE IF NOT EXISTS ${value}`, ), reason: null, } } function planCompositeTypeSync( expected: SchemaType, actual: SchemaType, ): TypeSyncPlan { const expectedAttributes = compositeTypeAttributes(expected) const actualAttributes = compositeTypeAttributes(actual) if (!expectedAttributes || !actualAttributes) { return { statements: [], reason: 'unsupported_composite_reconciliation' } } if ( !hasSequentialCompositePositions(expectedAttributes) || !hasSequentialCompositePositions(actualAttributes) || !isCompositePrefix(actualAttributes, expectedAttributes) ) { return { statements: [], reason: 'unsafe_composite_reconciliation' } } const definitions = compositeAttributeDefinitions( expectedAttributes.slice(actualAttributes.length), ) if (!definitions) { return { statements: [], reason: 'unsupported_composite_reconciliation' } } return { statements: definitions.map( definition => `ALTER TYPE public.${quoteIdent(expected.name)} ADD ATTRIBUTE ${definition}`, ), reason: null, } } function enumLabels(type: SchemaType): string[] | null { if ( type.kind !== 'e' || !Array.isArray(type.definition) || !type.definition.every(label => typeof label === 'string') ) { return null } return type.definition as string[] } function compositeTypeAttributes( type: SchemaType, ): SchemaCompositeAttribute[] | null { if ( type.kind !== 'c' || !Array.isArray(type.definition) || !type.definition.every(isSchemaCompositeAttribute) ) { return null } return type.definition as SchemaCompositeAttribute[] } function isSchemaCompositeAttribute( value: unknown, ): value is SchemaCompositeAttribute { if (!value || typeof value !== 'object') return false const attribute = value as Partial return ( typeof attribute.position === 'number' && Number.isSafeInteger(attribute.position) && attribute.position > 0 && typeof attribute.name === 'string' && typeof attribute.type === 'string' ) } function isEnumPrefix(actual: readonly string[], expected: readonly string[]): boolean { return actual.length <= expected.length && actual.every((label, index) => label === expected[index]) } function isCompositePrefix( actual: readonly SchemaCompositeAttribute[], expected: readonly SchemaCompositeAttribute[], ): boolean { return actual.length <= expected.length && actual.every((attribute, index) => { const expectedAttribute = expected[index] return attribute.position === expectedAttribute.position && attribute.name === expectedAttribute.name && attribute.type === expectedAttribute.type }) } function hasSequentialCompositePositions( attributes: readonly SchemaCompositeAttribute[], ): boolean { return attributes.every( (attribute, index) => attribute.position === index + 1, ) } function compositeAttributeDefinitions( attributes: readonly SchemaCompositeAttribute[], ): string[] | null { const definitions: string[] = [] for (const attribute of attributes) { if (!isSafeIdentifier(attribute.name) || !isSafeSqlFragment(attribute.type)) return null definitions.push(`${quoteIdent(attribute.name)} ${attribute.type}`) } return definitions } function quoteEnumValues(values: readonly string[]): string[] | null { const quotedValues: string[] = [] for (const value of values) { if (value.includes('\0')) return null quotedValues.push( `E'${value.replaceAll('\\', '\\\\').replaceAll('\'', '\'\'')}'`, ) } return quotedValues } function buildAddColumnStatement( column: SchemaColumn, actualTables: Set, ): string | null { if (!isSafeReplicaTable(column.table, actualTables)) return null if (!isSafeIdentifier(column.name)) return null if (column.identity || column.generated) return null if (!isSafeSqlFragment(column.type)) return null if ( column.default !== null && (!isSafeSqlFragment(column.default) || !isSafeConstantDefault(column.default)) ) { return null } if ( column.notNull && (column.default === null || isNullDefault(column.default)) ) { return null } const defaultSql = column.default === null ? '' : ` DEFAULT ${column.default}` const notNullSql = column.notNull ? ' NOT NULL' : '' return `ALTER TABLE ${quoteQualifiedTable(column.table)} ADD COLUMN IF NOT EXISTS ${quoteIdent(column.name)} ${column.type}${defaultSql}${notNullSql}` } function buildCreateIndexStatement( index: SchemaIndex, actualTables: Set, ): string | null { if (!isSafeReplicaTable(index.table, actualTables)) return null if (!isSafeQuotedIdentifier(index.name)) return null if (!isSafeSchemaDefinition(index.definition)) return null const parsedDefinition = parseCreateIndexDefinition(index.definition) if (!parsedDefinition) return null const { unique, indexName, tableName, indexTail } = parsedDefinition if (indexName !== index.name || tableName !== index.table) return null return `CREATE ${unique}INDEX CONCURRENTLY IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteQualifiedTable(index.table)} ${indexTail}` } function buildDropCheckConstraintStatement( constraint: Pick, actualTables: Set, ): string | null { if (!isSafeReplicaTable(constraint.table, actualTables)) return null if (!isSafeQuotedIdentifier(constraint.name)) return null return `ALTER TABLE ${quoteQualifiedTable(constraint.table)} DROP CONSTRAINT IF EXISTS ${quoteIdent(constraint.name)}` } function buildAddCheckConstraintStatement( constraint: SchemaConstraint, actualTables: Set, ): string | null { if ( constraint.type !== 'c' || !isSafeReplicaTable(constraint.table, actualTables) || !isSafeQuotedIdentifier(constraint.name) || !isSafeCheckConstraintDefinition(constraint.definition) ) { return null } return `ALTER TABLE ${quoteQualifiedTable(constraint.table)} ADD CONSTRAINT ${quoteIdent(constraint.name)} ${constraint.definition}` } function buildAttachConstraintStatement( constraint: SchemaConstraint, expectedIndex: SchemaIndex | undefined, actualIndex: SchemaIndex | undefined, actualTables: Set, ): string | null { if ( (constraint.type !== 'p' && constraint.type !== 'u') || !isSafeReplicaTable(constraint.table, actualTables) || !isSafeQuotedIdentifier(constraint.name) || expectedIndex?.name !== constraint.name || expectedIndex.table !== constraint.table || expectedIndex.constraintOwned !== true ) { return null } if ( actualIndex && ( actualIndex.table !== constraint.table || actualIndex.constraintOwned !== false ) ) { return null } if ( !actualIndex || !indexMatches(expectedIndex, actualIndex) ) { if (!buildCreateIndexStatement(expectedIndex, actualTables)) return null } const kind = constraint.type === 'p' ? 'PRIMARY KEY' : 'UNIQUE' return `ALTER TABLE ${quoteQualifiedTable(constraint.table)} ADD CONSTRAINT ${quoteIdent(constraint.name)} ${kind} USING INDEX ${quoteIdent(expectedIndex.name)}` } function buildCreateOrReplaceFunctionStatement( fn: SchemaFunction, ): string | null { const expectedPrefix = `CREATE OR REPLACE FUNCTION public.${fn.name}(` if ( !REPLICA_FUNCTION_SET.has(fn.name) || !isSafeIdentifier(fn.name) || fn.arguments.includes('\0') || !fn.definition.startsWith(expectedPrefix) || fn.definition.includes('\0') ) { return null } return fn.definition } function buildCreateSequenceStatement( sequence: SchemaSequence, actualTables: Set, ): string | null { if (!isSafeQuotedIdentifier(sequence.name)) return null const options = buildSequenceOptions(sequence, actualTables) if (!options) return null return `CREATE SEQUENCE IF NOT EXISTS public.${quoteIdent(sequence.name)} ${options}` } function buildAlterSequenceStatement( sequence: SchemaSequence, actualTables: Set, ): string | null { if (!isSafeQuotedIdentifier(sequence.name)) return null const options = buildSequenceOptions(sequence, actualTables) if (!options) return null return `ALTER SEQUENCE public.${quoteIdent(sequence.name)} ${options}` } function buildSequenceOptions( sequence: SchemaSequence, actualTables: Set, ): string | null { if (!isSafeSequenceDataType(sequence.type)) return null for (const value of [ sequence.start, sequence.increment, sequence.min, sequence.max, sequence.cache, ]) { if (!isSafeSequenceNumber(value)) return null } const ownedBy = buildSequenceOwnership(sequence, actualTables) if (!ownedBy) return null const cycle = sequence.cycle ? 'CYCLE' : 'NO CYCLE' return `AS ${sequence.type} START WITH ${sequence.start} INCREMENT BY ${sequence.increment} MINVALUE ${sequence.min} MAXVALUE ${sequence.max} CACHE ${sequence.cache} ${cycle} ${ownedBy}` } function buildSequenceOwnership( sequence: SchemaSequence, actualTables: Set, ): string | null { if (sequence.ownedTable === null && sequence.ownedColumn === null) return 'OWNED BY NONE' if ( typeof sequence.ownedTable !== 'string' || typeof sequence.ownedColumn !== 'string' || !isSafeReplicaTable(sequence.ownedTable, actualTables) || !isSafeIdentifier(sequence.ownedColumn) ) { return null } return `OWNED BY ${quoteQualifiedTable(sequence.ownedTable)}.${quoteIdent(sequence.ownedColumn)}` } function isSafeSequenceDataType(value: string): boolean { return value === 'smallint' || value === 'integer' || value === 'bigint' } function isSafeSequenceNumber(value: string): boolean { return /^-?\d+$/.test(value) } function buildDropIndexStatement( index: SchemaIndex, actualTables: Set, ): string | null { if (index.constraintOwned !== false) return null if (!isSafeReplicaTable(index.table, actualTables)) return null if (!isSafeQuotedIdentifier(index.name)) return null return `DROP INDEX CONCURRENTLY IF EXISTS public.${quoteIdent(index.name)}` } function buildReindexIndexStatement( index: SchemaIndex, actualTables: Set, ): string | null { if ( !isSafeReplicaTable(index.table, actualTables) || !isSafeQuotedIdentifier(index.name) ) { return null } return `REINDEX INDEX CONCURRENTLY public.${quoteIdent(index.name)}` } function parseCreateIndexDefinition( definition: string, ): { unique: string indexName: string tableName: string indexTail: string } | null { let rest = definition if (!rest.startsWith('CREATE ')) return null rest = rest.slice('CREATE '.length) const unique = rest.startsWith('UNIQUE ') ? 'UNIQUE ' : '' if (unique) rest = rest.slice(unique.length) if (!rest.startsWith('INDEX ')) return null rest = rest.slice('INDEX '.length) const parsedIndexName = parseSqlIdentifier(rest) if (!parsedIndexName) return null rest = parsedIndexName.rest.trimStart() const tablePrefix = 'ON public.' if (!rest.startsWith(tablePrefix)) return null rest = rest.slice(tablePrefix.length) const parsedTableName = parseSqlIdentifier(rest) if (!parsedTableName) return null rest = parsedTableName.rest.trimStart() if (!rest) return null return { unique, indexName: parsedIndexName.identifier, tableName: parsedTableName.identifier, indexTail: rest, } } function parseSqlIdentifier(value: string): { identifier: string rest: string } | null { if (value.startsWith('"')) { let identifier = '' for (let index = 1; index < value.length; index += 1) { if (value[index] !== '"') { identifier += value[index] continue } if (value[index + 1] === '"') { identifier += '"' index += 1 continue } return { identifier, rest: value.slice(index + 1) } } return null } const match = /^([A-Z_]\w*)/i.exec(value) if (!match) return null return { identifier: match[1], rest: value.slice(match[1].length), } } function isSafeReplicaTable(table: string, actualTables: Set): boolean { return ( REPLICA_TABLE_SET.has(table) && actualTables.has(table) && isSafeIdentifier(table) ) } function trackSkippedColumn( skippedColumnsByTable: Map>, column: SchemaColumn, ): void { const tableColumns = skippedColumnsByTable.get(column.table) ?? new Set() tableColumns.add(column.name) skippedColumnsByTable.set(column.table, tableColumns) } function hasUnresolvedColumnDependency( index: SchemaIndex, skippedColumnsByTable: Map>, ): boolean { const skippedColumns = skippedColumnsByTable.get(index.table) if (!skippedColumns?.size) return false const parsedDefinition = parseCreateIndexDefinition(index.definition) if (!parsedDefinition) return false for (const column of skippedColumns) { if (indexTailReferencesColumn(parsedDefinition.indexTail, column)) return true } return false } function indexTailReferencesColumn(indexTail: string, column: string): boolean { return ( indexTail.includes(quoteIdent(column)) || new RegExp( `(^|[^A-Za-z0-9_"])${escapeRegExp(column)}($|[^A-Za-z0-9_"])`, ).test(indexTail) ) } function isSafeIdentifier(value: string): boolean { return SAFE_IDENTIFIER_RE.test(value) } function isSafeQuotedIdentifier(value: string): boolean { return value.length > 0 && !value.includes('\0') } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`) } function isSafeSqlFragment(value: string): boolean { return ( SAFE_SQL_FRAGMENT_RE.test(value) && !value.includes(';') && !value.includes('--') && !value.includes('/*') && !value.includes('*/') ) } function isSafeSchemaDefinition(value: string): boolean { return ( value.length > 0 && !value.includes('\0') && !value.includes(';') && !value.includes('--') && !value.includes('/*') && !value.includes('*/') ) } function isSafeCheckConstraintDefinition(value: string): boolean { return ( value.startsWith('CHECK (') && isSafeSchemaDefinition(value) ) } function isConstraintType(value: unknown): value is SchemaConstraint['type'] { return value === 'c' || value === 'p' || value === 'u' } function constraintMatches( expected: SchemaConstraint, actual: SchemaConstraint | undefined, ): boolean { return ( constraintShapeMatches(expected, actual) && (expected.valid === undefined || actual?.valid === undefined || expected.valid === actual.valid) ) } function constraintShapeMatches( expected: SchemaConstraint, actual: SchemaConstraint | undefined, ): boolean { return ( actual?.table === expected.table && actual.type === expected.type && actual.definition === expected.definition ) } function functionMatches( expected: SchemaFunction, actual: SchemaFunction | undefined, ): boolean { return actual?.definition === expected.definition } function sequenceMatches( expected: SchemaSequence, actualSequences: readonly SchemaSequence[], ): boolean { const actual = actualSequences.find(sequence => sequence.name === expected.name) return ( actual?.type === expected.type && actual.start === expected.start && actual.increment === expected.increment && actual.min === expected.min && actual.max === expected.max && actual.cache === expected.cache && actual.cycle === expected.cycle && actual.ownedTable === expected.ownedTable && actual.ownedColumn === expected.ownedColumn ) } function isSafeConstantDefault(value: string): boolean { for (let index = 0; index < value.length; index += 1) { if (value[index] !== '(') continue const previous = previousNonWhitespace(value, index) if (previous && (isIdentifierChar(previous) || previous === '"')) return false } return true } function previousNonWhitespace(value: string, index: number): string | null { for (let cursor = index - 1; cursor >= 0; cursor -= 1) { if (!isWhitespace(value[cursor])) return value[cursor] } return null } function isWhitespace(value: string): boolean { return ( value === ' ' || value === '\n' || value === '\r' || value === '\t' || value === '\f' ) } function isIdentifierChar(value: string): boolean { return ( value === '_' || (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z') || (value >= '0' && value <= '9') ) } function isNullDefault(value: string): boolean { return /^NULL\b/i.test(value) } function quoteQualifiedTable(table: string): string { return `public.${quoteIdent(table)}` } function quoteIdent(value: string): string { return `"${value.replaceAll('"', '""')}"` } function columnKey(column: Pick): string { return `${column.table}.${column.name}` } function constraintKey( constraint: Pick, ): string { return `${constraint.table}.${constraint.name}` } function functionKey( fn: Pick, ): string { return `${fn.name}(${fn.arguments})` }