Ctrl K
ComunidadGuías…

read_replicate/schema_compatibility.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { stableJson } from './schema_json.ts'

interface SchemaColumn {
  table: string
  name: string
  position: number
  type: string
  notNull: boolean
  default: string | null
  identity: string
  generated: string
}

interface SchemaConstraint {
  table: string
  name: string
  type: string
  definition: string
  valid?: boolean
}

interface SchemaIndex {
  table: string
  name: string
  definition: string
  valid: boolean
  constraintOwned?: boolean
}

interface SchemaTable {
  name: string
  kind: string
  reloptions: unknown
}

interface SchemaType {
  name: string
  kind: string
  definition: unknown
}

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 SchemaCatalog {
  version?: number
  columns?: SchemaColumn[]
  constraints?: SchemaConstraint[]
  indexes?: SchemaIndex[]
  tables?: SchemaTable[]
  types?: SchemaType[]
  sequences?: SchemaSequence[]
  functions?: SchemaFunction[]
}

export interface SchemaCompatibilityIssue {
  kind: 'catalog_version' | 'column' | 'constraint' | 'function' | 'index' | 'sequence' | 'table' | 'type'
  object: string
  reason: string
}

// Strict comparison is retained for snapshot tooling. The release gate below is
// directional because PostgreSQL logical subscribers may deliberately retain
// columns and supporting objects that the publisher has already dropped.
export function readReplicaSchemaCompatibilityIssues(expected: unknown, actual: unknown): SchemaCompatibilityIssue[] {
  return readReplicaSchemaCompatibilityIssuesInternal(expected, actual, false)
}

// A logical subscriber must accept every publisher column, but it can safely
// retain nullable or default-backed legacy columns until the publisher removes
// them. This is the compatibility relation used before primary migrations.
export function readReplicaSubscriberCompatibilityIssues(expected: unknown, actual: unknown): SchemaCompatibilityIssue[] {
  return readReplicaSchemaCompatibilityIssuesInternal(expected, actual, true)
}

function readReplicaSchemaCompatibilityIssuesInternal(
  expected: unknown,
  actual: unknown,
  allowSafeSubscriberOnlyObjects: boolean,
): SchemaCompatibilityIssue[] {
  const expectedCatalog = assertSchemaCatalog(expected, 'expected')
  const actualCatalog = assertSchemaCatalog(actual, 'actual')
  const issues: SchemaCompatibilityIssue[] = []

  if (expectedCatalog.version !== actualCatalog.version) {
    issues.push({
      kind: 'catalog_version',
      object: 'catalog',
      reason: `expected version ${expectedCatalog.version ?? 'missing'}, found ${actualCatalog.version ?? 'missing'}`,
    })
  }

  compareTables(expectedCatalog, actualCatalog, issues)
  compareColumns(expectedCatalog, actualCatalog, issues, allowSafeSubscriberOnlyObjects)
  compareConstraints(expectedCatalog, actualCatalog, issues, allowSafeSubscriberOnlyObjects)
  compareIndexes(expectedCatalog, actualCatalog, issues, allowSafeSubscriberOnlyObjects)
  compareTypes(expectedCatalog, actualCatalog, issues, allowSafeSubscriberOnlyObjects)
  compareSequences(expectedCatalog, actualCatalog, issues, allowSafeSubscriberOnlyObjects)
  compareFunctions(expectedCatalog, actualCatalog, issues, allowSafeSubscriberOnlyObjects)

  return issues
}

function compareTables(
  expected: SchemaCatalog,
  actual: SchemaCatalog,
  issues: SchemaCompatibilityIssue[],
): void {
  const expectedTables = new Map((expected.tables ?? []).map(table => [table.name, table]))
  const actualTables = new Map((actual.tables ?? []).map(table => [table.name, table]))

  for (const expectedTable of expected.tables ?? []) {
    const actualTable = actualTables.get(expectedTable.name)
    if (!actualTable) {
      issues.push({ kind: 'table', object: expectedTable.name, reason: 'missing required table' })
      continue
    }
    if (actualTable.kind !== expectedTable.kind)
      issues.push({ kind: 'table', object: expectedTable.name, reason: `expected kind ${expectedTable.kind}, found ${actualTable.kind}` })
  }
  for (const actualTable of actual.tables ?? []) {
    if (!expectedTables.has(actualTable.name))
      issues.push({ kind: 'table', object: actualTable.name, reason: 'unexpected table' })
  }
}

function compareColumns(
  expected: SchemaCatalog,
  actual: SchemaCatalog,
  issues: SchemaCompatibilityIssue[],
  allowSafeSubscriberOnlyObjects: boolean,
): void {
  const expectedColumns = new Map((expected.columns ?? []).map(column => [columnKey(column), column]))
  const actualColumns = new Map((actual.columns ?? []).map(column => [columnKey(column), column]))

  compareExpectedColumns(expected.columns ?? [], actualColumns, issues)
  compareSubscriberOnlyColumns(
    actual.columns ?? [],
    expectedColumns,
    issues,
    allowSafeSubscriberOnlyObjects,
  )
}

function compareExpectedColumns(
  expectedColumns: SchemaColumn[],
  actualColumns: Map<string, SchemaColumn>,
  issues: SchemaCompatibilityIssue[],
): void {
  for (const expectedColumn of expectedColumns) {
    const key = columnKey(expectedColumn)
    const actualColumn = actualColumns.get(key)
    if (!actualColumn) {
      issues.push({ kind: 'column', object: key, reason: 'missing required column' })
      continue
    }

    compareMatchingColumns(expectedColumn, actualColumn, issues)
  }
}

function compareMatchingColumns(
  expectedColumn: SchemaColumn,
  actualColumn: SchemaColumn,
  issues: SchemaCompatibilityIssue[],
): void {
  const key = columnKey(expectedColumn)
  if (actualColumn.type !== expectedColumn.type) {
    issues.push({
      kind: 'column',
      object: key,
      reason: `expected type ${expectedColumn.type}, found ${actualColumn.type}`,
    })
  }
  if (actualColumn.notNull !== expectedColumn.notNull) {
    issues.push({
      kind: 'column',
      object: key,
      reason: expectedColumn.notNull
        ? 'publisher is NOT NULL while subscriber accepts NULL'
        : 'subscriber is NOT NULL while publisher accepts NULL',
    })
  }
  if (actualColumn.identity !== expectedColumn.identity) {
    issues.push({
      kind: 'column',
      object: key,
      reason: `expected identity kind ${displayValue(expectedColumn.identity)}, found ${displayValue(actualColumn.identity)}`,
    })
  }
  if (actualColumn.generated !== expectedColumn.generated) {
    issues.push({
      kind: 'column',
      object: key,
      reason: `expected generated kind ${displayValue(expectedColumn.generated)}, found ${displayValue(actualColumn.generated)}`,
    })
  }
}

function compareSubscriberOnlyColumns(
  actualColumns: SchemaColumn[],
  expectedColumns: Map<string, SchemaColumn>,
  issues: SchemaCompatibilityIssue[],
  allowSafeSubscriberOnlyObjects: boolean,
): void {
  for (const actualColumn of actualColumns) {
    const key = columnKey(actualColumn)
    if (expectedColumns.has(key))
      continue
    if (allowSafeSubscriberOnlyObjects && subscriberOnlyColumnCanAcceptPublisherRows(actualColumn))
      continue
    issues.push({
      kind: 'column',
      object: key,
      reason: allowSafeSubscriberOnlyObjects
        ? 'subscriber-only column can reject replicated rows'
        : 'unexpected column',
    })
  }
}

function compareConstraints(
  expected: SchemaCatalog,
  actual: SchemaCatalog,
  issues: SchemaCompatibilityIssue[],
  allowSafeSubscriberOnlyObjects: boolean,
): void {
  const expectedConstraints = new Map((expected.constraints ?? []).map(constraint => [constraintKey(constraint), constraint]))
  const actualConstraints = new Map((actual.constraints ?? []).map(constraint => [constraintKey(constraint), constraint]))

  for (const expectedConstraint of expected.constraints ?? []) {
    const key = constraintKey(expectedConstraint)
    const actualConstraint = actualConstraints.get(key)
    if (expectedConstraint.type === 'c') {
      // Publisher CHECK constraints are optional on read-only logical subscribers.
      // Missing CHECK constraints are ignored; stale subscriber CHECK constraints must not block deploy.
      if (
        actualConstraint
        && actualConstraint.type !== 'c'
      ) {
        issues.push({ kind: 'constraint', object: key, reason: 'subscriber CHECK constraint differs' })
      }
      else if (
        actualConstraint
        && !allowSafeSubscriberOnlyObjects
        && actualConstraint.definition !== expectedConstraint.definition
      ) {
        issues.push({ kind: 'constraint', object: key, reason: 'subscriber CHECK constraint differs' })
      }
      continue
    }

    if (!actualConstraint) {
      issues.push({ kind: 'constraint', object: key, reason: 'missing required constraint' })
      continue
    }

    if (actualConstraint.type !== expectedConstraint.type) {
      issues.push({
        kind: 'constraint',
        object: key,
        reason: `expected type ${expectedConstraint.type}, found ${actualConstraint.type}`,
      })
    }
    if (actualConstraint.definition !== expectedConstraint.definition)
      issues.push({ kind: 'constraint', object: key, reason: 'constraint definition differs' })
    if (expectedConstraint.valid !== undefined || actualConstraint.valid !== undefined) {
      if (actualConstraint.valid !== expectedConstraint.valid) {
        issues.push({
          kind: 'constraint',
          object: key,
          reason: `expected valid ${displayValue(expectedConstraint.valid)}, found ${displayValue(actualConstraint.valid)}`,
        })
      }
    }
  }

  for (const actualConstraint of actual.constraints ?? []) {
    const key = constraintKey(actualConstraint)
    if (!expectedConstraints.has(key))
      issues.push({ kind: 'constraint', object: key, reason: 'unexpected constraint' })
  }
}

function compareIndexes(
  expected: SchemaCatalog,
  actual: SchemaCatalog,
  issues: SchemaCompatibilityIssue[],
  allowSafeSubscriberOnlyObjects: boolean,
): void {
  const expectedIndexes = new Map((expected.indexes ?? []).map(index => [index.name, index]))
  const actualIndexes = new Map((actual.indexes ?? []).map(index => [index.name, index]))

  compareExpectedIndexes(expected.indexes ?? [], actualIndexes, issues)
  compareSubscriberOnlyIndexes(
    actual.indexes ?? [],
    expectedIndexes,
    issues,
    allowSafeSubscriberOnlyObjects,
  )
}

function compareExpectedIndexes(
  expectedIndexes: SchemaIndex[],
  actualIndexes: Map<string, SchemaIndex>,
  issues: SchemaCompatibilityIssue[],
): void {
  for (const expectedIndex of expectedIndexes) {
    const actualIndex = actualIndexes.get(expectedIndex.name)
    if (!actualIndex) {
      issues.push({ kind: 'index', object: expectedIndex.name, reason: 'missing required index' })
      continue
    }

    compareMatchingIndexes(expectedIndex, actualIndex, issues)
  }
}

function compareMatchingIndexes(
  expectedIndex: SchemaIndex,
  actualIndex: SchemaIndex,
  issues: SchemaCompatibilityIssue[],
): void {
  if (actualIndex.table !== expectedIndex.table) {
    issues.push({
      kind: 'index',
      object: expectedIndex.name,
      reason: `expected table ${expectedIndex.table}, found ${actualIndex.table}`,
    })
  }
  if (actualIndex.definition !== expectedIndex.definition)
    issues.push({ kind: 'index', object: expectedIndex.name, reason: 'index definition differs' })
  if (actualIndex.valid !== expectedIndex.valid) {
    issues.push({
      kind: 'index',
      object: expectedIndex.name,
      reason: expectedIndex.valid && !actualIndex.valid
        ? 'index is invalid'
        : `expected valid ${displayValue(expectedIndex.valid)}, found ${displayValue(actualIndex.valid)}`,
    })
  }
  if (indexConstraintOwnershipDiffers(expectedIndex, actualIndex)) {
    issues.push({
      kind: 'index',
      object: expectedIndex.name,
      reason: `expected constraintOwned ${displayValue(expectedIndex.constraintOwned)}, found ${displayValue(actualIndex.constraintOwned)}`,
    })
  }
}

function indexConstraintOwnershipDiffers(
  expectedIndex: SchemaIndex,
  actualIndex: SchemaIndex,
): boolean {
  return expectedIndex.constraintOwned !== undefined
    && actualIndex.constraintOwned !== undefined
    && actualIndex.constraintOwned !== expectedIndex.constraintOwned
}

function compareSubscriberOnlyIndexes(
  actualIndexes: SchemaIndex[],
  expectedIndexes: Map<string, SchemaIndex>,
  issues: SchemaCompatibilityIssue[],
  allowSafeSubscriberOnlyObjects: boolean,
): void {
  for (const actualIndex of actualIndexes) {
    if (expectedIndexes.has(actualIndex.name))
      continue
    if (allowSafeSubscriberOnlyObjects && subscriberOnlyIndexCannotRejectPublisherRows(actualIndex))
      continue
    issues.push({
      kind: 'index',
      object: actualIndex.name,
      reason: allowSafeSubscriberOnlyObjects
        ? 'subscriber-only unique index can reject replicated rows'
        : 'unexpected index adds storage and write cost',
    })
  }
}
function compareTypes(
  expected: SchemaCatalog,
  actual: SchemaCatalog,
  issues: SchemaCompatibilityIssue[],
  allowSafeSubscriberOnlyObjects: boolean,
): void {
  const expectedTypes = new Map((expected.types ?? []).map(type => [type.name, type]))
  const actualTypes = new Map((actual.types ?? []).map(type => [type.name, type]))

  for (const expectedType of expected.types ?? []) {
    const actualType = actualTypes.get(expectedType.name)
    if (!actualType) {
      issues.push({ kind: 'type', object: expectedType.name, reason: 'missing required type' })
      continue
    }
    if (actualType.kind !== expectedType.kind) {
      issues.push({
        kind: 'type',
        object: expectedType.name,
        reason: `expected kind ${expectedType.kind}, found ${actualType.kind}`,
      })
      continue
    }
    if (!sameValue(actualType.definition, expectedType.definition))
      issues.push({ kind: 'type', object: expectedType.name, reason: 'type definition differs' })
  }

  if (allowSafeSubscriberOnlyObjects)
    return

  for (const actualType of actual.types ?? []) {
    if (!expectedTypes.has(actualType.name))
      issues.push({ kind: 'type', object: actualType.name, reason: 'unexpected type' })
  }
}

function compareSequences(
  expected: SchemaCatalog,
  actual: SchemaCatalog,
  issues: SchemaCompatibilityIssue[],
  allowSafeSubscriberOnlyObjects: boolean,
): void {
  const expectedSequences = new Map((expected.sequences ?? []).map(sequence => [sequence.name, sequence]))
  const actualSequences = new Map((actual.sequences ?? []).map(sequence => [sequence.name, sequence]))
  const fields = [
    ['type', 'type'],
    ['start', 'start'],
    ['increment', 'increment'],
    ['min', 'min'],
    ['max', 'max'],
    ['cache', 'cache'],
    ['cycle', 'cycle'],
    ['ownedTable', 'owned table'],
    ['ownedColumn', 'owned column'],
  ] as const

  for (const expectedSequence of expected.sequences ?? []) {
    const actualSequence = actualSequences.get(expectedSequence.name)
    if (!actualSequence) {
      issues.push({ kind: 'sequence', object: expectedSequence.name, reason: 'missing required sequence' })
      continue
    }

    for (const [field, label] of fields) {
      if (actualSequence[field] !== expectedSequence[field]) {
        issues.push({
          kind: 'sequence',
          object: expectedSequence.name,
          reason: `expected ${label} ${displayValue(expectedSequence[field])}, found ${displayValue(actualSequence[field])}`,
        })
      }
    }
  }

  if (allowSafeSubscriberOnlyObjects)
    return

  for (const actualSequence of actual.sequences ?? []) {
    if (!expectedSequences.has(actualSequence.name))
      issues.push({ kind: 'sequence', object: actualSequence.name, reason: 'unexpected sequence' })
  }
}

function compareFunctions(
  expected: SchemaCatalog,
  actual: SchemaCatalog,
  issues: SchemaCompatibilityIssue[],
  allowSafeSubscriberOnlyObjects: boolean,
): void {
  const expectedFunctions = new Map((expected.functions ?? []).map(fn => [functionKey(fn), fn]))
  const actualFunctions = new Map((actual.functions ?? []).map(fn => [functionKey(fn), fn]))

  for (const expectedFunction of expected.functions ?? []) {
    const key = functionKey(expectedFunction)
    const actualFunction = actualFunctions.get(key)
    if (!actualFunction) {
      issues.push({ kind: 'function', object: key, reason: 'missing required function overload' })
      continue
    }
    if (actualFunction.definition !== expectedFunction.definition)
      issues.push({ kind: 'function', object: key, reason: 'function definition differs' })
  }

  if (allowSafeSubscriberOnlyObjects)
    return

  for (const actualFunction of actual.functions ?? []) {
    const key = functionKey(actualFunction)
    if (!expectedFunctions.has(key))
      issues.push({ kind: 'function', object: key, reason: 'unexpected function overload' })
  }
}
function subscriberOnlyColumnCanAcceptPublisherRows(column: SchemaColumn): boolean {
  return !column.notNull || hasNonNullDefault(column.default)
}

function hasNonNullDefault(defaultValue: string | null): boolean {
  if (defaultValue === null)
    return false

  const normalized = defaultValue.trim().toUpperCase()
  return normalized !== 'NULL' && !/^NULL::[A-Z0-9_." ]+$/u.test(normalized)
}

function subscriberOnlyIndexCannotRejectPublisherRows(index: SchemaIndex): boolean {
  return index.constraintOwned !== true && !/^CREATE\s+UNIQUE\s+INDEX\b/iu.test(index.definition)
}

function assertSchemaCatalog(value: unknown, label: string): SchemaCatalog {
  if (!value || typeof value !== 'object')
    throw new Error(`Expected ${label} 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] !== undefined && !Array.isArray(catalog[collection]))
      throw new Error(`Read-replica schema catalog ${collection} must be an array`)
  }
  return catalog
}

function sameValue(left: unknown, right: unknown): boolean {
  return stableJson(left) === stableJson(right)
}

function columnKey(column: Pick<SchemaColumn, 'table' | 'name'>): string {
  return `${column.table}.${column.name}`
}

function constraintKey(constraint: Pick<SchemaConstraint, 'table' | 'name'>): string {
  return `${constraint.table}.${constraint.name}`
}

function functionKey(fn: Pick<SchemaFunction, 'name' | 'arguments'>): string {
  return `${fn.name}(${fn.arguments})`
}

function displayValue(value: unknown): string {
  if (value === undefined)
    return 'missing'
  if (value === null)
    return 'null'
  if (value === '')
    return 'none'
  if (typeof value === 'string')
    return value
  return stableJson(value)
}